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);
});