diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index 1d42836fc..87ecbfda6 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -81,7 +81,9 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do before HEAD; needs --range-base): the stop after a round-2 full pass with a fixed blocking finding or a gate-class edit plans that fix-check instead; a fix-check never earns another, except that with --post-review --last fix-check a new grant at a stop head after --range-base (the last fix-check's - commit) plans the next link of the fix-check chain; any other row is refused (exit 3) + commit) plans the next link of the fix-check chain, and in the loop after a fix-check a new grant + naming that fix-check's commit (--range-base) plans the next link; a grant recorded before a later + logged fix-check already bought a link; any other row is refused (exit 3) --fix-checks fix-checks already run in this invocation: the owner's grant adds one only after the first, in the lean loop and after a round-2 fix-check in the full loop (GEVA-223) --post-review plan the Step 9 that /ship Step 16 stage 2.1 inserted for edits after the review bound (finish's @@ -150,6 +152,7 @@ function main(): number { const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); + let inLoopLink = false; if (grant) { const loc = locate({ cwd: root, env: process.env }); const row = ('file' in loc ? readRecord(loc).rows : []).find(r => r.id === grant && r.outcome === 'approved' && `${r.ask} ${r.scope}`.includes('review_loop_grant')); @@ -157,7 +160,11 @@ function main(): number { const ancestor = (a: string, d: string) => git(['merge-base', '--is-ancestor', a, d]) !== null; const chained = has('--post-review') && last === 'fix-check'; const stopHead = row && passHead ? `${row.ask} ${row.scope}`.match(/\b[0-9a-f]{7,40}\b/g)?.map(s => git(['rev-parse', '--verify', '--quiet', `${s}^{commit}`])?.trim()).find(s => s && ancestor(passHead, s) && ancestor(s, 'HEAD') && !(chained && s === passHead)) : undefined; + inLoopLink = !chained && last === 'fix-check' && stopHead === passHead; if (!stopHead) { process.stderr.write(`gstack-review-plan: --grant ${grant}: no approved row ${grant} naming review_loop_grant and this invocation's stop head (a commit ${chained ? `after --range-base ${rangeBase}, the last fix-check's commit, whose own grant does not carry over` : `at or after --range-base ${rangeBase}`}, at or before HEAD) in this branch's approval record (${'file' in loc ? 'gstack-approvals list' : loc.message}); record the owner's grant with gstack-approvals add --ask '... (review_loop_grant )' first\n`); return EXIT.refused; } + const logged = inLoopLink ? runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }) : null; + if (logged && logged.status !== 0) { process.stderr.write(`gstack-review-plan: --grant ${grant}: gstack-review-read exited ${logged.status}, so whether a fix-check already spent this grant is unknown; no plan is safer than a fix-check the grant may not buy\n`); return EXIT.fail; } + if (logged && row && (logged.stdout ?? '').split('\n---CONFIG---')[0]!.split('\n').some(l => { try { const v = JSON.parse(l); return v?.skill === 'review-fix-check' && Date.parse(v.timestamp) > Date.parse(row.recorded_at); } catch { return false; } })) { process.stderr.write(`gstack-review-plan: --grant ${grant}: a fix-check was logged after this grant was recorded, so the grant already bought a link of the fix-check chain; the stop at --range-base ${rangeBase} needs a new grant recorded after the last fix-check (gstack-approvals add --ask '... (review_loop_grant ${passHead!.slice(0, 12)})')\n`); return EXIT.refused; } } const collected = collectLive(root, base); if (collected.errors.length) { @@ -236,7 +243,7 @@ function main(): number { const plan = planReview({ ...collected, ...exclusionInput, ...riskPaths, declared: declared as RiskClass[], shape: { panel: review.panel, readerModel: review.reader_model, platform: review.platform, head, platformReview: platform?.ok ? platform.review : null }, scope, gateTagged, targeted: { classes: targeted.classes, ran }, numbers: scanNumbers(kept(added)), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host', criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), - history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: (has('--fixed-below') || (postReview && pass > 0)) && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate || (postReview && pass > 0), pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant, fixChecks: has('--fix-checks') ? fixChecks : undefined, postReview }, + history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: (has('--fixed-below') || (postReview && pass > 0)) && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate || (postReview && pass > 0), pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines, granted: grant, fixChecks: has('--fix-checks') ? fixChecks : undefined, postReview, chained: inLoopLink }, }); if (postReview && pass > 0) plan.next.reason = `edits after the review bound (Step 16 stage 2.1) plan as a fix to a gate-class path, never a note: ${plan.next.reason}`; const ranArg = flag('--targeted-ran'); diff --git a/changelog.d/capy-gst-1681-granted-third-fix-round.md b/changelog.d/capy-gst-1681-granted-third-fix-round.md new file mode 100644 index 000000000..f7da7362b --- /dev/null +++ b/changelog.d/capy-gst-1681-granted-third-fix-round.md @@ -0,0 +1 @@ +- **A Captain-granted fix round after a fix-check is planned, not hand-run.** After round 2's fix-check and one granted fix-check, an owner who granted one more round at the last fix-check's stop head got `NEXT: stop` from `gstack-review-plan`, because in the loop a fix-check never earned another. The lane ran the fix-check by hand on a fix-range token, and finish printed `FIX_CHECK: not counted (fix-range pass; only a later full pass can clear review)` (gstack-evals#254). A new recorded grant naming the last fix-check's commit (`--range-base`) now plans one more scoped fix-check, the next link of the fix-check chain finish already counts; the previous link's grant (one recorded before a later logged fix-check) is refused and a grant at any other commit still stops, so one grant buys one link. (GST-1681) diff --git a/docs/review-policy.md b/docs/review-policy.md index 79f9cb415..44097d6e2 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -118,7 +118,10 @@ incomplete coverage, which blocks until the reviewer is rerun. `gstack-review-plan --post-review --last fix-check --range-base --grant `, plans one more fix-check, the next link of the [fix-check chain](ship-policy.md#fix-check-chain) (GST-1676). The last link's - own grant is refused there, so each link still needs the owner's word. + own grant is refused there, so each link still needs the owner's word. In the + loop, a new grant naming the last fix-check's commit (`--range-base`) plans + the next link the same way; a grant recorded before a later logged fix-check + already bought a link and is refused (GST-1681). ### Transitions @@ -144,6 +147,7 @@ incomplete coverage, which blocks until the reviewer is rerun. | a round-2 full pass that stops for a fixed blocking finding or a gate-class edit, with the owner's recorded grant (`--grant `) | one scoped fix-check (round 3), after Step 11 on gate class | | a round-2 fix-check that stops the same way, with the owner's recorded grant and `--fix-checks 1` | one scoped fix-check (round 3), after Step 11 on gate class | | `--post-review` after a fix-check (edits after the review bound), with a new recorded grant at a stop head after the last fix-check's commit | one scoped fix-check, the next link of the fix-check chain, after Step 11 on gate class | +| a fix-check that stops in the loop, with a new recorded grant naming its own commit (`--range-base`, the stop it answers), after any number of fix-checks | one scoped fix-check, the next link of the fix-check chain, after Step 11 on gate class | A **fix-check** is one reviewer reading the touched hunks, the fix list, the test results and the callers of every changed symbol, at full effort. It is diff --git a/docs/ship-policy.md b/docs/ship-policy.md index c3cd6581d..148d1a525 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1825,7 +1825,13 @@ the lean panel). At the cap it stops for the owner, whose recorded grant fix-check, pass `--range-base` at that fix-check's commit: a new grant at a stop head after it plans the next link of the chain below (GST-1676), and the last link's own grant is refused. So `finish` never asks for a step the -planner refuses. +planner refuses. In the loop, a fix-check that stops is answered the same way: +a new grant naming that fix-check's commit (`--range-base`, the stop it +answers) plans one more fix-check, the next link of the chain, however many +fix-checks ran before (GST-1681, GEVA-363's third round). The previous link's +grant is refused: it names an earlier stop head, or it was recorded before a +later logged fix-check (a post-review link's grant names the commit its +fix-check read). A grant at any other commit plans nothing: one grant, one link. **Chained fix-checks (GST-1627).** A second granted fix-check after the first one (a merge resolution, say) logs its row with diff --git a/lib/review-depth.ts b/lib/review-depth.ts index 3f3188573..5b98d582a 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -386,6 +386,8 @@ export interface History { fixChecks?: number; /** The Step 9 /ship Step 16 stage 2.1 inserted for edits after the review bound (gstack-review-plan --post-review). */ postReview?: boolean; + /** In the loop, the CLI bound `granted` to the last fix-check's own stop head, its commit (--range-base): a new grant, never the previous link's. */ + chained?: boolean; } export interface NextStep { step: Step; reason: string } @@ -401,7 +403,8 @@ export interface NextStep { step: Step; reason: string } * and the owner's recorded grant (`granted`) turns the stop after a round-2 full pass with a fixed finding, or after a round-2 * fix-check when `fixChecks` says it was the only one (GEVA-223), into its one fix-check; a fix-check never earns another, so * the grant cannot repeat. Edits after the review bound (`postReview`) that follow a fix-check are a new stop, and a new grant - * there adds one fix-check, the next link of the chain finish counts (GST-1676, docs/ship-policy.md#fix-check-chain). + * there adds one fix-check, the next link of the chain finish counts (GST-1676, docs/ship-policy.md#fix-check-chain). In the + * loop, a grant the CLI bound to the last fix-check's own stop head (`chained`) adds one more the same way (GST-1681). */ export function nextStep(h: History, depth?: Depth): NextStep { const n = h.lean ? leanStep(h, depth) : loopStep(h, depth); @@ -409,6 +412,7 @@ export function nextStep(h: History, depth?: Depth): NextStep { if (h.gate && !h.adversarialRan) return { step: 'voices', reason: `gate class: Step 11's ${h.lean ? 'one reader' : 'voices (adversarial, outside, targeted)'} run${h.lean ? 's' : ''} before the owner's exit, then ask again with --adversarial-ran (${n.reason})` }; const afterFixCheck = h.last === 'fix-check' && h.fullPasses === FULL_PASS_CAP - 1 && h.fixChecks === 1; if (h.postReview && h.last === 'fix-check' && h.granted && !h.blockingOpen) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check for the edits after the last fix-check, a new link of the fix-check chain (docs/ship-policy.md#fix-check-chain); a later post-review edit needs a grant of its own` }; + if (h.chained && h.last === 'fix-check' && h.granted && !h.blockingOpen) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check at the last fix-check's stop, a new link of the fix-check chain (docs/ship-policy.md#fix-check-chain); another stop needs a grant of its own` }; if (!h.lean && h.granted && !h.blockingOpen && (afterFixCheck || (h.last === 'full' && h.fullPasses === FULL_PASS_CAP))) return { step: 'fix-check', reason: `the owner's grant ${h.granted} adds one scoped fix-check after round ${FULL_PASS_CAP} (review_loop_exception), ${afterFixCheck ? 'never a second' : 'never a fourth round'}` }; return n; } diff --git a/test/fixtures/review-pins/files/lib/review-depth.ts.json b/test/fixtures/review-pins/files/lib/review-depth.ts.json index 9388d608f..c373e4397 100644 --- a/test/fixtures/review-pins/files/lib/review-depth.ts.json +++ b/test/fixtures/review-pins/files/lib/review-depth.ts.json @@ -1 +1 @@ -{"sha":"34685744162ba50d743abbf7c6e2e1ee57bf5ae0e6fd82347bca28f50a155b3f","replay":"GST-1676"} +{"sha":"eac38277f567d80e6ec5613e050c05c2b2b837b405f14df25329814b9f3c34f9","replay":"GST-1681"} diff --git a/test/fixtures/review-pins/replays/GST-1681.json b/test/fixtures/review-pins/replays/GST-1681.json new file mode 100644 index 000000000..c032c0723 --- /dev/null +++ b/test/fixtures/review-pins/replays/GST-1681.json @@ -0,0 +1 @@ +{"replay":"GST-1681","justification":"GST-1681 (granted fix-check after a fix-check, in the loop): lib/review-depth.ts is main's GST-1676 bytes plus one History field, chained, and one transition in nextStep: with chained set, last fix-check, a recorded grant and no open blocker, the stop becomes one fix-check (the next link of finish's fix-check chain, docs/ship-policy.md#fix-check-chain). Only bin/gstack-review-plan sets chained, outside --post-review, when the grant names exactly --range-base (the last fix-check's commit, the stop it answers). Deterministic no-loss replay, no paid run: the GST-967 grid (MAIN_PLANS) and the GST-972 grid (MAIN_972_PLANS) in test/review-depth.test.ts hash unchanged and the GST-967 transition-table invariant passes, since none of them sets chained; so no plan main builds drops, renames or re-efforts a reader, no stop main prints without a grant changes, and no brief body moves (MAIN_SECTIONS unchanged)."} diff --git a/test/review-depth.test.ts b/test/review-depth.test.ts index e616d0aea..517b72bbd 100644 --- a/test/review-depth.test.ts +++ b/test/review-depth.test.ts @@ -125,6 +125,20 @@ describe('GST-967: the round cap limits fix rounds, not the gate-class voices; a for (const o of [{ granted: undefined }, { blockingOpen: true }, { postReview: false }, { postReview: undefined }]) expect([o, nextStep({ ...h, ...o }).step]).toEqual([o, 'stop']); expect(nextStep({ ...h, last: 'full', fullPasses: 1, fixedBelow: true }).step).toBe('full'); }); + // Value: protects=in the loop, a grant the CLI bound to the last fix-check's own stop head (`chained`) plans one more fix-check + // after any number of fix-checks (GEVA-363's third round), after Step 11 on gate class; without that binding a fix-check still + // never earns another; fails_when=the planner prints stop for a new grant (GST-1681) or plans one for an unbound grant; + // seam=nextStep; no_claim=the CLI's binding (test/review-plan-cli.test.ts) + test('a new grant bound to the last fix-check\'s stop plans the next chained fix-check in the loop (GST-1681)', () => { + const h = { fullPasses: 1, last: 'fix-check', fixedBlocking: true, granted: 'a-5214be46db1e', fixChecks: 2, chained: true } as const; + expect(nextStep(h)).toMatchObject({ step: 'fix-check', reason: expect.stringContaining('a-5214be46db1e') }); + expect(nextStep({ ...h, lean: true }).step).toBe('fix-check'); + expect(nextStep({ ...h, fullPasses: FULL_PASS_CAP }).step).toBe('fix-check'); + expect(nextStep({ ...h, gate: true }).step).toBe('voices'); + expect(nextStep({ ...h, gate: true, adversarialRan: true }).step).toBe('fix-check'); + for (const o of [{ chained: false }, { chained: undefined }, { granted: undefined }, { blockingOpen: true }]) expect([o, nextStep({ ...h, ...o }).step]).toEqual([o, 'stop']); + expect(nextStep({ ...h, fixedBlocking: false }).step).toBe('done'); + }); test('docs/review-policy.md\'s transitions table and rule 3 say both', () => { const doc = fs.readFileSync(path.join(ROOT, 'docs', 'review-policy.md'), 'utf8'); const table = doc.slice(doc.indexOf(''), doc.indexOf('A **fix-check** is one reviewer')); diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts index 99e554c6b..a62313a7d 100644 --- a/test/review-plan-cli.test.ts +++ b/test/review-plan-cli.test.ts @@ -156,6 +156,86 @@ describe('gstack-review-plan', () => { expect([reused.status, reused.stdout]).toEqual([3, '']); expect(reused.stderr).toContain(`--grant ${g1}: no approved row ${g1} naming review_loop_grant and this invocation's stop head (a commit after --range-base ${link1}, the last fix-check's commit`); }, 30_000); + // Value: protects=a Captain's grant for a third fix round past a fix-check is planned, not hand-rendered: GEVA-363 (gstack-evals#254) + // ran pass 1 full, fc1 (round 2), fc2 under a grant at fc1's stop, then the owner granted a third round at fc2's stop head (its + // commit, the new --range-base); the in-loop Step 9 plans that one fix-check; the previous link's grant, a grant at another + // commit, no grant or an open blocker do not; GBRA-336's --post-review --last fix-check --fix-checks 2 --grant invocation plans + // with a grant after --range-base (GST-1676); fails_when=the planner prints stop despite the new grant (GEVA-363: the lane + // hand-ran fc3 on a fix-range token finish could not count) or lets fc2's grant buy fc3; seam=bin/gstack-review-plan, nextStep; + // no_claim=finish's chain count (test/ship-finish-ruled-notes.test.ts) + test('GST-1681: in the loop, a new grant at the last fix-check\'s stop head plans one more fix-check, never for the previous grant', () => { + const r = repo({ 'src/a.ts': 'export const a = 1;\n' }); + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: r.state, GSTACK_APPROVALS_MIRROR: '0' }; + delete env.GSTACK_APPROVALS_DIR; + const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const commit = (n: number) => { fs.appendFileSync(path.join(r.dir, 'src/a.ts'), `export const v${n} = ${n};\n`); git('commit', '-qam', `c${n}`); return git('rev-parse', 'HEAD'); }; + const add = (stop: string, n: string) => { + const a = spawnSync(path.join(path.dirname(BIN), 'gstack-approvals'), ['add', '--ask', `Owner grant: scoped fix-check ${n} (review_loop_grant ${stop.slice(0, 12)})`, '--words', 'do it', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + expect(a.status, a.stderr).toBe(0); + return JSON.parse(a.stdout).added[0] as string; + }; + const fc1 = commit(1); + const g1 = add(fc1, 'fc2'); + const fc2 = commit(2); + const g2 = add(fc2, 'fc3'); + const later = commit(3); + const g3 = add(later, 'at the fix'); + const plan = (a: string[]) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', '--pass', '1', '--last', 'fix-check', '--fix-checks', '2', '--range-base', fc2, ...a], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + const third = plan(['--fixed-blocking', '--grant', g2]); + expect(third.status, third.stderr).toBe(0); + expect(third.stdout).toContain(`NEXT: fix-check (the owner's grant ${g2} adds one scoped fix-check at the last fix-check's stop, a new link of the fix-check chain`); + expect(third.stdout).toContain('DISPATCH: fix-check fix-check effort=full'); + expect(plan(['--fixed-blocking']).stdout).toContain('NEXT: stop'); + expect(plan(['--fixed-blocking', '--grant', g2, '--blocking-open']).stdout).toContain('NEXT: stop'); + expect(plan(['--fixed-blocking', '--grant', g3]).stdout).toContain('NEXT: stop'); + const reused = plan(['--fixed-blocking', '--grant', g1]); + expect([reused.status, reused.stdout]).toEqual([3, '']); + expect(reused.stderr).toContain(`--grant ${g1}: no approved row ${g1} naming review_loop_grant and this invocation's stop head (a commit at or after --range-base ${fc2}`); + const gbra336 = plan(['--post-review', '--grant', g3]); + expect(gbra336.status, gbra336.stderr).toBe(0); + expect(gbra336.stdout).toContain(`NEXT: fix-check (edits after the review bound (Step 16 stage 2.1) plan as a fix to a gate-class path, never a note: the owner's grant ${g3} adds one scoped fix-check for the edits after the last fix-check`); + expect(plan(['--post-review', '--grant', g2]).status).toBe(3); + }, 30_000); + // Value: protects=one grant buys one link in the loop too: a grant recorded before a later logged fix-check already bought that + // fix-check (a post-review link's grant names the commit its fix-check read; a stale --range-base names an earlier link), so the + // in-loop replan refuses it, while a grant recorded after the newest fix-check plans; fails_when=a spent grant plans another + // paid fix-check that finish cannot count (the GST-1681 /ship review: red-team#1, specialist#1); seam=bin/gstack-review-plan; + // no_claim=finish's chain count (test/ship-finish-ruled-notes.test.ts) + test('GST-1681: in the loop, a grant recorded before a later logged fix-check is spent and refused', () => { + const r = repo({ 'src/a.ts': 'export const a = 1;\n' }); + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_STATE_ROOT: r.state, GSTACK_APPROVALS_MIRROR: '0' }; + delete env.GSTACK_APPROVALS_DIR; + const git = (...a: string[]) => spawnSync('git', a, { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + const commit = (n: number) => { fs.appendFileSync(path.join(r.dir, 'src/a.ts'), `export const v${n} = ${n};\n`); git('commit', '-qam', `c${n}`); return git('rev-parse', 'HEAD'); }; + const add = (stop: string, n: string) => { + const a = spawnSync(path.join(path.dirname(BIN), 'gstack-approvals'), ['add', '--ask', `Owner grant: scoped fix-check ${n} (review_loop_grant ${stop.slice(0, 12)})`, '--words', 'do it', '--by', 'GStack Minder', '--scope', 'this /ship invocation', '--json'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + expect(a.status, a.stderr).toBe(0); + return JSON.parse(a.stdout).added[0] as string; + }; + const logFixCheck = () => { + const l = spawnSync(path.join(path.dirname(BIN), 'gstack-review-log'), [JSON.stringify({ skill: 'review-fix-check', timestamp: new Date().toISOString(), status: 'clean', via: 'ship', completed: true, converged: true })], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + expect(l.status, l.stderr).toBe(0); + }; + const fc1 = commit(1); + logFixCheck(); + const g1 = add(fc1, 'fc2'); + commit(2); + logFixCheck(); + const c = commit(3); + const gc = add(c, 'post-review link at c'); + logFixCheck(); + commit(4); + const plan = (rangeBase: string, grant: string) => spawnSync(BIN, ['--base', 'main', '--gate-tagged', '0', '--pass', '1', '--last', 'fix-check', '--fix-checks', '3', '--fixed-blocking', '--range-base', rangeBase, '--grant', grant], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env }); + for (const [rangeBase, grant] of [[c, gc], [fc1, g1]] as const) { + const spent = plan(rangeBase, grant); + expect([spent.status, spent.stdout]).toEqual([3, '']); + expect(spent.stderr).toContain(`--grant ${grant}: a fix-check was logged after this grant was recorded, so the grant already bought a link of the fix-check chain`); + } + const fresh = add(c, 'fc5'); + const next = plan(c, fresh); + expect(next.status, next.stderr).toBe(0); + expect(next.stdout).toContain(`NEXT: fix-check (the owner's grant ${fresh} adds one scoped fix-check at the last fix-check's stop`); + }, 30_000); test('--fix-check-prompt renders the fix-check reviewer prompt from the parent\'s files and refuses a missing one', () => { const r = repo({ 'docs/howto.md': '# x\n' }); const w = (n: string, t: string) => { const f = path.join(r.state, n); fs.writeFileSync(f, t); return f; }; diff --git a/test/ship-finish-ruled-notes.test.ts b/test/ship-finish-ruled-notes.test.ts index 6f04ea3a5..eaca23f36 100644 --- a/test/ship-finish-ruled-notes.test.ts +++ b/test/ship-finish-ruled-notes.test.ts @@ -350,6 +350,38 @@ describe('finish counts a post-review fix-check chain whose grants name each lin }); }); +/** + * GST-1681 (GEVA-363, gstack-evals#254): pass 1, then fc1 (round 2) at the stop the voices read, then the owner's grant at that + * stop head buys fc2, and a second grant at fc2's commit (its stop head) buys a third round, fc3. Each in-loop grant names the + * stop it answers, the link's own range_base. + */ +describe('finish counts a Captain-granted third fix round as a chained fix-check (GST-1681)', () => { + // Value: protects=the GEVA-363 shape counts once fc3 is a planned fix-check: fc2 and fc3 each carry their own grant, the chain stops + // at fc1's tree (the reviewed tree), and the ungranted in-cap fc1 row is not a link; fails_when=finish refuses fc3 (GEVA-363 + // printed FIX_CHECK: not counted) or asks fc1 for a grant; seam=finish's chain walk and per-link grant assignment + test('GEVA-363: fc1 in the cap, then fc2 and fc3 under their own grants, and finish counts fc2..fc3', () => { + const f = shipFixture({ 'src/x.ts': 'export const x = 1;\n' }, { 'src/x.ts': 'export const x = 2;\n', 'test/x.test.ts': TEST_V1 }); + ships.push(f); + const p1 = headOf(f); + f.write({ 'src/x.ts': 'export const x = 3;\n' }); f.commit('round 1 fixes'); + const fc1 = headOf(f); + logLink(f, p1); + const reviewed = f.run(path.join(BIN, 'gstack-wtree'), []).stdout.trim(); + const reviewedBase = f.git('merge-base', 'origin/main', 'HEAD').stdout.trim(); + const g2 = grantLink(f, fc1, 'fc2'); + f.write({ 'src/x.ts': 'export const x = 4;\n' }); f.commit('fix the voices\' CRITICAL'); + const fc2 = headOf(f); + logLink(f, fc1); + const g3 = grantLink(f, fc2, 'fc3'); + f.write({ 'test/x.test.ts': TEST_V2 }); f.commit('fix fc2\'s CRITICAL'); + const fc3 = headOf(f); + logLink(f, fc2); + const r = finishAfterAudit(f, reviewed, reviewedBase); + expect(lineOf(r.stdout, 'FIX_CHECK'), r.stdout).toBe(`FIX_CHECK: reviewed src/x.ts,test/x.test.ts (fix-check chain of 2 from ${fc1.slice(0, 12)}: at ${fc2.slice(0, 12)} grant ${g2}; at ${fc3.slice(0, 12)} grant ${g3})`); + expect([r.status, lineOf(r.stdout, 'ROUTE')], r.stdout).toEqual([0, expect.stringMatching(/ behavior=0 .* fix_checked=2$/)]); + }); +}); + describe('ruledNotes reads only approved owner rulings (GST-1537)', () => { const HEAD = '0123456789abcdef0123456789abcdef01234567'; const row = (finding: string, o: Partial<{ ask: string; scope: string; by: string; outcome: string }> = {}) => {