diff --git a/README.md b/README.md index ab0a918..8152cce 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ A PostToolUse hook runs on every `Edit`, `MultiEdit`, or `Write` and ignores any | **Validate layers** (`validate_layers.sh`) | PostToolUse (`Edit`/`MultiEdit`/`Write`) | Runs the FFCA validator incrementally on the edited package and its direct dependents. Exits 2 on a violation (blocking: Claude must fix the dependency before continuing), printing the rule and the fix. Passes silently otherwise | | **Check VGV CLI** (`check_vgv_cli.sh`) | PreToolUse (`mcp__.*very-good-cli__.*`) | Auto-approves Very Good CLI MCP tool calls when the CLI is installed at 1.3.0 or newer, so they work in every run mode. Denies with an install or upgrade message when the CLI is missing or outdated. Stands aside for any other tool, or when the CLI version cannot be read | +The validator checks package naming under `features/`, the layer dependency rules, that nothing depends on an app, and cycles. It also checks that each feature's packages fit together: a data package depends on its own `_domain` under `dependencies`, a data package never exists without a `_domain` in its feature, and a `_domain` package never depends on the Flutter SDK. + The validator is also runnable directly for CI and audits, across the whole workspace: ```bash diff --git a/agents/ffca-layer-auditor.md b/agents/ffca-layer-auditor.md index 199898d..ab16c7a 100644 --- a/agents/ffca-layer-auditor.md +++ b/agents/ffca-layer-auditor.md @@ -39,7 +39,7 @@ From the repo root, run the validator in full-graph mode and treat every line it dart run ${CLAUDE_PLUGIN_ROOT}/scripts/validate_layers.dart --all ``` -This is the authoritative check for the pubspec dependency graph: package naming under `features/`, the layer dependency rules, the no-dependency-on-an-app rule, and workspace-wide cycles. It prints each violation with its rule and fix and exits 2 if any are found, 0 if the graph is clean. Do not re-derive these checks by hand; the script is the source of truth for them. Read `references/ffca/project_structure.md`, sections *Dependency rules* and *Naming conventions*, only if you need to explain a finding. +This is the authoritative check for the pubspec dependency graph: package naming under `features/`, the layer dependency rules, the no-dependency-on-an-app rule, workspace-wide cycles, and feature ownership (a data package depends on its own domain, a data package has a domain in its feature, a domain package never depends on the Flutter SDK). It prints each violation with its rule and fix and exits 2 if any are found, 0 if the graph is clean. Do not re-derive these checks by hand; the script is the source of truth for them. Read `references/ffca/project_structure.md`, sections *Dependency rules* and *Naming conventions*, only if you need to explain a finding. ## Step 2: source-level checks (qualitative) diff --git a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml index 0e0c476..022e7bd 100644 --- a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/pubspec.yaml @@ -1,3 +1,5 @@ +# Does not depend on alpha_domain: alpha_domain already depends on alpha_data, +# so adding the edge would plant a cycle instead of the domain -> data rule. name: alpha_data resolution: workspace environment: diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml index cc46698..0279f79 100644 --- a/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/pubspec.yaml @@ -3,4 +3,5 @@ resolution: workspace environment: sdk: ^3.11.0 dependencies: + beta_domain: any beta_presentation: any diff --git a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml index 5934847..c3abe8c 100644 --- a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/pubspec.yaml @@ -1,7 +1,8 @@ +# Indented by four spaces: the reader must still see both dependencies. name: gamma_presentation resolution: workspace environment: - sdk: ^3.11.0 + sdk: ^3.11.0 dependencies: - gamma_domain: any - delta_data: any + gamma_domain: any + delta_data: any diff --git a/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/pubspec.yaml new file mode 100644 index 0000000..7a4ddc7 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/pubspec.yaml @@ -0,0 +1,5 @@ +# Data layer that does not depend on its own domain. +name: iota_data +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/pubspec.yaml new file mode 100644 index 0000000..7aaddd4 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: iota_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/pubspec.yaml new file mode 100644 index 0000000..e902377 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/pubspec.yaml @@ -0,0 +1,5 @@ +# Data layer in a feature that has no domain package. +name: kappa_data +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/pubspec.yaml new file mode 100644 index 0000000..db00ff2 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/pubspec.yaml @@ -0,0 +1,8 @@ +# Domain layer that depends on the Flutter SDK. +name: lambda_domain +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + flutter: + sdk: flutter diff --git a/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/pubspec.yaml new file mode 100644 index 0000000..f371c1e --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/pubspec.yaml @@ -0,0 +1,7 @@ +# Data layer that declares its own domain only as a dev dependency. +name: mu_data +resolution: workspace +environment: + sdk: ^3.11.0 +dev_dependencies: + mu_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/pubspec.yaml new file mode 100644 index 0000000..1e5296d --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: mu_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml index 2e2e415..507179a 100644 --- a/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/pubspec.yaml @@ -3,5 +3,6 @@ resolution: workspace environment: sdk: ^3.11.0 dependencies: + theta_domain: any delta_data: path: ../../delta/delta_data diff --git a/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/pubspec.yaml new file mode 100644 index 0000000..4edba49 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: theta_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/pubspec.yaml index aab67c2..ad8d2dc 100644 --- a/scripts/test/fixtures/invalid_workspace/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/pubspec.yaml @@ -15,7 +15,14 @@ workspace: - features/eta/eta_domain - features/gamma/gamma_domain - features/gamma/gamma_presentation + - features/iota/iota_data + - features/iota/iota_domain + - features/kappa/kappa_data + - features/lambda/lambda_domain + - features/mu/mu_data + - features/mu/mu_domain - features/orders/orders_service - features/theta/theta_data + - features/theta/theta_domain - features/zeta/zeta_domain - shared/bad_shared diff --git a/scripts/test/validate_layers_test.dart b/scripts/test/validate_layers_test.dart index 01aa976..87ea32b 100644 --- a/scripts/test/validate_layers_test.dart +++ b/scripts/test/validate_layers_test.dart @@ -148,6 +148,36 @@ void main() { expect(err, contains('nothing may depend on an app')); }); + test('data does not depend on its own domain', () { + expect(err, contains('iota_data does not depend on iota_domain')); + expect(err, contains("a data layer implements its own feature's domain")); + expect(err, contains('Add iota_domain under dependencies')); + }); + + test('data declares its own domain only as a dev dependency', () { + expect( + err, + contains('mu_data declares mu_domain only outside dependencies'), + ); + }); + + test('data in a feature with no domain', () { + expect( + err, + contains('kappa_data is a data layer but features/kappa/ has no ' + 'kappa_domain'), + ); + expect(err, contains('Add kappa_domain with the repository interfaces')); + }); + + test('domain depends on the Flutter SDK', () { + expect( + err, + contains('lambda_domain depends on the Flutter SDK through flutter'), + ); + expect(err, contains('the domain layer is pure Dart')); + }); + test('resolves dependencies by workspace name and by path', () { // The fixture is a Dart workspace: alpha_domain declares `alpha_data: // any` and resolves by name, theta_data keeps a `path:` dependency. @@ -156,9 +186,9 @@ void main() { }); test('every violation includes a fix line', () { - // One arrow per violation; there are at least the seven planted rules. + // One arrow per violation; there are at least the eleven planted rules. final arrows = '→'.allMatches(err).length; - expect(arrows, greaterThanOrEqualTo(7)); + expect(arrows, greaterThanOrEqualTo(11)); }); }); @@ -198,6 +228,35 @@ void main() { expect(r.err, contains('shared packages must never depend on a feature')); }); + test('reports a cycle on the same package from every member', () { + // The cycle is attributed to its alphabetically first package, so a + // suppression placed there works whichever package is edited. + for (final pkg in [ + 'features/epsilon/epsilon_domain', + 'features/zeta/zeta_domain', + ]) { + final r = _run(['--file', _pubspec(_invalid, pkg)]); + expect(r.code, 2, reason: pkg); + expect( + r.err, + contains('FFCA violation in features/epsilon/epsilon_domain/' + 'pubspec.yaml:\n ✗ dependency cycle detected: ' + 'epsilon_domain → zeta_domain → epsilon_domain'), + reason: pkg, + ); + } + }); + + test('reads dependencies indented by four spaces', () { + // gamma_presentation's pubspec uses four-space indentation. + final r = _run([ + '--file', + _pubspec(_invalid, 'features/gamma/gamma_presentation'), + ]); + expect(r.code, 2); + expect(r.err, contains('gamma_presentation depends on delta_data')); + }); + test('flags presentation depending on a data layer', () { final r = _run([ '--file', @@ -227,6 +286,59 @@ void main() { expect(r.code, 2); expect(r.err, contains('nothing may depend on an app')); }); + + test('flags data not depending on its own domain', () { + final r = _run([ + '--file', + _pubspec(_invalid, 'features/iota/iota_data'), + ]); + expect(r.code, 2); + expect(r.err, contains('iota_data does not depend on iota_domain')); + }); + + test('flags data declaring its own domain only as a dev dependency', () { + final r = _run([ + '--file', + _pubspec(_invalid, 'features/mu/mu_data'), + ]); + expect(r.code, 2); + expect(r.err, contains('mu_data declares mu_domain only outside')); + }); + + test('flags data in a feature with no domain', () { + final r = _run([ + '--file', + _pubspec(_invalid, 'features/kappa/kappa_data'), + ]); + expect(r.code, 2); + expect(r.err, contains('features/kappa/ has no kappa_domain')); + }); + + test('flags a domain depending on the Flutter SDK', () { + final r = _run([ + '--file', + _pubspec(_invalid, 'features/lambda/lambda_domain'), + ]); + expect(r.code, 2); + expect(r.err, contains('the domain layer is pure Dart')); + }); + + test('planted fixtures report only their own rule', () { + // beta and theta now depend on their own domain, so the ownership + // checks add nothing to the layer rule each of them plants. + for (final pkg in [ + 'features/beta/beta_data', + 'features/theta/theta_data', + ]) { + final r = _run(['--file', _pubspec(_invalid, pkg)]); + expect(r.code, 2); + expect( + '✗'.allMatches(r.err).length, + 1, + reason: '$pkg should report one violation, got: ${r.err}', + ); + } + }); }); group('graceful skips (exit 0)', () { diff --git a/scripts/validate_layers.dart b/scripts/validate_layers.dart index 0235e7c..dc34f82 100644 --- a/scripts/validate_layers.dart +++ b/scripts/validate_layers.dart @@ -31,6 +31,10 @@ import 'dart:io'; // - `data`/`presentation` never appear for `domain`/`data` -> domain and // data layers never depend on a presentation or (cross-feature) data layer. // - `shared` may depend only on `shared` -> shared never depends on a feature. +// +// On top of this table, _checkOwnership enforces how a feature's own packages +// fit together: a data layer depends on its own domain at runtime, a data +// layer never exists without a domain, and a domain never uses the Flutter SDK. // --------------------------------------------------------------------------- const Map> rules = { 'app': {'domain', 'data', 'presentation', 'shared'}, @@ -147,6 +151,8 @@ List<_Violation> _checkPackage(Package p, Workspace ws) { return out; // can't classify the source layer; skip its dependency checks } + out.addAll(_checkOwnership(p, ws)); + final allowed = rules[p.layer]; if (allowed == null) return out; // unknown source layer (e.g. app-less): skip @@ -160,6 +166,61 @@ List<_Violation> _checkPackage(Package p, Workspace ws) { return out; } +/// Checks how a feature's own packages fit together, beyond the rules table. +/// Implements the "Dependency rules" section of +/// references/ffca/project_structure.md, the feature archetypes in +/// references/ffca/overview.md, and the pure Dart domain in +/// references/ffca/domain.md. +List<_Violation> _checkOwnership(Package p, Workspace ws) { + final out = <_Violation>[]; + final domainName = '${p.feature}_domain'; + + if (p.layer == 'data') { + final domain = ws.packages + .where((d) => d.feature == p.feature && d.name == domainName) + .firstOrNull; + if (domain == null) { + out.add( + _Violation( + package: p, + rule: '${p.name} is a data layer but features/${p.feature}/ has no ' + '$domainName (a data layer implements its own feature\'s domain)', + fix: 'Add $domainName with the repository interfaces ${p.name} ' + 'implements, or move ${p.name} into the feature whose domain it ' + 'implements.', + ), + ); + } else if (!p.runtimeDeps.contains(domain.dir)) { + final devOnly = p.deps.contains(domain.dir); + out.add( + _Violation( + package: p, + rule: devOnly + ? '${p.name} declares $domainName only outside dependencies ' + '(a data layer implements its own feature\'s domain)' + : '${p.name} does not depend on $domainName (a data layer ' + 'implements its own feature\'s domain)', + fix: 'Add $domainName under dependencies in the ${p.name} pubspec.', + ), + ); + } + } + + if (p.layer == 'domain' && p.flutterSdkDeps.isNotEmpty) { + out.add( + _Violation( + package: p, + rule: '${p.name} depends on the Flutter SDK through ' + '${p.flutterSdkDeps.join(', ')} (the domain layer is pure Dart)', + fix: 'Remove the Flutter SDK dependencies and move Flutter code to ' + '${p.feature}_presentation; the domain holds models, repository ' + 'interfaces, Commands, and Queries.', + ), + ); + } + return out; +} + _Violation _dependencyViolation(Package src, Package tgt) { final pair = '${src.name} depends on ${tgt.name}'; @@ -256,15 +317,26 @@ List<_Violation> _cycleViolations(Workspace ws, {Package? from}) { final next = ws.byDir[depDir]; if (next == null) continue; if (onStack.contains(next.dir)) { - // Found a cycle: slice the stack from the repeated node. + // Found a cycle: slice the stack from the repeated node, then + // rotate it to start at its alphabetically first package. The cycle + // is reported on that package whichever package the walk started + // from, so a suppression for it works the same in every mode. final startIdx = stack.indexWhere((p) => p.dir == next.dir); - final cycle = stack.sublist(startIdx).map((p) => p.name).toList() - ..add(next.name); + final members = stack.sublist(startIdx); + final owner = members.reduce( + (a, b) => a.name.compareTo(b.name) <= 0 ? a : b, + ); + final at = members.indexOf(owner); + final cycle = [ + ...members.sublist(at), + ...members.sublist(0, at), + owner, + ].map((p) => p.name).toList(); final key = (cycle.toSet().toList()..sort()).join('|'); if (seenCycles.add(key)) { out.add( _Violation( - package: cycle.first == next.name ? next : stack[startIdx], + package: owner, rule: 'dependency cycle detected: ${cycle.join(' → ')}', fix: 'Break the cycle; FFCA requires an acyclic dependency ' 'graph.', @@ -308,12 +380,20 @@ Workspace _discover(String root) { for (final top in ['features', 'apps', 'shared']) { final topDir = Directory('$root/$top'); if (!topDir.existsSync()) continue; - for (final entity in topDir.listSync(recursive: true, followLinks: false)) { + // Sorted so that discovery, and with it every report, does not depend on + // the order the file system lists entries in. + final entities = topDir.listSync(recursive: true, followLinks: false) + ..sort((a, b) => a.path.compareTo(b.path)); + for (final entity in entities) { if (entity is! File) continue; if (_basename(entity.path) != 'pubspec.yaml') continue; final pkgDir = _normalize(entity.parent.absolute.path); final parsed = _parsePubspec(entity); final package = _buildPackage(pkgDir, entity, parsed.name, top, root); + package.flutterSdkDeps.addAll([ + for (final dep in parsed.deps) + if (dep.runtime && dep.sdk == 'flutter') dep.name, + ]); packages.add(package); depsByPackage[package] = parsed.deps; } @@ -325,11 +405,16 @@ Workspace _discover(String root) { for (final p in packages) { for (final dep in depsByPackage[p]!) { final path = dep.path; + final String dir; if (path != null) { - p.deps.add(_normalize(_join(p.dir, path))); + dir = _normalize(_join(p.dir, path)); } else if (byName[dep.name] case final target?) { - p.deps.add(target.dir); + dir = target.dir; + } else { + continue; } + p.deps.add(dir); + if (dep.runtime) p.runtimeDeps.add(dir); } } @@ -369,6 +454,8 @@ Package _buildPackage( feature: feature, layer: layer, deps: [], + runtimeDeps: [], + flutterSdkDeps: [], ); } @@ -390,6 +477,9 @@ _ParsedPubspec _parsePubspec(File file) { final lines = file.readAsLinesSync(); String? currentTop; + // The indentation of the entries in the current top-level section, taken + // from its first indented line, so 2- and 4-space pubspecs both read. + int? entryIndent; for (var i = 0; i < lines.length; i++) { final line = _stripComment(lines[i]); if (line.trim().isEmpty) continue; @@ -399,6 +489,7 @@ _ParsedPubspec _parsePubspec(File file) { if (indent == 0) { final key = content.split(':').first.trim(); currentTop = key; + entryIndent = null; if (key == 'name') { final value = content.substring(content.indexOf(':') + 1).trim(); if (value.isNotEmpty) name = _unquote(value); @@ -406,29 +497,40 @@ _ParsedPubspec _parsePubspec(File file) { continue; } + entryIndent ??= indent; final inDeps = currentTop == 'dependencies' || currentTop == 'dev_dependencies' || currentTop == 'dependency_overrides'; - if (inDeps && indent == 2 && content.contains(':')) { + if (inDeps && indent == entryIndent && content.contains(':')) { // A dependency entry: `foo: ^1.0.0`, a bare `foo:`, or `foo:` with a - // nested block. Scan the nested block for a `path:` value. + // nested block. Scan the nested block for `path:` and `sdk:` values. final depName = _unquote(content.split(':').first.trim()); String? path; + String? sdk; if (content.endsWith(':')) { for (var j = i + 1; j < lines.length; j++) { final l2 = _stripComment(lines[j]); if (l2.trim().isEmpty) continue; final ind2 = l2.length - l2.trimLeft().length; - if (ind2 <= 2) break; + if (ind2 <= indent) break; final c2 = l2.trim(); if (c2.startsWith('path:')) { path = _unquote(c2.substring('path:'.length).trim()); + } else if (c2.startsWith('sdk:')) { + sdk = _unquote(c2.substring('sdk:'.length).trim()); } } } // An override without a path only pins a version; it adds no edge. if (path != null || currentTop != 'dependency_overrides') { - deps.add(_Dependency(name: depName, path: path)); + deps.add( + _Dependency( + name: depName, + path: path, + sdk: sdk, + runtime: currentTop == 'dependencies', + ), + ); } } } @@ -525,6 +627,8 @@ class Package { required this.feature, required this.layer, required this.deps, + required this.runtimeDeps, + required this.flutterSdkDeps, }); final String dir; // normalized absolute package directory @@ -534,6 +638,8 @@ class Package { final String feature; // feature folder name (features/ only) final String layer; // domain | data | presentation | shared | app | unknown final List deps; // normalized absolute dirs of workspace dependencies + final List runtimeDeps; // the subset declared under `dependencies` + final List flutterSdkDeps; // runtime deps with `sdk: flutter` } class _ParsedPubspec { @@ -543,9 +649,16 @@ class _ParsedPubspec { } class _Dependency { - _Dependency({required this.name, this.path}); + _Dependency({ + required this.name, + required this.runtime, + this.path, + this.sdk, + }); final String name; + final bool runtime; // declared under `dependencies`, not dev or overrides final String? path; // relative `path:` value, null when resolved by name + final String? sdk; // `sdk:` value, for example `flutter` } class _Violation { diff --git a/skills/ffca-audit/SKILL.md b/skills/ffca-audit/SKILL.md index f06ad0e..fd28bf4 100644 --- a/skills/ffca-audit/SKILL.md +++ b/skills/ffca-audit/SKILL.md @@ -24,7 +24,7 @@ The audit itself is performed by the **`ffca-layer-auditor` agent**, so the work ## What to do 1. Confirm the repo is FFCA-shaped (a `features/` folder with `{feature}_{layer}` packages). If not, say so and stop. -2. Launch the `ffca-layer-auditor` agent (subagent type `ffca-layer-auditor`) against the repo. The agent runs `dart run scripts/validate_layers.dart --all` for the mechanical pass (naming, layer rules, cycles), then adds the source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, Command/Query necessity, module entry, misplaced packages, high fan-in) and reads `${CLAUDE_PLUGIN_ROOT}/references/ffca/` for the detail behind each. +2. Launch the `ffca-layer-auditor` agent (subagent type `ffca-layer-auditor`) against the repo. The agent runs `dart run scripts/validate_layers.dart --all` for the mechanical pass (naming, layer rules, cycles, feature ownership), then adds the source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, Command/Query necessity, module entry, misplaced packages, high fan-in) and reads `${CLAUDE_PLUGIN_ROOT}/references/ffca/` for the detail behind each. 3. Relay the agent's output: the per-package verdict table and the severity-ordered findings list. Each finding states the rule and the fix. The agent is read-only and does not auto-fix. If the agent is unavailable for any reason, fall back to running `dart run ${CLAUDE_PLUGIN_ROOT}/scripts/validate_layers.dart --all` yourself and reporting its violations, then note that the qualitative source-level checks were skipped.