From 8788d2402719c68d3ba2f5727cfdc79a5ec78b62 Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 3 Nov 2026 06:32:59 +0000 Subject: [PATCH] fix: pre-landing review fixes (J1 never re-checks a legacy receipt, a cut path walk or moved base context; legacy READY asks for the cross-family read) Review-Finding: p1/specialist-batched#1 reader=security Review-Finding: p1/specialist-batched#2 reader=testing Review-Finding: p1/red-team#1 reader=red-team Review-Finding: p1/red-team#2 reader=red-team Review-Finding: p1/red-team#3 reader=red-team Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-ship-receipt | 5 +- lib/ship-receipt.ts | 4 +- lib/ship-recheck.ts | 18 ++++- test/ship-receipt-runs.test.ts | 1 + test/ship-recheck.test.ts | 144 ++++++++++++++++++++++++--------- 5 files changed, 130 insertions(+), 42 deletions(-) diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 527160383..7a4f1dbb8 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -696,6 +696,7 @@ function cmdCarry(rest: string[], json: boolean): number { } const current = retarget ? headPatch : productHead; const changedProduct: string[] = []; + let walkCut = false; const contextPaths: string[] = []; const stampOnly: string[] = []; if (retarget && reviewed !== current) changedProduct.push(`HEAD's product diff against ${base} is not the one reviewed against ${reviewedBase} (the parent has not fully landed on ${base}, or the branch changed since)`); @@ -712,7 +713,7 @@ function cmdCarry(rest: string[], json: boolean): number { // A stamp file the branch changed as product (its own row) whose reviewed-to-current change is a stamp on its own lines // and that the base did not touch holds no unreviewed change, so the comparison leaves its bytes out (GST-980). for (const p of [...touched].filter(p => movedHead.has(p) || movedBase.has(p)).sort()) { - if (changedProduct.length >= CARRY_NAMED_PATHS) break; + if (changedProduct.length >= CARRY_NAMED_PATHS) { walkCut = true; break; } const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); if (cls === 'metadata' && spec.ownRows.includes(p) && !movedBase.has(p)) stampOnly.push(p); if (cls !== 'product') continue; @@ -764,7 +765,7 @@ function cmdCarry(rest: string[], json: boolean): number { const named = changedProduct.filter(p => !p.startsWith('(') && !p.includes(' (')); const inter = named.length ? spawnSync('git', ['diff', '--no-color', '--no-ext-diff', from, head, '--', ...named.map(p => `:(literal)${p}`)], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }) : null; const fullRange = rangeRaw.status === 0 && inter?.status === 0 ? `${rangeRaw.stdout.trim()}\n${inter.stdout.trim()}` : null; - const r = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor, retarget, rangeDiff: fullRange, unnamedPaths: named.length !== changedProduct.length }); + const r = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor, retarget, rangeDiff: fullRange, unnamedPaths: named.length !== changedProduct.length, legacy: !receipt.review_depth, walkCut, contextPaths: contextPaths.length }); const record = flag(rest, '--recheck'); if (!r.ok) process.stderr.write(`RECHECK: not eligible (${r.reason}); run /ship on ${head.slice(0, 12)}\n`); else if (record) { diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index bc5e37cce..1149bc647 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -169,11 +169,13 @@ export function readyTailLines(r: ShipReceipt | undefined): string[] { const loop = parseReviewLoop(r?.review_loop); const opt = (k: keyof ReviewLoopReceipt) => (loop?.[k] ? ` ${k}=${loop[k]}` : ''); const risk = riskClassReceipt(r); + const legacy = !!r && !r.review_depth; return [ loop ? `REVIEW_LOOP: passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred} noted=${loop.noted ?? 0} fixed=${loop.fixed ?? 0}${opt('unrecorded')}${opt('unbound')}${opt('fix_checks')}` : 'REVIEW_LOOP: none in the receipt', 'TRIAL_MERGES: ', 'Attention:', - risk ? `reviewed by (risk class: ${oneLine([r?.review_depth?.class === 'gate' ? 'gate' : '', ...(r?.review_depth?.risk ?? [])].filter(Boolean).join(', '))}; rule 27 cross-family trail read)` : 'self-audited (not a risk class: no cross-family trail read)', + legacy ? 'reviewed by (risk class unknown: the receipt records no review_depth; rule 27 cross-family trail read)' + : risk ? `reviewed by (risk class: ${oneLine([r?.review_depth?.class === 'gate' ? 'gate' : '', ...(r?.review_depth?.risk ?? [])].filter(Boolean).join(', '))}; rule 27 cross-family trail read)` : 'self-audited (not a risk class: no cross-family trail read)', '', ]; } diff --git a/lib/ship-recheck.ts b/lib/ship-recheck.ts index aa62ff37d..2253f295c 100644 --- a/lib/ship-recheck.ts +++ b/lib/ship-recheck.ts @@ -7,8 +7,9 @@ * * Qualifies: RECEIPT_CARRY_PRODUCT_CHANGED on linear history (the receipt's head is an ancestor of HEAD) against the same base. * Does not qualify: RECEIPT_CARRY_TREE_MISMATCH (rewritten history: the reviewed commits are gone, so there is no range to - * diff), a retargeted stack (the reviewed range was against another base), an unreadable or empty range-diff, a range-diff - * over RECHECK_RANGE_DIFF_CAP lines, and every RECEIPT_CARRY_REFUSED / RECEIPT_CARRY_PREGATE_STALE (those need their inputs + * diff), a retargeted stack (the reviewed range was against another base), a legacy receipt with no review_depth, more + * changed product paths than the carry walk names, a path it cannot name, base context moved next to reviewed hunks, + * an unreadable or empty range-diff, a range-diff over RECHECK_RANGE_DIFF_CAP lines, and every RECEIPT_CARRY_REFUSED / RECEIPT_CARRY_PREGATE_STALE (those need their inputs * repaired, not a review). */ import { createHash } from 'node:crypto'; @@ -16,7 +17,15 @@ import * as fs from 'node:fs'; export const RECHECK_RANGE_DIFF_CAP = 400; /** rangeDiff: \`git range-diff\` of the two ranges followed by the diff of the changed product paths between the heads (a range-diff names added commits without their content). */ -export type RecheckInput = { code: string; ancestor: boolean; retarget: boolean; rangeDiff: string | null; unnamedPaths?: boolean }; +export type RecheckInput = { + code: string; ancestor: boolean; retarget: boolean; rangeDiff: string | null; unnamedPaths?: boolean; + /** The receipt records no review_depth (a legacy receipt), so carry cannot compare HEAD's risk class with the reviewed one. */ + legacy?: boolean; + /** The changed-path walk stopped at CARRY_NAMED_PATHS, so later changed product paths would be missing from the packet. */ + walkCut?: boolean; + /** Paths whose only difference is base context next to reviewed hunks: they need RECEIPT_CARRY_SCOPED_REVIEW's packet, not this one. */ + contextPaths?: number; +}; export type RecheckPlan = { ok: true; lines: number; sha256: string } | { ok: false; reason: string }; const sha = (s: string) => createHash('sha256').update(s).digest('hex'); @@ -26,7 +35,10 @@ export function planRecheck(i: RecheckInput): RecheckPlan { if (i.code !== 'RECEIPT_CARRY_PRODUCT_CHANGED') return { ok: false, reason: `${i.code} is not a product change a review can clear` }; if (!i.ancestor) return { ok: false, reason: 'rewritten history: the reviewed head is not an ancestor of HEAD, so there is no range to diff' }; if (i.retarget) return { ok: false, reason: 'the receipt was reviewed against another base (a retargeted stack)' }; + if (i.legacy) return { ok: false, reason: 'the receipt records no review_depth, so a changed risk class could not be caught' }; + if (i.walkCut) return { ok: false, reason: 'more changed product paths than the walk names, so the packet would miss some' }; if (i.unnamedPaths) return { ok: false, reason: 'a changed product path could not be named, so the packet would miss it' }; + if (i.contextPaths) return { ok: false, reason: 'base context also moved next to reviewed hunks, which needs the scoped review of those hunks' }; if (i.rangeDiff === null) return { ok: false, reason: 'git range-diff failed or timed out' }; const text = i.rangeDiff.trim(); if (!text) return { ok: false, reason: 'git range-diff is empty, so it shows nothing for a reader to check' }; diff --git a/test/ship-receipt-runs.test.ts b/test/ship-receipt-runs.test.ts index 52a33c941..f9957f900 100644 --- a/test/ship-receipt-runs.test.ts +++ b/test/ship-receipt-runs.test.ts @@ -44,6 +44,7 @@ describe('READY tail (E1: the ready output is the whole READY)', () => { expect(lines[0]).toBe('REVIEW_LOOP: passes=2 blocking_found=3 deferred=0 noted=0 fixed=2 unrecorded=1 unbound=1'); expect(lines.slice(1, 3)).toEqual([expect.stringMatching(/^TRIAL_MERGES: /), 'Attention:']); expect(readyTailLines(undefined)[0]).toBe('REVIEW_LOOP: none in the receipt'); + expect(readyTailLines(receipt('f'.repeat(40)))[3]).toStartWith('reviewed by (risk class unknown: the receipt records no review_depth'); }); }); diff --git a/test/ship-recheck.test.ts b/test/ship-recheck.test.ts index 4722eb300..3d57f3279 100644 --- a/test/ship-recheck.test.ts +++ b/test/ship-recheck.test.ts @@ -1,10 +1,13 @@ /** * J1 (GSTA-258 L4): a scoped re-check after a refused receipt carry. Each refusal reason that does or does not qualify, * the record binding, and the carry end to end: a bounded product change prints RECHECK: eligible and writes the packet, - * and a clean record bound to that range-diff carries the receipt with the reader named. + * a clean record bound to that packet carries the receipt with the reader named, and a changed risk class, a legacy + * receipt, more paths than the walk names and moved base context never take the re-check (the /ship p1 red-team and + * security findings). */ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; import { extractReceipt, placeReceiptFirst } from '../lib/ship-receipt'; @@ -14,22 +17,25 @@ import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/re const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); const RANGE = '1: aaaaaaa ! 1: bbbbbbb feat: a=2\n @@ src/app.ts\n -export const a = 2;\n +export const a = 3;'; +const OK = { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE }; describe('planRecheck: which refusals qualify', () => { test('a bounded product change on linear history against the same base qualifies', () => { - const p = planRecheck({ code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE }); - expect(p).toMatchObject({ ok: true, lines: 4 }); + expect(planRecheck(OK)).toMatchObject({ ok: true, lines: 4 }); }); test.each([ - ['tree mismatch', { code: 'RECEIPT_CARRY_TREE_MISMATCH', ancestor: false, retarget: false, rangeDiff: RANGE }, 'is not a product change'], - ['refused inputs', { code: 'RECEIPT_CARRY_REFUSED', ancestor: true, retarget: false, rangeDiff: RANGE }, 'is not a product change'], - ['stale pregate', { code: 'RECEIPT_CARRY_PREGATE_STALE', ancestor: true, retarget: false, rangeDiff: RANGE }, 'is not a product change'], - ['rewritten history', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: false, retarget: false, rangeDiff: RANGE }, 'rewritten history'], - ['retargeted stack', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: true, rangeDiff: RANGE }, 'another base'], - ['unnamed product path', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: RANGE, unnamedPaths: true }, 'could not be named'], - ['range-diff failed', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: null }, 'failed or timed out'], - ['empty range-diff', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: '\n' }, 'empty'], - ['over the cap', { code: 'RECEIPT_CARRY_PRODUCT_CHANGED', ancestor: true, retarget: false, rangeDiff: Array.from({ length: RECHECK_RANGE_DIFF_CAP + 1 }, (_, i) => `l${i}`).join('\n') }, 'over the scoped re-check cap'], + ['tree mismatch', { ...OK, code: 'RECEIPT_CARRY_TREE_MISMATCH', ancestor: false }, 'is not a product change'], + ['refused inputs', { ...OK, code: 'RECEIPT_CARRY_REFUSED' }, 'is not a product change'], + ['stale pregate', { ...OK, code: 'RECEIPT_CARRY_PREGATE_STALE' }, 'is not a product change'], + ['rewritten history', { ...OK, ancestor: false }, 'rewritten history'], + ['retargeted stack', { ...OK, retarget: true }, 'another base'], + ['legacy receipt', { ...OK, legacy: true }, 'no review_depth'], + ['walk cut at the named-path limit', { ...OK, walkCut: true }, 'more changed product paths'], + ['unnamed product path', { ...OK, unnamedPaths: true }, 'could not be named'], + ['base context moved', { ...OK, contextPaths: 1 }, 'base context'], + ['range-diff failed', { ...OK, rangeDiff: null }, 'failed or timed out'], + ['empty range-diff', { ...OK, rangeDiff: '\n' }, 'empty'], + ['over the cap', { ...OK, rangeDiff: Array.from({ length: RECHECK_RANGE_DIFF_CAP + 1 }, (_, i) => `l${i}`).join('\n') }, 'over the scoped re-check cap'], ])('%s does not qualify', (_name, input, why) => { const p = planRecheck(input); expect(p.ok).toBe(false); @@ -39,7 +45,7 @@ describe('planRecheck: which refusals qualify', () => { describe('readRecheck binds to from, head and the range-diff', () => { test('only a clean record for this exact range-diff stands', () => { - const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'recheck-')); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'recheck-')); const bind = { from: 'a'.repeat(40), head: 'b'.repeat(40), sha256: 'c'.repeat(64) }; const write = (o: object) => { const f = path.join(dir, `${Math.random()}.json`); fs.writeFileSync(f, JSON.stringify(o)); return f; }; const good = { from: bind.from, head: bind.head, range_diff_sha256: bind.sha256, reader: 'anthropic/claude-opus-5-5', verdict: 'clean' }; @@ -51,38 +57,104 @@ describe('readRecheck binds to from, head and the range-diff', () => { }); }); +/** A branch reviewed at one commit, with a receipt written by gstack-ship-receipt write (so it records review_depth). */ +function reviewed(files: Record = { 'src/app.ts': 'export const a = 2;\n' }) { + const repo = makeFixtureRepo({ prefix: 'ship-recheck-' }); repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n'); + repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-qb', 'feat'); + for (const [f, text] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(repo.work, f), text); } + repo.git('add', '.'); repo.git('commit', '-qm', 'feat'); + const from = repo.git('rev-parse', 'HEAD'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; + const w = runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--body', body], env); + expect([w.status, w.err.slice(0, 300)]).toEqual([EXIT.ok, expect.any(String)]); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.review_depth).toBeDefined(); + const commit = (changes: Record, msg: string) => { + for (const [f, text] of Object.entries(changes)) { fs.mkdirSync(path.dirname(path.join(repo.work, f)), { recursive: true }); fs.writeFileSync(path.join(repo.work, f), text); } + repo.git('add', '.'); repo.git('commit', '-qm', msg); + return repo.git('rev-parse', 'HEAD'); + }; + return { repo, from, body, env, commit }; +} +const eligible = (err: string) => /^RECHECK: eligible range_diff_lines=(\d+) range_diff_sha256=([0-9a-f]{64}) packet=(\S+);/m.exec(err); + describe('carry with a scoped re-check (CLI)', () => { test('a bounded product change prints RECHECK: eligible, keeps the refusal, and a clean record carries with the reader named', () => { - const repo = makeFixtureRepo({ prefix: 'ship-recheck-' }); repos.push(repo); - fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true }); - fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n'); - repo.git('add', '.'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main'); - repo.git('checkout', '-qb', 'feat'); - fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n'); - repo.git('commit', '-qam', 'feat: a=2'); - const reviewed = repo.git('rev-parse', 'HEAD'); - fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 3;\n'); - repo.git('commit', '-qam', 'fix: a=3'); - const head = repo.git('rev-parse', 'HEAD'); - const body = path.join(repo.root, 'body.md'); - fs.writeFileSync(body, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 5, head: reviewed, base: 'main', review_loop: { passes: 1, blocking_found: 0, deferred: 0 } } as any)); - const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; + const { repo, from, body, env, commit } = reviewed(); + const head = commit({ 'src/app.ts': 'export const a = 3;\n' }, 'fix: a=3'); const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env); expect(refused.status).toBe(EXIT.refused); expect(refused.out.trim().split('\n')[0]).toBe('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); - const m = /^RECHECK: eligible range_diff_lines=(\d+) range_diff_sha256=([0-9a-f]{64}) packet=(\S+);/m.exec(refused.err); + const m = eligible(refused.err); expect(m).not.toBeNull(); expect(fs.readFileSync(m![3]!, 'utf8')).toContain('export const a = 3;'); const record = path.join(repo.root, 'recheck.json'); - fs.writeFileSync(record, JSON.stringify({ from: reviewed, head, range_diff_sha256: m![2], reader: 'anthropic/claude-opus-5-5', verdict: 'clean' })); + fs.writeFileSync(record, JSON.stringify({ from, head, range_diff_sha256: m![2], reader: 'anthropic/claude-opus-5-5', verdict: 'clean' })); const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', record, '--out', path.join(repo.root, 'run')], env); - expect(carried.status).toBe(EXIT.ok); - expect(carried.out.trim().split('\n')[0]).toBe(`CARRY: carried from ${reviewed.slice(0, 12)} to ${head.slice(0, 12)} (scoped re-check by anthropic/claude-opus-5-5 (range-diff ${m![1]} lines))`); - expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt).toMatchObject({ head, carry: { from: reviewed } }); + expect([carried.status, carried.err.slice(0, 400)]).toEqual([EXIT.ok, expect.any(String)]); + expect(carried.out.trim().split('\n')[0]).toBe(`CARRY: carried from ${from.slice(0, 12)} to ${head.slice(0, 12)} (scoped re-check by anthropic/claude-opus-5-5 (range-diff ${m![1]} lines))`); + expect(extractReceipt(fs.readFileSync(body, 'utf8')).receipt).toMatchObject({ head, carry: { from } }); const stale = path.join(repo.root, 'stale.json'); - fs.writeFileSync(stale, JSON.stringify({ from: reviewed, head, range_diff_sha256: 'f'.repeat(64), reader: 'x', verdict: 'clean' })); - fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 4;\n'); - repo.git('commit', '-qam', 'fix: a=4'); - expect(runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', stale], env).status).toBe(EXIT.refused); + fs.writeFileSync(stale, JSON.stringify({ from: head, head, range_diff_sha256: 'f'.repeat(64), reader: 'x', verdict: 'clean' })); + commit({ 'src/app.ts': 'export const a = 4;\n' }, 'fix: a=4'); + const bad = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', stale], env); + expect([bad.status, bad.out.trim().split('\n')[0]]).toEqual([EXIT.refused, 'CARRY: refused (RECEIPT_CARRY_REFUSED)']); + }); + + test('a clean re-check cannot carry across a changed risk class', () => { + const { repo, from, body, env, commit } = reviewed(); + const before = extractReceipt(fs.readFileSync(body, 'utf8')).receipt!.review_depth!; + const head = commit({ 'src/app.ts': 'export const a = 3;\n', 'src/billing.ts': 'export function charge(cents: number) { return stripe.charges.create({ amount: cents }); }\n' }, 'feat: billing'); + const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env); + const m = eligible(refused.err); + expect(m).not.toBeNull(); + const record = path.join(repo.root, 'recheck.json'); + fs.writeFileSync(record, JSON.stringify({ from, head, range_diff_sha256: m![2], reader: 'x', verdict: 'clean' })); + const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--recheck', record], env); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/risk class|review depth/); + expect(before.risk ?? []).not.toContain('money'); + }); + + test('a legacy receipt, more changed paths than the walk names, and moved base context print RECHECK: not eligible', () => { + const legacy = reviewed(); + fs.writeFileSync(legacy.body, placeReceiptFirst('## Summary\n', { schema_version: 1, pr: 5, head: legacy.from, base: 'main' } as any)); + legacy.commit({ 'src/app.ts': 'export const a = 3;\n' }, 'fix'); + expect(runBin(legacy.repo, 'gstack-ship-receipt', ['carry', '--body', legacy.body], legacy.env).err).toContain('RECHECK: not eligible (the receipt records no review_depth'); + + const many = Object.fromEntries(Array.from({ length: 22 }, (_, i) => [`src/f${i}.ts`, `export const f${i} = 1;\n`])); + const wide = reviewed(many); + wide.commit(Object.fromEntries(Object.keys(many).map(f => [f, 'export const changed = 2;\n'])), 'fix: all'); + expect(runBin(wide.repo, 'gstack-ship-receipt', ['carry', '--body', wide.body], wide.env).err).toContain('RECHECK: not eligible (more changed product paths'); + }); + + test('base context moved next to a reviewed hunk plus a product edit elsewhere is not eligible (red-team p1 repro)', () => { + const lines = (n: number, edits: Record = {}) => Array.from({ length: 10 }, (_, i) => edits[i + 1] ?? `line ${i + 1}`).join('\n') + '\n'; + const repo = makeFixtureRepo({ prefix: 'ship-recheck-ctx-' }); repos.push(repo); + fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10)); + fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 1;\n'); + repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-qb', 'feat'); + fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10, { 6: 'branch line 6' })); + repo.git('commit', '-qam', 'feat: c6'); + const body = path.join(repo.root, 'body.md'); + fs.writeFileSync(body, '## Summary\n'); + const env = { GSTACK_STATE_ROOT: path.join(repo.root, 'state'), GSTACK_SESSION_KIND: 'unattended' }; + expect(runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '5', '--base', 'main', '--body', body], env).status).toBe(EXIT.ok); + repo.git('checkout', '-q', 'main'); + fs.writeFileSync(path.join(repo.work, 'src', 'c.ts'), lines(10, { 4: 'main line 4' })); + repo.git('commit', '-qam', 'main: c4'); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feat'); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + fs.writeFileSync(path.join(repo.work, 'src', 'app.ts'), 'export const a = 2;\n'); + repo.git('commit', '-qam', 'fix: app'); + const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body], env); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain('RECHECK: not eligible (base context also moved'); }); });