mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
909a9e9577
commit
73cf0ed69a
+174
-72
@@ -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
|
||||
|
||||
@@ -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<string, string>; status: number; stdout: string } {
|
||||
const result = spawnSync('bash', [SCRIPT, 'main'], {
|
||||
cwd: dir, stdio: 'pipe', timeout: 5000,
|
||||
});
|
||||
const stdout = result.stdout.toString();
|
||||
const vars: Record<string, string> = {};
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user