From 73cf0ed69ae4fae619308531d59883b80caff4b1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 09:44:09 -0700 Subject: [PATCH] fix: diff-scope glob coverage, honest exit contract, dirty-tree visibility (#2526, #2455, #2299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three silent-skip classes in bin/gstack-diff-scope, each of which quietly disabled scope-gated reviewers in /ship and /review: 1. Pattern gaps (#2526, #2455). `*/api/*` required a path segment BEFORE api/, so a root-level api/ layout (Vercel serverless, Next.js pages/api at root) never set SCOPE_API — 63 serverless functions in the reporter's payments repo, none ever classified, the API-contract specialist silently skipped on every payment PR (it found a CRITICAL when run by hand). Same for root-level migrations/. And the Rails data_migrate gem's db/data/ data migrations — arbitrary Ruby run unattended against production data — fell through to plain BACKEND, so the [NEVER_GATE] data-migration specialist never got the chance to run. Added: api/*, migrations/*, db/data/*, data_migrations/*. 2. All-false was indistinguishable from "could not look" (#2526). New contract: empty change set → all false exit 0; >=1 match → flags exit 0; changed files with ZERO matches → SCOPE_ERROR=unmatched + the unmatched paths as comment lines + exit 2 (a new top-level layout now trips loudly instead of invisibly disabling reviewers); unresolvable base ref (shallow CI checkout) → SCOPE_ERROR=no_base + exit 2 instead of a green that means "we could not look". Every output line stays a shell-safe assignment or comment for sourcing consumers, which tolerate the nonzero exit today (source ... || true / eval). 3. Uncommitted work was invisible (#2299). /ship detects scope in Step 9, BEFORE it commits in Step 15, so the common start-work-then-ship flow ran the classifier against an empty diff and skipped every reviewer. The change set is now the UNION of committed diff + working tree + untracked files. Also from #2299: the single first-match-wins case made the nine flags mutually exclusive (Button.test.jsx set FRONTEND but not TESTS; util.test.ts the opposite) — each category now gets its own case, with BACKEND deliberately still excluding frontend component/view files. And file listing is NUL-safe (git diff -z), so non-ASCII paths no longer defeat extension globs via octal quoting. Deliberate behavior change (flagged in #2299): with independent flags, a backend test file sets BACKEND and TESTS, which can trip the security specialist's SCOPE_BACKEND gate on test-only PRs — errs toward more review, not less. Table-driven tests cover every glob class (root api/, nested api/, controllers, openapi, root/nested/prisma/db-migrate/db-data migrations, dual-category test files, auth, prompts, docs, plain classes), the four-state exit contract, dirty-tree + untracked visibility, and the non-ASCII path case (39 pass in test/diff-scope.test.ts). Fixes shaped by the reporters' patches: @grant-ship-it (#2526), @mkyed (#2455), @ShahriarLak (#2299). Fixes #2526 Fixes #2455 Fixes #2299 Co-Authored-By: Claude Fable 5 --- bin/gstack-diff-scope | 246 ++++++++++++++++++++++++++++------------ test/diff-scope.test.ts | 157 +++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 72 deletions(-) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 38450e6b1..17da9c656 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -2,6 +2,24 @@ # gstack-diff-scope — categorize what changed in the diff against a base branch # Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... # Or: gstack-diff-scope main → prints SCOPE_*=... lines +# +# Output contract (#2526 — all-false must be distinguishable from "we could +# not look" and from "nothing matched"): +# exit 0 changed-file set empty → all false, legitimately nothing +# exit 0 changed files, >=1 category match → flags +# exit 2 changed files, ZERO matches → flags + SCOPE_ERROR=unmatched +# (+ the unmatched paths as comment lines, so a new top-level layout +# trips loudly instead of silently disabling reviewers) +# exit 2 base ref unresolvable → all false + SCOPE_ERROR=no_base +# (shallow CI checkout / missing fetch — a green here would mean +# "we could not look") +# Every line is shell-safe for `source <(...)` consumers: assignments or +# `#`-comments only. +# +# The changed-file set is the UNION of committed diff + working tree + +# untracked files (#2299): /ship detects scope in Step 9, BEFORE it commits in +# Step 15, so uncommitted work must be visible or every scope-gated reviewer +# is skipped on the common start-work-then-ship flow. set -euo pipefail # Detect the repo's default branch when no arg is given (#703-class @@ -14,22 +32,6 @@ _default_base() { } BASE="${1:-$(_default_base)}" -# Get changed file list -FILES=$(git diff "${BASE}...HEAD" --name-only 2>/dev/null || git diff "${BASE}" --name-only 2>/dev/null || echo "") - -if [ -z "$FILES" ]; then - echo "SCOPE_FRONTEND=false" - echo "SCOPE_BACKEND=false" - echo "SCOPE_PROMPTS=false" - echo "SCOPE_TESTS=false" - echo "SCOPE_DOCS=false" - echo "SCOPE_CONFIG=false" - echo "SCOPE_MIGRATIONS=false" - echo "SCOPE_API=false" - echo "SCOPE_AUTH=false" - exit 0 -fi - FRONTEND=false BACKEND=false PROMPTS=false @@ -40,62 +42,162 @@ MIGRATIONS=false API=false AUTH=false -while IFS= read -r f; do +_print_flags() { + echo "SCOPE_FRONTEND=$FRONTEND" + echo "SCOPE_BACKEND=$BACKEND" + echo "SCOPE_PROMPTS=$PROMPTS" + echo "SCOPE_TESTS=$TESTS" + echo "SCOPE_DOCS=$DOCS" + echo "SCOPE_CONFIG=$CONFIG" + echo "SCOPE_MIGRATIONS=$MIGRATIONS" + echo "SCOPE_API=$API" + echo "SCOPE_AUTH=$AUTH" +} + +# Base reachability (#2526): a shallow CI checkout or an unfetched ref makes +# `git diff` return an empty list — all-false with exit 0, a green that means +# "we could not look". Distinguish it before diffing. +if ! git rev-parse --verify -q "${BASE}^{commit}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_base" + echo "# base ref '${BASE}' is not resolvable — shallow checkout or missing fetch. Run: git fetch origin ${BASE}" + exit 2 +fi + +# Changed files, NUL-delimited (#2526 minor: `git diff --name-only` octal-quotes +# non-ASCII paths, and the trailing quote defeats extension globs; -z avoids it). +FILES_LIST=() +_collect() { + local f + while IFS= read -r -d '' f; do + [ -n "$f" ] && FILES_LIST+=("$f") + done +} +# Committed diff vs base (merge-base form; two-dot fallback when no merge base). +_collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true) +# Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo +# with no commits; tolerated. +_collect < <(git diff -z HEAD --name-only 2>/dev/null || true) +# Untracked files: a brand-new component/migration/test is exactly what a +# reviewer should see, and /ship commits it in Step 15 regardless. +_collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true) + +if [ "${#FILES_LIST[@]}" -eq 0 ]; then + _print_flags + exit 0 +fi + +UNMATCHED=() + +# Categories are INDEPENDENT booleans (#2299): a single first-match-wins case +# made them mutually exclusive, so Button.test.jsx set FRONTEND but not TESTS +# while util.test.ts set TESTS but not BACKEND — same intent, opposite result, +# purely from arm ordering. Each category now gets its own case; only BACKEND +# stays deliberately exclusive of frontend component/view files. +for f in ${FILES_LIST[@]+"${FILES_LIST[@]}"}; do + m_frontend=false; m_prompts=false; m_tests=false; m_docs=false + m_config=false; m_migrations=false; m_api=false; m_auth=false; m_backend=false + + # Frontend: CSS, views, components, templates case "$f" in - # Frontend: CSS, views, components, templates - *.css|*.scss|*.less|*.sass|*.pcss|*.module.css|*.module.scss) FRONTEND=true ;; - *.tsx|*.jsx|*.vue|*.svelte|*.astro) FRONTEND=true ;; - *.erb|*.haml|*.slim|*.hbs|*.ejs) FRONTEND=true ;; - *.html) FRONTEND=true ;; - tailwind.config.*|postcss.config.*) FRONTEND=true ;; - app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) FRONTEND=true ;; - - # Prompts: prompt builders, system prompts, generation services - *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) PROMPTS=true ;; - *evaluator*|*scorer*|*classifier_service*|*analyzer*) PROMPTS=true ;; - *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) PROMPTS=true ;; - app/services/chat_tools/*|app/services/x_thread_tools/*) PROMPTS=true ;; - config/system_prompts/*) PROMPTS=true ;; - - # Tests - *.test.*|*.spec.*|*_test.*|*_spec.*) TESTS=true ;; - test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) TESTS=true ;; - - # Docs - *.md) DOCS=true ;; - - # Config - package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) CONFIG=true ;; - Gemfile|Gemfile.lock) CONFIG=true ;; - *.yml|*.yaml) CONFIG=true ;; - .github/*) CONFIG=true ;; - requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) CONFIG=true ;; - - # Migrations: database migration files - db/migrate/*|*/migrations/*|alembic/*|prisma/migrations/*) MIGRATIONS=true ;; - - # API: routes, controllers, endpoints, GraphQL/OpenAPI schemas - *controller*|*route*|*endpoint*|*/api/*) API=true ;; - *.graphql|*.gql|openapi.*|swagger.*) API=true ;; - - # Auth: authentication, authorization, sessions, permissions - *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) AUTH=true ;; - - # Backend: everything else that's code (excluding views/components already matched) - *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) BACKEND=true ;; - # Non-component TS/JS is backend. Include ESM/CJS (.mjs/.cjs) and - # explicit-module TS (.mts/.cts) — #1810: these matched no category, so an - # ESM/CJS-only PR skipped the backend reviewer entirely. - *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) BACKEND=true ;; + *.css|*.scss|*.less|*.sass|*.pcss) m_frontend=true ;; + *.tsx|*.jsx|*.vue|*.svelte|*.astro) m_frontend=true ;; + *.erb|*.haml|*.slim|*.hbs|*.ejs) m_frontend=true ;; + *.html) m_frontend=true ;; + tailwind.config.*|postcss.config.*) m_frontend=true ;; + app/views/*|*/components/*|styles/*|css/*|app/assets/stylesheets/*) m_frontend=true ;; esac -done <<< "$FILES" -echo "SCOPE_FRONTEND=$FRONTEND" -echo "SCOPE_BACKEND=$BACKEND" -echo "SCOPE_PROMPTS=$PROMPTS" -echo "SCOPE_TESTS=$TESTS" -echo "SCOPE_DOCS=$DOCS" -echo "SCOPE_CONFIG=$CONFIG" -echo "SCOPE_MIGRATIONS=$MIGRATIONS" -echo "SCOPE_API=$API" -echo "SCOPE_AUTH=$AUTH" + # Prompts: prompt builders, system prompts, generation services + case "$f" in + *prompt_builder*|*generation_service*|*writer_service*|*designer_service*) m_prompts=true ;; + *evaluator*|*scorer*|*classifier_service*|*analyzer*) m_prompts=true ;; + *voice*.rb|*writing*.rb|*prompt*.rb|*token*.rb) m_prompts=true ;; + app/services/chat_tools/*|app/services/x_thread_tools/*) m_prompts=true ;; + config/system_prompts/*) m_prompts=true ;; + esac + + # Tests + case "$f" in + *.test.*|*.spec.*|*_test.*|*_spec.*) m_tests=true ;; + test/*|tests/*|spec/*|__tests__/*|cypress/*|e2e/*) m_tests=true ;; + esac + + # Docs + case "$f" in + *.md) m_docs=true ;; + esac + + # Config + case "$f" in + package.json|package-lock.json|yarn.lock|bun.lock|bun.lockb) m_config=true ;; + Gemfile|Gemfile.lock) m_config=true ;; + *.yml|*.yaml) m_config=true ;; + .github/*) m_config=true ;; + requirements.txt|pyproject.toml|go.mod|Cargo.toml|composer.json) m_config=true ;; + esac + + # Migrations: database migration files. Bare migrations/* covers a + # root-level migrations dir (#2526); db/data covers the Rails data_migrate + # gem's DATA migrations (#2455) — arbitrary Ruby run unattended against + # production data, strictly higher-risk than a schema migration (they also + # match BACKEND below via their extension, as ordinary app code should). + case "$f" in + db/migrate/*|migrations/*|*/migrations/*|alembic/*|prisma/migrations/*) m_migrations=true ;; + db/data/*|data_migrations/*|*/data_migrations/*) m_migrations=true ;; + esac + + # API: routes, controllers, endpoints, GraphQL/OpenAPI schemas. Bare api/* + # covers root-level serverless layouts (Vercel functions, Next.js pages/api + # at root) that */api/* silently missed (#2526). + case "$f" in + api/*|*/api/*|*controller*|*route*|*endpoint*) m_api=true ;; + *.graphql|*.gql|openapi.*|swagger.*) m_api=true ;; + esac + + # Auth: authentication, authorization, sessions, permissions + case "$f" in + *auth*|*session*|*jwt*|*oauth*|*permission*|*role*) m_auth=true ;; + esac + + # Backend: code that isn't a frontend component/view file. Includes ESM/CJS + # (.mjs/.cjs) and explicit-module TS (.mts/.cts) — #1810: these matched no + # category, so an ESM/CJS-only PR skipped the backend reviewer entirely. + if [ "$m_frontend" = false ]; then + case "$f" in + *.rb|*.py|*.go|*.rs|*.java|*.php|*.ex|*.exs) m_backend=true ;; + *.ts|*.js|*.mjs|*.cjs|*.mts|*.cts) m_backend=true ;; + esac + fi + + [ "$m_frontend" = true ] && FRONTEND=true + [ "$m_prompts" = true ] && PROMPTS=true + [ "$m_tests" = true ] && TESTS=true + [ "$m_docs" = true ] && DOCS=true + [ "$m_config" = true ] && CONFIG=true + [ "$m_migrations" = true ] && MIGRATIONS=true + [ "$m_api" = true ] && API=true + [ "$m_auth" = true ] && AUTH=true + [ "$m_backend" = true ] && BACKEND=true + + if [ "$m_frontend" = false ] && [ "$m_prompts" = false ] && [ "$m_tests" = false ] \ + && [ "$m_docs" = false ] && [ "$m_config" = false ] && [ "$m_migrations" = false ] \ + && [ "$m_api" = false ] && [ "$m_auth" = false ] && [ "$m_backend" = false ]; then + UNMATCHED+=("$f") + fi +done + +_print_flags + +# Changed files but ZERO category matches (#2526): a classifier bug, an +# unrecognised layout, or a new top-level directory would otherwise present +# as "no reviewers needed" with the skip invisible. Trip loudly instead. +if [ "$FRONTEND" = false ] && [ "$BACKEND" = false ] && [ "$PROMPTS" = false ] \ + && [ "$TESTS" = false ] && [ "$DOCS" = false ] && [ "$CONFIG" = false ] \ + && [ "$MIGRATIONS" = false ] && [ "$API" = false ] && [ "$AUTH" = false ]; then + echo "SCOPE_ERROR=unmatched" + printf '%s\n' ${UNMATCHED[@]+"${UNMATCHED[@]}"} | sort -u | head -50 | while IFS= read -r u; do + [ -n "$u" ] && printf '# unmatched: %s\n' "$u" + done + exit 2 +fi diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 3e80fe451..6b4104451 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -178,3 +178,160 @@ describe('gstack-diff-scope', () => { expect(scope).toHaveProperty('SCOPE_AUTH'); }); }); + +// --------------------------------------------------------------------------- +// #2526 / #2455 / #2299 — glob classes, exit-code contract, dirty-tree union, +// independent categories. +// --------------------------------------------------------------------------- + +function runScopeFull(dir: string): { vars: Record; status: number; stdout: string } { + const result = spawnSync('bash', [SCRIPT, 'main'], { + cwd: dir, stdio: 'pipe', timeout: 5000, + }); + const stdout = result.stdout.toString(); + const vars: Record = {}; + for (const line of stdout.trim().split('\n')) { + if (line.startsWith('#')) continue; + const [key, val] = line.split('='); + if (key && val) vars[key] = val; + } + return { vars, status: result.status ?? -1, stdout }; +} + +describe('glob classes (table-driven, #2526 + #2455)', () => { + // One row per glob class the case arms cover. `expects` lists every scope + // that MUST be true; categories are independent (#2299), so extra true + // flags beyond `expects` are asserted per-row via `alsoFalse`. + const TABLE: { name: string; file: string; expects: string[]; alsoFalse?: string[] }[] = [ + // API — the #2526 headline: root-level api/ (Vercel/serverless layout). + { name: 'root-level api/ (#2526)', file: 'api/ipospays/process-payment.ts', expects: ['SCOPE_API', 'SCOPE_BACKEND'] }, + { name: 'nested */api/*', file: 'src/api/foo.ts', expects: ['SCOPE_API', 'SCOPE_BACKEND'] }, + { name: 'controller name', file: 'app/controllers/users_controller.rb', expects: ['SCOPE_API', 'SCOPE_BACKEND'] }, + { name: 'openapi schema', file: 'openapi.yaml', expects: ['SCOPE_API', 'SCOPE_CONFIG'] }, + // Migrations — root-level migrations/ + the data_migrate gem's db/data (#2455). + { name: 'root-level migrations/ (#2526)', file: 'migrations/0001_initial.sql', expects: ['SCOPE_MIGRATIONS'] }, + { name: 'nested */migrations/*', file: 'app/migrations/0001_initial.py', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] }, + { + name: 'db/data data migration (#2455) — MIGRATIONS and BACKEND both', + file: 'db/data/20260804123456_backfill_x.rb', + expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'], + }, + { name: 'data_migrations/ dir', file: 'data_migrations/backfill.rb', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] }, + { name: 'db/migrate schema migration', file: 'db/migrate/20260330_create_users.rb', expects: ['SCOPE_MIGRATIONS', 'SCOPE_BACKEND'] }, + // Independent categories (#2299): test-suffixed frontend files carry BOTH. + { + name: 'Button.test.jsx is FRONTEND and TESTS (#2299)', + file: 'src/Button.test.jsx', + expects: ['SCOPE_FRONTEND', 'SCOPE_TESTS'], + alsoFalse: ['SCOPE_BACKEND'], // frontend files never claim backend + }, + { name: 'util.test.ts is BACKEND and TESTS (#2299)', file: 'src/util.test.ts', expects: ['SCOPE_BACKEND', 'SCOPE_TESTS'] }, + { name: 'auth code carries AUTH and BACKEND', file: 'src/lib/auth.ts', expects: ['SCOPE_AUTH', 'SCOPE_BACKEND'] }, + // Plain classes unchanged. + { name: 'plain component', file: 'src/A.jsx', expects: ['SCOPE_FRONTEND'], alsoFalse: ['SCOPE_TESTS', 'SCOPE_BACKEND'] }, + { name: 'plain backend', file: 'server.go', expects: ['SCOPE_BACKEND'], alsoFalse: ['SCOPE_FRONTEND'] }, + { name: 'docs', file: 'docs/guide.md', expects: ['SCOPE_DOCS'] }, + { name: 'prompts', file: 'app/services/prompt_builder.rb', expects: ['SCOPE_PROMPTS', 'SCOPE_BACKEND'] }, + ]; + + for (const row of TABLE) { + test(row.name, () => { + const { vars, status } = runScopeFull(createRepo([row.file])); + expect(status).toBe(0); + for (const key of row.expects) { + expect(`${key}=${vars[key]}`).toBe(`${key}=true`); + } + for (const key of row.alsoFalse ?? []) { + expect(`${key}=${vars[key]}`).toBe(`${key}=false`); + } + expect(vars.SCOPE_ERROR).toBeUndefined(); + }); + } +}); + +describe('exit-code contract (#2526)', () => { + test('clean tree, no changes → all false, exit 0, no SCOPE_ERROR', () => { + const dir = createRepo([]); + const { vars, status } = runScopeFull(dir); + expect(status).toBe(0); + expect(vars.SCOPE_ERROR).toBeUndefined(); + expect(Object.values(vars).every((v) => v === 'false')).toBe(true); + }); + + test('changed files but ZERO matches → SCOPE_ERROR=unmatched + exit 2 + paths listed', () => { + const dir = createRepo(['Makefile.custom', 'weird/layout.xyz']); + const { vars, status, stdout } = runScopeFull(dir); + expect(status).toBe(2); + expect(vars.SCOPE_ERROR).toBe('unmatched'); + expect(stdout).toContain('# unmatched: weird/layout.xyz'); + // Still prints all nine flags so `source <(...)` consumers get vars. + expect(vars.SCOPE_FRONTEND).toBe('false'); + expect(vars.SCOPE_API).toBe('false'); + }); + + test('unresolvable base → SCOPE_ERROR=no_base + exit 2 (a green would mean "could not look")', () => { + const dir = createRepo(['app.ts']); + const result = spawnSync('bash', [SCRIPT, 'no-such-branch'], { cwd: dir, stdio: 'pipe', timeout: 5000 }); + expect(result.status).toBe(2); + const out = result.stdout.toString(); + expect(out).toContain('SCOPE_ERROR=no_base'); + expect(out).toContain('SCOPE_FRONTEND=false'); + }); + + test('output stays shell-safe for sourcing consumers in every state', () => { + // eval'd rather than `source <(...)`: macOS system bash 3.2 sources a + // process substitution as 0 bytes (st_size-based buffer on a FIFO), which + // would test the shell, not the script. The property under test is that + // every output line is a valid assignment or comment. + const dir = createRepo(['weird/layout.xyz']); + const result = spawnSync('bash', ['-c', `out="$(bash "${SCRIPT}" main)"; eval "$out"; echo "ERR=$SCOPE_ERROR FRONT=$SCOPE_FRONTEND"`], { + cwd: dir, stdio: 'pipe', timeout: 5000, + }); + expect(result.stdout.toString()).toContain('ERR=unmatched FRONT=false'); + }); +}); + +describe('uncommitted work is visible (#2299)', () => { + test('uncommitted change on a branch with no commits sets the scope', () => { + const dir = mkdtempSync(join(tmpdir(), 'diff-scope-dirty-')); + dirs.push(dir); + const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 }); + run('git', ['init', '-b', 'main']); + run('git', ['config', 'user.email', 't@t.com']); + run('git', ['config', 'user.name', 'T']); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'src', 'Button.jsx'), '// x\n'); + run('git', ['add', '.']); + run('git', ['commit', '-m', 'initial']); + run('git', ['checkout', '-b', 'feat/x']); // no commits on the branch + writeFileSync(join(dir, 'src', 'Button.jsx'), '// modified, uncommitted\n'); + const { vars, status } = runScopeFull(dir); + expect(status).toBe(0); + expect(vars.SCOPE_FRONTEND).toBe('true'); // was false pre-fix (all-false early exit) + }); + + test('an UNTRACKED new migration sets SCOPE_MIGRATIONS (reviewers must see it)', () => { + const dir = mkdtempSync(join(tmpdir(), 'diff-scope-untracked-')); + dirs.push(dir); + const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: dir, stdio: 'pipe', timeout: 5000 }); + run('git', ['init', '-b', 'main']); + run('git', ['config', 'user.email', 't@t.com']); + run('git', ['config', 'user.name', 'T']); + writeFileSync(join(dir, 'README.md'), '# t\n'); + run('git', ['add', '.']); + run('git', ['commit', '-m', 'initial']); + mkdirSync(join(dir, 'db', 'data'), { recursive: true }); + writeFileSync(join(dir, 'db', 'data', '20260816_backfill.rb'), '# data migration\n'); + const { vars, status } = runScopeFull(dir); + expect(status).toBe(0); + expect(vars.SCOPE_MIGRATIONS).toBe('true'); + expect(vars.SCOPE_BACKEND).toBe('true'); + }); + + test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => { + const dir = createRepo(['docs/M2 — Notes.md']); + const { vars, status } = runScopeFull(dir); + expect(status).toBe(0); + expect(vars.SCOPE_DOCS).toBe('true'); // pre-fix: git's octal quoting defeated *.md + }); +});