diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c57f156..48b78d9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.160.0] - 2026-10-19 + +**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.159.0] - 2026-10-19 **Security: the repository gstack runs in can no longer configure gstack's own programs.** Bun loads `.env`, `.env.local` and `.env.` from the directory it starts in. It also runs any `preload` that a `bunfig.toml` there names, before the program's first line. gstack runs inside the repository it audits, so a committed or branch `.env` could set `GSTACK_STATE_ROOT`, `GSTACK_PROJECT_SLUG`, `GSTACK_CLAUDE_BIN` (an attacker-chosen binary then ran as "claude"), `GSTACK_SPEND_CAP_USD` or a provider base URL for every gstack bin, hook and skill step. A `bunfig.toml` preload ran arbitrary code in all of them. Every Bun that gstack starts now passes `--no-env-file` and an empty `--config`. Your shell environment works exactly as before. Found in review by GSTA-54. diff --git a/VERSION b/VERSION index 1a457a831..3c90dd4b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.159.0 +1.91.160.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3b68c2480..cfb3bc065 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.159.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.160.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index f327c31b3..22a6cd77a 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 23d5b2c76..42e10d81d 100644 --- a/design-review/SKILL.md +++ b/design-review/SKILL.md @@ -525,17 +525,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" @@ -543,14 +544,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" @@ -564,6 +565,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` | @@ -572,11 +574,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.** @@ -620,6 +622,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 614304063..e6cb489bf 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/package.json b/package.json index 03f42b400..493be469b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.159", + "version": "1.91.160", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/plan-eng-review/sections/review-sections.md b/plan-eng-review/sections/review-sections.md index 410a4cc2f..0f43c741c 100644 --- a/plan-eng-review/sections/review-sections.md +++ b/plan-eng-review/sections/review-sections.md @@ -586,20 +586,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 07bb94048..374cc2ab0 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 914818c5e..13459f3f2 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 8a35d0554..348458c08 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 9482bb22a..9083336de 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": 130504, "agy/gstack-design-consultation": 108184, - "agy/gstack-design-review": 143533, + "agy/gstack-design-review": 143528, "agy/gstack-land-and-deploy": 116611, "agy/gstack-plan-design-review": 140013, "agy/gstack-plan-devex-review": 135760, - "agy/gstack-plan-eng-review": 152702, - "agy/gstack-review": 124398, - "claude/design-review": 136536, + "agy/gstack-plan-eng-review": 152701, + "agy/gstack-review": 124218, + "claude/design-review": 136531, "codex/gstack-autoplan": 130628, "codex/gstack-design-consultation": 109452, - "codex/gstack-design-review": 145033, + "codex/gstack-design-review": 145028, "codex/gstack-land-and-deploy": 121188, "codex/gstack-office-hours": 102339, "codex/gstack-plan-design-review": 142466, "codex/gstack-plan-devex-review": 138649, - "codex/gstack-plan-eng-review": 155248, - "codex/gstack-review": 125302, + "codex/gstack-plan-eng-review": 155247, + "codex/gstack-review": 125122, "copilot/gstack-autoplan": 131637, "copilot/gstack-codex": 100235, "copilot/gstack-design-consultation": 109536, - "copilot/gstack-design-review": 144838, + "copilot/gstack-design-review": 144833, "copilot/gstack-land-and-deploy": 117881, "copilot/gstack-office-hours": 100495, "copilot/gstack-plan-design-review": 140884, "copilot/gstack-plan-devex-review": 136912, - "copilot/gstack-plan-eng-review": 153427, - "copilot/gstack-review": 125669, + "copilot/gstack-plan-eng-review": 153426, + "copilot/gstack-review": 125489, "cursor/gstack-autoplan": 129278, "cursor/gstack-design-consultation": 107222, - "cursor/gstack-design-review": 142512, + "cursor/gstack-design-review": 142507, "cursor/gstack-land-and-deploy": 115550, "cursor/gstack-plan-design-review": 138968, "cursor/gstack-plan-devex-review": 134591, - "cursor/gstack-plan-eng-review": 151556, - "cursor/gstack-review": 140769, + "cursor/gstack-plan-eng-review": 151555, + "cursor/gstack-review": 140750, "factory/gstack-autoplan": 131562, "factory/gstack-codex": 100655, "factory/gstack-design-consultation": 108670, - "factory/gstack-design-review": 144302, + "factory/gstack-design-review": 144297, "factory/gstack-land-and-deploy": 119733, "factory/gstack-office-hours": 101374, "factory/gstack-plan-design-review": 141749, "factory/gstack-plan-devex-review": 137960, - "factory/gstack-plan-eng-review": 154670, - "factory/gstack-review": 144548, + "factory/gstack-plan-eng-review": 154669, + "factory/gstack-review": 144529, "gbrain/gstack-autoplan": 129815, - "gbrain/gstack-design-review": 132847, + "gbrain/gstack-design-review": 132842, "gbrain/gstack-land-and-deploy": 116072, "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": 129905, - "hermes/gstack-design-review": 132954, + "hermes/gstack-design-review": 132949, "hermes/gstack-land-and-deploy": 116174, "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": 129040, "kiro/gstack-design-consultation": 107064, - "kiro/gstack-design-review": 142326, + "kiro/gstack-design-review": 142321, "kiro/gstack-land-and-deploy": 115440, "kiro/gstack-plan-design-review": 138808, "kiro/gstack-plan-devex-review": 134431, - "kiro/gstack-plan-eng-review": 151406, - "kiro/gstack-review": 140581, + "kiro/gstack-plan-eng-review": 151405, + "kiro/gstack-review": 140562, "openclaw/gstack-autoplan": 129520, - "openclaw/gstack-design-review": 131652, + "openclaw/gstack-design-review": 131647, "openclaw/gstack-land-and-deploy": 115660, "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": 131640, "opencode/gstack-design-consultation": 109357, - "opencode/gstack-design-review": 144710, + "opencode/gstack-design-review": 144705, "opencode/gstack-land-and-deploy": 117658, "opencode/gstack-office-hours": 100329, "opencode/gstack-plan-design-review": 140738, "opencode/gstack-plan-devex-review": 136812, - "opencode/gstack-plan-eng-review": 153308, - "opencode/gstack-review": 143004, + "opencode/gstack-plan-eng-review": 153307, + "opencode/gstack-review": 142985, "slate/gstack-autoplan": 129159, "slate/gstack-design-consultation": 107143, - "slate/gstack-design-review": 142419, + "slate/gstack-design-review": 142414, "slate/gstack-land-and-deploy": 115495, "slate/gstack-plan-design-review": 138888, "slate/gstack-plan-devex-review": 134511, - "slate/gstack-plan-eng-review": 151481, - "slate/gstack-review": 140675 + "slate/gstack-plan-eng-review": 151480, + "slate/gstack-review": 140656 } } 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'); + }); +});