diff --git a/AGENTS.md b/AGENTS.md index c98ff3b..6e1d0c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,14 +40,16 @@ references/ domain_templates.md # Model, repository interface, Command and Query shapes presentation_templates.md # Cubit, Module, route shapes scripts/ + analysis_options.yaml # Excludes the test fixtures from analysis pubspec.yaml # Dev dependency on package:test for the validator tests sync_reference.dart # Regenerates references/ffca/ from VGV Engineering, --check for drift - validate_layers.dart # FFCA layer, naming, and cycle validator + validate_layers.dart # FFCA layer, naming, cycle, import, and barrel validator test/ validate_layers_test.dart # Validator tests fixtures/ valid_workspace/ # FFCA repo that must pass invalid_workspace/ # One package per violation the validator must catch + warning_workspace/ # Warnings and suppressed errors only, must exit 0 not_ffca/ # Non-FFCA repo the validator must skip skills/ ffca-architecture/SKILL.md # Orientation, layer rules, naming diff --git a/CLAUDE.md b/CLAUDE.md index 20d2198..ecc947e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,14 +7,14 @@ `hooks/hooks.json` defines one PostToolUse hook and one PreToolUse hook. -- `Edit|MultiEdit|Write` matcher → `validate_layers.sh`. The matcher is a filter, not a guarantee, so the hook reads `tool_name` from the payload and exits 0 unless it is `Edit`, `MultiEdit`, or `Write`. When the edited file is a `pubspec.yaml` inside an FFCA-shaped repo, meaning a `features/` folder exists above it, the hook runs `dart run scripts/validate_layers.dart --file `. The validator checks the edited package and its direct dependents. Exit 2 means a layer violation. The hook prints the rule and the fix to stderr and exits 2, which blocks Claude until the dependency is fixed. +- `Edit|MultiEdit|Write` matcher → `validate_layers.sh`. The matcher is a filter, not a guarantee, so the hook reads `tool_name` from the payload and exits 0 unless it is `Edit`, `MultiEdit`, or `Write`. When the edited file is a `pubspec.yaml` or a Dart file inside an FFCA-shaped repo, meaning a `features/` folder exists above it, the hook runs `dart run scripts/validate_layers.dart --file `. For a pubspec, the validator checks the edited package and its direct dependents, reporting the edited package's warnings and errors but only the errors of its dependents, and running the import checks and suppression validation for the edited package only, and it does not require a barrel from a package with no `lib/` yet. For a Dart file under a package's `lib/` or `bin/`, it reports only the import and barrel findings located in that file, so a Dart edit never blocks on a violation elsewhere. Exit 2 means at least one error-severity finding. The hook forwards only the validator's report, from the first `FFCA findings in` line, so `dart run`'s own notices never reach Claude. It prints the findings to stderr and exits 2, which blocks Claude until the dependency is fixed. When the validator exits 0 with output, every finding is a warning: the hook returns it as `additionalContext` JSON and exits 0, so Claude sees it without being blocked. A `# ffca-ignore: — ` comment in a pubspec suppresses that rule for that package, and `// ffca-ignore:` in a Dart file suppresses it for that file, except for rules reported on the pubspec, which a Dart file cannot suppress. - Any other file, or a repo with no `features/` folder, exits 0 silently. -- The hook skips with exit 0 when `jq` or `dart` is missing from `PATH`. The `dart` check runs only for FFCA pubspec edits. A validator failure other than exit 2 is reported to stderr but never blocks. +- The hook skips with exit 0 when `jq` or `dart` is missing from `PATH`. The `dart` check runs only for FFCA pubspec and Dart edits. A validator failure other than exit 2 is reported to stderr but never blocks. - `mcp__.*very-good-cli__.*` matcher → `check_vgv_cli.sh`. For a Very Good CLI MCP tool, it returns `allow` when `very_good --version` is 1.3.0 or newer and `deny` with an install or upgrade message when the CLI is missing or older. It stands aside with exit 0 for any other tool, when the version cannot be read, or when `jq` is missing. `allowed-tools` grants only last for the invoking turn, so this hook is what keeps the tools approved afterwards. `hooks/check_vgv_cli_test.sh` covers the PreToolUse hook against a stubbed `very_good` and runs in CI under the **Hook Tests** job. -`scripts/test/validate_layers_test.dart` covers the validator and runs in CI under the **Layer Validator** job. Add a fixture and a case there when changing a rule. +`scripts/test/validate_layers_test.dart` covers the validator and runs in CI under the **Layer Validator** job. Add a fixture and a case there when changing a rule, and add a new rule as a value of the `Rule` enum, with its ID and severity, and to the rules table in the **Hooks** section of `README.md`. `hooks/validate_layers_test.sh` covers the PostToolUse hook against the same fixtures and runs in CI under the **Hook Tests** job. diff --git a/README.md b/README.md index 8152cce..bf1aba7 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,14 @@ Skills activate automatically when Claude detects an FFCA repo or an FFCA-shaped ## Hooks -A PostToolUse hook runs on every `Edit`, `MultiEdit`, or `Write` and ignores any other tool call. When the edited file is a `pubspec.yaml` inside an FFCA-shaped repo, it validates the package's layer dependencies. A PreToolUse hook gates every Very Good CLI MCP tool call. +A PostToolUse hook runs on every `Edit`, `MultiEdit`, or `Write` and ignores any other tool call. When the edited file is a `pubspec.yaml` inside an FFCA-shaped repo, it validates the package's layer dependencies. When it is a Dart file, it validates that file's imports and exports. A PreToolUse hook gates every Very Good CLI MCP tool call. | Hook | Event | Behavior | | --- | --- | --- | -| **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 | +| **Validate layers** (`validate_layers.sh`) | PostToolUse (`Edit`/`MultiEdit`/`Write`) | Runs the FFCA validator incrementally: on the edited package and its direct dependents for a pubspec edit (errors only for dependents), and on the edited file alone for a Dart edit. Exits 2 on an error (blocking: Claude must fix the dependency before continuing), printing the rule and the fix. Passes warnings to Claude as non-blocking context. 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 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. In `lib/` and `bin/`, it checks that each feature package has a primary barrel `lib/{package}.dart`, that imports of other workspace packages go through a barrel at the root of their `lib/`, that another feature's presentation is imported through a dedicated barrel rather than its primary one, that a data barrel never exports DTOs or mappers, that every declared workspace dependency is imported, and that every imported workspace package is declared. An import of an undeclared package is checked against the same layer rules as a pubspec dependency, since a Dart workspace resolves it anyway. On a pubspec edit, a package with no `lib/` yet is not required to have a barrel, so a new package can be scaffolded pubspec first. The validator is also runnable directly for CI and audits, across the whole workspace: @@ -129,6 +129,52 @@ The validator is also runnable directly for CI and audits, across the whole work dart run scripts/validate_layers.dart --all ``` +### Rules, severities, and suppressions + +Every finding carries a rule ID in brackets. An **error** exits 2 and blocks the hook. A **warning** is reported but exits 0. + +| Rule ID | Severity | Finding | +| --- | --- | --- | +| `package-name` | error | A package under `features/{f}/` is not named `{f}_domain`, `{f}_data`, `{f}_data_{backend}`, or `{f}_presentation` | +| `depends-on-app` | error | Any package depends on an app | +| `shared-depends-on-feature` | error | A shared package depends on a feature package | +| `presentation-depends-on-data` | error | A presentation package depends on a data package | +| `domain-depends-on-data` | error | A domain package depends on a data package | +| `domain-depends-on-presentation` | error | A domain package depends on a presentation package | +| `data-depends-on-presentation` | error | A data package depends on a presentation package | +| `data-depends-on-feature-data` | error | A data package depends on another feature's data package | +| `layer-dependency` | error | Any other dependency the rules table does not allow | +| `dependency-cycle` | error | Packages depend on each other in a cycle | +| `data-missing-own-domain` | error | A data package does not depend on its own `_domain` under `dependencies` | +| `data-without-domain` | error | A data package's feature has no `_domain` package | +| `domain-uses-flutter` | error | A domain package depends on the Flutter SDK | +| `presentation-missing-own-domain` | warning | A presentation package does not depend on its feature's existing `_domain` | +| `missing-primary-barrel` | error | A feature package has no `lib/{package}.dart` | +| `deep-import` | error | A Dart file imports or exports another workspace package past the barrels at the root of its `lib/` | +| `cross-feature-primary-barrel` | error | A feature imports another feature's primary presentation barrel instead of a dedicated one | +| `data-barrel-exports-internals` | error | A data package's primary barrel exports from `dtos/` or `mappers/`. Data sources may be exported, since the app constructs them | +| `undeclared-dependency` | error | A Dart file imports a workspace package its pubspec does not declare under `dependencies`. When the layer rules forbid that dependency, the layer rule's ID is reported instead | +| `unused-dependency` | warning | A package declares a workspace dependency under `dependencies` but never imports it in `lib/` or `bin/` | +| `suppression-missing-reason` | error | An `ffca-ignore` comment has no reason | +| `suppression-unknown-rule` | error | An `ffca-ignore` comment names a rule that does not exist or cannot be suppressed, or a `//` comment in a Dart file names a rule reported on the pubspec | +| `unused-suppression` | warning | An `ffca-ignore` comment matches no finding (`--all` only) | + +To accept a finding as a deliberate exception, add a comment with the rule ID and a reason to the pubspec of the package it is reported on: + +```yaml +# ffca-ignore: data-missing-own-domain — legacy repository, moving to orders_domain in #123 +name: orders_data +``` + +A finding in a Dart file can also be suppressed in that file, with `//`: + +```dart +// ffca-ignore: deep-import — the model is not exported from orders_domain yet +import 'package:orders_domain/models/order.dart'; +``` + +The separator after the rule ID can be an em dash, an en dash, a hyphen, or a colon. A suppression in a pubspec covers every finding with that rule ID in that package, and one in a Dart file covers that file. Rules reported on the pubspec, such as `unused-dependency` or `missing-primary-barrel`, can only be suppressed in the pubspec. The reason is required, so each exception explains itself in review. The last three rules cannot be suppressed. `--all` reports how many findings were suppressed, and flags suppressions that no longer match anything so they can be removed. + ### Prerequisites - **Dart SDK** must be available on your `PATH`. The validator imports only `dart:io`, so it runs with just the SDK, no `dart pub get` required. @@ -138,6 +184,7 @@ Run the hook tests with: ```bash bash hooks/check_vgv_cli_test.sh +bash hooks/validate_layers_test.sh ``` ## MCP Integration @@ -161,7 +208,7 @@ The server needs **Very Good CLI** 1.3.0 or newer. Install it with `dart pub glo | Agent | Behavior | | --- | --- | -| [**ffca-layer-auditor**](agents/ffca-layer-auditor.md) | Read-only architecture auditor. Runs the validator in `--all` mode, then adds source-level checks (declared-but-unused dependencies, barrel hygiene, DTO leakage, Command/Query necessity, module entry, split routing tables, deferred-loading reachability, misplaced packages, high fan-in) and returns a per-package verdict table. Reports violations, never auto-fixes | +| [**ffca-layer-auditor**](agents/ffca-layer-auditor.md) | Read-only architecture auditor. Runs the validator in `--all` mode, then adds source-level checks (barrel scope, DTO leakage, Command/Query necessity, module entry, split routing tables, deferred-loading reachability, misplaced packages, high fan-in) and returns a per-package verdict table. Reports violations, never auto-fixes | The auditor runs in its own context, so the same architecture review can be dispatched from the `ffca-audit` skill, a refactor, or a pre-PR flow without crowding the main conversation. The per-edit hook prevents bad pubspec dependencies as they are written; the agent answers whether the whole repo is healthy on demand. diff --git a/agents/ffca-layer-auditor.md b/agents/ffca-layer-auditor.md index ab16c7a..66fcce9 100644 --- a/agents/ffca-layer-auditor.md +++ b/agents/ffca-layer-auditor.md @@ -39,23 +39,22 @@ 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, 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. +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, 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), and imports and barrels (each feature package has a primary barrel, no import reaches past another package's barrels, another feature's presentation is imported through a dedicated barrel, a data barrel never exports DTOs or mappers, every declared workspace dependency is imported, and every imported workspace package is declared and allowed by the layer rules). It prints each finding with its rule ID, severity (`✗` error, `⚠` warning), and fix, then a summary line with the error, warning, and suppressed counts. It exits 2 if any error is found, and 0 when there are only warnings or nothing at all. An error makes the package `fail`; a warning makes it `warn`. A finding suppressed with an `# ffca-ignore:` comment is an accepted exception: do not report it as a violation, but list the suppression and its reason in the package's notes. 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) -The script validates the declared pubspec graph. These checks need the source, so do them by reading and grepping the tree. Cite the reference section for each. +The script validates the pubspec graph and the import and export directives in `lib/` and `bin/`. These checks need judgment or a deeper read of the source, so do them by reading and grepping the tree. Cite the reference section for each. 1. **Package inventory and classification.** Enumerate every package under `apps/`, `features/`, `shared/`. Classify each by folder, name, and inferred type: full feature, headless feature (domain plus data, no presentation), presentation-only feature (a `_presentation` with no domain or data sibling), or shared package. All four are legitimate; a missing sibling is a signal, not a violation. See `references/ffca/overview.md`, sections *Features*, *Headless features*, *Presentation-only features*, *Shared libraries*. -2. **Declared-but-unused dependencies.** For each package, every dependency on another workspace package in its pubspec, by `path:` or by name, should be imported somewhere in its source. A declared dependency that is never imported is a finding. Pay special attention to presentation packages. -3. **Barrel hygiene.** Each package has a primary barrel `lib/{package}.dart` re-exporting `src/` (or the layer's public files). Features with multiple independent entry points have subfeature barrels. No barrel re-exports a private symbol. Detect private re-exports with `rg -n "^export '.*/_" features shared apps`. A widget exported for another feature has its own narrow barrel whose transitive imports do not reach the feature's modules or screens; a cross-feature import that targets the primary barrel is a finding. See `references/ffca/presentation.md`, sections *Subfeature barrel files* and *Sharing a widget across features*, and `references/ffca/project_structure.md`, section *Layer subfolders*. -4. **DTO leakage.** Generated or transport types (`*.g.dart`, `*.freezed.dart`, anything under a `dtos/` folder) must not be imported outside the data package that owns them. Detect with `rg -n "import .*\.(g|freezed)\.dart'" features shared apps` and check the importing package owns the source. See `references/ffca/data.md`, section *DTOs and mappers*. -5. **Command and Query necessity.** A `Query` or `Command` class in a `*_domain` package is justified only when it combines two or more repositories or removes duplication across Blocs. A single-repository pass-through is an anti-pattern. Open each `*_query.dart` / `*_command.dart` and count injected repositories. Also check the verbs: commands expose `execute`, queries expose `get` or `watch`, and neither is a callable class. Classes named `*UseCase` are a naming finding. See `references/ffca/domain.md`, section *Business rules*, and `references/ffca/faq.md`, section *Should we use callable classes for Commands and Queries?* -6. **Module entry points.** App route bindings should instantiate a feature `*Module`, not a screen or page widget directly, so dependencies stay explicit. Inspect the app's router and verify each route builds a `*Module`. See `references/ffca/presentation.md`, section *The module*, and `references/ffca/navigation.md`. -7. **Misplaced packages.** Volatile or app-specific business logic sitting in `shared/`, or a generic, pub-publishable utility sitting in `features/`. Apply the decision rule and component table in `references/ffca/overview.md`, section *Shared libraries*. -8. **High fan-in domains.** A domain many features depend on may be doing too much. Note it for review against `references/ffca/domain.md`, section *Composing features*. - -9. **Split routing tables.** If the app's routes live in more than one file, every file but the entry library must begin with `part of`. A per-feature route file that is a separate library is silently dropped by `go_router_builder`, and the build still succeeds. Detect with `rg -L "^part of" apps/*/lib/**/[a-z_]*routes.dart`. See `references/ffca/navigation.md`, section *Splitting the routing table across files*. -10. **Deferred loading reachability.** For each feature the app imports with a `deferred as` prefix, check that no non-deferred import path from the app reaches the same package. An eager edge, typically one presentation package importing another, silently cancels the code splitting. Advisory on apps that ship only to iOS and Android. See `references/ffca/project_structure.md`, section *Deferred loading*. +2. **Barrel scope.** The validator checks that barrels exist and that imports go through them. Check what they expose: a feature with several independent entry points has a subfeature barrel for each, no barrel re-exports a private symbol (`rg -n "^export '.*/_" features shared apps`), and a barrel made for another feature exports only that widget, with transitive imports that do not reach the feature's modules or screens. See `references/ffca/presentation.md`, sections *Subfeature barrel files* and *Sharing a widget across features*. +3. **DTO leakage.** Generated or transport types (`*.g.dart`, `*.freezed.dart`, anything under a `dtos/` folder) must not be imported outside the data package that owns them. The validator already flags a data barrel that exports them and any import that reaches past a barrel, so look for the remaining path: a DTO or generated type in a public signature, such as a repository method. Detect with `rg -n "import .*\.(g|freezed)\.dart'" features shared apps` and check the importing package owns the source. See `references/ffca/data.md`, section *DTOs and mappers*. +4. **Command and Query necessity.** A `Query` or `Command` class in a `*_domain` package is justified only when it combines two or more repositories or removes duplication across Blocs. A single-repository pass-through is an anti-pattern. Open each `*_query.dart` / `*_command.dart` and count injected repositories. Also check the verbs: commands expose `execute`, queries expose `get` or `watch`, and neither is a callable class. Classes named `*UseCase` are a naming finding. See `references/ffca/domain.md`, section *Business rules*, and `references/ffca/faq.md`, section *Should we use callable classes for Commands and Queries?* +5. **Module entry points.** App route bindings should instantiate a feature `*Module`, not a screen or page widget directly, so dependencies stay explicit. Inspect the app's router and verify each route builds a `*Module`. See `references/ffca/presentation.md`, section *The module*, and `references/ffca/navigation.md`. +6. **Misplaced packages.** Volatile or app-specific business logic sitting in `shared/`, or a generic, pub-publishable utility sitting in `features/`. Apply the decision rule and component table in `references/ffca/overview.md`, section *Shared libraries*. +7. **High fan-in domains.** A domain many features depend on may be doing too much. Note it for review against `references/ffca/domain.md`, section *Composing features*. + +8. **Split routing tables.** If the app's routes live in more than one file, every file but the entry library must begin with `part of`. A per-feature route file that is a separate library is silently dropped by `go_router_builder`, and the build still succeeds. Detect with `rg -L "^part of" apps/*/lib/**/[a-z_]*routes.dart`. See `references/ffca/navigation.md`, section *Splitting the routing table across files*. +9. **Deferred loading reachability.** For each feature the app imports with a `deferred as` prefix, check that no non-deferred import path from the app reaches the same package. An eager edge, typically one presentation package importing another, silently cancels the code splitting. Advisory on apps that ship only to iOS and Android. See `references/ffca/project_structure.md`, section *Deferred loading*. ## Step 3: report diff --git a/hooks/hooks.json b/hooks/hooks.json index 8499b5c..c54908c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "VGV FFCA plugin hooks: enforces Feature-First Clean Architecture layer rules on pubspec.yaml edits and gates Very Good CLI MCP tool calls", + "description": "VGV FFCA plugin hooks: enforces Feature-First Clean Architecture layer rules on pubspec.yaml and Dart edits and gates Very Good CLI MCP tool calls", "hooks": { "PreToolUse": [ { diff --git a/hooks/validate_layers.sh b/hooks/validate_layers.sh index a235895..39b09e8 100755 --- a/hooks/validate_layers.sh +++ b/hooks/validate_layers.sh @@ -1,10 +1,12 @@ #!/bin/bash set -euo pipefail -# PostToolUse hook: enforce FFCA layer dependency rules on pubspec.yaml edits. +# PostToolUse hook: enforce FFCA layer dependency rules on pubspec.yaml edits, +# and the import and barrel rules on Dart edits. # Mirrors the analyze hook in vgv-ai-flutter-plugin: parse the payload with jq, # filter to the files we care about, skip gracefully when prerequisites are # missing, then run the validator and propagate its exit code (2 blocks Claude). +# Warnings exit 0 and reach Claude as non-blocking additional context. # Read the hook payload from stdin. input=$(cat) @@ -26,10 +28,12 @@ esac # Extract the edited file path. file_path=$(jq -r '.tool_input.file_path // empty' <<<"$input") -# Skip unless this is a pubspec.yaml. -if [[ -z "$file_path" || "$(basename "$file_path")" != "pubspec.yaml" ]]; then - exit 0 -fi +# Skip unless this is a pubspec.yaml or a Dart file. For a Dart file the +# validator reports only the findings located in that file. +case "$(basename "$file_path")" in + pubspec.yaml | *.dart) ;; + *) exit 0 ;; +esac # Skip silently if the repo is not FFCA-shaped (no features/ folder above the # edited file). Only walks absolute paths; relative paths fall through to the @@ -61,8 +65,13 @@ output=$(dart run "${CLAUDE_PLUGIN_ROOT}/scripts/validate_layers.dart" --file "$ code=$? set -e +# The report starts at the first "FFCA findings in" line. Anything before it +# is dart run's own output, such as an SDK or first-run notice, and is never +# forwarded as a finding. +report=$(sed -n '/^FFCA findings in /,$p' <<<"$output") + if [[ "$code" -eq 2 ]]; then - echo "$output" >&2 + echo "${report:-$output}" >&2 exit 2 fi @@ -74,4 +83,11 @@ if [[ "$code" -ne 0 ]]; then exit 0 fi +# Exit 0 with a report means warnings only. Stderr on exit 0 never reaches +# Claude, so hand the warnings over as additional context, without blocking. +if [[ -n "$report" ]]; then + jq -n --arg ctx "FFCA validator warnings (non-blocking):"$'\n'"$report" \ + '{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $ctx}}' +fi + exit 0 diff --git a/hooks/validate_layers_test.sh b/hooks/validate_layers_test.sh index 9aedae3..6b29b94 100755 --- a/hooks/validate_layers_test.sh +++ b/hooks/validate_layers_test.sh @@ -4,7 +4,8 @@ # Usage: bash hooks/validate_layers_test.sh # # The hook reads a JSON payload from stdin and either passes (exit 0, silent), skips -# (exit 0 with a "skipping" note on stderr) or blocks (exit 2). Any other exit is its +# (exit 0 with a "skipping" note on stderr), warns (exit 0 with the validator's +# warnings as additionalContext JSON on stdout) or blocks (exit 2). Any other exit is its # own outcome, never mistaken for a pass, and so is the validator itself failing to run. # Cases run against the validator fixtures in scripts/test/fixtures, so the runner needs # dart and jq installed. HOME is passed through because SDK version managers such as @@ -39,24 +40,44 @@ for util in cat dirname basename; do done ln -s "$(command -v jq)" "$STUB_DIR/jq/jq" +# A dart that prints an SDK-style notice before running the real one, as a +# first run or an SDK warning can. The notice must never reach Claude. +mkdir -p "$STUB_DIR/noisy" +printf '#!/bin/sh\necho "Resolving dependencies... (SDK notice)" >&2\nexec "%s" "$@"\n' \ + "$(command -v dart)" > "$STUB_DIR/noisy/dart" +chmod +x "$STUB_DIR/noisy/dart" + VALID="$FIXTURES/valid_workspace/features/cart/cart_domain/pubspec.yaml" VIOLATING="$FIXTURES/invalid_workspace/features/alpha/alpha_domain/pubspec.yaml" NOT_FFCA="$FIXTURES/not_ffca/packages/some_pkg/pubspec.yaml" -NOT_PUBSPEC="$FIXTURES/invalid_workspace/features/alpha/alpha_domain/lib/alpha_domain.dart" +WARNING="$FIXTURES/warning_workspace/features/nu/nu_presentation/pubspec.yaml" +SUPPRESSED="$FIXTURES/warning_workspace/features/xi/xi_data/pubspec.yaml" +NOT_PUBSPEC="$FIXTURES/invalid_workspace/features/alpha/alpha_domain/README.md" +# alpha_domain's pubspec is violating, its barrel is not: a Dart edit only reports +# findings located in the edited file. +CLEAN_DART="$FIXTURES/invalid_workspace/features/alpha/alpha_domain/lib/alpha_domain.dart" +VIOLATING_DART="$FIXTURES/invalid_workspace/features/sigma/sigma_presentation/lib/sigma_presentation.dart" +SUPPRESSED_DART="$FIXTURES/warning_workspace/features/omega/omega_presentation/lib/omega_presentation.dart" +# A deep import in a Dart file outside lib/ and bin/, which the validator does not check. +OUTSIDE_LIB_DART="$FIXTURES/invalid_workspace/features/phi/phi_presentation/test/phi_helpers.dart" payload() { printf '{"tool_name":"%s","tool_input":{"file_path":"%s"}}' "$1" "$2"; } -# Prints "pass" (exit 0, silent), "skip" (exit 0, missing-tool note), "block" (exit 2), -# "error" (exit 0 after the validator itself failed) or "exit:" for anything -# else, so a crashing hook or validator cannot pass as a pass. +# Prints "pass" (exit 0, silent), "skip" (exit 0, missing-tool note), "warn" (exit 0, +# findings in additionalContext), "block" (exit 2), "error" (exit 0 after the validator +# itself failed) or "exit:" for anything else, so a crashing hook or validator +# cannot pass as a pass. # Usage: run_hook [path] run_hook() { local input="$1" path="${2:-$PATH}" - local stderr status=0 + local stderr stdout status=0 stderr=$(printf '%s' "$input" \ | env -i PATH="$path" HOME="$HOME" CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" \ - "$BASH_BIN" "$HOOK" 2>&1 >/dev/null) || status=$? - if [ "$status" -eq 2 ]; then + "$BASH_BIN" "$HOOK" 2>&1 >"$STUB_DIR/stdout") || status=$? + stdout=$(cat "$STUB_DIR/stdout") + if [[ "$stdout$stderr" == *"SDK notice"* ]]; then + echo "exit:noise-forwarded" + elif [ "$status" -eq 2 ]; then echo "block" elif [ "$status" -ne 0 ]; then echo "exit:$status" @@ -64,6 +85,13 @@ run_hook() { echo "error" elif [[ "$stderr" == *"not found, skipping"* ]]; then echo "skip" + elif [ -n "$stdout" ]; then + if jq -r '.hookSpecificOutput.additionalContext // empty' <<<"$stdout" \ + | grep -q 'FFCA findings'; then + echo "warn" + else + echo "exit:malformed" + fi else echo "pass" fi @@ -94,7 +122,7 @@ assert_outcome pass "null tool_name" "{\"tool_name\":null, echo "" echo "--- Files the hook does not validate (pass) ---" -assert_outcome pass "non-pubspec file" "$(payload Edit "$NOT_PUBSPEC")" +assert_outcome pass "neither pubspec nor Dart file" "$(payload Edit "$NOT_PUBSPEC")" assert_outcome pass "missing file_path" '{"tool_name":"Write","tool_input":{}}' assert_outcome pass "pubspec in a non-FFCA repo" "$(payload Edit "$NOT_FFCA")" @@ -105,6 +133,21 @@ assert_outcome pass "Write on a valid pubspec" "$(payload Write "$VA assert_outcome block "Edit on a violating pubspec" "$(payload Edit "$VIOLATING")" assert_outcome block "Write on a violating pubspec" "$(payload Write "$VIOLATING")" assert_outcome block "MultiEdit on a violating pubspec" "$(payload MultiEdit "$VIOLATING")" +assert_outcome warn "Edit on a pubspec with a warning" "$(payload Edit "$WARNING")" +assert_outcome pass "Edit on a suppressed finding" "$(payload Edit "$SUPPRESSED")" + +echo "" +echo "--- FFCA Dart edits ---" +assert_outcome block "Edit on a violating import" "$(payload Edit "$VIOLATING_DART")" +assert_outcome pass "Edit on a clean file in a violating package" "$(payload Edit "$CLEAN_DART")" +assert_outcome pass "Edit on an import suppressed in the file" "$(payload Edit "$SUPPRESSED_DART")" +assert_outcome pass "Edit on a Dart file outside lib/ and bin/" "$(payload Edit "$OUTSIDE_LIB_DART")" + +echo "" +echo "--- dart run noise is never forwarded ---" +assert_outcome pass "notice on a clean pubspec" "$(payload Edit "$VALID")" "$STUB_DIR/noisy:$PATH" +assert_outcome warn "notice before a warning" "$(payload Edit "$WARNING")" "$STUB_DIR/noisy:$PATH" +assert_outcome block "notice before an error" "$(payload Edit "$VIOLATING")" "$STUB_DIR/noisy:$PATH" echo "" echo "--- Missing prerequisites (skip) ---" diff --git a/scripts/analysis_options.yaml b/scripts/analysis_options.yaml new file mode 100644 index 0000000..edfb184 --- /dev/null +++ b/scripts/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + exclude: + # Fixture packages import workspace packages that are never resolved. + - test/fixtures/** diff --git a/scripts/test/fixtures/invalid_workspace/apps/mobile_app/lib/mobile_app.dart b/scripts/test/fixtures/invalid_workspace/apps/mobile_app/lib/mobile_app.dart new file mode 100644 index 0000000..6f192fd --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/apps/mobile_app/lib/mobile_app.dart @@ -0,0 +1 @@ +// Barrel of mobile_app. diff --git a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/lib/alpha_data.dart b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/lib/alpha_data.dart new file mode 100644 index 0000000..7391c87 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_data/lib/alpha_data.dart @@ -0,0 +1 @@ +// Barrel of alpha_data. diff --git a/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/lib/alpha_domain.dart b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/lib/alpha_domain.dart new file mode 100644 index 0000000..55c67ce --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/alpha/alpha_domain/lib/alpha_domain.dart @@ -0,0 +1 @@ +import 'package:alpha_data/alpha_data.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/lib/beta_data.dart b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/lib/beta_data.dart new file mode 100644 index 0000000..b0ebc66 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_data/lib/beta_data.dart @@ -0,0 +1,2 @@ +import 'package:beta_domain/beta_domain.dart'; +import 'package:beta_presentation/beta_presentation.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/lib/beta_domain.dart b/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/lib/beta_domain.dart new file mode 100644 index 0000000..2fcde69 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_domain/lib/beta_domain.dart @@ -0,0 +1 @@ +// Barrel of beta_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/lib/beta_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/lib/beta_presentation.dart new file mode 100644 index 0000000..84cff8e --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/lib/beta_presentation.dart @@ -0,0 +1 @@ +import 'package:beta_domain/beta_domain.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml index 9aeab72..5b4124d 100644 --- a/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/features/beta/beta_presentation/pubspec.yaml @@ -2,3 +2,5 @@ name: beta_presentation resolution: workspace environment: sdk: ^3.11.0 +dependencies: + beta_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/chi/chi_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/chi/chi_domain/pubspec.yaml new file mode 100644 index 0000000..3ea3a2a --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/chi/chi_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: chi_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/lib/delta_data.dart b/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/lib/delta_data.dart new file mode 100644 index 0000000..0437200 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/delta/delta_data/lib/delta_data.dart @@ -0,0 +1 @@ +import 'package:delta_domain/delta_domain.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/lib/delta_domain.dart b/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/lib/delta_domain.dart new file mode 100644 index 0000000..c71da08 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/delta/delta_domain/lib/delta_domain.dart @@ -0,0 +1 @@ +// Barrel of delta_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/lib/epsilon_domain.dart b/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/lib/epsilon_domain.dart new file mode 100644 index 0000000..da4dd18 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/epsilon/epsilon_domain/lib/epsilon_domain.dart @@ -0,0 +1 @@ +import 'package:zeta_domain/zeta_domain.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/lib/eta_data.dart b/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/lib/eta_data.dart new file mode 100644 index 0000000..a75d5d8 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/eta/eta_data/lib/eta_data.dart @@ -0,0 +1,2 @@ +import 'package:eta_domain/eta_domain.dart'; +import 'package:mobile_app/mobile_app.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/lib/eta_domain.dart b/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/lib/eta_domain.dart new file mode 100644 index 0000000..e556e4c --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/eta/eta_domain/lib/eta_domain.dart @@ -0,0 +1 @@ +// Barrel of eta_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/lib/gamma_domain.dart b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/lib/gamma_domain.dart new file mode 100644 index 0000000..da9b1ec --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_domain/lib/gamma_domain.dart @@ -0,0 +1 @@ +// Barrel of gamma_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/lib/gamma_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/lib/gamma_presentation.dart new file mode 100644 index 0000000..62127fc --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/gamma/gamma_presentation/lib/gamma_presentation.dart @@ -0,0 +1,2 @@ +import 'package:gamma_domain/gamma_domain.dart'; +import 'package:delta_data/delta_data.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/lib/iota_data.dart b/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/lib/iota_data.dart new file mode 100644 index 0000000..1c6e938 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/iota/iota_data/lib/iota_data.dart @@ -0,0 +1 @@ +// Barrel of iota_data. diff --git a/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/lib/iota_domain.dart b/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/lib/iota_domain.dart new file mode 100644 index 0000000..778970c --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/iota/iota_domain/lib/iota_domain.dart @@ -0,0 +1 @@ +// Barrel of iota_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/lib/kappa_data.dart b/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/lib/kappa_data.dart new file mode 100644 index 0000000..828e23b --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/kappa/kappa_data/lib/kappa_data.dart @@ -0,0 +1 @@ +// Barrel of kappa_data. diff --git a/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/lib/lambda_domain.dart b/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/lib/lambda_domain.dart new file mode 100644 index 0000000..d1d6046 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/lambda/lambda_domain/lib/lambda_domain.dart @@ -0,0 +1 @@ +import 'package:flutter/flutter.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/lib/mu_data.dart b/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/lib/mu_data.dart new file mode 100644 index 0000000..2b4b587 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/mu/mu_data/lib/mu_data.dart @@ -0,0 +1 @@ +// Barrel of mu_data. diff --git a/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/lib/mu_domain.dart b/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/lib/mu_domain.dart new file mode 100644 index 0000000..b1f70dd --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/mu/mu_domain/lib/mu_domain.dart @@ -0,0 +1 @@ +// Barrel of mu_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/lib/omicron_data.dart b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/lib/omicron_data.dart new file mode 100644 index 0000000..f060f65 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/lib/omicron_data.dart @@ -0,0 +1 @@ +// Barrel of omicron_data. diff --git a/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/pubspec.yaml new file mode 100644 index 0000000..754cc8c --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_data/pubspec.yaml @@ -0,0 +1,6 @@ +# A suppression with no reason suppresses nothing and is itself an error. +# ffca-ignore: data-missing-own-domain +name: omicron_data +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/lib/omicron_domain.dart b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/lib/omicron_domain.dart new file mode 100644 index 0000000..02b983e --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/lib/omicron_domain.dart @@ -0,0 +1 @@ +// Barrel of omicron_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/pubspec.yaml new file mode 100644 index 0000000..fda88ce --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/omicron/omicron_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: omicron_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/models/phi.dart b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/models/phi.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/models/phi.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/phi_domain.dart b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/phi_domain.dart new file mode 100644 index 0000000..a7c741f --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/lib/phi_domain.dart @@ -0,0 +1 @@ +// Barrel of phi_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/pubspec.yaml new file mode 100644 index 0000000..3e7306d --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: phi_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/bin/phi_tool.dart b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/bin/phi_tool.dart new file mode 100644 index 0000000..d76a099 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/bin/phi_tool.dart @@ -0,0 +1 @@ +import 'package:phi_domain/models/phi.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/lib/phi_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/lib/phi_presentation.dart new file mode 100644 index 0000000..f0605db --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/lib/phi_presentation.dart @@ -0,0 +1,5 @@ +// Reaches past the phi_domain barrel. +import 'package:phi_domain/models/phi.dart'; +export 'package:phi_domain/models/phi.dart'; +import 'package:phi_domain/phi_domain.dart' + if (dart.library.io) 'package:phi_domain/models/phi.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/pubspec.yaml new file mode 100644 index 0000000..ce686e9 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/pubspec.yaml @@ -0,0 +1,6 @@ +name: phi_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + phi_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/test/phi_helpers.dart b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/test/phi_helpers.dart new file mode 100644 index 0000000..ab048b8 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/phi/phi_presentation/test/phi_helpers.dart @@ -0,0 +1,2 @@ +// Tests may reach into a package; only lib/ and bin/ are checked. +import 'package:phi_domain/models/phi.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/lib/pi_domain.dart b/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/lib/pi_domain.dart new file mode 100644 index 0000000..3de538b --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/lib/pi_domain.dart @@ -0,0 +1 @@ +// Barrel of pi_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/pubspec.yaml new file mode 100644 index 0000000..6c0e958 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/pi/pi_domain/pubspec.yaml @@ -0,0 +1,6 @@ +# A suppression naming a rule that does not exist. +# ffca-ignore: domain-uses-widgets — not a rule ID +name: pi_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/lib/psi_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/lib/psi_presentation.dart new file mode 100644 index 0000000..aa2d66e --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/lib/psi_presentation.dart @@ -0,0 +1,3 @@ +// ffca-ignore: deep-import +// ffca-ignore: unused-dependency — Dart files cannot suppress pubspec rules +import 'package:phi_domain/models/phi.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/pubspec.yaml new file mode 100644 index 0000000..e1e7a2c --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/psi/psi_presentation/pubspec.yaml @@ -0,0 +1,6 @@ +name: psi_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + phi_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/lib/sigma_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/lib/sigma_presentation.dart new file mode 100644 index 0000000..b37e481 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/lib/sigma_presentation.dart @@ -0,0 +1,2 @@ +// Imports another feature's primary barrel instead of a dedicated one. +import 'package:upsilon_presentation/upsilon_presentation.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/pubspec.yaml new file mode 100644 index 0000000..108d158 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/sigma/sigma_presentation/pubspec.yaml @@ -0,0 +1,6 @@ +name: sigma_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + upsilon_presentation: any diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/dtos/tau_dto.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/dtos/tau_dto.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/dtos/tau_dto.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/tau_remote_data_source.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/tau_remote_data_source.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/data_sources/tau_remote_data_source/tau_remote_data_source.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/mappers/tau_mapper.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/mappers/tau_mapper.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/mappers/tau_mapper.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/repositories/tau_repository.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/repositories/tau_repository.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/repositories/tau_repository.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_data.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_data.dart new file mode 100644 index 0000000..30f1982 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_data.dart @@ -0,0 +1,8 @@ +import 'package:tau_domain/tau_domain.dart'; + +// Exports its DTOs and mappers along with the repository and data source. +export 'data_sources/tau_remote_data_source/dtos/tau_dto.dart'; +export 'mappers/tau_mapper.dart'; +export 'repositories/tau_repository.dart'; +// The app constructs the data source, so exporting it is allowed. +export 'data_sources/tau_remote_data_source/tau_remote_data_source.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_wire.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_wire.dart new file mode 100644 index 0000000..342b768 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/tau_wire.dart @@ -0,0 +1 @@ +export 'wire/wire.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/wire/wire.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/wire/wire.dart new file mode 100644 index 0000000..750be25 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/lib/wire/wire.dart @@ -0,0 +1 @@ +export '../mappers/tau_mapper.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/pubspec.yaml new file mode 100644 index 0000000..ef9b357 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_data/pubspec.yaml @@ -0,0 +1,6 @@ +name: tau_data +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + tau_domain: any diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/lib/tau_domain.dart b/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/lib/tau_domain.dart new file mode 100644 index 0000000..82f8ad4 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/lib/tau_domain.dart @@ -0,0 +1 @@ +// Barrel of tau_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/pubspec.yaml new file mode 100644 index 0000000..88fcffa --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/tau/tau_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: tau_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/lib/theta_data.dart b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/lib/theta_data.dart new file mode 100644 index 0000000..e77395e --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/theta/theta_data/lib/theta_data.dart @@ -0,0 +1,2 @@ +import 'package:theta_domain/theta_domain.dart'; +import 'package:delta_data/delta_data.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/lib/theta_domain.dart b/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/lib/theta_domain.dart new file mode 100644 index 0000000..5e72892 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/theta/theta_domain/lib/theta_domain.dart @@ -0,0 +1 @@ +// Barrel of theta_domain. diff --git a/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/lib/upsilon_presentation.dart b/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/lib/upsilon_presentation.dart new file mode 100644 index 0000000..d31af98 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/lib/upsilon_presentation.dart @@ -0,0 +1,3 @@ +// Imports workspace packages its pubspec does not declare. +import 'package:delta_data/delta_data.dart'; +import 'package:gamma_domain/gamma_domain.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/pubspec.yaml new file mode 100644 index 0000000..fc9b6b6 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/upsilon/upsilon_presentation/pubspec.yaml @@ -0,0 +1,4 @@ +name: upsilon_presentation +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/lib/zeta_domain.dart b/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/lib/zeta_domain.dart new file mode 100644 index 0000000..b4dd3f9 --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/features/zeta/zeta_domain/lib/zeta_domain.dart @@ -0,0 +1 @@ +import 'package:epsilon_domain/epsilon_domain.dart'; diff --git a/scripts/test/fixtures/invalid_workspace/pubspec.yaml b/scripts/test/fixtures/invalid_workspace/pubspec.yaml index ad8d2dc..0a3c021 100644 --- a/scripts/test/fixtures/invalid_workspace/pubspec.yaml +++ b/scripts/test/fixtures/invalid_workspace/pubspec.yaml @@ -8,6 +8,7 @@ workspace: - features/beta/beta_data - features/beta/beta_domain - features/beta/beta_presentation + - features/chi/chi_domain - features/delta/delta_data - features/delta/delta_domain - features/epsilon/epsilon_domain @@ -21,8 +22,18 @@ workspace: - features/lambda/lambda_domain - features/mu/mu_data - features/mu/mu_domain + - features/omicron/omicron_data + - features/omicron/omicron_domain - features/orders/orders_service + - features/phi/phi_domain + - features/phi/phi_presentation + - features/pi/pi_domain + - features/psi/psi_presentation + - features/sigma/sigma_presentation + - features/tau/tau_data + - features/tau/tau_domain - features/theta/theta_data - features/theta/theta_domain + - features/upsilon/upsilon_presentation - features/zeta/zeta_domain - shared/bad_shared diff --git a/scripts/test/fixtures/invalid_workspace/shared/bad_shared/lib/bad_shared.dart b/scripts/test/fixtures/invalid_workspace/shared/bad_shared/lib/bad_shared.dart new file mode 100644 index 0000000..2181ccc --- /dev/null +++ b/scripts/test/fixtures/invalid_workspace/shared/bad_shared/lib/bad_shared.dart @@ -0,0 +1 @@ +import 'package:alpha_domain/alpha_domain.dart'; diff --git a/scripts/test/fixtures/valid_workspace/apps/mobile_app/lib/mobile_app.dart b/scripts/test/fixtures/valid_workspace/apps/mobile_app/lib/mobile_app.dart new file mode 100644 index 0000000..05b52e2 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/apps/mobile_app/lib/mobile_app.dart @@ -0,0 +1,5 @@ +import 'package:product_presentation/product_presentation.dart'; +import 'package:cart_presentation/cart_presentation.dart'; +import "package:ideas_presentation/ideas_presentation.dart" deferred as ideas; +import 'package:ui_kit/ui_kit.dart'; +import 'package:api_client/api_client.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/lib/auth_data_firebase.dart b/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/lib/auth_data_firebase.dart new file mode 100644 index 0000000..34b8a35 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/auth/auth_data_firebase/lib/auth_data_firebase.dart @@ -0,0 +1 @@ +import 'package:auth_domain/auth_domain.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/lib/auth_domain.dart b/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/lib/auth_domain.dart new file mode 100644 index 0000000..40e7820 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/auth/auth_domain/lib/auth_domain.dart @@ -0,0 +1 @@ +// Barrel of auth_domain. diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_data/lib/cart_data.dart b/scripts/test/fixtures/valid_workspace/features/cart/cart_data/lib/cart_data.dart new file mode 100644 index 0000000..bb1c498 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_data/lib/cart_data.dart @@ -0,0 +1 @@ +import 'package:cart_domain/cart_domain.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/lib/cart_domain.dart b/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/lib/cart_domain.dart new file mode 100644 index 0000000..b9fad3f --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_domain/lib/cart_domain.dart @@ -0,0 +1,2 @@ +// A directive spanning two lines. +export 'package:product_domain/product_domain.dart' show Product; diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_badge.dart b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_badge.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_badge.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_presentation.dart b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_presentation.dart new file mode 100644 index 0000000..2d9d1b3 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/cart/cart_presentation/lib/cart_presentation.dart @@ -0,0 +1,3 @@ +import 'package:cart_domain/cart_domain.dart'; +import 'package:product_domain/product_domain.dart'; +import 'package:ui_kit/ui_kit.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/lib/ideas_presentation.dart b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/lib/ideas_presentation.dart new file mode 100644 index 0000000..ef5720d --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/ideas/ideas_presentation/lib/ideas_presentation.dart @@ -0,0 +1,5 @@ +import 'package:cart_domain/cart_domain.dart'; +// Another feature's widget comes through its dedicated subfeature barrel. +import 'package:cart_presentation/cart_badge.dart'; +import 'package:product_domain/product_domain.dart'; +import 'package:ui_kit/ui_kit.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/product_data.dart b/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/product_data.dart new file mode 100644 index 0000000..08a45c7 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/product_data.dart @@ -0,0 +1,4 @@ +import 'package:product_domain/product_domain.dart'; + +// The repository implementation is public, its data sources are not. +export 'repositories/products_repository.dart'; diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/repositories/products_repository.dart b/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/repositories/products_repository.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/product/product_data/lib/repositories/products_repository.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_domain/lib/product_domain.dart b/scripts/test/fixtures/valid_workspace/features/product/product_domain/lib/product_domain.dart new file mode 100644 index 0000000..bc3595d --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/product/product_domain/lib/product_domain.dart @@ -0,0 +1 @@ +// Barrel of product_domain. diff --git a/scripts/test/fixtures/valid_workspace/features/product/product_presentation/lib/product_presentation.dart b/scripts/test/fixtures/valid_workspace/features/product/product_presentation/lib/product_presentation.dart new file mode 100644 index 0000000..7bfad37 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/features/product/product_presentation/lib/product_presentation.dart @@ -0,0 +1,7 @@ +import 'package:product_domain/product_domain.dart'; +import 'package:ui_kit/ui_kit.dart'; + +// An import inside a string is not a directive. +const sample = ''' +import 'package:product_domain/models/product.dart'; +'''; diff --git a/scripts/test/fixtures/valid_workspace/shared/ui_kit/lib/ui_kit.dart b/scripts/test/fixtures/valid_workspace/shared/ui_kit/lib/ui_kit.dart new file mode 100644 index 0000000..3677a22 --- /dev/null +++ b/scripts/test/fixtures/valid_workspace/shared/ui_kit/lib/ui_kit.dart @@ -0,0 +1 @@ +// Barrel of ui_kit. diff --git a/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/kappa_domain.dart b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/kappa_domain.dart new file mode 100644 index 0000000..ef74a09 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/kappa_domain.dart @@ -0,0 +1 @@ +// Barrel of kappa_domain. diff --git a/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/models/kappa.dart b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/models/kappa.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/lib/models/kappa.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/pubspec.yaml new file mode 100644 index 0000000..2c02724 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: kappa_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/lib/kappa_presentation.dart b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/lib/kappa_presentation.dart new file mode 100644 index 0000000..0bb4750 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/lib/kappa_presentation.dart @@ -0,0 +1 @@ +import 'package:kappa_domain/models/kappa.dart'; diff --git a/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/pubspec.yaml new file mode 100644 index 0000000..70f4d5e --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/kappa/kappa_presentation/pubspec.yaml @@ -0,0 +1,8 @@ +# A pubspec suppression covers the deep import in lib/. +# ffca-ignore: deep-import — the model is not exported from kappa_domain yet +name: kappa_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + kappa_domain: any diff --git a/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/lib/nu_domain.dart b/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/lib/nu_domain.dart new file mode 100644 index 0000000..7a2cf1f --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/lib/nu_domain.dart @@ -0,0 +1 @@ +// Barrel of nu_domain. diff --git a/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/pubspec.yaml new file mode 100644 index 0000000..a0f6da9 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/nu/nu_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: nu_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/lib/nu_presentation.dart b/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/lib/nu_presentation.dart new file mode 100644 index 0000000..2d71251 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/lib/nu_presentation.dart @@ -0,0 +1 @@ +import 'package:rho_domain/rho_domain.dart'; diff --git a/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/pubspec.yaml new file mode 100644 index 0000000..62f802d --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/nu/nu_presentation/pubspec.yaml @@ -0,0 +1,8 @@ +# Presentation layer that does not depend on its own feature's domain. It +# depends on rho_domain, so an edit to rho_domain must not repeat its warning. +name: nu_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + rho_domain: any diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_data/lib/omega_data.dart b/scripts/test/fixtures/warning_workspace/features/omega/omega_data/lib/omega_data.dart new file mode 100644 index 0000000..84b1a71 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_data/lib/omega_data.dart @@ -0,0 +1 @@ +// Declares omega_domain but never imports it. diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_data/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/omega/omega_data/pubspec.yaml new file mode 100644 index 0000000..78552f3 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_data/pubspec.yaml @@ -0,0 +1,6 @@ +name: omega_data +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + omega_domain: any diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/models/omega.dart b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/models/omega.dart new file mode 100644 index 0000000..b335484 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/models/omega.dart @@ -0,0 +1 @@ +// Fixture source file. diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/omega_domain.dart b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/omega_domain.dart new file mode 100644 index 0000000..b4cc1cb --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/lib/omega_domain.dart @@ -0,0 +1 @@ +// Barrel of omega_domain. diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/pubspec.yaml new file mode 100644 index 0000000..15f429f --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: omega_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/lib/omega_presentation.dart b/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/lib/omega_presentation.dart new file mode 100644 index 0000000..6d41b75 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/lib/omega_presentation.dart @@ -0,0 +1,2 @@ +// ffca-ignore: deep-import — the model is not exported from omega_domain yet +import 'package:omega_domain/models/omega.dart'; diff --git a/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/pubspec.yaml new file mode 100644 index 0000000..58dde0c --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/omega/omega_presentation/pubspec.yaml @@ -0,0 +1,6 @@ +name: omega_presentation +resolution: workspace +environment: + sdk: ^3.11.0 +dependencies: + omega_domain: any diff --git a/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/lib/rho_domain.dart b/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/lib/rho_domain.dart new file mode 100644 index 0000000..55a2c20 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/lib/rho_domain.dart @@ -0,0 +1 @@ +// Barrel of rho_domain. diff --git a/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/pubspec.yaml new file mode 100644 index 0000000..223a241 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/rho/rho_domain/pubspec.yaml @@ -0,0 +1,6 @@ +# A suppression that no longer matches any finding. +# ffca-ignore: domain-uses-flutter — Flutter dependency already removed +name: rho_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/features/xi/xi_data/lib/xi_data.dart b/scripts/test/fixtures/warning_workspace/features/xi/xi_data/lib/xi_data.dart new file mode 100644 index 0000000..98354ef --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/xi/xi_data/lib/xi_data.dart @@ -0,0 +1 @@ +// Barrel of xi_data. diff --git a/scripts/test/fixtures/warning_workspace/features/xi/xi_data/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/xi/xi_data/pubspec.yaml new file mode 100644 index 0000000..5819aef --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/xi/xi_data/pubspec.yaml @@ -0,0 +1,6 @@ +# An error suppressed with a reason, using the hyphen separator with no space. +# ffca-ignore: data-missing-own-domain- legacy repository, moving to xi_domain +name: xi_data +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/lib/xi_domain.dart b/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/lib/xi_domain.dart new file mode 100644 index 0000000..d44f7fc --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/lib/xi_domain.dart @@ -0,0 +1 @@ +// Barrel of xi_domain. diff --git a/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/pubspec.yaml b/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/pubspec.yaml new file mode 100644 index 0000000..87d5eab --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/features/xi/xi_domain/pubspec.yaml @@ -0,0 +1,4 @@ +name: xi_domain +resolution: workspace +environment: + sdk: ^3.11.0 diff --git a/scripts/test/fixtures/warning_workspace/pubspec.yaml b/scripts/test/fixtures/warning_workspace/pubspec.yaml new file mode 100644 index 0000000..73bc7f0 --- /dev/null +++ b/scripts/test/fixtures/warning_workspace/pubspec.yaml @@ -0,0 +1,15 @@ +# Warnings and suppressed errors only: --all must exit 0. +name: _warning_workspace +environment: + sdk: ^3.11.0 +workspace: + - features/kappa/kappa_domain + - features/kappa/kappa_presentation + - features/nu/nu_domain + - features/nu/nu_presentation + - features/omega/omega_data + - features/omega/omega_domain + - features/omega/omega_presentation + - features/rho/rho_domain + - features/xi/xi_data + - features/xi/xi_domain diff --git a/scripts/test/validate_layers_test.dart b/scripts/test/validate_layers_test.dart index 87ea32b..9c91d32 100644 --- a/scripts/test/validate_layers_test.dart +++ b/scripts/test/validate_layers_test.dart @@ -12,6 +12,7 @@ const _script = 'validate_layers.dart'; const _valid = 'test/fixtures/valid_workspace'; const _invalid = 'test/fixtures/invalid_workspace'; const _notFfca = 'test/fixtures/not_ffca'; +const _warning = 'test/fixtures/warning_workspace'; /// Runs the validator and returns (exitCode, stdout, stderr). ({int code, String out, String err}) _run(List args, {String? cwd}) { @@ -38,6 +39,14 @@ String _absScript(String? cwd) => String _pubspec(String workspaceRelPath, String pkgPath) => '$workspaceRelPath/$pkgPath/pubspec.yaml'; +/// The findings reported under [path], up to the next file's header. +String _group(String err, String path) { + final start = err.indexOf('FFCA findings in $path:'); + if (start == -1) return ''; + final next = err.indexOf('FFCA findings in ', start + 1); + return err.substring(start, next == -1 ? err.length : next); +} + void main() { group('valid workspace', () { test('passes in --all mode', () { @@ -186,9 +195,150 @@ void main() { }); test('every violation includes a fix line', () { - // One arrow per violation; there are at least the eleven planted rules. + // One arrow per violation; there are at least the seventeen planted + // rules. final arrows = '→'.allMatches(err).length; - expect(arrows, greaterThanOrEqualTo(11)); + expect(arrows, greaterThanOrEqualTo(17)); + }); + + test('every finding carries a rule ID', () { + final findings = err.split('\n').where((l) => l.startsWith(' ✗')); + expect(findings, isNotEmpty); + for (final line in findings) { + expect(line, matches(RegExp(r'^ ✗ \[[a-z-]+\] '))); + } + }); + + test('ends with a summary line', () { + expect(err, contains(RegExp(r'FFCA: \d+ errors, 0 warnings\.'))); + }); + + test('feature package without a primary barrel', () { + expect( + err, + contains('[missing-primary-barrel] chi_domain has no primary barrel ' + 'lib/chi_domain.dart'), + ); + }); + + test('deep import past a barrel, reported in the Dart file', () { + expect( + err, + contains('FFCA findings in features/phi/phi_presentation/lib/' + 'phi_presentation.dart:'), + ); + expect( + err, + contains('[deep-import] line 2 imports ' + 'package:phi_domain/models/phi.dart'), + ); + }); + + test("another feature's primary presentation barrel", () { + expect( + err, + contains('[cross-feature-primary-barrel] line 2 imports the primary ' + 'barrel of upsilon_presentation'), + ); + }); + + test('data barrel exporting its DTOs and mappers', () { + expect( + err, + contains('[data-barrel-exports-internals] line 4 exports ' + 'data_sources/tau_remote_data_source/dtos/tau_dto.dart'), + ); + expect( + err, + contains('[data-barrel-exports-internals] line 5 exports ' + 'mappers/tau_mapper.dart'), + ); + expect(err, isNot(contains('repositories/tau_repository.dart'))); + // The app constructs data sources, so a data barrel may export them. + expect( + err, + isNot(contains('tau_remote_data_source/tau_remote_data_source.dart')), + ); + }); + + test('an undeclared import that breaks a layer rule', () { + expect( + err, + contains('[presentation-depends-on-data] line 2 imports ' + 'package:delta_data/delta_data.dart: upsilon_presentation depends ' + 'on delta_data'), + ); + }); + + test('an undeclared import of an allowed layer', () { + expect( + err, + contains('[undeclared-dependency] line 3 imports ' + 'package:gamma_domain/gamma_domain.dart, but gamma_domain is not ' + 'under dependencies'), + ); + }); + + test('a deep export and both URIs of a conditional import', () { + final phi = _group( + err, + 'features/phi/phi_presentation/lib/phi_presentation.dart', + ); + expect( + phi, + contains('line 3 exports package:phi_domain/models/phi.dart'), + ); + // The second URI, on the directive's continuation line. + expect( + phi, + contains('line 5 imports package:phi_domain/models/phi.dart'), + ); + }); + + test('scans bin/ as well as lib/', () { + expect( + _group(err, 'features/phi/phi_presentation/bin/phi_tool.dart'), + contains('[deep-import] line 1 imports'), + ); + }); + + test('a data barrel re-exporting a mapper through another file', () { + expect( + _group(err, 'features/tau/tau_data/lib/tau_wire.dart'), + contains('line 1 exports wire/wire.dart, which re-exports ' + 'lib/mappers/tau_mapper.dart'), + ); + }); + + test('invalid Dart-file suppressions, reported in that file', () { + final psi = _group( + err, + 'features/psi/psi_presentation/lib/psi_presentation.dart', + ); + expect( + psi, + contains('[suppression-missing-reason] ffca-ignore: ' + 'deep-import on line 1')); + expect( + psi, + contains('names "unused-dependency", which is reported on ' + 'the pubspec, so a Dart file cannot suppress it')); + // Neither comment suppresses the deep import. + expect(psi, contains('[deep-import] line 3 imports')); + }); + + test('ignores Dart files outside lib/ and bin/', () { + expect(err, isNot(contains('phi_presentation/test/'))); + }); + + test('a suppression without a reason suppresses nothing', () { + expect(err, contains('[suppression-missing-reason]')); + expect(err, contains('omicron_data does not depend on omicron_domain')); + }); + + test('a suppression naming an unknown rule', () { + expect(err, contains('[suppression-unknown-rule]')); + expect(err, contains('"domain-uses-widgets"')); }); }); @@ -239,9 +389,9 @@ void main() { 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'), + contains('FFCA findings in features/epsilon/epsilon_domain/' + 'pubspec.yaml:\n ✗ [dependency-cycle] dependency cycle ' + 'detected: epsilon_domain → zeta_domain → epsilon_domain'), reason: pkg, ); } @@ -255,6 +405,8 @@ void main() { ]); expect(r.code, 2); expect(r.err, contains('gamma_presentation depends on delta_data')); + // gamma_domain is read too, or this warning would appear. + expect(r.err, isNot(contains('presentation-missing-own-domain'))); }); test('flags presentation depending on a data layer', () { @@ -314,6 +466,38 @@ void main() { expect(r.err, contains('features/kappa/ has no kappa_domain')); }); + test("a pubspec edit ignores a dependent's Dart-file findings", () { + // phi_domain is clean; its dependent phi_presentation has deep imports, + // and psi_presentation has invalid suppressions. Neither changes with + // an edit to phi_domain's pubspec. + final r = _run([ + '--file', + _pubspec(_invalid, 'features/phi/phi_domain'), + ]); + expect(r.code, 0, reason: r.err); + expect(r.err, isEmpty); + }); + + test('a pubspec edit does not require a barrel before lib/ exists', () { + // chi_domain has a pubspec and no lib/ yet, as when a package is being + // scaffolded. --all still reports its missing barrel. + final r = _run([ + '--file', + _pubspec(_invalid, 'features/chi/chi_domain'), + ]); + expect(r.code, 0, reason: r.err); + expect(r.err, isEmpty); + }); + + test('a pubspec edit reports Dart-file findings in the package', () { + final r = _run([ + '--file', + _pubspec(_invalid, 'features/phi/phi_presentation'), + ]); + expect(r.code, 2); + expect(r.err, contains('[deep-import]')); + }); + test('flags a domain depending on the Flutter SDK', () { final r = _run([ '--file', @@ -341,6 +525,180 @@ void main() { }); }); + group('severities and suppressions', () { + test('--all exits 0 when every finding is a warning or suppressed', () { + final r = _run(['--all', _warning]); + expect(r.code, 0, reason: r.err); + expect(r.err, contains('FFCA: 0 errors, 3 warnings, 3 suppressed.')); + }); + + test('reports a warning with its rule ID', () { + final r = _run(['--all', _warning]); + expect( + r.err, + contains('⚠ [presentation-missing-own-domain] nu_presentation does ' + 'not depend on nu_domain'), + ); + }); + + test('does not print a suppressed finding', () { + final r = _run(['--all', _warning]); + expect(r.err, isNot(contains('xi_data'))); + }); + + test('a Dart-file suppression covers a finding in that file', () { + final r = _run(['--all', _warning]); + expect(r.err, isNot(contains('omega_presentation'))); + }); + + test('a pubspec suppression covers a finding in a Dart file', () { + final r = _run(['--all', _warning]); + expect(r.err, isNot(contains('kappa_presentation'))); + }); + + test('warns about a dependency that is never imported', () { + final r = _run(['--all', _warning]); + expect( + r.err, + contains('⚠ [unused-dependency] omega_data declares omega_domain but ' + 'never imports it'), + ); + }); + + test('reports an unused suppression in --all mode', () { + final r = _run(['--all', _warning]); + expect( + r.err, + contains('⚠ [unused-suppression] ffca-ignore: domain-uses-flutter on ' + 'line 2 of the rho_domain pubspec matches no finding'), + ); + }); + + test('incremental mode exits 0 on a warning and still prints it', () { + final r = _run([ + '--file', + _pubspec(_warning, 'features/nu/nu_presentation'), + ]); + expect(r.code, 0); + expect(r.err, contains('[presentation-missing-own-domain]')); + }); + + test('incremental mode is silent on a suppressed finding', () { + final r = _run(['--file', _pubspec(_warning, 'features/xi/xi_data')]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + + test("a pubspec edit does not repeat a dependent's warnings", () { + // nu_presentation depends on rho_domain and has a warning of its own, + // which an edit to rho_domain cannot have caused. + final r = _run([ + '--file', + _pubspec(_warning, 'features/rho/rho_domain'), + ]); + expect(r.code, 0); + expect(r.err, isNot(contains('nu_presentation'))); + }); + + test('accepts a hyphen separator with no space before it', () { + // xi_data writes `data-missing-own-domain- legacy repository…`. + final r = _run(['--all', _warning]); + expect(r.err, isNot(contains('suppression-unknown-rule'))); + expect(r.err, isNot(contains('xi_data'))); + }); + + test('incremental mode does not report unused suppressions', () { + final r = _run([ + '--file', + _pubspec(_warning, 'features/rho/rho_domain'), + ]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + }); + + group('Dart file mode', () { + String dart(String workspace, String pkg, String file) => + '$workspace/$pkg/$file'; + + test('blocks on a finding in the edited file', () { + final r = _run([ + '--file', + dart( + _invalid, + 'features/sigma/sigma_presentation', + 'lib/sigma_presentation.dart', + ), + ]); + expect(r.code, 2); + expect(r.err, contains('[cross-feature-primary-barrel]')); + }); + + test('ignores findings elsewhere in the package', () { + // alpha_domain's pubspec has a domain -> data violation, but its barrel + // imports nothing wrong. + final r = _run([ + '--file', + dart(_invalid, 'features/alpha/alpha_domain', 'lib/alpha_domain.dart'), + ]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + + test('is silent on a finding suppressed in the file', () { + final r = _run([ + '--file', + dart( + _warning, + 'features/omega/omega_presentation', + 'lib/omega_presentation.dart', + ), + ]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + + test('passes directives the scanner must read correctly', () { + // An import inside a multi-line string after the first declaration, an + // export spanning two lines, and a double-quoted deferred import. + for (final file in [ + 'features/product/product_presentation/lib/product_presentation.dart', + 'features/cart/cart_domain/lib/cart_domain.dart', + 'apps/mobile_app/lib/mobile_app.dart', + ]) { + final r = _run(['--file', '$_valid/$file']); + expect(r.code, 0, reason: '$file: ${r.err}'); + expect(r.err, isEmpty, reason: file); + } + }); + + test('passes a cross-feature import of a dedicated barrel', () { + final r = _run([ + '--file', + dart( + _valid, + 'features/ideas/ideas_presentation', + 'lib/ideas_presentation.dart', + ), + ]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + + test('skips a Dart file outside lib/ and bin/', () { + final r = _run([ + '--file', + dart( + _invalid, + 'features/phi/phi_presentation', + 'test/phi_helpers.dart', + ), + ]); + expect(r.code, 0); + expect(r.err, isEmpty); + }); + }); + group('graceful skips (exit 0)', () { test('non-pubspec file', () { final r = _run(['--file', '$_valid/README.md']); diff --git a/scripts/validate_layers.dart b/scripts/validate_layers.dart index dc34f82..f134359 100644 --- a/scripts/validate_layers.dart +++ b/scripts/validate_layers.dart @@ -2,12 +2,15 @@ // // Enforces the Feature-First Clean Architecture dependency rules on a Dart // monorepo's pubspec dependencies between its own packages. Used two ways: -// - by the plugin hook, incrementally, on every pubspec.yaml edit: +// - by the plugin hook, incrementally, on every pubspec.yaml or Dart edit: // dart run validate_layers.dart --file +// dart run validate_layers.dart --file // - by CI and the ffca-audit skill, across the whole workspace: // dart run validate_layers.dart --all // -// Exit codes: 0 = pass or graceful skip, 2 = FFCA violation, 64 = usage error. +// Exit codes: 0 = pass, warnings only, or graceful skip; 2 = at least one +// error-severity FFCA finding; 64 = usage error. Findings go to stderr, each +// with a rule ID that a `# ffca-ignore:` comment can suppress (see below). // // Imports are restricted to dart:io and dart:core so the script runs with just // the Dart SDK, no `dart pub get` required on the user's machine. @@ -44,6 +47,74 @@ const Map> rules = { 'shared': {'shared'}, }; +// --------------------------------------------------------------------------- +// Rule IDs and severities +// +// Every finding carries one of these stable IDs, which is also what a +// suppression names. An error exits 2 and blocks the hook; a warning is printed +// but leaves the exit code at 0. +// +// A finding is suppressed with a comment in the pubspec of the package it is +// reported on, or, for a finding in a Dart file, in that file. The reason is +// required: +// # ffca-ignore: — (pubspec.yaml) +// // ffca-ignore: — (Dart file) +// The suppression-* rules and unused-suppression cannot be suppressed. +// A hyphen separator with no space before it also works: +// # ffca-ignore: - +// --------------------------------------------------------------------------- +enum Severity { error, warning } + +/// Every rule the validator reports. A finding takes one of these values, so a +/// rule that is not registered here cannot be reported at all. +enum Rule { + packageName('package-name', pubspecOnly: true), + dependsOnApp('depends-on-app'), + sharedDependsOnFeature('shared-depends-on-feature'), + presentationDependsOnData('presentation-depends-on-data'), + domainDependsOnData('domain-depends-on-data'), + domainDependsOnPresentation('domain-depends-on-presentation'), + dataDependsOnPresentation('data-depends-on-presentation'), + dataDependsOnFeatureData('data-depends-on-feature-data'), + layerDependency('layer-dependency'), + dependencyCycle('dependency-cycle', pubspecOnly: true), + dataMissingOwnDomain('data-missing-own-domain', pubspecOnly: true), + dataWithoutDomain('data-without-domain', pubspecOnly: true), + domainUsesFlutter('domain-uses-flutter', pubspecOnly: true), + presentationMissingOwnDomain('presentation-missing-own-domain', + severity: Severity.warning, pubspecOnly: true), + missingPrimaryBarrel('missing-primary-barrel', pubspecOnly: true), + deepImport('deep-import'), + crossFeaturePrimaryBarrel('cross-feature-primary-barrel'), + dataBarrelExportsInternals('data-barrel-exports-internals'), + unusedDependency('unused-dependency', + severity: Severity.warning, pubspecOnly: true), + undeclaredDependency('undeclared-dependency'), + suppressionMissingReason('suppression-missing-reason', suppressible: false), + suppressionUnknownRule('suppression-unknown-rule', suppressible: false), + unusedSuppression('unused-suppression', + severity: Severity.warning, suppressible: false); + + const Rule( + this.id, { + this.severity = Severity.error, + this.suppressible = true, + this.pubspecOnly = false, + }); + + /// The stable ID shown in brackets in the report, which suppressions name. + final String id; + final Severity severity; + final bool suppressible; + + /// Reported on a pubspec, never on a Dart file, so a `//` suppression in a + /// Dart file cannot name it. + final bool pubspecOnly; + + static Rule? byId(String id) => + values.where((rule) => rule.id == id).firstOrNull; +} + void main(List args) { if (args.contains('--all')) { exit(_runAll(args)); @@ -55,7 +126,8 @@ void main(List args) { } stderr.writeln( - 'Usage: validate_layers.dart --file | --all [rootDir]', + 'Usage: validate_layers.dart --file | ' + '--all [rootDir]', ); exit(64); } @@ -65,9 +137,11 @@ void main(List args) { // --------------------------------------------------------------------------- /// Incremental mode (the hook default): validate the edited package plus its -/// direct dependents. Graceful-skips (exit 0) when the target is not a -/// pubspec.yaml, does not exist, or is not inside an FFCA-shaped workspace. +/// direct dependents, or, for a Dart file, only that file's imports and +/// exports. Graceful-skips (exit 0) when the target is neither a pubspec.yaml +/// nor a Dart file, does not exist, or is not inside an FFCA-shaped workspace. int _runIncremental(String rawPath) { + if (rawPath.endsWith('.dart')) return _runDartFile(rawPath); if (_basename(rawPath) != 'pubspec.yaml') return 0; final file = File(rawPath); @@ -76,8 +150,10 @@ int _runIncremental(String rawPath) { final root = _findWorkspaceRoot(file.parent.absolute.path); if (root == null) return 0; // not FFCA-shaped - final workspace = _discover(root); + // Only the edited package's imports are checked, so only its Dart files are + // read: the cost of an edit stays proportional to one package. final editedDir = _normalize(file.parent.absolute.path); + final workspace = _discover(root, readDartFilesOf: {editedDir}); final edited = workspace.byDir[editedDir]; if (edited == null) return 0; // root pubspec or outside features/apps/shared @@ -86,15 +162,74 @@ int _runIncremental(String rawPath) { if (p.deps.contains(edited.dir)) scope.add(p); // direct dependents } + // A pubspec edit cannot change another package's imports or suppressions, + // so dependents only get the layer and ownership checks. Their errors can + // come from the edit, so they are reported; their warnings cannot, so only + // the edited package's are, which keeps an edit to a shared package from + // repeating every dependent's warnings. final violations = <_Violation>[]; for (final p in scope) { - violations.addAll(_checkPackage(p, workspace)); + final found = _checkPackage( + p, + workspace, + imports: p == edited, + incremental: true, + ); + violations.addAll( + p == edited ? found : found.where((v) => v.severity == Severity.error), + ); } violations.addAll(_cycleViolations(workspace, from: edited)); - if (violations.isEmpty) return 0; // silent pass - _report(violations, root); - return 2; + final result = _applySuppressions( + violations, + _declaredSuppressions([edited]), + reportUnused: false, + ); + if (result.findings.isEmpty) return 0; // silent pass + _report(result, root); + return _exitCode(result.findings); +} + +/// Dart-file mode: report only the findings located in the edited file, so a +/// Dart edit never blocks on a violation elsewhere in the package. Package-wide +/// findings, such as a missing barrel or an unused dependency, are reported on +/// pubspec edits and in full mode. +int _runDartFile(String rawPath) { + final file = File(rawPath); + if (!file.existsSync()) return 0; + + final root = _findWorkspaceRoot(file.parent.absolute.path); + if (root == null) return 0; // not FFCA-shaped + + // Read the Dart files of the package containing the edited file only. + final path = _normalize(file.absolute.path); + final packageDir = _packageDirOf(path, root); + if (packageDir == null) return 0; // not inside a package + final workspace = _discover(root, readDartFilesOf: {packageDir}); + final dartFile = workspace.packages + .expand((p) => p.dartFiles) + .where((f) => f.path == path) + .firstOrNull; + if (dartFile == null) return 0; // outside lib/ and bin/ of a known package + + final package = workspace.packages.firstWhere( + (p) => p.dartFiles.contains(dartFile), + ); + final violations = _checkImports( + package, + workspace, + ).where((v) => v.file == dartFile).toList(); + + final result = _applySuppressions( + violations, + [ + for (final s in dartFile.suppressions) (package, s, dartFile), + ], + reportUnused: false); + if (result.findings.isEmpty) return 0; + _report(result, root); + return _exitCode(result.findings); } /// Full-graph mode (CI and ffca-audit): validate every package and run a @@ -119,29 +254,149 @@ int _runAll(List args) { } violations.addAll(_cycleViolations(workspace)); - if (violations.isEmpty) { + final result = _applySuppressions( + violations, + _declaredSuppressions(workspace.packages), + reportUnused: true, + ); + if (result.findings.isEmpty) { + final suppressed = + result.suppressed > 0 ? ', ${result.suppressed} suppressed' : ''; stdout.writeln( - 'No FFCA violations found (${workspace.packages.length} packages checked).', + 'No FFCA violations found ' + '(${workspace.packages.length} packages checked$suppressed).', ); return 0; } - _report(violations, root); - return 2; + _report(result, root); + return _exitCode(result.findings); +} + +int _exitCode(List<_Violation> findings) => + findings.any((v) => v.severity == Severity.error) ? 2 : 0; + +// --------------------------------------------------------------------------- +// Suppressions +// --------------------------------------------------------------------------- + +/// Every suppression declared in [packages], in their pubspecs and Dart files. +List<(Package, _Suppression, _DartFile?)> _declaredSuppressions( + Iterable packages, +) => + [ + for (final p in packages) ...[ + for (final s in p.suppressions) (p, s, null), + for (final f in p.dartFiles) + for (final s in f.suppressions) (p, s, f), + ], + ]; + +/// Drops the findings suppressed in their package's pubspec, or in the Dart +/// file they are located in, and adds findings for the invalid suppressions in +/// [declared]. Unused suppressions are only reported with [reportUnused], in +/// full mode, where every finding has been computed. +({List<_Violation> findings, int suppressed}) _applySuppressions( + List<_Violation> violations, + List<(Package, _Suppression, _DartFile?)> declared, { + required bool reportUnused, +}) { + bool canName(_Suppression s) { + final rule = Rule.byId(s.ruleId); + return rule != null && + rule.suppressible && + !(s.marker == '//' && rule.pubspecOnly); + } + + bool valid(_Suppression s) => s.reason.isNotEmpty && canName(s); + + final used = <_Suppression>{}; + final findings = <_Violation>[]; + var suppressed = 0; + for (final v in violations) { + final match = [...?v.file?.suppressions, ...v.package.suppressions] + .where((s) => valid(s) && s.ruleId == v.rule.id) + .firstOrNull; + if (match == null) { + findings.add(v); + } else { + used.add(match); + suppressed++; + } + } + + for (final (p, s, f) in declared) { + final where = s.where; + if (s.marker == '//' && (Rule.byId(s.ruleId)?.pubspecOnly ?? false)) { + findings.add( + _Violation( + rule: Rule.suppressionUnknownRule, + package: p, + file: f, + message: 'ffca-ignore on $where names "${s.ruleId}", which is ' + 'reported on the pubspec, so a Dart file cannot suppress it', + fix: 'Move the comment to the ${p.name} pubspec: ' + '# ffca-ignore: ${s.ruleId} — ', + ), + ); + } else if (!canName(s)) { + findings.add( + _Violation( + rule: Rule.suppressionUnknownRule, + package: p, + file: f, + message: 'ffca-ignore on $where names "${s.ruleId}", which is not a ' + 'rule that can be suppressed', + fix: 'Use one of the rule IDs shown in brackets in the validator ' + 'output, or remove the comment.', + ), + ); + } else if (s.reason.isEmpty) { + findings.add( + _Violation( + rule: Rule.suppressionMissingReason, + package: p, + file: f, + message: 'ffca-ignore: ${s.ruleId} on $where has no reason, so it ' + 'suppresses nothing', + fix: 'Add the reason after the rule ID: ' + '${s.marker} ffca-ignore: ${s.ruleId} — ', + ), + ); + } else if (reportUnused && !used.contains(s)) { + findings.add( + _Violation( + rule: Rule.unusedSuppression, + package: p, + file: f, + message: 'ffca-ignore: ${s.ruleId} on $where matches no finding', + fix: 'Remove the comment; the exception is no longer needed.', + ), + ); + } + } + return (findings: findings, suppressed: suppressed); } // --------------------------------------------------------------------------- // Checks // --------------------------------------------------------------------------- -List<_Violation> _checkPackage(Package p, Workspace ws) { +List<_Violation> _checkPackage( + Package p, + Workspace ws, { + bool imports = true, + bool incremental = false, +}) { final out = <_Violation>[]; // Naming: a package under features/{f}/ must be a valid FFCA package name. if (p.top == 'features' && p.layer == 'unknown') { out.add( _Violation( + rule: Rule.packageName, package: p, - rule: '${p.name} is under features/${p.feature}/ but is not a valid ' + message: '${p.name} is under features/${p.feature}/ but is not a valid ' 'FFCA package name (expected ${p.feature}_domain, ${p.feature}_data, ' '${p.feature}_data_{backend}, or ${p.feature}_presentation)', fix: 'Rename it to one of ${p.feature}_domain, ${p.feature}_data, ' @@ -152,6 +407,7 @@ List<_Violation> _checkPackage(Package p, Workspace ws) { } out.addAll(_checkOwnership(p, ws)); + if (imports) out.addAll(_checkImports(p, ws, incremental: incremental)); final allowed = rules[p.layer]; if (allowed == null) return out; // unknown source layer (e.g. app-less): skip @@ -182,8 +438,10 @@ List<_Violation> _checkOwnership(Package p, Workspace ws) { if (domain == null) { out.add( _Violation( + rule: Rule.dataWithoutDomain, package: p, - rule: '${p.name} is a data layer but features/${p.feature}/ has no ' + message: + '${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 ' @@ -194,8 +452,9 @@ List<_Violation> _checkOwnership(Package p, Workspace ws) { final devOnly = p.deps.contains(domain.dir); out.add( _Violation( + rule: Rule.dataMissingOwnDomain, package: p, - rule: devOnly + message: 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 ' @@ -206,11 +465,32 @@ List<_Violation> _checkOwnership(Package p, Workspace ws) { } } + // A presentation-only feature has no domain, so this only applies when the + // feature has one. + if (p.layer == 'presentation') { + final domain = ws.packages + .where((d) => d.feature == p.feature && d.name == domainName) + .firstOrNull; + if (domain != null && !p.runtimeDeps.contains(domain.dir)) { + out.add( + _Violation( + rule: Rule.presentationMissingOwnDomain, + package: p, + message: '${p.name} does not depend on $domainName (a presentation ' + "layer renders its own feature's domain)", + fix: 'Add $domainName under dependencies in the ${p.name} pubspec, ' + 'or suppress this with the reason the screen does not use it.', + ), + ); + } + } + if (p.layer == 'domain' && p.flutterSdkDeps.isNotEmpty) { out.add( _Violation( + rule: Rule.domainUsesFlutter, package: p, - rule: '${p.name} depends on the Flutter SDK through ' + message: '${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 ' @@ -221,14 +501,196 @@ List<_Violation> _checkOwnership(Package p, Workspace ws) { return out; } +/// Checks the package's barrels and its imports and exports of other +/// workspace packages. Implements the barrels in "Folder layout" and "Layer +/// subfolders" and the dedicated-barrel condition in "Dependency rules" of +/// references/ffca/project_structure.md, and "Widgets that own state" in +/// references/ffca/presentation.md ("never the primary barrel"). The general +/// rule that packages import each other only through barrels is on the VGV +/// Engineering barrel files page, which the FFCA section does not state yet +/// (VGVentures/very_good_engineering#294). Imports go through the same rules +/// table as pubspec dependencies, since a workspace resolves an undeclared +/// import of another member. In [incremental] mode a package with no lib/ yet +/// is being scaffolded, so its missing barrel is not reported until --all. +List<_Violation> _checkImports( + Package p, + Workspace ws, { + bool incremental = false, +}) { + final out = <_Violation>[]; + final barrel = 'lib/${p.name}.dart'; + final scaffolding = incremental && !Directory('${p.dir}/lib').existsSync(); + + if (p.top == 'features' && + !scaffolding && + !p.dartFiles.any((f) => f.libRel == barrel)) { + out.add( + _Violation( + rule: Rule.missingPrimaryBarrel, + package: p, + message: '${p.name} has no primary barrel $barrel', + fix: 'Add $barrel exporting the public API of ${p.name}.', + ), + ); + } + + // Files of this package by their path in it, to follow re-exports. + final byLibRel = {for (final f in p.dartFiles) f.libRel: f}; + String? ownPath(_DartFile from, String uri) { + final match = _packageUri.firstMatch(uri); + if (match != null) { + return match.group(1) == p.name ? 'lib/${match.group(2)}' : null; + } + if (uri.contains(':')) return null; // dart: and other schemes + final dir = from.libRel.substring(0, from.libRel.lastIndexOf('/')); + return _normalize('$dir/$uri'); + } + + final used = {}; + for (final f in p.dartFiles) { + // Any barrel at the root of a data package's lib/ that exports its own + // DTOs or mappers, directly or through one re-exporting file. Data + // sources are exported: the app constructs them to inject them. + final rootBarrel = + f.libRel.startsWith('lib/') && !f.libRel.substring(4).contains('/'); + if (p.layer == 'data' && rootBarrel) { + for (final d in f.directives.where((d) => d.kind == 'export')) { + final target = ownPath(f, d.uri); + if (target == null) continue; + final via = byLibRel[target]; + final leaked = _dataInternals.hasMatch(target) + ? d.uri + : via?.directives + .where((e) => e.kind == 'export') + .map((e) => ownPath(via, e.uri)) + .whereType() + .where(_dataInternals.hasMatch) + .firstOrNull; + if (leaked == null) continue; + out.add( + _Violation( + rule: Rule.dataBarrelExportsInternals, + package: p, + file: f, + message: leaked == d.uri + ? 'line ${d.line} exports ${d.uri} (a data barrel never ' + 'exposes its DTOs or mappers)' + : 'line ${d.line} exports ${d.uri}, which re-exports ' + '$leaked (a data barrel never exposes its DTOs or ' + 'mappers)', + fix: 'Remove the export; DTOs and mappers are implementation ' + 'details of the repository, which returns domain models.', + ), + ); + } + } + + for (final d in f.directives) { + final match = _packageUri.firstMatch(d.uri); + final name = match?.group(1); + final rest = match?.group(2) ?? d.uri; + if (match == null || name == p.name) continue; // within the package + + final target = ws.byName[name]; + if (target == null) continue; // external package + used.add(target.name); + + // An import of a workspace member that the pubspec does not declare. + // Check it against the rules table like a declared dependency. + if (!p.runtimeDeps.contains(target.dir)) { + final allowed = rules[p.layer]; + if (allowed != null && + target.layer != 'unknown' && + !allowed.contains(target.layer)) { + final layer = _dependencyViolation(p, target); + out.add( + _Violation( + rule: layer.rule, + package: p, + file: f, + message: 'line ${d.line} ${d.kind}s ${d.uri}: ${layer.message}', + fix: layer.fix, + ), + ); + } else { + out.add( + _Violation( + rule: Rule.undeclaredDependency, + package: p, + file: f, + message: 'line ${d.line} ${d.kind}s ${d.uri}, but ${target.name} ' + 'is not under dependencies in the ${p.name} pubspec', + fix: 'Add ${target.name} under dependencies in the ${p.name} ' + 'pubspec.', + ), + ); + } + } + + if (rest.contains('/')) { + out.add( + _Violation( + rule: Rule.deepImport, + package: p, + file: f, + message: + 'line ${d.line} ${d.kind}s ${d.uri}, which reaches past the ' + 'barrels of ${target.name}', + fix: 'Import a barrel at the root of ${target.name}/lib/ instead, ' + 'and export what is missing from it.', + ), + ); + } else if (p.top == 'features' && + target.layer == 'presentation' && + target.feature != p.feature && + rest == '${target.name}.dart') { + out.add( + _Violation( + rule: Rule.crossFeaturePrimaryBarrel, + package: p, + file: f, + message: 'line ${d.line} ${d.kind}s the primary barrel of ' + '${target.name} (another feature\'s presentation is imported ' + 'through a dedicated barrel)', + fix: + 'Add a subfeature barrel to ${target.name}/lib/ exporting only ' + 'the widget ${p.name} needs, and import that instead.', + ), + ); + } + } + } + + for (final depDir in p.runtimeDeps) { + final target = ws.byDir[depDir]; + if (target == null || used.contains(target.name)) continue; + out.add( + _Violation( + rule: Rule.unusedDependency, + package: p, + message: + '${p.name} declares ${target.name} but never imports it in lib/ ' + 'or bin/', + fix: 'Remove ${target.name} from the ${p.name} pubspec, or import it ' + 'where it is needed.', + ), + ); + } + return out; +} + +final _packageUri = RegExp(r'^package:([^/]+)/(.+)$'); +final _dataInternals = RegExp(r'(^|/)(dtos|mappers)/'); + _Violation _dependencyViolation(Package src, Package tgt) { final pair = '${src.name} depends on ${tgt.name}'; // Target is an app: nothing may depend on an app. if (tgt.layer == 'app') { return _Violation( + rule: Rule.dependsOnApp, package: src, - rule: '$pair (nothing may depend on an app)', + message: '$pair (nothing may depend on an app)', fix: 'Apps are top-level composition roots; remove the dependency on ' '${tgt.name}.', ); @@ -237,8 +699,9 @@ _Violation _dependencyViolation(Package src, Package tgt) { // Source is shared depending on a feature package. if (src.layer == 'shared') { return _Violation( + rule: Rule.sharedDependsOnFeature, package: src, - rule: '$pair (shared packages must never depend on a feature)', + message: '$pair (shared packages must never depend on a feature)', fix: 'Move the shared code into the feature, or invert the dependency; ' 'shared/ depends only on external and other shared/ packages.', ); @@ -247,45 +710,54 @@ _Violation _dependencyViolation(Package src, Package tgt) { switch ('${src.layer}->${tgt.layer}') { case 'presentation->data': return _Violation( + rule: Rule.presentationDependsOnData, package: src, - rule: '$pair (presentation must never depend on a data layer)', + message: '$pair (presentation must never depend on a data layer)', fix: 'Depend on ${tgt.feature}_domain instead and access data through ' 'its repository interface.', ); case 'domain->data': return _Violation( + rule: Rule.domainDependsOnData, package: src, - rule: '$pair (a domain layer must never depend on a data layer)', + message: '$pair (a domain layer must never depend on a data layer)', fix: 'Domain packages define interfaces only; depend on other *_domain ' 'or shared/ packages instead.', ); case 'domain->presentation': return _Violation( + rule: Rule.domainDependsOnPresentation, package: src, - rule: + message: '$pair (a domain layer must never depend on a presentation layer)', fix: 'Domain packages define interfaces only; depend on other *_domain ' 'or shared/ packages instead.', ); case 'data->presentation': return _Violation( + rule: Rule.dataDependsOnPresentation, package: src, - rule: '$pair (a data layer must never depend on a presentation layer)', + message: + '$pair (a data layer must never depend on a presentation layer)', fix: 'Data packages implement domain interfaces; depend on ' '${src.feature}_domain or shared/ packages instead.', ); case 'data->data': return _Violation( + rule: Rule.dataDependsOnFeatureData, package: src, - rule: "$pair (a data layer must never depend on another feature's data " + message: + "$pair (a data layer must never depend on another feature's data " 'layer)', fix: 'Depend on ${tgt.feature}_domain and access it through its ' 'repository interface.', ); default: return _Violation( + rule: Rule.layerDependency, package: src, - rule: '$pair (a ${src.layer} layer must not depend on a ${tgt.layer} ' + message: + '$pair (a ${src.layer} layer must not depend on a ${tgt.layer} ' 'layer)', fix: 'Remove the dependency on ${tgt.name}; review the Dependency ' 'rules in references/ffca/project_structure.md.', @@ -336,8 +808,9 @@ List<_Violation> _cycleViolations(Workspace ws, {Package? from}) { if (seenCycles.add(key)) { out.add( _Violation( + rule: Rule.dependencyCycle, package: owner, - rule: 'dependency cycle detected: ${cycle.join(' → ')}', + message: 'dependency cycle detected: ${cycle.join(' → ')}', fix: 'Break the cycle; FFCA requires an acyclic dependency ' 'graph.', ), @@ -374,7 +847,20 @@ String? _findWorkspaceRoot(String startDir) { } } -Workspace _discover(String root) { +/// The nearest directory above [path], up to [root], that holds a pubspec. +String? _packageDirOf(String path, String root) { + var dir = path.substring(0, path.lastIndexOf('/')); + while (dir.length > root.length) { + if (File('$dir/pubspec.yaml').existsSync()) return dir; + dir = dir.substring(0, dir.lastIndexOf('/')); + } + return null; +} + +/// Discovers every package in the workspace from its pubspec. Dart files are +/// read only for the packages in [readDartFilesOf], or for all of them when it +/// is null, as in --all. +Workspace _discover(String root, {Set? readDartFilesOf}) { final packages = []; final depsByPackage = >{}; for (final top in ['features', 'apps', 'shared']) { @@ -390,6 +876,10 @@ Workspace _discover(String root) { final pkgDir = _normalize(entity.parent.absolute.path); final parsed = _parsePubspec(entity); final package = _buildPackage(pkgDir, entity, parsed.name, top, root); + package.suppressions.addAll(parsed.suppressions); + if (readDartFilesOf == null || readDartFilesOf.contains(pkgDir)) { + package.dartFiles.addAll(_readDartFiles(pkgDir, root)); + } package.flutterSdkDeps.addAll([ for (final dep in parsed.deps) if (dep.runtime && dep.sdk == 'flutter') dep.name, @@ -419,9 +909,82 @@ Workspace _discover(String root) { } final byDir = {for (final p in packages) p.dir: p}; - return Workspace(packages: packages, byDir: byDir); + return Workspace(packages: packages, byDir: byDir, byName: byName); +} + +/// Reads the imports, exports, and suppressions of every Dart file under the +/// package's lib/ and bin/. +List<_DartFile> _readDartFiles(String pkgDir, String root) { + final files = <_DartFile>[]; + for (final sub in ['lib', 'bin']) { + final dir = Directory('$pkgDir/$sub'); + if (!dir.existsSync()) continue; + final entities = dir.listSync(recursive: true, followLinks: false) + ..sort((a, b) => a.path.compareTo(b.path)); + for (final entity in entities) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final path = _normalize(entity.absolute.path); + final relPath = path.substring(root.length + 1); + final lines = entity.readAsLinesSync(); + files.add( + _DartFile( + path: path, + relPath: relPath, + libRel: path.substring(pkgDir.length + 1), + directives: _readDirectives(lines), + suppressions: _parseSuppressions(lines, where: relPath), + ), + ); + } + } + return files; } +/// Reads the URIs of a file's `import` and `export` directives. Dart requires +/// directives before any declaration, so the scan stops at the first line that +/// is not a directive, a comment, an annotation, or blank: an `import` inside a +/// string or a doc comment further down is never read. A directive can span +/// lines (`show`, `hide`, conditional URIs), and every quoted URI in it is +/// read, so both sides of a conditional import are checked. +List<_Directive> _readDirectives(List lines) { + final directives = <_Directive>[]; + String? kind; // the directive being read, until its `;` + var inComment = false; + for (var i = 0; i < lines.length; i++) { + final line = lines[i].trim(); + if (inComment) { + inComment = !line.contains('*/'); + continue; + } + if (kind == null) { + if (line.isEmpty || + line.startsWith('//') || + line.startsWith('@') || + line.startsWith('#!')) { + continue; + } + if (line.startsWith('/*')) { + inComment = !line.contains('*/'); + continue; + } + final start = _directiveStart.firstMatch(line); + if (start == null) break; // first declaration: directives are over + kind = start.group(1); + } + if (kind == 'import' || kind == 'export') { + final code = line.split('//').first; + for (final m in _quotedUri.allMatches(code)) { + directives.add(_Directive(kind: kind!, uri: m.group(1)!, line: i + 1)); + } + } + if (line.contains(';')) kind = null; + } + return directives; +} + +final _directiveStart = RegExp(r'^(import|export|library|part)\b'); +final _quotedUri = RegExp(r'''['"]([^'"]+)['"]'''); + Package _buildPackage( String pkgDir, File pubspec, @@ -456,6 +1019,8 @@ Package _buildPackage( deps: [], runtimeDeps: [], flutterSdkDeps: [], + suppressions: [], + dartFiles: [], ); } @@ -476,6 +1041,12 @@ _ParsedPubspec _parsePubspec(File file) { final deps = <_Dependency>[]; final lines = file.readAsLinesSync(); + // Suppressions are comments, so read them before comments are stripped. + final suppressions = _parseSuppressions( + lines, + where: 'the ${_basename(_normalize(file.parent.absolute.path))} pubspec', + ); + 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. @@ -534,9 +1105,34 @@ _ParsedPubspec _parsePubspec(File file) { } } } - return _ParsedPubspec(name: name, deps: deps); + return _ParsedPubspec(name: name, deps: deps, suppressions: suppressions); } +/// Reads `ffca-ignore` comments from a pubspec or a Dart file. [where] names +/// the file in messages, for example `the cart_data pubspec`. +List<_Suppression> _parseSuppressions( + List lines, { + required String where, +}) => + [ + for (var i = 0; i < lines.length; i++) + if (_suppressionPattern.firstMatch(lines[i]) case final m?) + _Suppression( + ruleId: m.group(2)!, + reason: m.group(3)!.replaceFirst(_reasonSeparator, '').trim(), + marker: m.group(1)!, + where: 'line ${i + 1} of $where', + ), + ]; + +/// `# ffca-ignore: ` in a pubspec, or the same +/// after `//` in a Dart file, where the separator is an em dash, an en dash, a +/// hyphen, or a colon. +final _suppressionPattern = RegExp( + r'(#|//)\s*ffca-ignore:\s*([A-Za-z0-9_]+(?:-[A-Za-z0-9_]+)*)(.*)$', +); +final _reasonSeparator = RegExp(r'^\s*[—–:-]+'); + String _stripComment(String line) { final hash = line.indexOf('#'); if (hash == -1) return line; @@ -562,22 +1158,41 @@ String _unquote(String s) { // Output // --------------------------------------------------------------------------- -void _report(List<_Violation> violations, String root) { - // Group by the pubspec file the violation belongs to. +void _report( + ({List<_Violation> findings, int suppressed}) result, + String root, +) { + // Group by the file the finding is located in: a Dart file, or else the + // pubspec of the package it belongs to. final byPackage = >{}; - for (final v in violations) { - byPackage.putIfAbsent(v.package.relPath, () => []).add(v); + for (final v in result.findings) { + byPackage + .putIfAbsent(v.file?.relPath ?? v.package.relPath, () => []) + .add(v); } final paths = byPackage.keys.toList()..sort(); for (final path in paths) { - stderr.writeln('FFCA violation in $path:'); + stderr.writeln('FFCA findings in $path:'); for (final v in byPackage[path]!) { - stderr.writeln(' ✗ ${v.rule}'); + final mark = v.severity == Severity.error ? '✗' : '⚠'; + stderr.writeln(' $mark [${v.rule.id}] ${v.message}'); stderr.writeln(' → ${v.fix}'); } } + + final errors = + result.findings.where((v) => v.severity == Severity.error).length; + final warnings = result.findings.length - errors; + final suppressed = + result.suppressed > 0 ? ', ${result.suppressed} suppressed' : ''; + stderr.writeln( + 'FFCA: ${_plural(errors, 'error')}, ${_plural(warnings, 'warning')}' + '$suppressed.', + ); } +String _plural(int n, String word) => '$n $word${n == 1 ? '' : 's'}'; + // --------------------------------------------------------------------------- // Path helpers (avoid package:path so the script stays dependency-free) // --------------------------------------------------------------------------- @@ -613,9 +1228,14 @@ String _normalize(String path) { // --------------------------------------------------------------------------- class Workspace { - Workspace({required this.packages, required this.byDir}); + Workspace({ + required this.packages, + required this.byDir, + required this.byName, + }); final List packages; final Map byDir; + final Map byName; } class Package { @@ -629,6 +1249,8 @@ class Package { required this.deps, required this.runtimeDeps, required this.flutterSdkDeps, + required this.suppressions, + required this.dartFiles, }); final String dir; // normalized absolute package directory @@ -640,12 +1262,41 @@ class Package { 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` + final List<_Suppression> suppressions; // `# ffca-ignore:` comments + final List<_DartFile> dartFiles; // Dart files under lib/ and bin/ +} + +class _DartFile { + _DartFile({ + required this.path, + required this.relPath, + required this.libRel, + required this.directives, + required this.suppressions, + }); + final String path; // normalized absolute path + final String relPath; // path relative to the workspace root + final String libRel; // path relative to the package, for example lib/a.dart + final List<_Directive> directives; + final List<_Suppression> suppressions; // `// ffca-ignore:` comments +} + +class _Directive { + _Directive({required this.kind, required this.uri, required this.line}); + final String kind; // import | export + final String uri; + final int line; // 1-based } class _ParsedPubspec { - _ParsedPubspec({required this.name, required this.deps}); + _ParsedPubspec({ + required this.name, + required this.deps, + required this.suppressions, + }); final String? name; final List<_Dependency> deps; + final List<_Suppression> suppressions; } class _Dependency { @@ -662,8 +1313,31 @@ class _Dependency { } class _Violation { - _Violation({required this.package, required this.rule, required this.fix}); + _Violation({ + required this.rule, + required this.package, + required this.message, + required this.fix, + this.file, + }); + final Rule rule; final Package package; - final String rule; + final _DartFile? file; // the Dart file the finding is located in, if any + final String message; final String fix; + + Severity get severity => rule.severity; +} + +class _Suppression { + _Suppression({ + required this.ruleId, + required this.reason, + required this.marker, + required this.where, + }); + final String ruleId; + final String reason; // empty when none was given + final String marker; // `#` in a pubspec, `//` in a Dart file + final String where; // for example `line 2 of the cart_data pubspec` } diff --git a/skills/ffca-audit/SKILL.md b/skills/ffca-audit/SKILL.md index fd28bf4..8dc58e5 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, 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. +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, imports and barrels), then adds the source-level checks (barrel scope, 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.