From f4fb0ebabeda26c0e3e9c49cd8ca1d32f3694b07 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 19 Oct 2026 18:42:29 +0000 Subject: [PATCH] fix(scope,review,ship): recognise Dart and Flutter in diff scope and test detection (#3284) Port of arlophoenix's #3284 onto current main, squashed: - gstack-diff-scope: .dart is frontend and backend; pubspec.lock is config. - lib/frontend-scope.ts mirrors the frontend half; gstack-design-detect never sends .dart to the impeccable engine. - Test bootstrap, coverage audits and review-army detect Dart/Flutter (RUNTIME:dart, FRAMEWORK:flutter, dart_test.yaml, *_test.dart). - Adversarial DIFF_SIZE sums git diff --numstat. On top of the contributor's diff: shorten the bootstrap's ecosystem-marker comment so /design-review stays at its SKILL.md size pin, lower the 31 pins that shrank, and add the CHANGELOG entry. Co-authored-by: arlophoenix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++ bin/gstack-design-detect.ts | 3 +- bin/gstack-diff-scope | 9 ++ design-review/SKILL.md | 21 +++-- docs/skills.md | 4 +- lib/frontend-scope.ts | 3 + plan-eng-review/sections/review-sections.md | 9 +- review/sections/adversarial.md | 4 +- review/sections/review-army.md | 2 + scripts/resolvers/outside-voice-steps.ts | 4 +- scripts/resolvers/review-army.ts | 2 + scripts/resolvers/testing.ts | 30 ++++--- ship/sections/adversarial.md | 4 +- ship/sections/review-army.md | 2 + ship/sections/test-coverage.md | 9 +- ship/sections/tests.md | 21 +++-- test/diff-scope.test.ts | 13 +++ test/fixtures/skill-md-size-allowlist.json | 62 +++++++------- test/frontend-scope.test.ts | 5 +- test/gstack-design-detect.test.ts | 7 +- test/outside-voice-preflight.test.ts | 28 +++++++ test/ship-test-detection-markers.test.ts | 93 ++++++++++++++++++--- 22 files changed, 254 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b30aaaf..f25f9bc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +**Dart and Flutter projects get the right reviewers and the right test command.** A Dart change matched no diff-scope category except its test file, so a Flutter widget PR ran only the testing pass and skipped design, performance and security. Contributed by @arlophoenix (#3284). + +### Fixed +- **`gstack-diff-scope` counts `.dart` as both frontend and backend.** The extension cannot tell a Flutter widget from a repository class or a pure-Dart server, and a skipped reviewer is invisible, so both specialists run. `pubspec.lock` (at the root or in a workspace package) is config. `lib/frontend-scope.ts` mirrors the frontend half, and `gstack-design-detect` still never hands a `.dart` file to the impeccable engine, which reads web sources only. +- **Test detection knows Dart.** `/ship`'s bootstrap and coverage audit, `/design-review` and `/plan-eng-review` print `RUNTIME:dart` for a `pubspec.yaml` and `FRAMEWORK:flutter` for an indented `sdk: flutter` dependency (a commented-out one or an `environment.sdk` constraint does not count). They treat `dart_test.yaml` and `*_test.dart` files anywhere as existing tests and offer `flutter test` or `dart test`. Review-army reports `STACK: dart` and `TEST_FW: dart-test` or `flutter-test`. +- **Adversarial `DIFF_SIZE` sums `git diff --numstat`,** the same count review-army's `DIFF_LINES` uses, instead of parsing the `--stat` summary line. Binary files count as zero. + +### Changed +- The test-bootstrap prose is shorter so the generated skills stay inside the parity size cap and `/design-review` stays at its SKILL.md size pin. The rules it states are unchanged. `/design-review`, `/plan-eng-review` and `/review` shrink, so their pins in `test/fixtures/skill-md-size-allowlist.json` go down (31 entries). +- `test/frontend-scope.test.ts`'s bash parity test gets a 30 s budget. It spawns git six times and the scope script once per sample, and it already ran past Bun's 5 s default on a 4-core machine before this change. + ## [1.91.149.0] - 2026-10-19 **Paid eval cases can now run on the Codex host, and the Monday census runs two arms: Claude Opus 5.5 and Codex gpt-6.1-sol.** `EVALS_HOST=codex` runs the 52 adapter-only gate cases through a full Codex runtime per case. On its first pilot gpt-6.1-sol passed 48 of 52 with no retries, at $4.63 for the 52 cases, against $28.93 for the same cases on Fable (0.16×). Claude-only cases are skipped on Codex with a recorded reason and never count as failed. GSTA-49 TM2. diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index 76a24224a..e138bd92c 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -829,7 +829,8 @@ function resolveTargets(args: ScanArgs, p: Probe): { targets: string[]; refusedB runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', 'HEAD']); runZ(['ls-files', '-z', '--others', '--exclude-standard']); for (const rel of [...files].sort()) { - if (!isFrontendPath(rel)) continue; + // impeccable reads web sources; a Dart file is frontend scope but not a scan target. + if (!isFrontendPath(rel) || rel.endsWith('.dart')) continue; const abs = path.join(top, rel); try { if (fs.lstatSync(abs).isSymbolicLink()) { refuse(rel, 'symlink named by git is never scanned'); continue; } } catch { continue; } const real = realpathOrNull(abs); diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 17da9c656..b923f737a 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -104,6 +104,11 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do *.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;; *.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;; *.html) m_frontend=true ;; + # Dart: the extension cannot tell a Flutter widget from a repository or a + # pure-Dart server, so it is BOTH frontend and backend (BACKEND set below). + # A skipped reviewer is invisible; an extra one (design-lite included) is a + # visible pass that costs a review. + *.dart) m_frontend=true ;; tailwind.config.*|postcss.config.*) m_frontend=true ;; app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;; esac @@ -135,6 +140,7 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do *.yml|*.yaml) m_config=true ;; .github/*) m_config=true ;; requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;; + pubspec.lock|*/pubspec.lock) m_config=true ;; esac # Migrations: database migration files. Bare migrations/* covers a @@ -169,6 +175,9 @@ for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;; esac fi + case "$f" in + *.dart) m_backend=true ;; + esac [ "$m_frontend" = true ] && FRONTEND=true [ "$m_prompts" = true ] && PROMPTS=true diff --git a/design-review/SKILL.md b/design-review/SKILL.md index 1df794548..5d6821efc 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -535,17 +535,18 @@ Label `$B` output with the same evidence lines (`URL=`, `CONSOLE_ERRORS=`, `DIFF **Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5. -**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse. +**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker names the ecosystem and the command to OFFER; it does not prove the command works. Never execute a candidate to "check" it: probing a runner the project never had fails loudly and teaches nothing. ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Definitive ecosystem markers (presence = ecosystem, NOT a command to run) +# Ecosystem markers [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f composer.json ] && echo "RUNTIME:php" [ -f mix.exs ] && echo "RUNTIME:elixir" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" @@ -553,14 +554,14 @@ setopt +o nomatch 2>/dev/null || true # zsh compat # Detect sub-frameworks [ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails" [ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs" -# Existing test path — config files, declared scripts, AND test FILES. -# A project with real tests and no config file is the common miss. -ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null +grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && echo "FRAMEWORK:flutter" +# Test evidence: config files, declared scripts, AND test FILES (tested projects often lack a config) +ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" [ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' -# Rust keeps unit tests inside src/, so file names alone miss them +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' +# Rust unit tests live in src/; file names alone miss them [ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source" # Check opt-out marker [ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" @@ -574,6 +575,7 @@ Map the markers to the command you will OFFER — never to one you run on a gues | `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` | | `go.mod` (+ any `*_test.go`) | Go | `go test ./...` | | `Cargo.toml` | Rust | `cargo test` | +| `pubspec.yaml` | Dart / Flutter | `flutter test` (FRAMEWORK:flutter) or `dart test`; `fvm` prefix if `.fvmrc` | | `pom.xml` | JVM (Maven) | `mvn test` | | `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` | | `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` | @@ -582,11 +584,11 @@ Map the markers to the command you will OFFER — never to one you run on a gues | `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names | | `Makefile` with a `test:` target | any | `make test` | -**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. +**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Dart, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns). Store conventions as prose context for use in Phase 8e.5 or Step 7. **Skip the rest of bootstrap.** -Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate. +Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. **If BOOTSTRAP_DECLINED** appears: Print "Test bootstrap previously declined — skipping." **Skip the rest of bootstrap.** @@ -630,6 +632,7 @@ If Aside is not installed or not running (`command -v aside` prints nothing, or | Go | stdlib testing + testify | stdlib only | | JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only | | Rust | cargo test (built-in) + mockall | — | +| Dart | test / flutter_test + mocktail | mockito | | PHP | phpunit + mockery | pest | | Elixir | ExUnit (built-in) + ex_machina | — | diff --git a/docs/skills.md b/docs/skills.md index 39b09dddf..fbc0e18eb 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -413,7 +413,7 @@ It runs an 80-item visual audit on your live site — in your Aside browser, so The self-regulation heuristic is tuned for design work — CSS-only changes get a free pass (they are inherently safe and reversible), but changes to component JSX/TSX files count against the risk budget. Hard cap at 30 fixes. If the risk score exceeds 20%, it stops and asks. -**Design detector.** When you have [impeccable](https://impeccable.style) installed, its engine runs first: on a URL the page's rendered DOM is dumped (linked styles inlined) and scanned; on a feature branch with no URL the changed frontend files are scanned. Every mechanical finding arrives as a `FINDING-NNN` tagged with its rule id (`[nested-cards]`, `[low-contrast]`), and the report closes with `Detector: N → M`. gstack never runs impeccable's installer; when no engine is present it offers, once, to download the engine binary (checksum-pinned, logged in the egress ledger) and remembers the answer; without it the audit is unchanged. `gstack-config set design_detector off` disables the pre-pass. +**Design detector.** When you have [impeccable](https://impeccable.style) installed, its engine runs first: on a URL the page's rendered DOM is dumped (linked styles inlined) and scanned; on a feature branch with no URL the changed frontend files are scanned (`.dart` files excepted: they count as frontend scope, but the engine reads web sources). Every mechanical finding arrives as a `FINDING-NNN` tagged with its rule id (`[nested-cards]`, `[low-contrast]`), and the report closes with `Detector: N → M`. gstack never runs impeccable's installer; when no engine is present it offers, once, to download the engine binary (checksum-pinned, logged in the egress ledger) and remembers the answer; without it the audit is unchanged. `gstack-config set design_detector off` disables the pre-pass. ### Example @@ -586,7 +586,7 @@ Findings get action, not just listed. Obvious mechanical fixes (dead code, stale One exception: a shortcut you took deliberately and logged. A `gstack-shortcut(dec-)` marker whose decision id resolves in the decision ledger downgrades the finding to acknowledged debt. An orphan marker — one with no ledger entry behind it — doesn't suppress anything; the gap is reported normally and the marker itself gets flagged. -**Design pass.** When the diff touches frontend files, the Design specialist reads `review/design-checklist.md`, which is generated from `lib/design-catalog.ts`, so `/review`, `/ship`, and `/design-review` flag the same patterns under the same rule ids. If you have [impeccable](https://impeccable.style) installed, its engine scans the changed frontend files first: its rows bucket by tier (auto-fix, ask, possible), a detector hit and a checklist hit at the same file:line collapse into one row, and your repo's `.impeccable/config*.json` ignores are read as settled decisions. Without it, the checklist pass runs alone. +**Design pass.** When the diff touches frontend files, the Design specialist reads `review/design-checklist.md`, which is generated from `lib/design-catalog.ts`, so `/review`, `/ship`, and `/design-review` flag the same patterns under the same rule ids. If you have [impeccable](https://impeccable.style) installed, its engine scans the changed frontend files first (`.dart` files get the checklist pass only): its rows bucket by tier (auto-fix, ask, possible), a detector hit and a checklist hit at the same file:line collapse into one row, and your repo's `.impeccable/config*.json` ignores are read as settled decisions. Without it, the checklist pass runs alone. ### Example diff --git a/lib/frontend-scope.ts b/lib/frontend-scope.ts index 0b22c9b64..880741fba 100644 --- a/lib/frontend-scope.ts +++ b/lib/frontend-scope.ts @@ -11,6 +11,9 @@ const EXTENSIONS = new Set([ '.tsx', '.jsx', '.vue', '.svelte', '.astro', '.erb', '.haml', '.slim', '.hbs', '.ejs', '.html', + // Dart: a Flutter widget and a repository share the extension, so the bash + // script counts .dart as frontend AND backend; only the frontend half lives here. + '.dart', ]); // Root-level only: the bash arm's glob (`tailwind.config.*`) is matched against the diff --git a/plan-eng-review/sections/review-sections.md b/plan-eng-review/sections/review-sections.md index 634a008eb..f57969f05 100644 --- a/plan-eng-review/sections/review-sections.md +++ b/plan-eng-review/sections/review-sections.md @@ -585,20 +585,21 @@ Before analyzing coverage, detect the project's test framework: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Detect project runtime (markers are evidence, not commands to run blind) +# Runtime markers: evidence, not commands to run [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" { [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle" -# Check for existing test infrastructure — config files, scripts, AND test files -ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null +# Test infra: configs, scripts, test files +ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' ``` 3. **If no framework detected:** State that the framework is unknown; continue the diagram and planned assertions. If proposing a new framework, settle that choice through Decision procedure in Test step 5. Reuse an exact prior approval; with no selection proposed, ask no framework question. Do not install a framework or write the proposed tests during this review. diff --git a/review/sections/adversarial.md b/review/sections/adversarial.md index a8ebcd46a..67549cc33 100644 --- a/review/sections/adversarial.md +++ b/review/sections/adversarial.md @@ -8,9 +8,7 @@ Every diff gets the Claude adversarial pass. Add Codex when its preflight is rea ```bash DIFF_BASE=$(git merge-base origin/ HEAD) -DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0") -DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") -DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) +DIFF_TOTAL=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') echo "DIFF_SIZE: $DIFF_TOTAL" ``` diff --git a/review/sections/review-army.md b/review/sections/review-army.md index 2043282e0..f6e17e1c4 100644 --- a/review/sections/review-army.md +++ b/review/sections/review-army.md @@ -14,6 +14,7 @@ STACK="" [ -f requirements.txt ] || [ -f pyproject.toml ] && STACK="${STACK}python " [ -f go.mod ] && STACK="${STACK}go " [ -f Cargo.toml ] && STACK="${STACK}rust " +[ -f pubspec.yaml ] && STACK="${STACK}dart " echo "STACK: ${STACK:-unknown}" DIFF_BASE=$(git merge-base origin/ HEAD) DIFF_LINES=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') @@ -25,6 +26,7 @@ TEST_FW="" { [ -f spec/spec_helper.rb ] || [ -f .rspec ]; } && TEST_FW="rspec" { [ -f pytest.ini ] || [ -f conftest.py ]; } && TEST_FW="pytest" [ -f go.mod ] && TEST_FW="go-test" +[ -f pubspec.yaml ] && TEST_FW="dart-test" && grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && TEST_FW="flutter-test" echo "TEST_FW: ${TEST_FW:-unknown}" ``` diff --git a/scripts/resolvers/outside-voice-steps.ts b/scripts/resolvers/outside-voice-steps.ts index 40d7664ed..b187a1d5d 100644 --- a/scripts/resolvers/outside-voice-steps.ts +++ b/scripts/resolvers/outside-voice-steps.ts @@ -243,9 +243,7 @@ Every diff gets the ${outsideVoiceFor(ctx).nativeLabel} adversarial pass. Add ${ \`\`\`bash DIFF_BASE=$(git merge-base origin/ HEAD) -DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0") -DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") -DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) +DIFF_TOTAL=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') echo "DIFF_SIZE: $DIFF_TOTAL" \`\`\` diff --git a/scripts/resolvers/review-army.ts b/scripts/resolvers/review-army.ts index 4601341fd..f8bea9068 100644 --- a/scripts/resolvers/review-army.ts +++ b/scripts/resolvers/review-army.ts @@ -33,6 +33,7 @@ STACK="" [ -f requirements.txt ] || [ -f pyproject.toml ] && STACK="\${STACK}python " [ -f go.mod ] && STACK="\${STACK}go " [ -f Cargo.toml ] && STACK="\${STACK}rust " +[ -f pubspec.yaml ] && STACK="\${STACK}dart " echo "STACK: \${STACK:-unknown}" DIFF_BASE=$(git merge-base origin/ HEAD) DIFF_LINES=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') @@ -44,6 +45,7 @@ TEST_FW="" { [ -f spec/spec_helper.rb ] || [ -f .rspec ]; } && TEST_FW="rspec" { [ -f pytest.ini ] || [ -f conftest.py ]; } && TEST_FW="pytest" [ -f go.mod ] && TEST_FW="go-test" +[ -f pubspec.yaml ] && TEST_FW="dart-test" && grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && TEST_FW="flutter-test" echo "TEST_FW: \${TEST_FW:-unknown}" \`\`\` diff --git a/scripts/resolvers/testing.ts b/scripts/resolvers/testing.ts index eb43ff804..e66b5b712 100644 --- a/scripts/resolvers/testing.ts +++ b/scripts/resolvers/testing.ts @@ -8,17 +8,18 @@ export function generateTestBootstrap(ctx: TemplateContext): string { **Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5. -**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse. +**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker names the ecosystem and the command to OFFER; it does not prove the command works. Never execute a candidate to "check" it: probing a runner the project never had fails loudly and teaches nothing. \`\`\`bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Definitive ecosystem markers (presence = ecosystem, NOT a command to run) +# Ecosystem markers [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f composer.json ] && echo "RUNTIME:php" [ -f mix.exs ] && echo "RUNTIME:elixir" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" @@ -26,14 +27,14 @@ setopt +o nomatch 2>/dev/null || true # zsh compat # Detect sub-frameworks [ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails" [ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs" -# Existing test path — config files, declared scripts, AND test FILES. -# A project with real tests and no config file is the common miss. -ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null +grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && echo "FRAMEWORK:flutter" +# Test evidence: config files, declared scripts, AND test FILES (tested projects often lack a config) +ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" [ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\\.py$|(^|/)test_[^/]+\\.py$|_test\\.(go|py|rb|ts|js|exs)$|\\.(test|spec)\\.[jt]sx?$|_spec\\.rb$|Test\\.(java|kt)$' | sed 's/^/TESTFILES:/' -# Rust keeps unit tests inside src/, so file names alone miss them +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\\.py$|(^|/)test_[^/]+\\.py$|_test\\.(go|py|rb|ts|js|exs|dart)$|\\.(test|spec)\\.[jt]sx?$|_spec\\.rb$|Test\\.(java|kt)$' | sed 's/^/TESTFILES:/' +# Rust unit tests live in src/; file names alone miss them [ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source" # Check opt-out marker [ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" @@ -47,6 +48,7 @@ Map the markers to the command you will OFFER — never to one you run on a gues | \`pytest.ini\` / \`tox.ini\` / pytest in \`pyproject.toml\` / \`test_*.py\` | Python | \`pytest\` | | \`go.mod\` (+ any \`*_test.go\`) | Go | \`go test ./...\` | | \`Cargo.toml\` | Rust | \`cargo test\` | +| \`pubspec.yaml\` | Dart / Flutter | \`flutter test\` (FRAMEWORK:flutter) or \`dart test\`; \`fvm\` prefix if \`.fvmrc\` | | \`pom.xml\` | JVM (Maven) | \`mvn test\` | | \`build.gradle\` / \`build.gradle.kts\` | JVM (Gradle) | \`./gradlew test\` | | \`Gemfile\` / \`Rakefile\` / \`.rspec\` | Ruby | \`bundle exec rspec\`, \`bin/rails test\`, or \`rake test\` | @@ -55,11 +57,11 @@ Map the markers to the command you will OFFER — never to one you run on a gues | \`package.json\` with a \`test\` script | Node | that script, run with the package manager the lockfile names | | \`Makefile\` with a \`test:\` target | any | \`make test\` | -**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero \`TESTFILES:\` count, or \`TESTS:rust in-source\`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's \`## Testing\` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. +**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero \`TESTFILES:\` count, or \`TESTS:rust in-source\`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's \`## Testing\` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Dart, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns). Store conventions as prose context for use in ${ctx.skillName === 'ship' ? 'Step 7' : 'Phase 8e.5 or Step 7'}. **Skip the rest of bootstrap.** -Absent config files and absent \`tests/\` directories are NOT evidence of "no tests": Django keeps tests in \`/tests.py\`, Go in \`*_test.go\` beside the source, Rust in \`#[test]\` blocks inside \`src/\`. A green \`python manage.py test\` with no \`pytest.ini\` is a tested project, not a bootstrap candidate. +Absent config files and absent \`tests/\` directories are NOT evidence of "no tests": Django keeps tests in \`/tests.py\`, Go in \`*_test.go\` beside the source, Rust in \`#[test]\` blocks inside \`src/\`. ${ctx.skillName === 'ship' ? '**If BOOTSTRAP_DECLINED** appears:\n- Step 5\'s explicit Add tests choice overrides that marker for this invocation only: continue to runtime detection and B2–B3, including framework approval.\n- Otherwise print "Test bootstrap previously declined — skipping" and **skip the rest of bootstrap**.' @@ -99,6 +101,7 @@ If Aside is not installed or not running (\`command -v aside\` prints nothing, o | Go | stdlib testing + testify | stdlib only | | JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only | | Rust | cargo test (built-in) + mockall | — | +| Dart | test / flutter_test + mocktail | mockito | | PHP | phpunit + mockery | pest | | Elixir | ExUnit (built-in) + ex_machina | — | @@ -269,20 +272,21 @@ Before analyzing coverage, detect the project's test framework: \`\`\`bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Detect project runtime (markers are evidence, not commands to run blind) +# Runtime markers: evidence, not commands to run [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" { [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle" -# Check for existing test infrastructure — config files, scripts, AND test files -ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null +# Test infra: configs, scripts, test files +ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\\.py$|(^|/)test_[^/]+\\.py$|_test\\.(go|py|rb|ts|js|exs)$|\\.(test|spec)\\.[jt]sx?$|_spec\\.rb$|Test\\.(java|kt)$' | sed 's/^/TESTFILES:/' +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\\.py$|(^|/)test_[^/]+\\.py$|_test\\.(go|py|rb|ts|js|exs|dart)$|\\.(test|spec)\\.[jt]sx?$|_spec\\.rb$|Test\\.(java|kt)$' | sed 's/^/TESTFILES:/' \`\`\` 3. **If no framework detected:**${mode === 'ship' ? ' use the bootstrap decision already made in Step 4; report diagram-only coverage if setup was declined. Do not restart bootstrap from this audit.' : ' State that the framework is unknown; continue the diagram and planned assertions. If proposing a new framework, settle that choice through Decision procedure in Test step 5. Reuse an exact prior approval; with no selection proposed, ask no framework question. Do not install a framework or write the proposed tests during this review.'}`); diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index bfcafef8e..8a32faeea 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -8,9 +8,7 @@ Every diff gets the Claude adversarial pass. Add Codex when its preflight is rea ```bash DIFF_BASE=$(git merge-base origin/ HEAD) -DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0") -DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") -DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) +DIFF_TOTAL=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') echo "DIFF_SIZE: $DIFF_TOTAL" ``` diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index dab702dd8..89f1e7a42 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -276,6 +276,7 @@ STACK="" [ -f requirements.txt ] || [ -f pyproject.toml ] && STACK="${STACK}python " [ -f go.mod ] && STACK="${STACK}go " [ -f Cargo.toml ] && STACK="${STACK}rust " +[ -f pubspec.yaml ] && STACK="${STACK}dart " echo "STACK: ${STACK:-unknown}" DIFF_BASE=$(git merge-base origin/ HEAD) DIFF_LINES=$(git diff "$DIFF_BASE" --numstat | awk '{n+=$1+$2} END {print n+0}') @@ -287,6 +288,7 @@ TEST_FW="" { [ -f spec/spec_helper.rb ] || [ -f .rspec ]; } && TEST_FW="rspec" { [ -f pytest.ini ] || [ -f conftest.py ]; } && TEST_FW="pytest" [ -f go.mod ] && TEST_FW="go-test" +[ -f pubspec.yaml ] && TEST_FW="dart-test" && grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && TEST_FW="flutter-test" echo "TEST_FW: ${TEST_FW:-unknown}" ``` diff --git a/ship/sections/test-coverage.md b/ship/sections/test-coverage.md index ef8859a58..1d4dfb904 100644 --- a/ship/sections/test-coverage.md +++ b/ship/sections/test-coverage.md @@ -48,20 +48,21 @@ Before analyzing coverage, detect the project's test framework: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Detect project runtime (markers are evidence, not commands to run blind) +# Runtime markers: evidence, not commands to run [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" { [ -f build.gradle ] || [ -f build.gradle.kts ]; } && echo "RUNTIME:jvm BUILD:gradle" -# Check for existing test infrastructure — config files, scripts, AND test files -ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml 2>/dev/null +# Test infra: configs, scripts, test files +ls jest.config.* vitest.config.* playwright.config.* cypress.config.* .rspec pytest.ini tox.ini phpunit.xml dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' ``` 3. **If no framework detected:** use the bootstrap decision already made in Step 4; report diagram-only coverage if setup was declined. Do not restart bootstrap from this audit. diff --git a/ship/sections/tests.md b/ship/sections/tests.md index 14a488649..280834f6d 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -6,17 +6,18 @@ **Read the project's CLAUDE.md (and TESTING.md if present) FIRST.** If it documents a test command, the project already told you: no detection, no bootstrap. Skip the rest of bootstrap and use that command in Step 5. -**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker tells you which ecosystem you're in and which command to OFFER. It does not tell you the command works. Do not execute a candidate test command to "check" it: a probe on a project that never had that runner fails loudly and teaches you nothing, and installing a second framework over a working one is worse. +**Otherwise gather markers. Every marker below is EVIDENCE for the question you ask — never a command to run blind.** A marker names the ecosystem and the command to OFFER; it does not prove the command works. Never execute a candidate to "check" it: probing a runner the project never had fails loudly and teaches nothing. ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -# Definitive ecosystem markers (presence = ecosystem, NOT a command to run) +# Ecosystem markers [ -f manage.py ] && echo "RUNTIME:python FRAMEWORK:django MARKER:manage.py" { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f tox.ini ] || [ -f setup.cfg ] || [ -f requirements.txt ]; } && echo "RUNTIME:python" { [ -f Gemfile ] || [ -f Rakefile ] || [ -f .rspec ]; } && echo "RUNTIME:ruby" [ -f package.json ] && echo "RUNTIME:node" [ -f go.mod ] && echo "RUNTIME:go" [ -f Cargo.toml ] && echo "RUNTIME:rust" +[ -f pubspec.yaml ] && echo "RUNTIME:dart" [ -f composer.json ] && echo "RUNTIME:php" [ -f mix.exs ] && echo "RUNTIME:elixir" [ -f pom.xml ] && echo "RUNTIME:jvm BUILD:maven" @@ -24,14 +25,14 @@ setopt +o nomatch 2>/dev/null || true # zsh compat # Detect sub-frameworks [ -f Gemfile ] && grep -q "rails" Gemfile 2>/dev/null && echo "FRAMEWORK:rails" [ -f package.json ] && grep -q '"next"' package.json 2>/dev/null && echo "FRAMEWORK:nextjs" -# Existing test path — config files, declared scripts, AND test FILES. -# A project with real tests and no config file is the common miss. -ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* 2>/dev/null +grep -qsE '^ +sdk: *.?flutter' pubspec.yaml && echo "FRAMEWORK:flutter" +# Test evidence: config files, declared scripts, AND test FILES (tested projects often lack a config) +ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini tox.ini phpunit.xml* dart_test.yaml 2>/dev/null [ -f package.json ] && grep -q '"test"[[:space:]]*:' package.json && echo "SCRIPT:package.json test" [ -f Makefile ] && grep -qE '^(test|check):' Makefile && echo "TARGET:make test" [ -f pyproject.toml ] && grep -q "pytest" pyproject.toml && echo "CONFIG:pyproject pytest" -git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' -# Rust keeps unit tests inside src/, so file names alone miss them +git ls-files | grep -cE '(^|/)(tests?|spec|__tests__)/|(^|/)tests?\.py$|(^|/)test_[^/]+\.py$|_test\.(go|py|rb|ts|js|exs|dart)$|\.(test|spec)\.[jt]sx?$|_spec\.rb$|Test\.(java|kt)$' | sed 's/^/TESTFILES:/' +# Rust unit tests live in src/; file names alone miss them [ -f Cargo.toml ] && git grep -lF '#[test]' -- 'src' >/dev/null 2>&1 && echo "TESTS:rust in-source" # Check opt-out marker [ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" @@ -45,6 +46,7 @@ Map the markers to the command you will OFFER — never to one you run on a gues | `pytest.ini` / `tox.ini` / pytest in `pyproject.toml` / `test_*.py` | Python | `pytest` | | `go.mod` (+ any `*_test.go`) | Go | `go test ./...` | | `Cargo.toml` | Rust | `cargo test` | +| `pubspec.yaml` | Dart / Flutter | `flutter test` (FRAMEWORK:flutter) or `dart test`; `fvm` prefix if `.fvmrc` | | `pom.xml` | JVM (Maven) | `mvn test` | | `build.gradle` / `build.gradle.kts` | JVM (Gradle) | `./gradlew test` | | `Gemfile` / `Rakefile` / `.rspec` | Ruby | `bundle exec rspec`, `bin/rails test`, or `rake test` | @@ -53,11 +55,11 @@ Map the markers to the command you will OFFER — never to one you run on a gues | `package.json` with a `test` script | Node | that script, run with the package manager the lockfile names | | `Makefile` with a `test:` target | any | `make test` | -**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. +**If ANY existing-test evidence appears** (a config file, a declared test script or make target, a nonzero `TESTFILES:` count, or `TESTS:rust in-source`): the project has tests. **Do NOT bootstrap.** Print "Existing tests detected: {the evidence}." Then get the command the same way Step 5 does — CLAUDE.md/TESTING.md if documented, otherwise AskUserQuestion offering the candidates from the table above plus "Other", and persist the answer to CLAUDE.md's `## Testing` section so it is never asked again. When the ecosystem ships a runner (Django, Go, Rust, Dart, Elixir, Maven/Gradle), that runner is the candidate — never install a second framework beside a working one. Read 2-3 existing test files to learn conventions (naming, imports, assertion style, setup patterns). Store conventions as prose context for use in Step 7. **Skip the rest of bootstrap.** -Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. A green `python manage.py test` with no `pytest.ini` is a tested project, not a bootstrap candidate. +Absent config files and absent `tests/` directories are NOT evidence of "no tests": Django keeps tests in `/tests.py`, Go in `*_test.go` beside the source, Rust in `#[test]` blocks inside `src/`. **If BOOTSTRAP_DECLINED** appears: - Step 5's explicit Add tests choice overrides that marker for this invocation only: continue to runtime detection and B2–B3, including framework approval. @@ -103,6 +105,7 @@ If Aside is not installed or not running (`command -v aside` prints nothing, or | Go | stdlib testing + testify | stdlib only | | JVM (Maven/Gradle) | JUnit 5 + AssertJ | JUnit 5 only | | Rust | cargo test (built-in) + mockall | — | +| Dart | test / flutter_test + mocktail | mockito | | PHP | phpunit + mockery | pest | | Elixir | ExUnit (built-in) + ex_machina | — | diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 6b4104451..b5bc4fd10 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -87,6 +87,19 @@ describe('gstack-diff-scope', () => { } }); + // A Dart change matched no category but its test file, so a Flutter widget PR + // set SCOPE_TESTS alone and skipped the design, performance and security passes. + test('detects Dart as both frontend and backend; pubspec.lock is config', () => { + const scope = runScope(createRepo(['lib/theme.dart', 'lib/widgets/drawer.dart', 'test/drawer_test.dart'])); + expect(scope.SCOPE_FRONTEND).toBe('true'); + expect(scope.SCOPE_BACKEND).toBe('true'); + expect(scope.SCOPE_TESTS).toBe('true'); + expect(runScope(createRepo(['pubspec.lock'])).SCOPE_CONFIG).toBe('true'); + // Value: protects=a workspace package's pubspec.lock still reads as config; fails_when=the */pubspec.lock arm is dropped; + // why_new=only the root lock was checked; seam=none; no_claim=none beyond protects + expect(runScope(createRepo(['packages/cache/pubspec.lock'])).SCOPE_CONFIG).toBe('true'); + }); + test('detects test files', () => { const dir = createRepo(['test/app.test.ts']); const scope = runScope(dir); diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index b51d87adb..9cd3e0183 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -4,92 +4,92 @@ "pins": { "agy/gstack-autoplan": 130339, "agy/gstack-design-consultation": 108037, - "agy/gstack-design-review": 143386, + "agy/gstack-design-review": 143381, "agy/gstack-land-and-deploy": 116578, "agy/gstack-plan-design-review": 139980, "agy/gstack-plan-devex-review": 135727, - "agy/gstack-plan-eng-review": 152669, - "agy/gstack-review": 124266, - "claude/design-review": 136826, + "agy/gstack-plan-eng-review": 152668, + "agy/gstack-review": 124086, + "claude/design-review": 136821, "codex/gstack-autoplan": 130298, "codex/gstack-design-consultation": 109239, - "codex/gstack-design-review": 144820, + "codex/gstack-design-review": 144815, "codex/gstack-land-and-deploy": 121155, "codex/gstack-office-hours": 102108, "codex/gstack-plan-design-review": 142367, "codex/gstack-plan-devex-review": 138550, - "codex/gstack-plan-eng-review": 155149, - "codex/gstack-review": 125071, + "codex/gstack-plan-eng-review": 155148, + "codex/gstack-review": 124891, "copilot/gstack-autoplan": 131472, "copilot/gstack-codex": 100103, "copilot/gstack-design-consultation": 109389, - "copilot/gstack-design-review": 144691, + "copilot/gstack-design-review": 144686, "copilot/gstack-land-and-deploy": 117848, "copilot/gstack-office-hours": 100396, "copilot/gstack-plan-design-review": 140851, "copilot/gstack-plan-devex-review": 136879, - "copilot/gstack-plan-eng-review": 153394, - "copilot/gstack-review": 125537, + "copilot/gstack-plan-eng-review": 153393, + "copilot/gstack-review": 125357, "cursor/gstack-autoplan": 129113, "cursor/gstack-design-consultation": 107075, - "cursor/gstack-design-review": 142365, + "cursor/gstack-design-review": 142360, "cursor/gstack-land-and-deploy": 115517, "cursor/gstack-plan-design-review": 138935, "cursor/gstack-plan-devex-review": 134558, - "cursor/gstack-plan-eng-review": 151523, - "cursor/gstack-review": 140637, + "cursor/gstack-plan-eng-review": 151522, + "cursor/gstack-review": 140618, "factory/gstack-autoplan": 131397, "factory/gstack-codex": 100523, "factory/gstack-design-consultation": 108523, - "factory/gstack-design-review": 144155, + "factory/gstack-design-review": 144150, "factory/gstack-land-and-deploy": 119700, "factory/gstack-office-hours": 101275, "factory/gstack-plan-design-review": 141716, "factory/gstack-plan-devex-review": 137927, - "factory/gstack-plan-eng-review": 154637, - "factory/gstack-review": 144416, + "factory/gstack-plan-eng-review": 154636, + "factory/gstack-review": 144397, "gbrain/gstack-autoplan": 129650, - "gbrain/gstack-design-review": 132733, + "gbrain/gstack-design-review": 132728, "gbrain/gstack-land-and-deploy": 116039, "gbrain/gstack-plan-design-review": 127855, "gbrain/gstack-plan-devex-review": 115129, - "gbrain/gstack-plan-eng-review": 134288, + "gbrain/gstack-plan-eng-review": 134287, "hermes/gstack-autoplan": 129740, - "hermes/gstack-design-review": 132840, + "hermes/gstack-design-review": 132835, "hermes/gstack-land-and-deploy": 116141, "hermes/gstack-plan-design-review": 127964, "hermes/gstack-plan-devex-review": 115215, - "hermes/gstack-plan-eng-review": 134373, + "hermes/gstack-plan-eng-review": 134372, "kiro/gstack-autoplan": 128875, "kiro/gstack-design-consultation": 106917, - "kiro/gstack-design-review": 142179, + "kiro/gstack-design-review": 142174, "kiro/gstack-land-and-deploy": 115407, "kiro/gstack-plan-design-review": 138775, "kiro/gstack-plan-devex-review": 134398, - "kiro/gstack-plan-eng-review": 151373, - "kiro/gstack-review": 140449, + "kiro/gstack-plan-eng-review": 151372, + "kiro/gstack-review": 140430, "openclaw/gstack-autoplan": 129355, - "openclaw/gstack-design-review": 131538, + "openclaw/gstack-design-review": 131533, "openclaw/gstack-land-and-deploy": 115627, "openclaw/gstack-plan-design-review": 126706, "openclaw/gstack-plan-devex-review": 113968, - "openclaw/gstack-plan-eng-review": 132685, + "openclaw/gstack-plan-eng-review": 132684, "opencode/gstack-autoplan": 131475, "opencode/gstack-design-consultation": 109210, - "opencode/gstack-design-review": 144563, + "opencode/gstack-design-review": 144558, "opencode/gstack-land-and-deploy": 117625, "opencode/gstack-office-hours": 100230, "opencode/gstack-plan-design-review": 140705, "opencode/gstack-plan-devex-review": 136779, - "opencode/gstack-plan-eng-review": 153275, - "opencode/gstack-review": 142872, + "opencode/gstack-plan-eng-review": 153274, + "opencode/gstack-review": 142853, "slate/gstack-autoplan": 128994, "slate/gstack-design-consultation": 106996, - "slate/gstack-design-review": 142272, + "slate/gstack-design-review": 142267, "slate/gstack-land-and-deploy": 115462, "slate/gstack-plan-design-review": 138855, "slate/gstack-plan-devex-review": 134478, - "slate/gstack-plan-eng-review": 151448, - "slate/gstack-review": 140543 + "slate/gstack-plan-eng-review": 151447, + "slate/gstack-review": 140524 } } diff --git a/test/frontend-scope.test.ts b/test/frontend-scope.test.ts index 4cfca65fd..24977a7ce 100644 --- a/test/frontend-scope.test.ts +++ b/test/frontend-scope.test.ts @@ -44,6 +44,9 @@ const SAMPLES: Array<[string, boolean]> = [ ['components.md', false], ['public/Index.HTML', false], // bash globs are case-sensitive; the mirror must agree ['src/App.TSX', false], + ['lib/widgets/bbtm_drawer/view/bbtm_drawer.dart', true], + ['packages/cache/lib/cache.dart', true], + ['test/ui/drawer_widget_test.dart', true], ]; describe('isFrontendPath', () => { @@ -86,5 +89,5 @@ describe.skipIf(!POSIX)('parity with bin/gstack-diff-scope', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } - }); + }, 30_000); // one git commit + one bash run per sample: past bun's 5 s default }); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 59572e841..6349ec7d9 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -29,7 +29,7 @@ let IMPECCABLE_HOME: string; function git(cwd: string, ...args: string[]) { const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 }); - if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: status=${r.status} signal=${r.signal} error=${r.error?.message ?? ''} stderr=${r.stderr ?? ''}`); return r.stdout.trim(); } @@ -907,6 +907,10 @@ describe('scan', () => { fs.mkdirSync(path.join(REPO, 'styles'), { recursive: true }); fs.writeFileSync(path.join(REPO, 'styles', 'new.css'), 'body { color: red }\n'); // untracked frontend fs.writeFileSync(path.join(REPO, 'notes.md'), 'x\n'); // untracked non-frontend + // Value: protects=a .dart file (frontend scope since lib/frontend-scope.ts counts it) never reaches the web-source engine; + // fails_when=the .dart exclusion in resolveTargets is dropped; why_new=no --changed case had a Dart file; seam=none; + // no_claim=the real impeccable engine (fake engine logs argv) + fs.writeFileSync(path.join(REPO, 'drawer.dart'), 'class Drawer {}\n'); // untracked Dart: frontend scope, not a scan target const log = path.join(SANDBOX, 'argv4.log'); fs.rmSync(log, { force: true }); try { @@ -918,6 +922,7 @@ describe('scan', () => { } finally { fs.rmSync(path.join(REPO, 'styles'), { recursive: true, force: true }); fs.rmSync(path.join(REPO, 'notes.md'), { force: true }); + fs.rmSync(path.join(REPO, 'drawer.dart'), { force: true }); git(REPO, 'checkout', '-q', 'main'); git(REPO, 'branch', '-q', '-D', 'feature'); } diff --git a/test/outside-voice-preflight.test.ts b/test/outside-voice-preflight.test.ts index 0bf7fc93a..04ea85524 100644 --- a/test/outside-voice-preflight.test.ts +++ b/test/outside-voice-preflight.test.ts @@ -240,3 +240,31 @@ describe('outside reviewer runtime discovery in fresh shells', () => { expect(fs.existsSync(path.join(f.repo, 'NEVER'))).toBe(false); }); }); + +// Value: protects=the adversarial DIFF_SIZE sums insertions+deletions across files and ignores binary rows; +// fails_when=the numstat/awk sum reads the wrong field or a binary "-" row breaks it; why_new=no test ran the +// size block; seam=none; no_claim=the size thresholds that consume DIFF_SIZE +test('adversarial DIFF_SIZE counts changed lines from numstat', () => { + const ctx: TemplateContext = { host: 'claude', skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', paths: HOST_PATHS.claude }; + const fence = generateAdversarialStep(ctx).match(/```bash\n(DIFF_BASE=[\s\S]*?)\n```/); + expect(fence).not.toBeNull(); + const repo = fs.mkdtempSync(path.join(TEMP, 'diff-size-')); + const git = (...args: string[]) => { + const r = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: repo, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(0); + }; + git('init', '-q', '-b', 'main'); + fs.writeFileSync(path.join(repo, 'a.txt'), 'one\ntwo\n'); + git('add', '-A'); + git('commit', '-qm', 'base'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + git('checkout', '-q', '-b', 'feature'); + fs.writeFileSync(path.join(repo, 'a.txt'), 'one\nTWO\n'); // 1 insertion + 1 deletion + fs.writeFileSync(path.join(repo, 'b.txt'), 'x\ny\nz\n'); // 3 insertions + fs.writeFileSync(path.join(repo, 'c.bin'), Buffer.from([0, 1, 2, 0])); // binary: "-\t-" in numstat + git('add', '-A'); + git('commit', '-qm', 'change'); + const r = spawnSync('bash', ['-c', fence![1].replaceAll('', 'main')], { cwd: repo, encoding: 'utf8', timeout: 30_000 }); + expect(r.stderr).toBe(''); + expect(r.stdout.trim()).toBe('DIFF_SIZE: 5'); +}); diff --git a/test/ship-test-detection-markers.test.ts b/test/ship-test-detection-markers.test.ts index e842642a4..c170b41ca 100644 --- a/test/ship-test-detection-markers.test.ts +++ b/test/ship-test-detection-markers.test.ts @@ -20,21 +20,23 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { generateTestBootstrap } from '../scripts/resolvers/testing'; +import { generateTestBootstrap, generateTestCoverageAuditShip } from '../scripts/resolvers/testing'; +import { generateReviewArmy } from '../scripts/resolvers/review-army'; +import { HOST_PATHS } from '../scripts/resolvers/types'; const section = generateTestBootstrap({} as never); /** The detection block the skill tells the agent to run (first bash fence). */ -function detectionScript(): string { - const open = section.indexOf('```bash\n'); +function detectionScript(from: string = section): string { + const open = from.indexOf('```bash\n'); expect(open).toBeGreaterThan(-1); const start = open + '```bash\n'.length; - const end = section.indexOf('```', start); - return section.slice(start, end); + const end = from.indexOf('```', start); + return from.slice(start, end); } /** Run the detection block in a throwaway git repo laid out by `files`. */ -function detect(files: Record): string { +function detect(files: Record, script: string = detectionScript()): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-detect-')); try { for (const [rel, body] of Object.entries(files)) { @@ -42,15 +44,15 @@ function detect(files: Record): string { fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); } - const script = path.join(dir, '.detect.sh'); - fs.writeFileSync(script, detectionScript()); + const scriptPath = path.join(dir, '.detect.sh'); + fs.writeFileSync(scriptPath, script); const git = (...args: string[]) => execFileSync('git', args, { timeout: 30_000, cwd: dir }); git('init', '-q', '.'); git('add', '-A'); git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture'); // The block's last line is a `[ -f marker ] && echo`, so a clean project // exits 1 by design — read stdout, don't trust the status. - return execFileSync('bash', [script], { timeout: 30_000, cwd: dir, encoding: 'utf-8' }); + return execFileSync('bash', [scriptPath], { timeout: 30_000, cwd: dir, encoding: 'utf-8' }); } catch (err: unknown) { const e = err as { stdout?: string }; if (typeof e.stdout === 'string') return e.stdout; @@ -93,6 +95,37 @@ describe('/ship Step 4 detection is multi-ecosystem', () => { expect(rust).toContain('TESTS:rust in-source'); }); + test('Dart and Flutter report their runtime, the Flutter SDK, and *_test.dart outside test/', () => { + const flutter = detect({ + 'pubspec.yaml': 'name: app\ndependencies:\n flutter:\n sdk: flutter\n', + 'lib/src/theme_test.dart': 'void main() {}\n', + }); + expect(flutter).toContain('RUNTIME:dart'); + expect(flutter).toContain('FRAMEWORK:flutter'); + expect(testFileCount(flutter)).toBe(1); + + // A commented-out Flutter dependency is not Flutter. + const dart = detect({ 'pubspec.yaml': 'name: x\n# flutter:\n# sdk: flutter\n', 'lib/x.dart': 'int x = 1;\n' }); + expect(dart).toContain('RUNTIME:dart'); + expect(dart).not.toContain('FRAMEWORK:flutter'); + expect(testFileCount(dart)).toBe(0); + + // dart_test.yaml can move discovery off *_test.dart (paths/filename), so it is test evidence on its own. + const configured = detect({ 'pubspec.yaml': 'name: x\n', 'dart_test.yaml': 'paths: [checks]\n', 'checks/test_math.dart': 'void main() {}\n' }); + expect(configured).toContain('dart_test.yaml'); + }); + + // Value: protects=the Step 7 coverage audit sees a Dart project and its *_test.dart files, like bootstrap does; + // fails_when=the coverage-audit block drops RUNTIME:dart or `dart` from its _test regex; why_new=only the + // bootstrap block was run; seam=none; no_claim=none beyond protects + test('the Step 7 coverage-audit detection block agrees with bootstrap on Dart', () => { + const audit = generateTestCoverageAuditShip({ host: 'claude', skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', paths: HOST_PATHS.claude } as never); + const block = audit.slice(audit.indexOf('Test Framework Detection')); + const out = detect({ 'pubspec.yaml': 'name: x\n', 'lib/src/theme_test.dart': 'void main() {}\n' }, detectionScript(block)); + expect(out).toContain('RUNTIME:dart'); + expect(testFileCount(out)).toBe(1); + }); + test('a genuinely untested project reports no test evidence', () => { const out = detect({ 'go.mod': 'module x\n', 'x.go': 'package x\n' }); expect(out).toContain('RUNTIME:go'); @@ -103,7 +136,7 @@ describe('/ship Step 4 detection is multi-ecosystem', () => { test('every ecosystem marker maps to a command to OFFER, not to run blind', () => { expect(section).toContain('never a command to run blind'); for (const marker of ['manage.py', 'go.mod', 'Cargo.toml', 'pom.xml', - 'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini']) { + 'build.gradle', 'mix.exs', 'composer.json', 'Gemfile', 'pytest.ini', 'pubspec.yaml']) { expect(section).toContain(marker); } // The ask-and-persist contract, not a hardcoded project command. @@ -111,3 +144,43 @@ describe('/ship Step 4 detection is multi-ecosystem', () => { expect(section).toContain('persist the answer to CLAUDE.md'); }); }); + +// Value: protects=review-army STACK/TEST_FW name Dart and tell flutter_test from dart test; fails_when=the pubspec.yaml lines +// are dropped or the Flutter grep matches any `sdk:` (environment.sdk), a comment, or misses a quoted value; why_new=no free test runs the review-army +// detection lines; seam=none; no_claim=specialist selection from these values (only the emitted lines run) +describe('review-army stack detection names Dart and Flutter', () => { + const block = generateReviewArmy({ host: 'claude', skillName: 'ship', tmplPath: 'ship/SKILL.md.tmpl', paths: HOST_PATHS.claude }); + const lines = (from: string, to: string): string => { + const start = block.indexOf(from); + const end = block.indexOf(to, start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return block.slice(start, block.indexOf('\n', end)); + }; + const script = `${lines('STACK=""', 'echo "STACK:')}\n${lines('TEST_FW=""', 'echo "TEST_FW:')}\n`; + + function stack(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-army-stack-')); + try { + for (const [rel, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), body); + return execFileSync('bash', ['-c', script], { timeout: 30_000, cwd: dir, encoding: 'utf-8' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + test('Flutter app, pure Dart package, and a Dart SDK constraint that is not Flutter', () => { + // Quoted `sdk: "flutter"` is the same YAML value. + const flutter = stack({ 'pubspec.yaml': 'name: app\ndependencies:\n flutter:\n sdk: "flutter"\n' }); + expect(flutter).toContain('STACK: dart'); + expect(flutter).toContain('TEST_FW: flutter-test'); + + const dart = stack({ 'pubspec.yaml': 'name: x\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n# sdk: flutter\n' }); + expect(dart).toContain('STACK: dart'); + expect(dart).toContain('TEST_FW: dart-test'); + + const none = stack({ 'README.md': 'x\n' }); + expect(none).toContain('STACK: unknown'); + expect(none).toContain('TEST_FW: unknown'); + }); +});