From 4ad8a6bfd925a57ab9be2e134a7bfee50de0c310 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 00:53:12 +0000 Subject: [PATCH] fix: pre-landing review fixes (depth scan binding, queue scope, carry checks) - carry refuses a --head other than the checked-out HEAD, a receipt with gstack_version but no review_depth, and an owner exception that no longer stands; a dirty or foreign checkout makes the write-time gate scan unavailable (full) - the queue binds a gate scan only to the full bound commit and recomputes the scope signals with gstack-diff-scope --head , the same range the writer uses - the queue checks the review-loop exception's fix-check ancestry - a package.json version line counts as stamp-only only when it is a bare version key - docs and help name what review_depth is and which registry the queue reads Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-diff-scope | 21 +++++++++++ bin/gstack-ship-receipt | 13 +++++-- docs/review-policy.md | 2 +- docs/ship-policy.md | 24 ++++++++----- lib/headless-artifacts.ts | 2 +- lib/review-collect.ts | 2 +- lib/ship-receipt-depth.ts | 63 +++++++++++++++++++++------------ test/diff-scope.test.ts | 21 +++++++++++ test/review-collect.test.ts | 9 +++++ test/ship-receipt-depth.test.ts | 53 +++++++++++++++++++++++++++ 10 files changed, 172 insertions(+), 38 deletions(-) diff --git a/bin/gstack-diff-scope b/bin/gstack-diff-scope index 2fa10b41d..3ec81cf09 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -2,6 +2,9 @@ # 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 +# Or: gstack-diff-scope --head → the committed range ... only +# (no working tree, no untracked files): what a receipt writer and the +# merge queue compute for one commit, from any checkout (GSTA-147). # # Output contract (#2526 — all-false must be distinguishable from "we could # not look" and from "nothing matched"): @@ -31,6 +34,8 @@ _default_base() { echo "main" } BASE="${1:-$(_default_base)}" +HEAD_REF="" +if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi FRONTEND=false BACKEND=false @@ -79,6 +84,21 @@ _collect() { [ -n "$f" ] && FILES_LIST+=("$f") done } +if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then + if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_head" + echo "# --head '${HEAD_REF}' is not a commit in this repository" + exit 2 + fi + if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_merge_base" + echo "# '${BASE}' and '${HEAD_REF}' share no merge base" + exit 2 + fi + _collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null) +else # 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 @@ -87,6 +107,7 @@ _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) +fi if [ "${#FILES_LIST[@]}" -eq 0 ]; then _print_flags diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 2214af5d3..6d50425b1 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -44,7 +44,7 @@ import { receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; -import { deeper, depthBelow, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -87,7 +87,8 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the "Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and - review_depth (gstack-review-plan --json at HEAD; --head must be HEAD, and no plan means nothing written). + review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an + unreadable input records full). --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -599,7 +600,13 @@ function cmdCarry(rest: string[], json: boolean): number { return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate `); } let depthField: Partial = {}; - if (receipt.review_depth) { + const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0; + const problems = exceptionProblems(body, receipt, isAncestor); + if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`); + if (receipt.review_depth || receipt.gstack_version) { + if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase(); + if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`); const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head); if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`); depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } }; diff --git a/docs/review-policy.md b/docs/review-policy.md index d9e87dd77..bafc25869 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -166,7 +166,7 @@ invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat could not produce, and a binary code-class path (it is never counted as 0 lines). **The registry rule.** Only the base commit's registry counts (the merge base's -`origin/` commit for live /ship, the receipt's base sha for the queue), and only +`origin/` commit for live /ship and the current base ref's commit for the queue: `origin/` in `ready`, `/` in `train build`), and only entries with a `command`, which pregate's regen check proves for that tree. An entry added on the branch never excludes anything; `command: null` entries, vendored code and lockfiles stay runtime. To adopt it, commit an entry like this on the base branch: diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 60bd1a6b7..ad60544a3 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -818,21 +818,25 @@ repair is trimming the file, never raising the cap in the same PR. wrote the receipt and the review depth at the checked-out head ([review-policy.md#review-depth](review-policy.md#review-depth)): `{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`, -computed from git objects for the range against `origin/`, with the -gate scan run on that head. `--head` must be the checked-out HEAD +computed from git objects for the range against `origin/` +(`gstack-diff-scope --head ` for the scope signals), with the gate +scan run on that head. `--head` must be the checked-out HEAD (`RECEIPT_INVALID` otherwise), so the record and `head_tree` describe the -tree the review read. `gstack-ship-queue ready` and `train build` recompute -the depth the head needs and print `REVIEW_DEPTH: receipt= required= +tree the review read; tracked edits in the checkout make the scan unavailable, +which records `full`. `gstack-ship-queue ready` and `train build` recompute +the depth the head needs from the same range and scope and print `REVIEW_DEPTH: receipt= required= runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`; -a recorded gate scan counts only when it names the bound head, otherwise the -scan is unavailable and the head needs `full`. A receipt below the required +a recorded gate scan counts only when its `head` is the full bound commit, +otherwise the scan is unavailable and the head needs `full`. A receipt below the required depth, or one whose writer records `gstack_version` but no `review_depth`, is [`QUEUE_RECEIPT_DEPTH_RAISED`](troubleshooting.md#queue-receipt-depth-raised): the receipt is left unchanged and the fix is /ship on that head. A receipt with neither field predates depth receipts and is accepted as legacy. A carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs a deeper review than the receipt records (a test change that -skips a test raises the diff to gate class). +skips a test raises the diff to gate class), when `--head` is not the +checked-out HEAD, when the receipt records `gstack_version` without +`review_depth`, or when an owner exception no longer stands. **`review_loop_exception`.** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559), from `write --review-loop-exception @@ -843,8 +847,10 @@ that is the receipt head or its ancestor, and, with `--body`, the line word for word; otherwise [`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid) and nothing is written. The queue checks both owner exceptions the same way -before READY and train build: the record must parse and the body must still -carry its line, or the receipt is +before READY and train build: the record must parse, the body must still +carry its line, and the fix-check head must be the bound head or its ancestor +(after a carry across rewritten history the old fix-check commit is off the +branch, so that check is skipped), or the receipt is [`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid). **Prose against the receipt.** `gstack-ship-receipt read --pr --check-prose` diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index c9498174a..c82ba790c 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -104,7 +104,7 @@ export interface ShipReceipt { carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ head_tree?: string; product_patch_id?: string; - /** The gstack that wrote the receipt, and the review depth gstack-review-plan computed at the reviewed head (lib/ship-receipt-depth.ts). */ + /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ gstack_version?: string; review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] }; /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ diff --git a/lib/review-collect.ts b/lib/review-collect.ts index b6c236a83..3846df0f3 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -84,7 +84,7 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:/.test(l))); + const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l))); return { perPath, deleted, stampOnly, added }; } diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts index e2c22ee02..8cff3b954 100644 --- a/lib/ship-receipt-depth.ts +++ b/lib/ship-receipt-depth.ts @@ -3,9 +3,11 @@ * review read the head, and what the queue checks against it * (docs/review-policy.md#review-depth, docs/ship-policy.md#ship-receipt). * - * `review_depth`: written by `gstack-ship-receipt write` from - * gstack-review-plan at the reviewed head (depth, runtime lines, the rules - * hash, the base and registry commits, the gate scan bound to that head). + * `review_depth`: the depth the reviewed head needs, recomputed by + * `gstack-ship-receipt write` (receiptDepthAtHead: the committed range from + * git objects, gstack-diff-scope on that range and the gate scan of the clean + * checkout at that head), with the runtime lines, the rules hash, the base and + * registry commits and the gate scan bound to the head. * `review_loop_exception`: the owner's acceptance of a stop after a * fix-check edit under frozen scope (#3559), shaped like the docs exception. * The queue recomputes the depth the head needs from git objects @@ -26,6 +28,7 @@ export type ReviewLoopException = NonNullable = { light: 0, standard: 1, full: 2 }; const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v); const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v); +const BIN_DIR = path.join(import.meta.dir, '..', 'bin'); const short = (s: string | null | undefined) => (s ? s.slice(0, 12) : 'none'); function git(root: string, args: string[]): { ok: boolean; out: string } { @@ -33,29 +36,37 @@ function git(root: string, args: string[]): { ok: boolean; out: string } { return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; } +/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */ +function scopeOfRange(root: string, binDir: string, base: string, head: string): Record { + const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + const scope: Record = {}; + for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; } + if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`; + return scope; +} + /** - * The depth record for `head` (the checked-out commit), computed the way the - * queue recomputes it: the range from git objects (collectCommit), plus - * gstack-diff-scope and, for a tests or code diff, the gate scan of this - * checkout, which therefore names `head`. + * The depth record for `head`: the range from git objects (collectCommit) and + * its diff-scope, as the queue recomputes them, plus, for a tests or code diff, + * the gate scan of this checkout. The scan reads the working tree, so it counts + * only when the checkout is `head` with no tracked edits; otherwise it is + * unavailable and the depth fails closed to full. */ export function receiptDepthAtHead(repoRoot: string, base: string, binDir: string, head: string): ReceiptDepth { const branch = base.replace(/^origin\//, ''); const baseSha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}^{commit}`]); - const c = collectCommit(repoRoot, baseSha.ok ? baseSha.out : branch, head); - const run = (bin: string, timeout: number) => runScriptSync(path.join(binDir, bin), [branch], { cwd: repoRoot, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024 }); - const scope: Record = {}; - const sr = run('gstack-diff-scope', 60_000); - for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; } - if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`; + const baseRev = baseSha.ok ? baseSha.out : branch; + const c = collectCommit(repoRoot, baseRev, head); + const scope = scopeOfRange(repoRoot, binDir, baseRev, head); let tagged: number | null = 0; if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) { - const g = run('gstack-gate-diff', 120_000); - const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? ''); - tagged = g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; + const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok; + const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null; + const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? ''); + tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; } const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); - return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseSha.ok ? baseSha.out : branch, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) }; + return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) }; } export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need]; @@ -86,8 +97,11 @@ export function reviewLoopExceptionLine(e: ReviewLoopException): string { const carries = (body: string, line: string) => body.split(/\r?\n/).some(l => l.trim() === line); -/** The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim. */ -export function exceptionProblems(body: string, r: ShipReceipt): string[] { +/** + * The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim. + * `isAncestor`, when given, also requires the review-loop exception's fix-check head to be the receipt head or its ancestor. + */ +export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (commit: string) => boolean): string[] { const out: string[] = []; const raw = r as ShipReceipt & { docs?: unknown }; if (raw.docs !== undefined) { @@ -100,6 +114,7 @@ export function exceptionProblems(body: string, r: ShipReceipt): string[] { if (!e.ok) out.push(e.message); else { if (!r.review_loop?.fix_checks) out.push('review_loop_exception needs review_loop.fix_checks >= 1 (the stop it accepts follows a fix-check)'); + if (isAncestor && !isAncestor(e.exception.fix_check.head)) out.push(`review_loop_exception.fix_check.head ${e.exception.fix_check.head.slice(0, 12)} is not the receipt head or an ancestor of it`); const line = reviewLoopExceptionLine(e.exception); if (!carries(body, line)) out.push(`the body does not carry the review-loop exception line verbatim: ${JSON.stringify(line)}`); } @@ -120,9 +135,10 @@ export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceip const rd = r.review_depth; if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }; const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); - const c = collectCommit(root, baseSha.ok ? baseSha.out : baseRef, head); - const scanBound = !!rd && rd.gate_scan.tagged !== null && head.startsWith(rd.gate_scan.head); - const need = depthOfCommitInput({ ...c, gateTagged: scanBound ? rd!.gate_scan.tagged : null }); + const baseRev = baseSha.ok ? baseSha.out : baseRef; + const c = collectCommit(root, baseRev, head); + const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase(); + const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null }); const have = rd?.depth; const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`; @@ -138,7 +154,8 @@ export function bindShippedReceipt(root: string, baseRef: string, body: string, | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } { const b = bindReceipt(body, pr, accepted); if (!b.ok) return b; - const problems = exceptionProblems(body, b.receipt); + const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok; + const problems = exceptionProblems(body, b.receipt, ancestor); if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line }; const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head); if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line }; diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 9597362a9..e33469c1a 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -367,6 +367,27 @@ describe('uncommitted work is visible (#2299)', () => { expect(vars.SCOPE_BACKEND).toBe('true'); }); + // Value: protects=--head scopes one committed range from any checkout, the input the receipt writer and the merge queue + // share; fails_when=--head still reads the working tree or untracked files, or an unknown head reads as all-false; seam=none + test('--head scopes only the committed range: working-tree and untracked files are ignored, an unknown head is an error', () => { + const dir = createRepo(['lib/auth.ts']); + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' }).stdout.toString().trim(); + mkdirSync(join(dir, 'db', 'migrate'), { recursive: true }); + writeFileSync(join(dir, 'db', 'migrate', '001.rb'), '# untracked\n'); + writeFileSync(join(dir, 'styles.css'), 'a{}\n'); + const scope = (args: string[]) => spawnSync('bash', [SCRIPT, 'main', ...args], { cwd: dir, stdio: 'pipe', timeout: 5000 }); + const ranged = scope(['--head', head]); + expect(ranged.status).toBe(0); + const out = ranged.stdout.toString(); + expect(out).toContain('SCOPE_AUTH=true'); + expect(out).toContain('SCOPE_MIGRATIONS=false'); + expect(out).toContain('SCOPE_FRONTEND=false'); + expect(runScopeFull(dir).vars.SCOPE_MIGRATIONS).toBe('true'); + const unknown = scope(['--head', 'f'.repeat(40)]); + expect(unknown.status).toBe(2); + expect(unknown.stdout.toString()).toContain('SCOPE_ERROR=no_head'); + }); + 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); diff --git a/test/review-collect.test.ts b/test/review-collect.test.ts index f611fe57a..0c86ac6ef 100644 --- a/test/review-collect.test.ts +++ b/test/review-collect.test.ts @@ -25,6 +25,15 @@ function repo(baseFiles: Record) { } describe('collectCommit: git objects only', () => { + // Value: protects=only a bare version bump in package.json is release metadata; fails_when=a version line that also + // carries scripts counts as stamp-only (docs class, light); seam=none + test('a package.json version line that also carries scripts is not stamp-only; a bare version bump is', () => { + const r = repo({ 'package.json': '{\n "name": "x",\n "version": "1.0.0"\n}\n' }); + const bare = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.1"\n}\n' }); + expect(collectCommit(r.dir, r.baseSha, bare).stampOnly).toEqual(['package.json']); + const riding = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.2", "scripts": { "postinstall": "node x.js" }\n}\n' }); + expect(collectCommit(r.dir, r.baseSha, riding).stampOnly).toEqual([]); + }); test('a registered glob output is excluded, a command:null entry is not, and a .tmpl edit counts once', () => { const r = repo({ '.gstack/generated.json': REGISTRY, 'ship/SKILL.md.tmpl': 'a\n', 'ship/SKILL.md': 'a\n' }); const head = r.commit({ 'ship/SKILL.md.tmpl': 'a\nb\nc\n', 'ship/SKILL.md': lines(120), 'src/hand.ts': lines(5) }); diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts index a22ea057d..1a7e2e8fd 100644 --- a/test/ship-receipt-depth.test.ts +++ b/test/ship-receipt-depth.test.ts @@ -91,6 +91,33 @@ describe('the queue refuses a receipt below the depth its head needs', () => { const verdict = receiptDepthVerdict(repo.work, 'origin/main', extractReceipt(legacy).receipt!, head); expect(verdict).toEqual({ ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }); }, 60_000); + + // Value: protects=the queue's required depth keeps the scope signals the writer used, and a gate scan binds only the full + // bound commit; fails_when=the queue recomputes without diff-scope (auth reads light) or binds an empty or short scan head + // by prefix; seam=bindShippedReceipt + test('an auth path needs full at the queue though the receipt says light; an empty or short gate scan head binds nothing', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/auth.js': lines(3) }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', class: 'gate' }); + const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); + const light = bind(rewrite(body, r => { r.review_depth.depth = 'light'; })); + expect(!light.ok && light.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(!light.ok && light.depthLine).toContain('receipt=light required=full'); + expect(!light.ok && light.depthLine).toContain('class=gate'); + + const plain = makeFixtureRepo(); repos.push(plain); + const plainHead = commit(plain, { 'lib/util.js': lines(3) }); + const plainBody = path.join(plain.root, 'body.md'); + expect(writeReceipt(plain, plainBody).status).toBe(EXIT.ok); + for (const scanHead of ['', plainHead.slice(0, 12)]) { + const r = bindShippedReceipt(plain.work, 'origin/main', rewrite(plainBody, x => { x.review_depth.gate_scan.head = scanHead; }), '7', [plainHead]); + expect(r.ok).toBe(false); + expect(!r.ok && r.depthLine).toContain('required=full'); + expect(!r.ok && r.depthLine).toContain('gate_scan@missing'); + } + }, 90_000); }); describe('carry and the review depth', () => { @@ -114,6 +141,27 @@ describe('carry and the review depth', () => { expect(refused.err).toContain("above the receipt's light"); expect(receiptIn(body).head).toBe(plain); }, 90_000); + + // Value: protects=a carry never binds a scan of one checkout to another commit, and never rebinds a receipt the queue would + // refuse; fails_when=carry --head from another checkout carries a skipped test at light, or a current receipt without + // review_depth carries; seam=none + test('carry --head from another checkout refuses; a receipt with gstack_version but no review_depth refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('a', () => {});\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + const skipped = commit(repo, { 'test/util.test.js': "test.skip('a', () => {});\n" }); + repo.git('checkout', '-q', reviewed); + const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', skipped]); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain(`--head ${skipped.slice(0, 12)} is not the checked-out HEAD ${reviewed.slice(0, 12)}`); + expect(receiptIn(body).head).toBe(reviewed); + repo.git('checkout', '-q', skipped); + rewrite(body, x => { delete x.review_depth; }); + const noDepth = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(noDepth.status).toBe(EXIT.refused); + expect(noDepth.err).toContain('records gstack_version but no review_depth'); + }, 90_000); }); describe('the review-loop exception (#3559)', () => { @@ -150,5 +198,10 @@ describe('the review-loop exception (#3559)', () => { const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]); expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID'); expect(!dropped.ok && dropped.message).toContain('does not carry the review-loop exception line verbatim'); + const foreign = { ...exception, fix_check: { head: 'f'.repeat(40), findings: 1 } }; + const forged = rewrite(body, x => { x.review_loop_exception = foreign; }).replace(line, reviewLoopExceptionLine(foreign)); + const offBranch = bindShippedReceipt(repo.work, 'origin/main', forged, '7', [head]); + expect(!offBranch.ok && offBranch.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID'); + expect(!offBranch.ok && offBranch.message).toContain('is not the receipt head or an ancestor of it'); }, 90_000); });