diff --git a/docs/users/features/code-review.md b/docs/users/features/code-review.md index 13e9077f5f9..aeaba350ceb 100644 --- a/docs/users/features/code-review.md +++ b/docs/users/features/code-review.md @@ -369,6 +369,8 @@ If you switch models (via `/model`) and re-review the same PR, `/review` detects The model match also gates incremental scoping, not just the skip: "clean up to the cached commit" is the previous model's verdict, so when new commits have landed since the cached review, a model mismatch never scopes to `lastCommitSha..HEAD` — the range is the full diff, noting "Previous round was reviewed by qwen3-coder. Running full review with gpt-4o." — unless an anchor certified by the model now running is recovered from the last posted review (below), which scopes the range instead. The previous round's findings still carry over to be re-ruled; only the anchor does not. The same gate binds the anchor recovered from the last posted review's machine-ledger marker when the cache is absent or its anchor is unusable (CI, another clone): it scopes the incremental range only if the model now running certified it — a marker certified by a different model, or carrying no model (a review posted with `review.attribution` off, or one from before the field), falls back to the full diff. A round that did not close cleanly posts its marker without an anchor (it cannot certify a range), but that loss is not sticky when the round's work list survived whole: recovery grafts the anchor forward from the most recent earlier marker your own account posted with one, so a single non-clean round no longer forces every later round to re-read the full diff — the next round scopes `anchor..HEAD`, which re-covers the range the non-clean round could not certify. A size-capped round's graft is refused (dropped findings would fall outside the grafted scope and retire silently), so later rounds keep re-reading the full diff until a complete marker lands. +**Critical-posture re-reviews run a narrower round (fix-audit shape).** Once a PR's re-review resolves the round-adaptive posting floor to critical-only (from round 6, or earlier when the flat first-time-finding trend latches — see the convergence posture) _and_ a usable incremental anchor exists, the round stops re-running round 1's shape. The capture command predicts the floor from the previous round's posted marker and records `incremental.posture: "critical"` in the plan, and the round then: fans out one territory agent per chunk of the delta (whatever its size — no 3A dimension set, but keeping the issue-fidelity agent: it re-checks head against the issue whatever the delta displays, and is the one auditor that can see a fix commit removing behaviour the issue required when the removal appears on neither side of the narrowed range); republishes each still-clean _interaction_ file seam-bounded — only the hunks displaying a line that imports or uses what changed, with a `seam: {kept, total}` census in the plan and a header-only section when nothing qualifies, so the file still gets an agent briefed to check the seam from the worktree (a file whose full-range slice classifies as heavy is exempt and republishes whole, so its invariant agents still launch; so is every read TypeScript's parser cannot certify — the parser is resolved at run time from the reviewed repository, never bundled into the CLI, and no parser, a syntax error, a computed `require`/`import(` specifier or a required value that escapes into an expression each republish the file in full with no census); and narrows the reverse-audit waves instead of capping them — from wave 3, a chunk holding no delta file leaves the schedule after one substantive dry audit, while delta territories keep the full retirement rules and any non-delta chunk the waves could not certify dry stays in (a stale dry receipt returns it to the ordinary rules), so the late waves (where fix-induced Criticals measurably surface) keep running over a shrinking front. Severities are unchanged — the posture governs what posts, never what is found — and every reduction is disclosed: in the plan, in the agents' briefs, in the round's `posture narrowing:` note, and as a one-sentence "Round shape" disclosure in the posted review body. The plan's posture record is itself an arm of the compose-time floor resolution, so a fix-audit round's posting bar cannot disagree with the shape it ran (sub-Critical findings defer even if the floor's usual inputs are unavailable at compose time). An explicit `--severity-floor suggestion` turns the shape off along with the posture (and outranks a stale plan record at posting time); an explicit `--severity-floor critical` enables it from the first anchored re-review. Two deployment conditions gate the seam bound itself, each recorded rather than silent: the seam oracle reads files through TypeScript's own parser, resolved at run time and never bundled — where no parser resolves (a global install carries no runtime dependencies, and the review workflow's runners install nothing into the base checkout) the bound does not run, every interaction file republishes in full, and the plan's `seamOracle: "unavailable"` record and the Round-shape disclosure say so; and the bound stays off unless the merge base held still since the previous round (the side file carries each published round's base stamp), because shedding a hunk is only licensed while a prior round provably published it. + Cache is stored in `.qwen/review-cache/` and tracks both the commit SHA and model ID. Make sure this directory is in your `.gitignore` (a broader rule like `.qwen/*` also works). On GitHub, if the cached commit was rebased or force-pushed away, it falls back to a full review; Aone rules the cached anchor differently — see its paragraph below. Only high-effort reviews consult or write the cache — a `--effort low|medium` quick pass never counts as "already reviewed". ## Review Reports diff --git a/packages/cli/src/commands/review/agent-prompt.test.ts b/packages/cli/src/commands/review/agent-prompt.test.ts index a55dab2b509..2c9403a3ffa 100644 --- a/packages/cli/src/commands/review/agent-prompt.test.ts +++ b/packages/cli/src/commands/review/agent-prompt.test.ts @@ -5538,6 +5538,426 @@ describe('per-chunk retirement — cold territories stop costing a round', () => expect(rounds).toContain(2); }); + it('a fix-audit round narrows the wave and the note names it (#10104)', () => { + // Chunk 13 is the delta territory; 14 and 15 hold interaction files. + const fixAuditPlan = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'a.ts', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + seam: { kept: 2, total: 2 }, + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(fixAuditPlan)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + answerRound(1, { 13: YIELD, 14: DRY, 15: YIELD }); + answerRound(2, { 13: DRY, 14: DRY, 15: YIELD }); + const out = runRound(3); + + expect(process.exitCode).toBeUndefined(); + // 13 is delta (ordinary rules, yield+dry: hot); 15 yielded last wave; + // 14 is a non-delta territory whose latest audit is dry — narrowed out + // after ONE dry receipt, where plain retirement would need two. + expect(out).toContain('2 auditors required this round'); + expect(out).toContain('— chunk 13 ─'); + expect(out).toContain('— chunk 15 ─'); + expect(out).not.toContain('— chunk 14 ─'); + expect(out).toContain('posture-narrowed chunk(s) skipped'); + expect(out).toContain('posture narrowing (#10104)'); + expect(out).toContain('chunk 14 — not a delta territory, dry in round 2'); + // The note states the scheduler's inclusion rule — a non-delta chunk + // whose latest receipt is uncertified stays hot too (#10136 R1-13) — + // and the preamble's tail grammar announces the note (R1-17). + expect(out).toContain( + 'every non-delta chunk the previous waves could not certify dry', + ); + expect(out).toContain('uncertified (unknown)'); + expect(out).toContain( + 'stale against a same-digest yield or uncertified receipt', + ); + expect(out).toContain('under the ordinary retirement rules'); + expect(out).toContain('returns to the ordinary retirement rules'); + expect(out).toContain( + 'followed by the retirement and posture-narrowing notes, when there are any', + ); + // The reverse-auditor's territory bullet for chunk 15's interaction + // file: a census that kept every hunk is named as such, never as a + // shed (#10136 R1-7). + const key15 = [...readRecordedPrompts(plan).keys()].find((k) => + k.startsWith('reverse-audit--chunk-15--round-3--'), + ); + if (key15 === undefined) throw new Error('chunk 15 was not built'); + const brief = readFileSync(briefPath(plan, key15), 'utf8'); + expect(brief).toContain( + 'all 2 hunk(s) display a seam line and are republished', + ); + expect(brief).not.toContain('not re-shown'); + // The reverse auditor's brief carries the fix-audit framing too — the + // floor governs posting, never finding (#10136). + expect(brief).toContain('Fix-audit round (critical posting posture)'); + expect(brief).toContain('the floor governs posting, never'); + }); + + it('a reverse-audit brief names a shed census as seam-bounded, and a plain round carries no banner (#10136)', () => { + const shed = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'a.ts', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + seam: { kept: 1, total: 3 }, + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(shed)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + runRound(1); + const key15 = [...readRecordedPrompts(plan).keys()].find((k) => + k.startsWith('reverse-audit--chunk-15--round-1--'), + ); + if (key15 === undefined) throw new Error('chunk 15 was not built'); + const brief = readFileSync(briefPath(plan, key15), 'utf8'); + expect(brief).toContain( + 'seam-bounded: 1 of 3 hunk(s) republished; the rest were cleared by an earlier round and are not re-shown', + ); + expect(brief).toContain('Fix-audit round (critical posting posture)'); + + // Without the posture: the same incremental scope, no banner. + const plain = { + ...shed, + incremental: { ...shed.incremental, posture: undefined }, + }; + writeFileSync(plan, JSON.stringify(plain)); + utimesSync(plan, old, old); + runRound(1); + const key15b = [...readRecordedPrompts(plan).keys()].find((k) => + k.startsWith('reverse-audit--chunk-15--round-1--'), + ); + if (key15b === undefined) throw new Error('chunk 15 was not built'); + const plainBrief = readFileSync(briefPath(plan, key15b), 'utf8'); + expect(plainBrief).not.toContain('Fix-audit round'); + }); + + it('a round that converges through narrowing names the narrowed chunks in CONVERGED (#10136 R1-10)', () => { + // The cleanest fix-audit run: the delta chunk retires on two dry + // receipts, the interaction chunks narrow out on their single one, and + // round 3 builds nothing. No round output carries the `posture + // narrowing:` note for it, so the CONVERGED explanation must — chunk by + // chunk, exactly as a built round would have. + const fixAuditPlan = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'a.ts', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(fixAuditPlan)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + answerRound(1, { 13: DRY, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + const out = runRound(3); + + expect(process.exitCode).toBe(5); + expect(out).toBe(''); + const msg = (writeStderrLine as unknown as Mock).mock.calls + .map((c) => c[0]) + .join('\n'); + expect(msg).toContain('CONVERGED'); + expect(msg).toContain('Posture-narrowed this round (#10104):'); + expect(msg).toContain('chunk 14 — not a delta territory, dry in round 2'); + expect(msg).toContain('chunk 15 — not a delta territory, dry in round 2'); + expect(msg).not.toContain('chunk 13 — not a delta territory'); + }); + + it('a chunk the scope record never classified restores the ordinary schedule (#10136 R12-1)', () => { + // The scope covers chunk 13's delta file and names NO interaction + // files, yet chunks 14 and 15 hold files — a state an honest capture + // cannot produce (the published sections tile touched ∪ interaction). + // Narrowing would price 14 and 15 out of the wave on one dry receipt + // each; the containment check reads the plan as corrupted and runs the + // ordinary schedule instead: every chunk audited, nothing narrowed. + const corrupt = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [], + }, + }, + }; + writeFileSync(plan, JSON.stringify(corrupt)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + // Two dry receipts each for 14 and 15 — the narrowing would price both + // out on the single receipt and the ordinary rules RETIRE them: both + // skip round 3, and only the note tells the two schedules apart. The + // containment gate is what makes it the retirement note. + answerRound(1, { 13: YIELD, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + const out = runRound(3); + + expect(process.exitCode).toBeUndefined(); + expect(out).toContain('1 auditors required this round'); + expect(out).toContain('2 retired chunk(s) skipped'); + expect(out).toContain('retirement: a chunk whose two most recent audits'); + expect(out).not.toContain('posture narrowing'); + expect(out).not.toContain('posture-narrowed'); + }); + + it('a chunk file entry without a usable path is malformed input: ordinary schedule (#10136)', () => { + // The containment gate must not skip what it cannot read: a file entry + // whose path is not a non-empty string is a chunk the record cannot + // classify either way, and the schedule falls back to auditing it. + const corrupt = { + ...PLAN, + chunks: (PLAN.chunks as Array<{ id: number; files: unknown[] }>).map( + (c) => (c.id === 14 ? { ...c, files: [{ path: 7 }] } : c), + ), + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(corrupt)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + // Two dry receipts each: the narrowing would narrow, the ordinary + // rules retire — the note names which schedule ran. + answerRound(1, { 13: YIELD, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + const out = runRound(3); + + expect(process.exitCode).toBeUndefined(); + expect(out).toContain('2 retired chunk(s) skipped'); + expect(out).not.toContain('posture-narrowed'); + }); + + for (const [label, corruptFiles] of [ + ['null', null], + ['absent', undefined], + ] as const) { + it(`a chunk whose files list is ${label} restores the ordinary schedule (#10136 R17-5)`, () => { + // An unreadable `files` list coerced to `[]` passes both of + // postureNarrowing's gates vacuously: the chunk is classified a + // NON-delta territory and priced out of the wave on its single + // latest dry receipt — the one malformed shape failing toward LESS + // coverage. It must return null like every sibling shape: an honest + // capture never emits a chunk without a files list. + const corrupt = { + ...PLAN, + chunks: (PLAN.chunks as Array<{ id: number; files?: unknown }>).map( + (c) => + c.id === 14 + ? label === 'absent' + ? (({ files: _files, ...rest }) => rest)(c) + : { ...c, files: corruptFiles } + : c, + ), + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(corrupt)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + // Two dry receipts each: the narrowing would narrow on the receipt, + // the ordinary rules retire — the note names which schedule ran. + answerRound(1, { 13: YIELD, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + const out = runRound(3); + + expect(process.exitCode).toBeUndefined(); + expect(out).toContain('1 auditors required this round'); + expect(out).toContain('2 retired chunk(s) skipped'); + expect(out).toContain('retirement: a chunk whose two most recent audits'); + expect(out).not.toContain('posture narrowing'); + expect(out).not.toContain('posture-narrowed'); + }); + } + + it('the per-chunk path names the narrowed chunks in CONVERGED too (#10136 R1-10)', () => { + const fixAuditPlan = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['packages/cli/src/commands/review/x.test.ts'], + interaction: [ + { + path: 'a.ts', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + { + path: 'bundle.min.js', + importsChanged: ['packages/cli/src/commands/review/x.test.ts'], + }, + ], + }, + }, + }; + writeFileSync(plan, JSON.stringify(fixAuditPlan)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + answerRound(1, { 13: DRY, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + + // A single-chunk rebuild of a round nobody admitted: the schedule has + // converged, and the refusal must carry the same narrowed list the + // `--all-chunks` refusal does. + process.exitCode = undefined; + (writeStderrLine as unknown as Mock).mockClear(); + (agentPromptCommand.handler as (a: unknown) => void)({ + plan, + role: 'reverse-audit', + chunk: 14, + findings, + round: 3, + }); + expect(process.exitCode).toBe(5); + const msg = (writeStderrLine as unknown as Mock).mock.calls + .map((c) => c[0]) + .join('\n'); + expect(msg).toContain('CONVERGED'); + expect(msg).toContain('Posture-narrowed this round (#10104):'); + expect(msg).toContain('chunk 14 — not a delta territory, dry in round 2'); + expect(msg).toContain('chunk 15 — not a delta territory, dry in round 2'); + }); + + it('a delta list no chunk covers degrades to the ordinary schedule, never an empty narrowing (#10104)', () => { + // A hand-edited plan can name delta files no chunk holds — an honest + // capture cannot produce the disjoint state, so the input class is the + // corrupted plan these gates exist for. An EMPTY delta-territory set + // would treat every chunk as non-delta: from round 3 each leaves after + // one dry receipt, the round exits 5 on a false "clean convergence", + // and the territory this round exists to audit was examined only in + // rounds 1-2. Every sibling reader fails malformed input toward MORE + // coverage; the schedule must too — null restores the byte-for-byte + // ordinary schedule. + const fixAuditPlan = { + ...PLAN, + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['src/disjoint.ts'], + // Every chunk file IS classified (interaction), so the + // containment gate passes and the EMPTY delta-coverage guard is + // the one ruling here. + interaction: [ + 'packages/cli/src/commands/review/x.test.ts', + 'a.ts', + 'bundle.min.js', + ].map((path) => ({ path, importsChanged: ['src/disjoint.ts'] })), + }, + }, + }; + writeFileSync(plan, JSON.stringify(fixAuditPlan)); + const old = new Date(2020, 0, 1); + utimesSync(plan, old, old); + + answerRound(1, { 13: YIELD, 14: DRY, 15: DRY }); + answerRound(2, { 13: DRY, 14: DRY, 15: DRY }); + const out = runRound(3); + + expect(process.exitCode).toBeUndefined(); + // Chunk 13 yielded in round 2, so the ordinary rules keep it hot; 14 + // and 15 retire on two dry rounds. + expect(out).toContain('1 auditors required this round'); + expect(out).toContain('— chunk 13 ─'); + expect(out).not.toContain('posture narrowing (#10104)'); + }); + it('round 3 skips a chunk dry in rounds 1 and 2, and the note names it', () => { answerRound(1, { 13: DRY, 14: YIELD, 15: YIELD }); answerRound(2, { 13: DRY, 14: YIELD, 15: YIELD }); @@ -7405,6 +7825,96 @@ describe('incremental-scope briefs', () => { expect(seam).toContain('src/changed.ts'); }); + it('a fix-audit round frames the brief and discloses the seam bound (#10104)', () => { + const fixAudit = { + ...INCREMENTAL_PLAN, + incremental: { + since: 'abc1234def5678900000', + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + ...INCREMENTAL_PLAN.incremental.scope, + interaction: [ + { + path: 'src/caller.ts', + importsChanged: ['src/changed.ts'], + seam: { kept: 1, total: 4 }, + }, + ], + }, + }, + }; + const delta = buildChunkAgentPrompt(fixAudit, 1); + expect(delta).toContain('Fix-audit round (critical posting posture)'); + expect(delta).toContain('the floor governs posting, never'); + // The banner's deferral claim carries the deterministic carve-out the + // reroute applies — an auditor trusting "never posted" unqualified + // could drop a `[test]` finding the floor keeps inline at any floor. + expect(delta).toContain('never posted — except pre-confirmed'); + + const seam = buildChunkAgentPrompt(fixAudit, 2); + expect(seam).toContain('SEAM-BOUNDED: 1 of 4 hunk(s)'); + expect(seam).toContain('still yours in full, from the worktree'); + + // Without the posture, no fix-audit frame and no seam clause — the seam + // census is a fix-audit fact, and a plan without one renders none. + const plain = buildChunkAgentPrompt(INCREMENTAL_PLAN, 2); + expect(plain).not.toContain('Fix-audit round'); + expect(plain).not.toContain('SEAM-BOUNDED'); + }); + + it('a census that kept every hunk is briefed as complete, never as seam-bounded (#10136 R1-7)', () => { + const whole = { + ...INCREMENTAL_PLAN, + incremental: { + since: 'abc1234def5678900000', + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + ...INCREMENTAL_PLAN.incremental.scope, + interaction: [ + { + path: 'src/caller.ts', + importsChanged: ['src/changed.ts'], + seam: { kept: 4, total: 4 }, + }, + ], + }, + }, + }; + const seam = buildChunkAgentPrompt(whole, 2); + expect(seam).not.toContain('SEAM-BOUNDED'); + expect(seam).not.toContain('not re-shown'); + expect(seam).toContain('The seam scan kept every one of its 4 hunk(s)'); + expect(seam).toContain('its diff here is complete'); + }); + + it('a malformed seam census renders no seam clause', () => { + const bad = { + ...INCREMENTAL_PLAN, + incremental: { + since: 'abc1234def5678900000', + effective: true, + posture: 'critical', + scope: { + ...INCREMENTAL_PLAN.incremental.scope, + interaction: [ + { + path: 'src/caller.ts', + importsChanged: ['src/changed.ts'], + seam: { kept: 5, total: 2 }, + }, + ], + }, + }, + }; + const seam = buildChunkAgentPrompt(bad, 2); + expect(seam).toContain('INTERACTION only'); + expect(seam).not.toContain('SEAM-BOUNDED'); + }); + it('whole-diff role briefs carry the frame once, up front', () => { const p = buildRoleBrief(INCREMENTAL_PLAN, '2'); expect(p).toContain('Incremental round'); diff --git a/packages/cli/src/commands/review/agent-prompt.ts b/packages/cli/src/commands/review/agent-prompt.ts index ecf6109abeb..192934d4719 100644 --- a/packages/cli/src/commands/review/agent-prompt.ts +++ b/packages/cli/src/commands/review/agent-prompt.ts @@ -51,7 +51,11 @@ import { MAX_RESUME_CALLS, SHELL_TOOL_MAX_TIMEOUT_MS, } from './lib/build-budget.js'; -import { launchToolBudget, reverseAuditRoundCap } from './lib/budget.js'; +import { + interactionEntryOf, + launchToolBudget, + reverseAuditRoundCap, +} from './lib/budget.js'; import { clearBudgetStop, claimRetirementDegradeNote, @@ -105,6 +109,7 @@ import { type WorktreeResidue, } from './lib/worktree.js'; import { + isFixAuditRound, isTerritoryFanOut, isPositivePrNumber, requiredAgents, @@ -190,9 +195,34 @@ interface PlanReport { interface IncrementalScope { anchor: string; deltaFiles: string[]; - interaction: Array<{ path: string; importsChanged: string[] }>; + interaction: Array<{ + path: string; + importsChanged: string[]; + /** The fix-audit seam bound's census, when the capture recorded one. */ + seam?: { kept: number; total: number }; + }>; } +/** + * The fix-audit round's framing (#10104), rendered wherever a territory is + * briefed under the posture — the chunk agent's brief and the reverse + * auditor's role brief alike (#10136): an auditor that never learns the + * floor governs posting, not finding, can drop or inflate a finding on the + * wave the posture exists to keep running. + */ +const FIX_AUDIT_BANNER = + `**Fix-audit round (critical posting posture).** The commits since the anchor ` + + `answer earlier rounds' findings, and this round's posting floor is Critical — ` + + `everything below it is recorded and deferred, never posted — except ` + + `pre-confirmed \`[build]\`/\`[test]\`/\`[probe]\` findings, which stay inline at any ` + + `floor. Spend your walk ` + + `where such a round's signal measurably lives: for each change in your ` + + `territory, work out what the fix changed and what that change could break — ` + + `the guard added with no test of its own, the caller the moved callee leaves ` + + `behind, the invariant the fix's shortcut skips. Severities are unchanged: ` + + `report every finding at its true severity (the floor governs posting, never ` + + `finding), and never inflate one to clear the floor.`; + /** * The per-file scope bullets for ONE chunk's files — uncapped, because the * agent holding that chunk is the sole reviewer of those files and has no @@ -221,7 +251,17 @@ function chunkScopeBullets( (e) => `- ${inertPath(e.path)} — **interaction only**: cleared last round, back in ` + `scope because it imports ${e.importsChanged.map(inertPath).join(', ')}. ` + - `Review that seam, not the rest of its diff.`, + `Review that seam, not the rest of its diff.` + + // The shed clause renders only where a shed happened (#10136): a + // census with `kept === total` republished the section whole, and + // telling the agent a remainder was withheld sent it hunting for + // hunks that were never hidden. + (e.seam && e.seam.kept < e.seam.total + ? ` (seam-bounded: ${e.seam.kept} of ${e.seam.total} hunk(s) republished; ` + + `the rest were cleared by an earlier round and are not re-shown)` + : e.seam + ? ` (seam scan: all ${e.seam.total} hunk(s) display a seam line and are republished)` + : ''), ), ]; } @@ -295,23 +335,14 @@ function incrementalScopeOf(report: PlanReport): IncrementalScope | null { Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string' && s.length > 0) : []; + // ONE admission per entry, shared with the roster and compose's + // round-shape disclosure (`interactionEntryOf`, #10136): an entry IS its + // edge, and a census that cannot be true is dropped from an entry that + // is otherwise kept. const interaction = Array.isArray(raw.interaction) ? raw.interaction - .filter( - (e): e is { path: string; importsChanged?: unknown } => - !!e && - typeof (e as { path?: unknown }).path === 'string' && - (e as { path: string }).path.length > 0 && - // An interaction entry IS its edge: with no surviving - // importsChanged the brief would read "because it imports , - // which changed" — a seam pointing at nothing. - strings((e as { importsChanged?: unknown }).importsChanged).length > - 0, - ) - .map((e) => ({ - path: e.path, - importsChanged: strings(e.importsChanged), - })) + .map(interactionEntryOf) + .filter((e): e is NonNullable => e !== null) : []; // The SAME validity notion the roster applies // (`incrementalInteractionPaths`): a partially corrupt delta list @@ -750,6 +781,9 @@ export function buildChunkAgentPrompt( `previous clean review round (anchor \`${inertPath(incremental.anchor.slice(0, 12))}\`), ` + `plus still-clean files one import hop from a change. Your files' scopes:`, ]; + if (isFixAuditRound(report)) { + lines.splice(1, 0, FIX_AUDIT_BANNER, ''); + } if (deltaHere.length > 0) { lines.push( ...deltaHere.map( @@ -771,7 +805,17 @@ export function buildChunkAgentPrompt( `still hold — signatures, argument contracts, invariants, error behaviour — ` + `now that the imported side moved? Read the changed side from the worktree to ` + `answer that. Do not re-review the rest of this file's diff from scratch, and ` + - `do not report defects in it that the change it imports does not affect.`, + `do not report defects in it that the change it imports does not affect.` + + (e.seam && e.seam.kept < e.seam.total + ? ` Its diff here is SEAM-BOUNDED: ${e.seam.kept} of ${e.seam.total} hunk(s) ` + + `republished — only the ones displaying a line that imports or uses what ` + + `changed; the rest were cleared by an earlier round and are not re-shown. ` + + `The seam question above is still yours in full, from the worktree.` + : e.seam + ? ` The seam scan kept every one of its ${e.seam.total} hunk(s): each ` + + `displays a line that imports or uses what changed, so its diff here ` + + `is complete.` + : ''), ), ); lines.push( @@ -1149,6 +1193,10 @@ function diffReadingBlock( // as the bare chunk agent's brief lists them. ...(incremental && scoped ? [ + // The posture's framing rides with the territory it frames: a + // reverse auditor briefed under the fix-audit shape must hear the + // same floor-governs-posting rule the chunk agent did (#10136). + ...(isFixAuditRound(report) ? [FIX_AUDIT_BANNER, ''] : []), ...chunkScopeBullets( incremental, chunks.find((c) => c.id === chunkId), @@ -2901,16 +2949,37 @@ function admitReverseAuditRound( * compose-review splice that dedups it no longer runs — only this * instruction removes it. */ -function refuseConverged(planPath: string): void { +function refuseConverged( + planPath: string, + narrowed: ReadonlyArray<{ chunkId: number; dryRound: number }> = [], +): void { clearBudgetStop(planPath); + // The round that converges through narrowing prints no round output, so + // its `posture narrowing:` note would never appear (#10136): the trade + // is named here instead, chunk by chunk, exactly as a built round names + // it — the cleanest run must disclose no less than the others. + const narrowedNote = + narrowed.length === 0 + ? '' + : ' Posture-narrowed this round (#10104): ' + + narrowed + .map( + (n) => + `chunk ${n.chunkId} — not a delta territory, dry in round ${n.dryRound}`, + ) + .join('; ') + + '.'; writeStderrLine( - 'CONVERGED: every chunk holds two consecutive substantive dry audits; ' + + 'CONVERGED: every chunk has left the wave — retired territories hold ' + + 'two consecutive substantive dry audits, and on a fix-audit round a ' + + 'posture-narrowed territory holds its single one; ' + 'the reverse audit has converged — stop the loop and proceed to ' + 'Step 6. This is a clean convergence, not a gap: no ' + 'unreviewedDimensions entry is owed. If an earlier round-cap or ' + 'budget refusal told you to add its stop entry to ' + 'unreviewedDimensions, remove it now — this convergence supersedes ' + - 'it.', + 'it.' + + narrowedNote, ); process.exitCode = 5; } @@ -2938,6 +3007,74 @@ function noteUncertifiedChunks(planPath: string, diagnostics: string[]): void { ); } +/** + * The fix-audit posture's wave-narrowing context (#10104): which chunks hold + * a delta file. Null everywhere the posture is off, so the schedule is + * byte-for-byte what it always was. A chunk holding only interaction files + * is NOT a delta territory — it is exactly the territory the narrowing + * exists to stop re-auditing once provably dry. Null ALSO when no chunk + * covers a delta file: an honest capture cannot produce that disjoint state + * (delta files come from the narrowing's own touched set, and chunks tile + * the published diff), so the input is a hand-edited plan — and an empty + * set would treat EVERY chunk as non-delta, converging the loop after one + * dry receipt each. Every sibling reader fails malformed input toward more + * coverage; null restores the ordinary schedule and does the same. + */ +function postureNarrowing( + report: PlanReport, +): { deltaChunkIds: ReadonlySet } | null { + if (!isFixAuditRound(report)) return null; + const scope = incrementalScopeOf(report); + if (scope === null) return null; + const delta = new Set(scope.deltaFiles); + const classified = new Set([ + ...scope.deltaFiles, + ...scope.interaction.map((e) => e.path), + ]); + const ids = new Set(); + const chunks = Array.isArray(report.chunks) + ? (report.chunks as Array<{ id?: unknown; files?: unknown }>) + : []; + for (const c of chunks) { + if (!Number.isSafeInteger(c?.id)) continue; + // An unreadable `files` list is the one malformed shape that must NOT + // be coerced to `[]` (#10136 R17-5): `[]` passes both gates below + // vacuously, silently classifying the chunk as a NON-delta territory — + // failing it toward LESS coverage where every sibling shape returns + // null. An honest capture never emits a chunk without a files list + // (`planChunks` tiles the published diff), so unreadable — or + // explicitly empty — is a hand-edited plan and restores the ordinary + // schedule like every other malformed shape here. + if (!Array.isArray(c?.files) || c.files.length === 0) return null; + const files = c.files as Array<{ path?: unknown }>; + // Containment (#10136 R12-1): a chunk holding a file the scope record + // classifies as NEITHER delta nor interaction is a chunk the record + // never ruled on. An honest capture cannot produce it (`widenScope` + // publishes exactly touched ∪ interaction, and the sections are tiled + // from that), so the input is a hand-edited or corrupted plan — and + // narrowing such a chunk out on one dry receipt would fail it toward + // LESS coverage. Null restores the ordinary schedule, like every + // sibling reader of malformed input. + if ( + files.some( + (f) => + typeof f?.path !== 'string' || + f.path === '' || + !classified.has(f.path), + ) + ) { + return null; + } + // Every path is a non-empty string here — the gate above returned + // otherwise. + if (files.some((f) => delta.has(f.path as string))) { + ids.add(c.id as number); + } + } + if (ids.size === 0) return null; + return { deltaChunkIds: ids }; +} + /** * The schedule read shared by the round builder and the per-chunk path * (#9272 — hand-rolled at both sites and edited in lockstep across three @@ -2949,11 +3086,11 @@ function noteUncertifiedChunks(planPath: string, diagnostics: string[]): void { * the build's own scope. */ function reverseAuditScheduleOrNote( + report: PlanReport, planPath: string, chunkIds: number[], round: number, env: NodeJS.ProcessEnv, - diffPathAbsolute: unknown, noteTail: string, ): { schedule: RoundSchedule | null; scheduleNote: string | null } { try { @@ -2963,7 +3100,10 @@ function reverseAuditScheduleOrNote( chunkIds, round, env, - typeof diffPathAbsolute === 'string' ? diffPathAbsolute : undefined, + typeof report.diffPathAbsolute === 'string' + ? report.diffPathAbsolute + : undefined, + postureNarrowing(report), ), scheduleNote: null, }; @@ -3063,11 +3203,11 @@ function runAllChunks( round >= retirementReadsFrom ) { const read = reverseAuditScheduleOrNote( + report, planPath, chunks.map((c) => c.id), round, process.env, - report.diffPathAbsolute, 'auditing every chunk.', ); schedule = read.schedule; @@ -3075,7 +3215,7 @@ function runAllChunks( } if (schedule !== null && schedule.converged) { - refuseConverged(planPath); + refuseConverged(planPath, schedule.narrowed); return; } @@ -3124,6 +3264,7 @@ function runAllChunks( ); const coldSet = new Set(schedule?.coldChecks ?? []); const skipped = schedule?.skipped ?? []; + const narrowedOut = schedule?.narrowed ?? []; const digest = findingsDigest(findingsContent, rules); const roundPart = roundPartOf(round); @@ -3160,11 +3301,16 @@ function runAllChunks( // counts; when nothing is retired the sentence is byte-identical to what // it always said. const scope = - skipped.length === 0 + skipped.length === 0 && narrowedOut.length === 0 ? 'one per chunk' - : `one per chunk still under audit (${skipped.length} retired ` + - `chunk(s) skipped; the retirement note after the end-of-round line ` + - `says which — relay it to the terminal)`; + : narrowedOut.length === 0 + ? `one per chunk still under audit (${skipped.length} retired ` + + `chunk(s) skipped; the retirement note after the end-of-round line ` + + `says which — relay it to the terminal)` + : `one per chunk still under audit (${skipped.length} retired and ` + + `${narrowedOut.length} posture-narrowed chunk(s) skipped; the ` + + `notes after the end-of-round line say which — relay them to ` + + `the terminal)`; const planRoundCap = reverseAuditRoundCap( report, hasReviewDeadline(process.env), @@ -3190,6 +3336,29 @@ function runAllChunks( ) .join('\n'), ]; + const narrowingNote = + narrowedOut.length === 0 + ? [] + : [ + `posture narrowing (#10104): on this critical-posture round the ` + + `wave re-launches the delta territories under the ordinary ` + + `retirement rules (a twice-dry one only on its cold-check ` + + `rounds) and every non-delta chunk the previous waves could not ` + + `certify dry — one that yielded, one whose latest receipt is ` + + `uncertified (unknown), or one with no audit history stays in ` + + `the wave, and one whose dry receipt is stale against a ` + + `same-digest yield or uncertified receipt returns to the ` + + `ordinary retirement rules; a chunk holding no delta file ` + + `leaves the schedule after one substantive dry audit and takes ` + + `no cold checks. Narrowed out this round:\n` + + narrowedOut + .map( + (n) => + `chunk ${n.chunkId} — not a delta territory, dry in round ` + + `${n.dryRound}`, + ) + .join('\n'), + ]; writeStdoutLine( [ `${dueChunks.length} auditors required this round — ${scope}. Launch ` + @@ -3198,7 +3367,8 @@ function runAllChunks( `deliverable, and a launch reconstructed from a sample matches no ` + `record. Blocks are numbered \`auditor k of ${dueChunks.length}\`, and ` + `the output ends with an end-of-round line — followed by the ` + - `retirement note, when there is one. If either the numbering or the ` + + `retirement and posture-narrowing notes, when there are any. If ` + + `either the numbering or the ` + `end-of-round line is missing, the output was truncated in transit; ` + `rebuild just the missing chunks with --chunk . Write each ` + `Agent call's \`description\` (the task ` + @@ -3208,6 +3378,7 @@ function runAllChunks( ...blocks, `───── end of round — ${dueChunks.length} auditors ─────`, ...retirementNote, + ...narrowingNote, ].join('\n\n'), ); // Admitted AND built: stamp now, so the next round's gate can measure @@ -3641,17 +3812,17 @@ function runAgentPrompt(args: AgentPromptArgs): void { let scheduleNote: string | null = null; if (args.round !== undefined) { const read = reverseAuditScheduleOrNote( + report, args.plan, planChunkIds, args.round, process.env, - report.diffPathAbsolute, 'auditing the chunk.', ); const schedule = read.schedule; scheduleNote = read.scheduleNote; if (!roundAdmitted && schedule !== null && schedule.converged) { - refuseConverged(args.plan); + refuseConverged(args.plan, schedule.narrowed); return; } // The round builder's diagnostic, narrowed to this chunk (#9213 on diff --git a/packages/cli/src/commands/review/compose-review.test.ts b/packages/cli/src/commands/review/compose-review.test.ts index 1bb841ccb79..676fbaa5f35 100644 --- a/packages/cli/src/commands/review/compose-review.test.ts +++ b/packages/cli/src/commands/review/compose-review.test.ts @@ -168,7 +168,13 @@ function plan( host?: string; /** The head fetch-pr resolved — the ledger marker's incremental anchor. */ fetchedSha?: string; - incremental?: { since: string; effective: boolean }; + incremental?: { + since: string; + effective: boolean; + posture?: string; + postureCause?: string; + scope?: unknown; + }; reviewModelId?: string; } = {}, ): string { @@ -471,7 +477,13 @@ function coveredPlan( prNumber?: string | number; host?: string; fetchedSha?: string; - incremental?: { since: string; effective: boolean }; + incremental?: { + since: string; + effective: boolean; + posture?: string; + postureCause?: string; + scope?: unknown; + }; reviewModelId?: string; } = {}, ): string { @@ -2561,6 +2573,700 @@ describe('composeReview — RC carries every applicable disclosure (no clause sq }); }); +describe('composeReview — the fix-audit round-shape disclosure (#10104)', () => { + const POSTURE = { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['src/a.ts'], + interaction: [ + { + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 2, total: 7 }, + }, + ], + }, + }; + + // `base()`'s own default `planPath: coveredPlan()` runs at call time and + // rewrites the shared plan.json AFTER an override argument was evaluated — + // so the posture'd plan must be written after `base()` returns, or the + // test silently measures the default plan. + function rcInput( + incremental: Record, + ): ReturnType { + const input = base({ criticalsInline: 1 }); + input.planPath = coveredPlan(['verify', 'reverse-audit'], { + incremental: incremental as never, + }); + return input; + } + + it('the plan posture is a floor-resolution arm: a context-unavailable round still defers (#10104)', () => { + // The adversarial shape: capture ran the narrow fix-audit round off the + // side file, then pr-context failed and compose runs context-unavailable + // — the two auto arms disengage, but the plan's own posture record must + // keep the posting bar aligned with the shape the round already ran. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.contextUnavailable = true; + // The verdict's resolved default, as the state carries it in a real + // run; the enforcement reading is deliberately strict on an ABSENT + // floor and must stay so. + input.severityFloor = 'auto'; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + // The Suggestion was rerouted into the deferral list, not posted inline. + expect(r.floorEnforced).toEqual([0]); + expect(r.body).toContain( + 'Findings below Critical were recorded and deferred, never posted — ' + + 'except pre-confirmed `[build]`/`[test]`/`[probe]` findings, which ' + + 'stay inline at any floor.', + ); + // The plan's posture record IS the deferral licence here: the round's + // shape was already spent on the critical resolution, so the licence + // block may not flag the very deferral the floor arm enforced — no + // unlicensed warning in the body, no false cap on the verdict. + expect(r.cappedBy).not.toContain('unlicensed-deferral'); + expect(r.body).not.toContain('without a posture licence'); + }); + + it('the plan arm licenses a deferral on a round-1 compose with the side file lost (#10104)', () => { + // The sibling state: the same postured plan, but the side file was + // rewritten between capture and compose, so `prevRound` recovers 0 and + // the context IS available. The plan arm still resolves the floor (it + // carries no round gate), so the licence block's round-1 doubt arm must + // not stamp the ONLY cap — a false cap here flips the composed event and + // withholds the incremental anchor over a licensed deferral. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.severityFloor = 'auto'; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([0]); + expect(r.cappedBy).not.toContain('unlicensed-deferral'); + expect(r.body).not.toContain('without a posture licence'); + expect(r.event).toBe('APPROVE'); + }); + + it('an ABSENT floor beside the plan record licences a model-side deferral (#10136)', () => { + // The fix-audit round whose compose state omits `severityFloor` — + // reachable because the field is model-written and omission is + // fail-closed. The model deferred its drafted Suggestion exactly as + // the shape intends; the licence chain's unknown-floor arm used to + // fire before consulting the plan record, capping the verdict over a + // deferral the posture itself produced. The record IS the licence: + // the round's shape was spent on the critical resolution. The two + // sibling doubt arms already consult it; the unknown-floor arm now + // does too. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 0; + input.deferredSuggestions = [ + { + file: 'src/a.ts', + line: 3, + source: 'review', + severity: 'Suggestion', + title: 'untested guard', + }, + ]; + const r = composeReview(input); + expect(r.cappedBy).not.toContain('unlicensed-deferral'); + expect(r.body).not.toContain('without a posture licence'); + expect(r.deferredCount).toBe(1); + // The same body carries the deferral list, and deferral IS the floor's + // withholding in this module's terminology — so the open-floor sentence + // may not assert the unqualified no-withholding universal the very same + // body falsifies; it says the backstop moved nothing and routes the + // deferrals to the posture (#10136). + expect(r.body).toContain('Deferred under the convergence posture'); + expect(r.body).toContain('resolved OPEN at compose time'); + // The cause clause names the ABSENT record — never the operator, never + // an unreadable value (#10136): folding the three causes into one + // must red here. + expect(r.body).toContain( + '(the floor record was absent, and the enforcement reading fails open)', + ); + expect(r.body).not.toContain('the operator turned the posture off'); + expect(r.body).not.toContain('no finding was withheld by a floor'); + expect(r.body).toContain('routed by the convergence posture'); + }); + + it('an ABSENT floor beside the plan record claims no deferral beside its inline Suggestion (#10104)', () => { + // The default config writes no `severityFloor` ("omit what does not + // apply"). The REPORTING reading folds absence to `auto` and the plan + // arm resolves critical, but the ENFORCEMENT reading stays strict and + // moves nothing — so the body must not claim a deferral beside the very + // Suggestion it posts inline. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([]); + expect(r.body).not.toContain('recorded and deferred, never posted'); + expect(r.body).toContain('resolved OPEN at compose time'); + }); + + it('a scope the brief builder rejects engages neither the floor arm nor the disclosure (#10104)', () => { + // `incrementalScopeOf` degrades to full scope on a both-empty scope and + // on a delta list carrying a non-string element; the shape readers must + // read the same plan as no fix-audit round, or the floor defers and the + // body discloses a shape the full roster never ran. + for (const scope of [ + { anchor: 'a'.repeat(40), deltaFiles: [], interaction: [] }, + { + anchor: 'a'.repeat(40), + deltaFiles: ['src/a.ts', 42], + interaction: [], + }, + ]) { + const input = rcInput({ ...POSTURE, scope }); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.contextUnavailable = true; + input.severityFloor = 'auto'; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([]); + expect(r.body).not.toContain('fix-audit round'); + } + }); + + it('a drifted floor beside the plan record never claims the operator turned the posture off (#10104)', () => { + // The compose state is model-written: a present-but-unrecognisable + // floor value ('blocker', a spelling drift, '') is NOT an operator + // decision — the open-floor sentence must say the value cannot be + // read, not assert a posture-off the operator never made. The cast + // simulates the transcribed drift the typed field cannot carry. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.severityFloor = 'blocker' as unknown as 'auto'; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([]); + expect(r.body).toContain('resolved OPEN at compose time'); + expect(r.body).not.toContain('the operator turned the posture off'); + expect(r.body).toContain('a floor value this module cannot read'); + }); + + it('an explicit `suggestion` floor beats a stale plan posture, and the body says the floor was open', () => { + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.severityFloor = 'suggestion'; + input.draftedComments = [ + { path: 'src/a.ts', line: 3, body: '**[Suggestion]** untested guard' }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([]); + expect(r.body).toContain('resolved OPEN at compose time'); + expect(r.body).not.toContain('recorded and deferred, never posted'); + }); + + it('a deterministic Suggestion stays inline at an engaged floor, and the sentence says so (#10104)', () => { + // `floorEnforcedReroute` leaves a `[test]`-tagged Suggestion inline at + // ANY floor — so the engaged sentence in the same body may not assert + // the unqualified universal that the very same body falsifies. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.suggestionsInline = 1; + input.severityFloor = 'auto'; + input.draftedComments = [ + { + path: 'src/a.ts', + line: 3, + body: '**[Suggestion]** [test] mutation survivor on the retry guard', + }, + ]; + const r = composeReview(input); + expect(r.floorEnforced).toEqual([]); + // Beside an EMPTY deferral list the sentence may not claim findings + // "were recorded and deferred" (#10136): it names the deterministic + // exemption as the only sub-Critical finding and claims no deferral. + expect(r.body).toContain( + 'nothing was deferred this round; the only Suggestions the floor leaves inline are pre-confirmed', + ); + expect(r.body).toContain('stay inline at any floor'); + expect(r.body).not.toContain('were recorded and deferred'); + }); + + it('an RC body owns the reduced shape, cause and seam census', () => { + const r = composeReview(rcInput(POSTURE)); + expect(r.event).toBe('REQUEST_CHANGES'); + expect(r.body).toContain( + 'fix-audit round under the critical posting posture', + ); + expect(r.body).toContain('engaged by the round schedule'); + expect(r.body).toContain('1 seam-bounded: 2 of 7 hunk(s) republished'); + // The wave sentence states the scheduler's INCLUSION rule (#10136 + // R12-2) — true of delta ∪ non-dry ∪ stale-dry ∪ no-history on every + // wave, engaged narrowing or not — never the past-tense "only delta + // territories and chunks whose previous wave yielded". + expect(r.body).toContain( + 'non-delta chunks the previous waves could not certify dry', + ); + expect(r.body).toContain( + 're-launched delta territories under the ordinary retirement rules (a twice-dry one only on its cold-check rounds)', + ); + expect(r.body).toContain( + 'a yield, an uncertified receipt or no audit history keeps a chunk in the wave; a dry receipt that shows no evidence of having seen an earlier yield or uncertified receipt — same list, same entries modulo verification tags, or no entry for the filed finding — returns it to the ordinary retirement rules', + ); + expect(r.body).not.toContain('chunks whose previous wave yielded'); + }); + + it('the Chinese half carries the same shape, rule, cause, census and licence tail', () => { + // The zh twin of every round-shape string, rendered through the same + // bilingual switch the posted body uses (a Han-description PR). + const hanInput = ( + incremental: Record, + ): ReturnType => { + const input = base({ criticalsInline: 1 }); + input.planPath = coveredPlan(['verify', 'reverse-audit'], { + han: true, + incremental: incremental as never, + }); + return input; + }; + const r = composeReview(hanInput(POSTURE)); + expect(r.body).toContain('
\n中文说明'); + expect(r.body).toContain( + '轮次形态:本次 re-review 以 critical 发布姿态下的 fix-audit 轮运行', + ); + expect(r.body).toContain('由轮次日程触发'); + expect(r.body).toContain('此前各波未能证实干燥的非 delta chunk'); + expect(r.body).toContain( + '按普通退役规则重发 delta 领地(两次干燥的只在其冷检轮重发)', + ); + expect(r.body).toContain( + '出过发现、收据未认证或无审计历史会让 chunk 留在波内;干燥收据若没有证据表明见过此前的发现或未认证收据', + ); + expect(r.body).toContain('1 个按接缝收窄:重发 2/7 个 hunk'); + expect(r.body).not.toContain('上一波出过发现的 chunk'); + + // The zh engaged arms: a Critical-only deferral list names the + // axes-Critical shape, never a below-Critical deferral (R16-1). + const critOnly = hanInput(POSTURE); + critOnly.criticalsInline = 0; + critOnly.severityFloor = 'critical'; + critOnly.deferredSuggestions = [ + { + file: 'src/a.ts', + line: 3, + source: 'review', + severity: 'Critical', + direction: 'fails-closed', + baseline: 'new-surface', + title: 'sparse checkout wedges the incremental round', + }, + ]; + const rc = composeReview(critOnly); + expect(rc.body).toContain( + '下方延后的发现都是按其轴向延后的 Critical(新表面上的 fails-closed)——本轮没有任何低于 Critical 的发现被扣留。', + ); + expect(rc.body).not.toContain('低于 Critical 的发现只记录延后'); + critOnly.deferredSuggestions.push({ + file: 'src/a.ts', + line: 9, + source: 'review', + severity: 'Suggestion', + title: 'nit', + }); + const mixed = composeReview(critOnly); + expect(mixed.body).toContain('低于 Critical 的发现只记录延后'); + expect(mixed.body).not.toContain('按其轴向延后的 Critical(新表面'); + + const explicit = composeReview( + hanInput({ ...POSTURE, postureCause: 'explicit' }), + ); + expect(explicit.body).toContain('由操作者显式设置的 critical 下限触发'); + + const off = hanInput(POSTURE); + off.criticalsInline = 0; + off.severityFloor = 'suggestion'; + off.deferredSuggestions = [ + { + file: 'src/a.ts', + line: 3, + source: 'review', + severity: 'Suggestion', + title: 'untested guard', + }, + ]; + const r2 = composeReview(off); + expect(r2.body).toContain('没有姿态授权(操作者关闭了该姿态)'); + expect(r2.body).not.toContain('由收敛姿态路由'); + // The auto-floor twin routes the same deferrals to the posture — the + // zh open tail's other arm, asserted positively, not by absence. + // An ABSENT floor: the enforcement reading fails open, the plan record + // licenses the list, and the open tail routes it to the posture. + const routed = hanInput(POSTURE); + routed.criticalsInline = 0; + delete routed.severityFloor; + routed.deferredSuggestions = off.deferredSuggestions; + const r3 = composeReview(routed); + expect(r3.body).toContain('(下限记录缺失,强制读取按开放放行)'); + expect(r3.body).toContain( + '机械兜底未移动任何内容——下方列出的延后由收敛姿态路由', + ); + expect(r3.body).not.toContain('没有姿态授权'); + + const none = composeReview( + hanInput({ ...POSTURE, scope: { ...POSTURE.scope, interaction: [] } }), + ); + expect(none.body).toContain('(没有仍然干净的 importer 重新进入范围)'); + }); + + it('renders the explicit-floor cause by name', () => { + const r = composeReview(rcInput({ ...POSTURE, postureCause: 'explicit' })); + expect(r.body).toContain('engaged by the operator-set critical floor'); + }); + + it('names a seam oracle that never ran instead of describing a bound that kept everything (#10136 R18-2)', () => { + // The capture recorded `seamOracle: 'unavailable'` — no TypeScript + // parser resolvable at run time, so the bound never executed and + // every interaction file republished in full. The round-shape + // sentence must say THAT; the plain reading ("plus their import-seam + // interaction files" with no census clause) is exactly what a round + // where the bound ran and kept everything renders. + const r = composeReview( + rcInput({ + ...POSTURE, + scope: { + ...POSTURE.scope, + seamOracle: 'unavailable', + interaction: [{ path: 'src/b.ts', importsChanged: ['src/a.ts'] }], + }, + }), + ); + expect(r.body).toContain( + 'the seam oracle could not resolve a TypeScript parser at run time, so every interaction file republished in full', + ); + expect(r.body).not.toContain('seam-bounded:'); + expect(r.body).not.toContain('republished whole'); + // The zh twin of the same clause, through the same bilingual switch. + const hanBody = (() => { + const input = base({ criticalsInline: 1 }); + input.planPath = coveredPlan(['verify', 'reverse-audit'], { + han: true, + incremental: { + ...POSTURE, + scope: { + ...POSTURE.scope, + seamOracle: 'unavailable', + interaction: [{ path: 'src/b.ts', importsChanged: ['src/a.ts'] }], + }, + } as never, + }); + return composeReview(input).body; + })(); + expect(hanBody).toContain( + '接缝 oracle 在运行时无法解析到 TypeScript 解析器,所有 interaction 文件均按全量重新发布', + ); + }); + + it('a census that kept every hunk is named as kept whole, never as a shed (#10136 R1-7)', () => { + const r = composeReview( + rcInput({ + ...POSTURE, + scope: { + ...POSTURE.scope, + interaction: [ + { + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 3, total: 3 }, + }, + { + path: 'src/c.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 1, total: 4 }, + }, + ], + }, + }), + ); + expect(r.body).toContain( + '1 seam-bounded: 1 of 4 hunk(s) republished; 1 republished whole, every hunk on the seam', + ); + expect(r.body).not.toContain('4 of 7'); + // Every file kept whole: no seam-bounded count at all. + const whole = composeReview( + rcInput({ + ...POSTURE, + scope: { + ...POSTURE.scope, + interaction: [ + { + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 3, total: 3 }, + }, + ], + }, + }), + ); + expect(whole.body).toContain( + '(1 republished whole, every hunk on the seam)', + ); + expect(whole.body).not.toContain('seam-bounded:'); + // Two seam-bounded files ACCUMULATE: 1 of 4 plus 2 of 3 is 3 of 7 — + // an assignment in place of the `+=` posts the last file alone. + const two = composeReview( + rcInput({ + ...POSTURE, + scope: { + ...POSTURE.scope, + interaction: [ + { + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 1, total: 4 }, + }, + { + path: 'src/c.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 2, total: 3 }, + }, + { + path: 'src/d.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 2, total: 2 }, + }, + { + path: 'src/e.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 5, total: 5 }, + }, + ], + }, + }), + ); + expect(two.body).toContain( + '2 seam-bounded: 3 of 7 hunk(s) republished; 2 republished whole, every hunk on the seam', + ); + }); + + it('an engaged floor whose only deferrals are axes-Criticals claims no below-Critical deferral (#10136 R16-1)', () => { + // Both channels reach the state: a fails-closed new-surface Critical + // the model routed into the deferral channel, and one the reroute + // moved. The merged list then holds NO finding below Critical, and the + // engaged sentence may not say findings below Critical were deferred. + const critical = { + file: 'src/a.ts', + line: 3, + source: 'review' as const, + severity: 'Critical' as const, + direction: 'fails-closed' as const, + baseline: 'new-surface' as const, + title: 'sparse checkout wedges the incremental round', + }; + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.severityFloor = 'critical'; + input.deferredSuggestions = [critical]; + const r = composeReview(input); + expect(r.deferredCount).toBe(1); + expect(r.body).toContain( + 'Critical(s) among them are deferred by their axes', + ); + expect(r.body).not.toContain( + 'Findings below Critical were recorded and deferred', + ); + expect(r.body).toContain( + 'The deferred findings below are Criticals deferred by their axes (fails-closed on new surface) — nothing below Critical was withheld this round.', + ); + // The reroute channel: a drafted tagged Critical the backstop moves. + const moved = rcInput(POSTURE); + moved.criticalsInline = 1; + moved.severityFloor = 'critical'; + moved.draftedComments = [ + { + path: 'src/a.ts', + line: 3, + body: '**[Critical]** [fails-closed] [new-surface] sparse checkout wedges the incremental round', + }, + ]; + const r2 = composeReview(moved); + expect(r2.floorEnforced).toEqual([0]); + expect(r2.body).not.toContain( + 'Findings below Critical were recorded and deferred', + ); + expect(r2.body).toContain('nothing below Critical was withheld this round'); + // One Suggestion beside the Critical restores the below-Critical claim. + input.deferredSuggestions = [ + critical, + { + file: 'src/a.ts', + line: 9, + source: 'review', + severity: 'Suggestion', + title: 'nit', + }, + ]; + const r3 = composeReview(input); + expect(r3.body).toContain( + 'Findings below Critical were recorded and deferred', + ); + expect(r3.body).not.toContain('nothing below Critical was withheld'); + }); + + it('claims interaction files only when the scope has some (#10136)', () => { + const r = composeReview( + rcInput({ ...POSTURE, scope: { ...POSTURE.scope, interaction: [] } }), + ); + expect(r.body).toContain( + 'covered the commits since the previous round (no still-clean importer re-entered the scope)', + ); + expect(r.body).not.toContain('import-seam interaction files'); + }); + + it('counts a seam census only on an entry the briefs would render — one admission (#10136)', () => { + // An entry with no surviving edge is one `incrementalScopeOf` drops; its + // census must not reach the body either, or the disclosure counts a + // reduction on a file no brief described. + const r = composeReview( + rcInput({ + ...POSTURE, + scope: { + ...POSTURE.scope, + interaction: [ + { + path: 'src/b.ts', + importsChanged: [], + seam: { kept: 1, total: 9 }, + }, + { + path: '', + importsChanged: ['src/a.ts'], + seam: { kept: 0, total: 5 }, + }, + ], + }, + }), + ); + expect(r.body).not.toContain('seam-bounded'); + expect(r.body).toContain('no still-clean importer re-entered the scope'); + }); + + it('an approving fix-audit round still owns its reduced shape (#10136 R1-6)', () => { + // The common successful fix round: no new Critical, the survivors + // deferred — composes through the APPROVE branch, and the body must + // carry the round-shape disclosure there too. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.severityFloor = 'critical'; + input.deferredSuggestions = [ + { + file: 'src/a.ts', + line: 3, + source: 'review', + severity: 'Suggestion', + title: 'untested guard', + }, + ]; + const r = composeReview(input); + expect(r.event).toBe('APPROVE'); + expect(r.body).toContain('No blocking issues'); + expect(r.body).toContain( + 'fix-audit round under the critical posting posture', + ); + expect(r.body).toContain('recorded and deferred, never posted'); + }); + + it('an engaged floor beside an empty deferral list claims no deferral (#10136)', () => { + const input = rcInput(POSTURE); + input.severityFloor = 'critical'; + const r = composeReview(input); + expect(r.body).toContain('nothing below Critical reached it this round'); + expect(r.body).not.toContain('were recorded and deferred'); + }); + + it('an explicit suggestion floor beside a postured plan and a deferral list: the deferrals are unlicensed, never posture-routed (#10136 R13-1)', () => { + // The boundary state the licence chain and the open-arm tail must + // agree on: the operator turned the posture off, the plan still + // records one, and the model deferred anyway. The licence caps the + // verdict as unlicensed-deferral; the tail may not attribute the same + // deferrals to the posture the operator disabled. + const input = rcInput(POSTURE); + input.criticalsInline = 0; + input.severityFloor = 'suggestion'; + input.deferredSuggestions = [ + { + file: 'src/a.ts', + line: 3, + source: 'review', + severity: 'Suggestion', + title: 'untested guard', + }, + ]; + const r = composeReview(input); + expect(r.cappedBy).toContain('unlicensed-deferral'); + expect(r.body).toContain('the operator turned the posture off'); + expect(r.body).toContain('deferred without a posture licence'); + expect(r.body).toContain('carry no posture licence'); + expect(r.body).not.toContain('routed by the convergence posture'); + // The SAME state with an `auto` floor routes them to the posture — + // the plan record is the licence there. + input.severityFloor = 'auto'; + const auto = composeReview(input); + expect(auto.cappedBy).not.toContain('unlicensed-deferral'); + expect(auto.body).not.toContain('carry no posture licence'); + }); + + it('renders the flat-trend cause by name', () => { + const r = composeReview( + rcInput({ ...POSTURE, postureCause: 'flat-trend' }), + ); + expect(r.body).toContain('engaged by the flat first-time-finding trend'); + }); + + it('says nothing on a plan without the posture', () => { + const r = composeReview( + rcInput({ since: 'a'.repeat(40), effective: true }), + ); + expect(r.body).not.toContain('fix-audit round'); + }); + + it('a malformed posture block silences the disclosure, never crashes', () => { + const r = composeReview(rcInput({ ...POSTURE, scope: 'garbled' })); + expect(r.body).not.toContain('fix-audit round'); + // …at the same bar the roster's reader applies (`isFixAuditRound`): a + // scope-shaped object missing its anchor or delta list is a plan the + // roster ran FULL, and neither the floor arm nor the disclosure may + // describe a fix-audit round nobody ran. + const r2 = composeReview( + rcInput({ ...POSTURE, scope: { anchor: '', deltaFiles: ['a.ts'] } }), + ); + expect(r2.body).not.toContain('fix-audit round'); + const r3 = composeReview( + rcInput({ + ...POSTURE, + scope: { anchor: 'a'.repeat(40), deltaFiles: 'garbled' }, + }), + ); + expect(r3.body).not.toContain('fix-audit round'); + }); +}); + describe('composeReview — not-reviewed entries that carry their own reason', () => { it('renders the entry verbatim instead of appending the whiff sentence (Agent 0 issue-fetch failure)', () => { const r = composeReview( diff --git a/packages/cli/src/commands/review/compose-review.ts b/packages/cli/src/commands/review/compose-review.ts index 99ac3c5260d..a5bc7571e23 100644 --- a/packages/cli/src/commands/review/compose-review.ts +++ b/packages/cli/src/commands/review/compose-review.ts @@ -54,7 +54,11 @@ import { roundCapStopDisclosure, readBudgetStop, } from './lib/deadline.js'; -import { LARGE_REVERSE_AUDIT_ROUNDS } from './lib/budget.js'; +import { + interactionEntryOf, + isFixAuditRound, + LARGE_REVERSE_AUDIT_ROUNDS, +} from './lib/budget.js'; import { shellQuotePath } from './lib/shell-quote.js'; import { HOSTNAME_RE, @@ -99,6 +103,7 @@ import { type LedgerFinding, } from './lib/ledger.js'; import { mdField, stripCommentGrammar } from './lib/md-field.js'; +import { CRITICAL_FLOOR_ROUND, FLAT_STREAK_TO_ENGAGE } from './lib/posture.js'; import { convergenceAdvisory, convergenceAssessment, @@ -966,6 +971,7 @@ export function criticalFloorKind( contextUnavailable: boolean, prevRound: number, signalEngaged?: boolean, + fixAuditPlan?: boolean, ): CriticalFloorKind | undefined { // The REPORTING reading, and it folds an absent or unrecognisable floor // into `auto` the way `composeReviewBody` already does ("A floor the @@ -1001,6 +1007,7 @@ export function criticalFloorKind( contextUnavailable, prevRound, signalEngaged, + fixAuditPlan, ); } @@ -1022,6 +1029,7 @@ export function criticalFloorInEffect( contextUnavailable: boolean, prevRound: number, signalEngaged?: boolean, + fixAuditPlan?: boolean, ): boolean { return ( floorResolvesCritical( @@ -1029,6 +1037,7 @@ export function criticalFloorInEffect( contextUnavailable, prevRound, signalEngaged, + fixAuditPlan, ) !== undefined ); } @@ -1039,6 +1048,7 @@ function floorResolvesCritical( contextUnavailable: boolean, prevRound: number, signalEngaged?: boolean, + fixAuditPlan?: boolean, ): CriticalFloorKind | undefined { // `prevRound` is the PREVIOUS posted round, so the review being composed // is `prevRound + 1` — spelled out because the equivalent `prevRound >= 5` @@ -1046,7 +1056,11 @@ function floorResolvesCritical( // critical". const thisRound = prevRound + 1; if (floor === 'critical') return 'explicit'; - if (floor === 'auto' && !contextUnavailable && thisRound >= 6) { + if ( + floor === 'auto' && + !contextUnavailable && + thisRound >= CRITICAL_FLOOR_ROUND + ) { return 'auto-resolved'; } // The signal-driven early trigger (#9903): the convergence diagnosis's @@ -1060,6 +1074,19 @@ function floorResolvesCritical( if (floor === 'auto' && !contextUnavailable && signalEngaged === true) { return 'auto-signaled'; } + // The fix-audit arm (#10104): the PLAN records that the capture resolved + // this round's posture to critical from the same side-file facts the two + // arms above read — and the round's SHAPE was spent on that resolution + // (narrowed fan-out, seam-bounded republication, narrowed waves). + // Where the arms above cannot re-derive it — a context-unavailable + // compose, or a side file rewritten between capture and compose — the + // plan's own record still resolves the floor, because the alternative is + // the one combination nothing licenses: a round that narrowed its + // coverage on the posture and then posts Suggestions in full, beside a + // disclosure describing the posture it did not run. Gated on `auto` + // exactly like the other arms: an explicit `suggestion` is the operator + // turning the posture off, and it wins over a stale plan record. + if (floor === 'auto' && fixAuditPlan === true) return 'auto-resolved'; return undefined; } @@ -1077,12 +1104,16 @@ function floorResolvesCritical( * exists as code, here, where the drafts are already in hand. * * Enforcement fires ONLY where the deferral licence already holds: an - * explicit `critical` floor at any round, `auto` at round ≥ 6, or `auto` - * with the flat-trend streak at its bar (#9903) — the `auto` arms only - * with the round knowable. Everything else fails OPEN exactly as the + * explicit `critical` floor at any round, `auto` at round ≥ 6, `auto` + * with the flat-trend streak at its bar (#9903) — those two `auto` arms + * only with the round knowable — or `auto` beside the plan's own fix-audit + * record (#10104), which holds even context-unavailable and at round 1 + * because the capture already spent the round's shape on the resolution. + * Everything else fails OPEN exactly as the * posture itself does — an unrecognisable floor, `auto` before round 6 with - * the streak below its bar, `auto` in the context-unavailable state (the - * round is unknowable), `--severity-floor suggestion` (posture off): a + * the streak below its bar, `auto` in the context-unavailable state without + * a fix-audit plan record (the round is unknowable), `--severity-floor + * suggestion` (posture off): a * posting bar in doubt posts. The rounds-2–5 * code-age rule stays model-side on purpose — it needs the worktree git * checks this module does not have. @@ -1101,6 +1132,7 @@ export function floorEnforcedReroute( prevRound: number, drafted: ReadonlyArray<{ path?: unknown; line?: unknown; body?: unknown }>, signalEngaged?: boolean, + fixAuditPlan?: boolean, ): { indices: number[]; entries: DeferredEntry[] } { if ( !criticalFloorInEffect( @@ -1108,6 +1140,7 @@ export function floorEnforcedReroute( contextUnavailable, prevRound, signalEngaged, + fixAuditPlan, ) ) { return { indices: [], entries: [] }; @@ -2281,12 +2314,17 @@ export function composeReview( // here (`=== true`); the authoritative shape check stays in // `composeReviewBody`, which runs on the same input immediately after // and throws the same TypeError either way. + // The plan's own posture record (#10104) — the third evidence source the + // resolution reads, so a fix-audit round's posting bar can never disagree + // with the shape it already ran. + const fixAuditPlan = fixAuditShapeFacts(input.planPath) !== null; const reroute = floorEnforcedReroute( input.severityFloor, input.contextUnavailable === true, prevRound, Array.isArray(input.draftedComments) ? input.draftedComments : [], signalEngaged, + fixAuditPlan, ); // The one resolution, read by the enforcement above and reported by the // diagnosis below — and stamped into this round's marker, so the NEXT round @@ -2296,6 +2334,7 @@ export function composeReview( input.contextUnavailable === true, prevRound, signalEngaged, + fixAuditPlan, ); let effective = input; if (reroute.indices.length > 0) { @@ -2351,6 +2390,7 @@ export function composeReview( input.contextUnavailable === true, prevRound, signalEngaged, + fixAuditPlan, ); const postedLedger = buildPostedLedger( effective, @@ -2589,18 +2629,9 @@ export const CHURN_MIN_FRESH = 4; */ export const CHURN_STREAK_TO_FILE = 2; -/** - * How many consecutive rounds of a not-falling first-time-finding rate - * engage the severity floor ahead of the round-6 schedule (#9903). - * - * Two, for the argument `CHURN_STREAK_TO_FILE` above states: one flat round - * is a step, two is the shortest window in which "the rate is not falling" - * is an observation. The bar is read off the ledger's `flatRounds` streak, - * which a round advances when its OWN measured trend fires and resets when - * it falls — so reaching it always takes two measured firing rounds; a - * carried or pinned streak never adds. - */ -export const FLAT_STREAK_TO_ENGAGE = 2; +// The flat-trend bar lives in `lib/posture.ts` beside the round schedule: +// the capture command's plan-time posture prediction (#10104) reads the same +// two constants this resolution does, so the two cannot drift. /** * This round's census, or null when it cannot be read as one. @@ -3317,12 +3348,17 @@ function ledgerMarkerFor( // ABSENT floor reads as `auto` in the REPORTING reading (a present but // unrecognisable one reads as nothing at all — see // `criticalFloorKind`), and `auto` resolves determinately from the - // round number and the context state. The ENFORCEMENT reading folds + // round number, the context state, and the plan's fix-audit posture + // record (#10104) — the last is the one that holds the posture + // through a context-unavailable round, so a stamp reading `c` on a + // round-3 context-unavailable compose is the record resolving, not a + // corrupt stamp. The ENFORCEMENT reading folds // nothing and fails open on both; the gap between the two is what the // mechanism-health check discloses. Recording it only when the state NAMED a floor // left the guard blind under the DEFAULT configuration — where the - // posture genuinely transitions at round 6 and again on a transient - // context failure — so a real posture change read as loop divergence, + // posture genuinely transitions at round 6 and, absent a fix-audit + // plan record, again on a transient context failure — so a real + // posture change read as loop divergence, // which is the misreading the field exists to prevent. What must not // be invented is a posture nobody can derive; this one is derived from // the same fold the advice and the enforcement backstop already use. @@ -3463,6 +3499,93 @@ export function tryIngestBodyCriticals(value: unknown): string[] | undefined { } } +/** + * The plan's fix-audit posture record (#10104), for the body's round-shape + * disclosure — null on every plan that did not run one. Read defensively: + * the plan sits behind a model-written path, and a malformed block must + * silence the disclosure rather than render `undefined` into a posted body. + */ +function fixAuditShapeFacts(planPath: string | undefined): { + cause: 'explicit' | 'round' | 'flat-trend' | null; + /** Interaction entries the brief builder would render — one admission. */ + interactionFiles: number; + /** Entries whose census sheds at least one hunk (`kept < total`). */ + seamFiles: number; + seamKept: number; + seamTotal: number; + /** Entries whose census kept every hunk (`kept === total`). */ + wholeFiles: number; + /** + * The capture recorded that no TypeScript parser could be resolved, so + * the seam bound never ran and every interaction file republished in + * full (#10136 R18-2) — named, or the sentence below would describe a + * bound that never executed. + */ + oracleUnavailable: boolean; +} | null { + try { + if (!planPath) return null; + const plan = JSON.parse(readFileSync(planPath, 'utf8')) as { + incremental?: unknown; + }; + // The SAME admission `isFixAuditRound` applies — one bar, called, + // not restated: this reader gates the floor arm and the body's + // disclosure, that one the roster and the tier, and a weaker bar here + // let a garbled scope run the FULL shape while the floor deferred and + // the body described a fix-audit round nobody ran. + if (!isFixAuditRound(plan)) return null; + const rec = plan.incremental as { + postureCause?: unknown; + scope?: unknown; + }; + const scope = rec.scope; + const interaction = (scope as { interaction?: unknown }).interaction; + let interactionFiles = 0; + let seamFiles = 0; + let seamKept = 0; + let seamTotal = 0; + let wholeFiles = 0; + if (Array.isArray(interaction)) { + for (const raw of interaction) { + // The SAME admission the brief builder applies — `interactionEntryOf`, + // called, not restated (#10136): an entry the briefs would not render + // (no path, no surviving edge) counts for nothing here either, and a + // census that cannot be true ("5 of 2 republished") is silenced, + // never rendered into a posted body. + const e = interactionEntryOf(raw); + if (e === null) continue; + interactionFiles += 1; + if (e.seam === undefined) continue; + if (e.seam.kept < e.seam.total) { + seamFiles += 1; + seamKept += e.seam.kept; + seamTotal += e.seam.total; + } else { + wholeFiles += 1; + } + } + } + const cause = + rec.postureCause === 'explicit' || + rec.postureCause === 'round' || + rec.postureCause === 'flat-trend' + ? rec.postureCause + : null; + return { + cause, + interactionFiles, + seamFiles, + seamKept, + seamTotal, + wholeFiles, + oracleUnavailable: + (scope as { seamOracle?: unknown }).seamOracle === 'unavailable', + }; + } catch { + return null; + } +} + function composeReviewBody( input: ComposeReviewInput, cliVersion: string, @@ -3611,12 +3734,14 @@ function composeReviewBody( } // The channel's OTHER precondition: deferring is only ever licensed by // the posture — `critical` at any round; `auto` from round 2 (the - // code-age rule) and round 6 (the floor); never an explicit `suggestion` - // (the operator turned the posture off) and never round 1 of `auto` (no - // posture, no age reference). An unlicensed deferral is a model - // mis-execution that would silently un-post findings — but the response - // is a CAP, not a refusal: a thrown compose loses the WHOLE round, - // Criticals included, and `prevRound` is a best-effort side-file read + // code-age rule) and round 6 (the floor); `auto` beside the plan's own + // fix-audit record (#10104); never an explicit `suggestion` (the + // operator turned the posture off) and never round 1 of `auto` without + // that record (no posture, no age reference). An unlicensed deferral + // is a model mis-execution that would silently un-post findings — but + // the response is a CAP, not a refusal: a thrown compose loses the + // WHOLE round, Criticals included, and `prevRound` is a best-effort + // side-file read // whose every failure mode returns 0 — a missing file at a true round 6 // must degrade to a disclosed, uncertified verdict, never to no verdict // at all. The findings render; the cap keeps anything from certifying @@ -3626,7 +3751,9 @@ function composeReviewBody( // A floor the module does not recognise — absent, null, or a // model-transcribed spelling drift ("Critical", "auto ", "") — is folded // into ONE state: unknown. It caps as unlicensed when a deferral list - // exists (fail-closed, disclosed) and is inert when it does not — a + // exists and no fix-audit plan record licenses it (#10104 — the record + // is the licence in that state, see `unlicensedDeferral`), fail-closed + // and disclosed, and is inert when no list exists — a // refusal here would lose the whole round over a field that changes no // output on a zero-deferral run, the exact outcome the licence block is // written to avoid. Model-transcribed prose is not a NaN count. @@ -4586,15 +4713,29 @@ function composeReviewBody( 'contextUnavailable', ); + // The plan's posture record, read BEFORE the licence below (#10104): the + // same fact the floor arm in `composeReview` acted on. Where it is + // present the round's shape was already spent on the critical resolution, + // so its deferrals are licensed even in the two states the doubt arms + // below name — the licence may not flag the very deferral the floor arm + // enforced. The disclosure further down reads this same const. + const fixAudit = fixAuditShapeFacts(input.planPath); + // The deferral licence, decided here because two of its arms need inputs // parsed above: deferring is only ever licensed by the posture — // `critical` at any round; `auto` from round 2 (the code-age rule) and - // round 6 (the floor); never an explicit `suggestion` (posture off), - // never round 1 of `auto` (no posture, no age reference), never `auto` in - // the context-unavailable state (the round is unknowable — SKILL resolves - // it as round 1), and never with the field ABSENT beside a non-empty list - // (the licence cannot be checked, and the channel ships in the same PR as - // the field — omission is fail-closed, not grandfathered). The response + // round 6 (the floor); `auto` beside the plan's own fix-audit record + // (#10104), whatever the round or the context state; never an explicit + // `suggestion` (posture off), never round 1 of `auto` WITHOUT the plan + // record (no posture, no age reference), never `auto` in the + // context-unavailable state WITHOUT the plan record (the round is + // unknowable — SKILL resolves it as round 1), and never with the field + // ABSENT beside a non-empty list when the plan carries no fix-audit + // record (the licence cannot be checked, and the channel ships in the + // same PR as the field — omission is fail-closed, not grandfathered). + // A present plan record IS the licence in that state: the round's shape + // was spent on the critical resolution, and the deferral is what the + // floor arm enforced. The response // is a CAP, not a refusal: a thrown compose loses the whole round, // Criticals included, and `prevRound` is a best-effort side-file read // whose every failure mode returns 0 — a missing file at a true round 6 @@ -4604,13 +4745,13 @@ function composeReviewBody( const unlicensedDeferral = deferredSuggestions.length === 0 ? null - : floorAbsent + : floorAbsent && fixAudit === null ? 'the state carried no recognisable `severityFloor`, so the licence cannot be checked' : severityFloor === 'suggestion' ? 'the operator turned the posture off (`--severity-floor suggestion`)' - : severityFloor === 'auto' && contextUnavailable + : severityFloor === 'auto' && contextUnavailable && fixAudit === null ? 'the round is unknowable in the context-unavailable state' - : severityFloor === 'auto' && prevRound === 0 + : severityFloor === 'auto' && prevRound === 0 && fixAudit === null ? 'no posture is engaged on round 1 and no age reference exists' : null; const presubmitRaw: unknown = input.presubmit ?? {}; @@ -6215,6 +6356,197 @@ function composeReviewBody( ] : []; + // The fix-audit round-shape disclosure (#10104), non-capping: when the + // capture resolved the critical posture, the round's SHAPE changed — the + // fan-out covered the delta and its seams instead of the full territory, + // interaction files republished seam-bounded, and the reverse-audit waves + // narrowed. Every one of those is a reduction the + // posted record must own rather than leave to a diff of agent counts, the + // same accounting rule the retirement and floor-enforcement notes follow. + const fixAuditCauseEn = + fixAudit?.cause === 'explicit' + ? 'the operator-set critical floor' + : fixAudit?.cause === 'flat-trend' + ? 'the flat first-time-finding trend' + : fixAudit?.cause === 'round' + ? 'the round schedule' + : 'the critical posting floor'; + const fixAuditCauseZh = + fixAudit?.cause === 'explicit' + ? '操作者显式设置的 critical 下限' + : fixAudit?.cause === 'flat-trend' + ? '首次发现速率持平的信号' + : fixAudit?.cause === 'round' + ? '轮次日程' + : 'critical 发布下限'; + // The deferral claim states what THIS round's floor actually ENFORCED — + // the strict reading `floorEnforcedReroute` acted on — not the reporting + // reading: the two diverge on a floor the state omitted, which the + // report folds to `auto` (the plan arm resolves it) while enforcement + // fails open, and keying the claim off the reporting reading posted + // "recorded and deferred" beside the very Suggestions posting inline in + // the same body. + const fixAuditFloorEngaged = convergence?.floorEnforcementEngaged === true; + // The open-floor sentence names its true cause, and the causes are THREE + // distinct facts: an explicit `suggestion` floor the operator set, a + // floor the state omitted, and a present value the module cannot read — + // the strict reading fails open on all of them. Keying the split on the + // REPORTING resolution folded the third into the first: the compose state + // is model-written, and a transcribed drift ("critcal", "blocker", "") + // beside the plan record posted "the operator turned the posture off" — + // an operator intent that never happened. + const fixAuditOpenCauseEn = + floorRaw === 'suggestion' + ? '(the operator turned the posture off)' + : input.severityFloor === undefined || input.severityFloor === null + ? '(the floor record was absent, and the enforcement reading fails open)' + : '(the state carried a floor value this module cannot read, and the strict reading cannot act on it)'; + const fixAuditOpenCauseZh = + floorRaw === 'suggestion' + ? '(操作者关闭了该姿态)' + : input.severityFloor === undefined || input.severityFloor === null + ? '(下限记录缺失,强制读取按开放放行)' + : '(状态携带了本模块无法识别的下限值,强制读取无法对其生效)'; + // Beside a non-empty deferral list the open arm may not assert the + // no-withholding universal the very same body falsifies: deferral IS the + // floor's withholding in this module's terminology — the convergence + // posture IS the floor resolution — so beside the list the sentence owns + // what the open arm DID (the backstop moved nothing) and routes the + // deferrals to the posture, never to a resolved floor (#10136). The + // empty-list arm keeps the universal. Beside an explicit `suggestion` + // floor the deferrals have NO posture to be routed by — the operator + // turned it off, and the licence chain above stamps them unlicensed + // (`unlicensedDeferral`) — so that arm says exactly that, in the same + // body the licence sentence caps (#10136 R13-1). + const fixAuditOpenTailEn = + deferredSuggestions.length > 0 + ? floorRaw === 'suggestion' + ? ', so the mechanical backstop moved nothing — the deferrals ' + + 'listed below carry no posture licence (the operator turned the ' + + 'posture off), and this verdict is capped for them; only the ' + + 'narrowed shape above applied.' + : ', so the mechanical backstop moved nothing — the deferrals listed ' + + 'below were routed by the convergence posture, not moved by a ' + + 'resolved floor; only the narrowed shape above applied.' + : ', so no finding was withheld by a floor — only the narrowed shape ' + + 'above applied.'; + const fixAuditOpenTailZh = + deferredSuggestions.length > 0 + ? floorRaw === 'suggestion' + ? ',机械兜底未移动任何内容——下方列出的延后没有姿态授权(操作者关闭了该姿态),本判定因此受限;只有上述收窄形态生效。' + : ',机械兜底未移动任何内容——下方列出的延后由收敛姿态路由,而非已解析下限的移动;只有上述收窄形态生效。' + : ',没有任何发现被下限扣留——只有上述收窄形态生效。'; + // The engaged arm asserts a below-Critical deferral only beside one + // (#10136 R16-1): the merged list the deferral block renders carries + // Criticals too — the fails-closed/new-surface ones the axes defer, from + // the reroute and the model channel alike — so a list holding only those + // has NO finding below Critical in it, and "findings below Critical were + // recorded and deferred" would name findings that do not exist. Keyed + // on the same merged list `deferredCriticals` reads. With nothing + // deferred at all the floor either saw nothing below Critical, or only + // the pre-confirmed deterministic findings it leaves inline — which is + // what `suggestionsInline` counts once enforcement has moved everything + // else. + const deferredBelowCritical = deferredSuggestions.some( + (e) => e.severity !== 'Critical', + ); + const fixAuditFloorEn = fixAuditFloorEngaged + ? deferredBelowCritical + ? 'Findings below Critical were recorded and deferred, never posted — except pre-confirmed `[build]`/`[test]`/`[probe]` findings, which stay inline at any floor.' + : deferredSuggestions.length > 0 + ? 'The deferred findings below are Criticals deferred by their axes (fails-closed on new surface) — nothing below Critical was withheld this round' + + (suggestionsInline > 0 + ? ', and the pre-confirmed `[build]`/`[test]`/`[probe]` findings below Critical stay inline at any floor.' + : '.') + : suggestionsInline > 0 + ? 'The floor was engaged and nothing was deferred this round; the only Suggestions the floor leaves inline are pre-confirmed `[build]`/`[test]`/`[probe]` findings, which stay inline at any floor.' + : 'The floor was engaged and nothing below Critical reached it this round — any such finding would have been recorded and deferred, never posted (pre-confirmed `[build]`/`[test]`/`[probe]` findings excepted).' + : 'The posting floor itself resolved OPEN at compose time this round ' + + fixAuditOpenCauseEn + + fixAuditOpenTailEn; + const fixAuditFloorZh = fixAuditFloorEngaged + ? deferredBelowCritical + ? '低于 Critical 的发现只记录延后,不发布——除了预确认的 `[build]`/`[test]`/`[probe]` 发现,它们在任何下限下都留在行内。' + : deferredSuggestions.length > 0 + ? '下方延后的发现都是按其轴向延后的 Critical(新表面上的 fails-closed)——本轮没有任何低于 Critical 的发现被扣留' + + (suggestionsInline > 0 + ? ',预确认的 `[build]`/`[test]`/`[probe]` 低于 Critical 的发现在任何下限下都留在行内。' + : '。') + : suggestionsInline > 0 + ? '下限已生效且本轮没有延后;下限唯一留在行内的 Suggestion 是预确认的 `[build]`/`[test]`/`[probe]` 发现,它们在任何下限下都留在行内。' + : '下限已生效,本轮没有任何低于 Critical 的发现触及它——若有,也只会记录延后、不发布(预确认的 `[build]`/`[test]`/`[probe]` 发现除外)。' + : '但本轮发布下限在 compose 期实际解析为开放' + + fixAuditOpenCauseZh + + fixAuditOpenTailZh; + // The census names a REDUCTION only where one happened (#10136): a file + // whose every hunk displays a seam line republished whole, and counting + // it among the seam-bounded ones claimed a shed that never was. Files + // the scan kept whole are named as such; files with no census at all + // (a doubt state) republished in full and are not counted either way. + // An oracle that never ran is named as such (#10136 R18-2): with no + // parser resolvable the bound never executed, and the sentence must not + // read as if it ran and kept everything. + const fixAuditSeamEn = + fixAudit && fixAudit.oracleUnavailable + ? ' — but the seam oracle could not resolve a TypeScript parser at ' + + 'run time, so every interaction file republished in full' + : fixAudit && fixAudit.seamFiles > 0 + ? ` (${fixAudit.seamFiles} seam-bounded: ${fixAudit.seamKept} of ${fixAudit.seamTotal} hunk(s) republished` + + (fixAudit.wholeFiles > 0 + ? `; ${fixAudit.wholeFiles} republished whole, every hunk on the seam)` + : ')') + : fixAudit && fixAudit.wholeFiles > 0 + ? ` (${fixAudit.wholeFiles} republished whole, every hunk on the seam)` + : ''; + const fixAuditSeamZh = + fixAudit && fixAudit.oracleUnavailable + ? '——但接缝 oracle 在运行时无法解析到 TypeScript 解析器,' + + '所有 interaction 文件均按全量重新发布' + : fixAudit && fixAudit.seamFiles > 0 + ? `(${fixAudit.seamFiles} 个按接缝收窄:重发 ${fixAudit.seamKept}/${fixAudit.seamTotal} 个 hunk` + + (fixAudit.wholeFiles > 0 + ? `;${fixAudit.wholeFiles} 个整体重发,其每个 hunk 都在接缝上)` + : ')') + : fixAudit && fixAudit.wholeFiles > 0 + ? `(${fixAudit.wholeFiles} 个整体重发,其每个 hunk 都在接缝上)` + : ''; + const fixAuditShapeBlock: Bi[] = fixAudit + ? [ + { + trim: 2, + en: + `Round shape: this re-review ran as a fix-audit round under the critical ` + + `posting posture (engaged by ${fixAuditCauseEn}) — the territory fan-out ` + + `covered the commits since the previous round` + + (fixAudit.interactionFiles > 0 + ? ` plus their import-seam interaction files${fixAuditSeamEn}` + : ' (no still-clean importer re-entered the scope)') + + `, and the reverse-audit waves ` + + `re-launched delta territories under the ordinary retirement ` + + `rules (a twice-dry one only on its cold-check rounds) and ` + + `non-delta chunks the previous waves could not certify dry (a ` + + `yield, an uncertified receipt or no audit history keeps a chunk ` + + `in the wave; a dry receipt that shows no evidence of having ` + + `seen an earlier yield or uncertified receipt — same list, same ` + + `entries modulo verification tags, or no entry for the filed ` + + `finding — returns it to the ordinary retirement rules). ` + + `${fixAuditFloorEn}`, + zh: + `轮次形态:本次 re-review 以 critical 发布姿态下的 fix-audit 轮运行` + + `(由${fixAuditCauseZh}触发)——领地扇出只覆盖上一轮以来的 commits` + + (fixAudit.interactionFiles > 0 + ? `及其 import 接缝 interaction 文件${fixAuditSeamZh}` + : '(没有仍然干净的 importer 重新进入范围)') + + `,反向审计各波按普通退役规则重发 delta ` + + `领地(两次干燥的只在其冷检轮重发),并重发此前各波未能证实干燥的非 delta ` + + `chunk(出过发现、收据未认证或无审计历史会让 chunk 留在波内;干燥收据若` + + `没有证据表明见过此前的发现或未认证收据——同一份清单、仅验证标记不同的` + + `同批条目、或清单中找不到该发现的条目——则让它回到普通退役规则)。` + + `${fixAuditFloorZh}`, + }, + ] + : []; + // The not-reviewed disclosures yield after the deferral display and before // the convergence observation: they say what the review could not certify, // which the verdict's own cap already carries, so trimming them costs @@ -6387,6 +6719,7 @@ function composeReviewBody( ...deferredBlock, ...testPlanBlock, ...repositoryContextBlock, + ...fixAuditShapeBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, ...convergenceBlock, @@ -6457,6 +6790,7 @@ function composeReviewBody( ...deferredBlock, ...testPlanBlock, ...repositoryContextBlock, + ...fixAuditShapeBlock, ...unlicensedDeferralBlock, ...deferredSuggestionsBlock, // Both of these are spread for symmetry with the branches above and @@ -6478,6 +6812,7 @@ function composeReviewBody( deferredBlock.length || testPlanBlock.length || repositoryContextBlock.length || + fixAuditShapeBlock.length || deferredSuggestionsBlock.length || // Unreachable today and kept deliberately: an APPROVE is composed // from zero findings, which means zero posted comments and zero @@ -6702,6 +7037,11 @@ function composeReviewBody( // planner recommends disclosing without claiming the code is defective. clauses.push(...repositoryContextBlock); + // 6d-2. Fix-audit round-shape disclosure (non-capping) — the critical + // posture changed what this round fanned out over; the posted record + // owns the reduction. + clauses.push(...fixAuditShapeBlock); + // 6e. Convergence-posture deferrals — the licence disclosure (capping) // precedes the list (non-capping). clauses.push(...unlicensedDeferralBlock); diff --git a/packages/cli/src/commands/review/emit-workflow.test.ts b/packages/cli/src/commands/review/emit-workflow.test.ts index ce3477996ef..5444cc06d95 100644 --- a/packages/cli/src/commands/review/emit-workflow.test.ts +++ b/packages/cli/src/commands/review/emit-workflow.test.ts @@ -240,6 +240,33 @@ describe('emit-workflow — what it refuses', () => { expect(readRecordedPrompts(planPath).size).toBe(0); }); + // The growth premise is a SIZE fact: a fix-audit round reads as a + // territory fan-out whatever its narrowed sizes say, but its roster is + // bounded by the same size fields — so the blocker rules on size alone. + it('serves a 3A-sized fix-audit round and still refuses a 3B-sized one (#10136)', () => { + const posture = { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + postureCause: 'round', + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['src/a.ts'], + interaction: [], + }, + }; + const narrow = localPlan({ incremental: posture }); + expect(fanOutBlocker(narrow as unknown as RosterPlan)).toBeNull(); + const wide = localPlan({ + incremental: posture, + srcDiffLines: 2000, + diffLines: 6000, + }); + expect(fanOutBlocker(wide as unknown as RosterPlan)).toMatch( + /territory fan-out \(Step 3B\)/, + ); + }); + // A plan whose sizes failed to arrive is not a small review — its topology // is UNKNOWABLE, and `isTerritoryFanOut`'s missing-to-zero coercion would // bake the guess that it is 3A into the script. diff --git a/packages/cli/src/commands/review/emit-workflow.ts b/packages/cli/src/commands/review/emit-workflow.ts index 635f972ee0e..4b44d2c249e 100644 --- a/packages/cli/src/commands/review/emit-workflow.ts +++ b/packages/cli/src/commands/review/emit-workflow.ts @@ -81,7 +81,13 @@ export function fanOutBlocker(plan: RosterPlan): string | null { // plus the whole-diff agents, so it grows with the diff toward them without // bound. This blocks on the roster's growth, not on the topology name, so // it lifts the moment the runtime's caps grow with the fan-out. - if (isTerritoryFanOut(plan)) { + // The growth premise is a SIZE fact: a fix-audit round (#10104) reads as + // a territory fan-out whatever its narrowed sizes say, but its roster is + // one agent per chunk of a delta the same size fields bound — so the + // ruling here is the size gate alone, the posture flip set aside; a + // fix-audit round whose narrowed diff would fan out by size is still + // refused, one that would not is served. + if (isTerritoryFanOut({ ...plan, incremental: undefined })) { return ( 'this plan is a territory fan-out (Step 3B), whose roster grows one ' + 'agent per chunk while a workflow run is wall-clock capped end to ' + diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index c8ddd8d475a..d6d34e38fe1 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -18,6 +18,7 @@ import { resolveIncrementalAnchor, type AnchorProbe, } from './fetch-pr.js'; +import { loadTypeScript } from './lib/import-graph.js'; import { clearReviewWorktreeLease, clearReviewWorktreeLeaseIfOwned, @@ -35,6 +36,7 @@ import { buildDiffPlan } from './lib/diff-plan.js'; import { buildPlanReport } from './lib/report.js'; import { operatorReviewSettings } from './lib/review-settings.js'; import { makeDiff } from './lib/test-utils.js'; +import { requiredAgents } from './lib/roster.js'; describe('classifyHeavy', () => { it('flags a substantially rewritten existing file', () => { @@ -367,6 +369,15 @@ vi.mock('./lib/merge-base.js', () => ({ resolveMergeBase: producerMocks.resolveMergeBase, })); +// One test below needs the seam oracle unresolvable (#10136 R18-2). The +// mock delegates EVERYTHING to the real module — the seam scan, the +// widening and the corpus all run for real in every other test — and the +// one test flips `loadTypeScript` alone. +vi.mock('./lib/import-graph.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadTypeScript: vi.fn(actual.loadTypeScript) }; +}); + // The ledger append is the wiring under test here, not the ledger itself // (run-ledger.test.ts owns that): a silently unwritten ledger would make a // later --resume find no prior sessions and re-run everything. @@ -1528,6 +1539,588 @@ describe('fetch-pr report assembly', () => { expect(writtenDiff()).toContain('b/b.ts'); }); + it('resolves the critical posture from the side file and seam-bounds the widening (#10104)', async () => { + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + // `b.ts` imports the changed file, but on lines its own single hunk + // (new-side 1-2) does not display — the seam-bounded widening keeps the + // file with none of its hunks. + const B_SOURCE = + '//x\n//y\nconst pad = 1;\n' + + "import { added } from './a.js';\nadded();\n"; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return B_SOURCE; + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + // The previous posted round: round 7, so this round is 8 — past the + // auto floor's round schedule. Its merge-base stamp matches this + // round's base, so the seam bound's continuity gate (#10136 R18-3) + // is satisfied and the bound actually runs. + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + mergeBaseSha: BASE, + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.postureCause).toBe('round'); + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'], seam: { kept: 0, total: 1 } }, + ]); + // The file publishes header-only: still in scope (a chunk holds it, its + // brief asks the seam question), none of its cleared hunks re-shown. + const diff = writtenDiff() ?? ''; + expect(diff).toContain('diff --git a/b.ts b/b.ts'); + expect(diff).not.toContain('+y2'); + expect(diff).toContain('+added'); + // The recorded round cap prices the territory tier the posture flips to, + // not the small tier the narrowed sizes would read. + expect(report.budget.reverseAuditRounds).toBe(5); + }); + + it('a moved merge base keeps the seam bound off — the interaction file republishes whole (#10136 R18-3)', async () => { + // The bound sheds hunks on the premise a prior round published them, + // which holds only while the merge base holds still. The side file's + // stamp names a DIFFERENT base than this round resolved — a retarget + // moved the base between rounds — so hunks the move smuggled into the + // full-range slice were never published. The bound stays off: no seam + // record, and the published diff carries the file's non-seam hunk. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const B_SOURCE = + '//x\n//y\nconst pad = 1;\n' + + "import { added } from './a.js';\nadded();\n"; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return B_SOURCE; + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + // The previous round's capture ran over a DIFFERENT base. + mergeBaseSha: 'c'.repeat(40), + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + // The posture still resolves — the roster, the round-cap tier and the + // wave narrowing all follow it. Only the seam bound is gated. + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.postureCause).toBe('round'); + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'] }, + ]); + const diff = writtenDiff() ?? ''; + expect(diff).toContain('diff --git a/b.ts b/b.ts'); + expect(diff).toContain('+y2'); + // The disclosure names why the bound stayed off, instead of reading as + // "no interaction file needed seam-bounding". + const err = producerMocks.writeStderrLine.mock.calls + .map((c) => String(c[0])) + .join('\n'); + expect(err).toContain( + 'merge-base continuity with the previous round is unproven', + ); + expect(err).not.toContain('no interaction file needed seam-bounding'); + }); + + it('no recorded merge base keeps the seam bound off until continuity is provable (#10136 R18-3)', async () => { + // Today's state: side files predate the stamp. The gate resolves false + // and whole-section republication remains the floor — the bound never + // engages on a premise it cannot prove, even with the posture on. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const B_SOURCE = + '//x\n//y\nconst pad = 1;\n' + + "import { added } from './a.js';\nadded();\n"; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return B_SOURCE; + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'] }, + ]); + expect(writtenDiff() ?? '').toContain('+y2'); + }); + + it('records and names the seam oracle as unavailable when no parser resolves (#10136 R18-2)', async () => { + // The review workflow's deployment: the CLI is installed globally, + // whose published dependency set is empty, and the base-branch + // checkout's devDependencies were never installed — so no + // `typescript` resolves at run time. Every interaction file + // republishes in full (the pre-bound behaviour), and the plan plus + // the capture note SAY the bound never ran, instead of reading as + // "no interaction file needed seam-bounding". + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const B_SOURCE = + '//x\n//y\nconst pad = 1;\n' + + "import { added } from './a.js';\nadded();\n"; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return B_SOURCE; + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + mergeBaseSha: BASE, + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + vi.mocked(loadTypeScript).mockReturnValueOnce(null); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.scope.seamOracle).toBe('unavailable'); + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'] }, + ]); + // Full republication — the file's non-seam hunk is still published. + expect(writtenDiff() ?? '').toContain('+y2'); + const err = producerMocks.writeStderrLine.mock.calls + .map((c) => String(c[0])) + .join('\n'); + expect(err).toContain('could not resolve a TypeScript parser'); + expect(err).not.toContain('no interaction file needed seam-bounding'); + }); + + it("stamps this round's merge base into the side file for the next round's gate (#10136 R18-3)", async () => { + // The carry chain: a published round records the base its diff was + // captured over, preserving the ledger's own fields, so the NEXT + // round's continuity gate has something to compare against. Written + // through the same write-temp-then-rename discipline the file's own + // writer uses. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ round: 7, findings: [], posted: 1 }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + await reportFor({ since: ANCHOR }); + + const stamp = producerMocks.writeFileSync.mock.calls.find(([path]) => + String(path).includes('qwen-review-pr-42-prev-ledger.json'), + ); + expect(stamp).toBeDefined(); + const written = JSON.parse(String(stamp?.[1])) as Record; + expect(written['mergeBaseSha']).toBe(BASE); + // The ledger's own fields survive the stamp. + expect(written['round']).toBe(7); + expect(written['posted']).toBe(1); + }); + + it('a retryable refusal does NOT stamp the base its discarded fallback was captured over (#10136 R18-3 round 19)', async () => { + // `capture-failed` publishes a fallback full range that the skill's + // same-round retry discards before any agent launches — publication + // there is not "a round reviewed it". If the stamp landed anyway, + // the retry's own continuity gate would pass on hunks no round ever + // published. Drive exactly that: the full range captures fine while + // the delta read fails, so the plan carries `diffPath !== null` + // beside `incremental.reason === 'capture-failed'`, and the side + // file's stamp must stay the OLD base. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + producerMocks.gitRaw.mockImplementation((...args: string[]) => { + if (args.includes(`${ANCHOR}..f00df00df00d`)) { + throw new Error('git diff timed out'); + } + if (args.includes(`${BASE}..f00df00df00d`)) { + return Buffer.from(FULL_DIFF); + } + return Buffer.from(''); + }); + const PREV_BASE = 'c'.repeat(40); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + mergeBaseSha: PREV_BASE, + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.effective).toBe(false); + expect(report.incremental.reason).toBe('capture-failed'); + expect(report.diffPath).not.toBeNull(); + // No stamp write reached the side file: the previous round's base + // survives for the retry's continuity gate. (In this mocked-fs + // harness the stamp's temp write is the observable half — the + // guarded code path never reaches it at all.) + const stamp = producerMocks.writeFileSync.mock.calls.find(([path]) => + String(path).includes('qwen-review-pr-42-prev-ledger.json'), + ); + expect(stamp).toBeUndefined(); + }); + + it('a nothing-to-narrow demotion still stamps — its full range IS reviewed (#10136 R18-3 round 19)', async () => { + // The guard keys on the RETRYABLE refusals only: `nothing-to-narrow` + // publishes a full range the round's agents DO consume (and SKILL.md + // forbids retrying it), so suppressing the stamp there would keep + // the bound permanently off on the long-lived PRs it exists for. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + // The delta carries a file the full range does not: the join fails + // closed, the anchor is refused `nothing-to-narrow`, and the full + // range publishes instead. + servesBothRanges( + FULL_DIFF, + `${FULL_DIFF}${[ + 'diff --git a/ghost.ts b/ghost.ts', + '--- a/ghost.ts', + '+++ b/ghost.ts', + '@@ -1,1 +1,2 @@', + ' keep', + '+added', + '', + ].join('\n')}`, + ); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ round: 7, findings: [], posted: 1 }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.effective).toBe(false); + expect(report.incremental.reason).toBe('nothing-to-narrow'); + const stamp = producerMocks.writeFileSync.mock.calls.find(([path]) => + String(path).includes('qwen-review-pr-42-prev-ledger.json'), + ); + expect(stamp).toBeDefined(); + expect(JSON.parse(String(stamp?.[1]))['mergeBaseSha']).toBe(BASE); + }); + + // The capture-time recovery of the operator's RECORDED floor (#10136 + // R1-5): the same record compose and submit read, bound to the same + // identity axes. Each test plants the CLI's own args record beside a side + // file that would otherwise resolve the posture. + function postureSideFile(path: unknown): string | null { + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + mergeBaseSha: BASE, + }); + } + return null; + } + + it('a recorded `--severity-floor suggestion` turns the posture off at capture (#10136)', async () => { + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const B_SOURCE = + '//x\n//y\nconst pad = 1;\n' + + "import { added } from './a.js';\nadded();\n"; + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return B_SOURCE; + const side = postureSideFile(path); + if (side !== null) return side; + // The session's own record: this PR, posture explicitly off. + if (String(path).endsWith('qwen-skill-args-review.txt')) { + return '42 --severity-floor suggestion'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + // The side file alone would resolve `round`; the record wins, and the + // round keeps the full shape: no posture, the interaction file + // republished in full with no seam census. + expect(report.incremental.posture).toBeUndefined(); + expect(report.incremental.postureCause).toBeUndefined(); + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'] }, + ]); + expect(writtenDiff() ?? '').toContain('+y2'); + }); + + it('a recorded `--severity-floor critical` engages the posture on an early anchored re-review (#10136)', async () => { + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) { + return "import { added } from './a.js';\nadded();\n"; + } + // Round 2 — years short of the schedule, no streak. + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + return JSON.stringify({ round: 1, findings: [], posted: 1 }); + } + if (String(path).endsWith('qwen-skill-args-review.txt')) { + return '42 --severity-floor critical'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.postureCause).toBe('explicit'); + expect(report.budget.reverseAuditRounds).toBe(5); + }); + + it("binds a URL-shaped record's host to the remote under review, as submit does (#10136 R1-12)", async () => { + // The record names a GHE host; no `--host` flag and no GH_HOST reach + // this capture. `resolveGhHost` alone reads github.com and the record + // would not bind — the operator's posture-off would be missed and the + // narrowed shape spent against it. The remote under review carries the + // host, and the chain reads it. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' + ? 'f00df00df00d' + : args[0] === 'remote' && args[1] === 'get-url' + ? 'git@ghe.example.com:acme/widgets.git\n' + : '', + ); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) { + return "import { added } from './a.js';\nadded();\n"; + } + const side = postureSideFile(path); + if (side !== null) return side; + if (String(path).endsWith('qwen-skill-args-review.txt')) { + return 'https://ghe.example.com/acme/widgets/pull/42 --severity-floor suggestion'; + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + const savedHost = process.env['GH_HOST']; + delete process.env['GH_HOST']; + try { + const report = await reportFor({ since: ANCHOR }); + expect(report.incremental.posture).toBeUndefined(); + expect(report.incremental.postureCause).toBeUndefined(); + } finally { + if (savedHost !== undefined) process.env['GH_HOST'] = savedHost; + } + + // The control: the same record with a remote on ANOTHER host does not + // bind, and the side file resolves the posture — the axis is live. + producerMocks.git.mockImplementation((...args: string[]) => + args[0] === 'rev-parse' + ? 'f00df00df00d' + : args[0] === 'remote' && args[1] === 'get-url' + ? 'git@github.com:acme/widgets.git\n' + : '', + ); + delete process.env['GH_HOST']; + try { + const report = await reportFor({ since: ANCHOR }); + expect(report.incremental.posture).toBe('critical'); + expect(report.incremental.postureCause).toBe('round'); + } finally { + if (savedHost !== undefined) process.env['GH_HOST'] = savedHost; + } + }); + + it('keeps a heavy interaction file unbounded so its invariant agents launch (#10136)', async () => { + // Heaviness is classified from the PUBLISHED slice. If the seam bound + // trimmed a file whose FULL-RANGE slice clears the heavy bar, the plan + // would flip it to non-heavy, `heavyFiles()` would drop it, and the + // invariant agents that read it whole from the worktree — the only + // auditors of hunks a backward base move smuggles in — would never + // launch, on exactly the rounds the bound runs. The bound therefore + // lifts wholesale for a heavy full-range slice: no seam record, every + // hunk republished, and the roster still owes the invariant agents. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + const heavyBulk = Array.from({ length: 800 }, (_, i) => `+heavy ${i}`); + const HEAVY_FULL = [ + 'diff --git a/a.ts b/a.ts', + '--- a/a.ts', + '+++ b/a.ts', + '@@ -1,3 +1,4 @@', + ' line', + '+added', + ' line2', + ' line3', + '', + 'diff --git a/b.ts b/b.ts', + '--- a/b.ts', + '+++ b/b.ts', + // Hunk on the seam (the import + its one use)… + '@@ -1,1 +1,2 @@', + " import { added } from './a.js';", + '+added();', + // …and a heavy hunk far from it. Full-range changedLines = 801. + '@@ -100,2 +101,802 @@', + ' ctx', + ...heavyBulk, + ' ctx2', + '', + ].join('\n'); + // fileLines 1102 — preLines = 1102 - 801 = 301 clears the heavy bar's + // pre-image floor beside the 801 changed lines. + const B_SOURCE = + "import { added } from './a.js';\nadded();\n" + + Array.from({ length: 1100 }, (_, i) => `filler ${i}`).join('\n'); + servesBothRanges(HEAVY_FULL, DELTA_DIFF); + // The plan report resolves post-image line counts via `git show + // :` — serve b.ts's content there too, or the plan sees a + // zero-line file and classifies it non-heavy whatever the bound did. + producerMocks.gitRaw.mockImplementation((...args: string[]) => + args[0] === 'show' && args[1] === 'f00df00df00d:b.ts' + ? Buffer.from(B_SOURCE) + : args.includes(`${ANCHOR}..f00df00df00d`) + ? Buffer.from(DELTA_DIFF) + : args.includes(`${BASE}..f00df00df00d`) + ? Buffer.from(HEAVY_FULL) + : Buffer.from(''), + ); + producerMocks.lstatSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return { isFile: () => true }; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) { + return B_SOURCE; + } + if (String(path).endsWith('qwen-review-pr-42-prev-ledger.json')) { + // Continuity proven (#10136 R18-3): the stamp matches this round's + // base, so the seam bound engages and the HEAVY exemption — not + // the continuity gate — is what lifts it. + return JSON.stringify({ + round: 7, + findings: [], + posted: 1, + floor: 'c', + mergeBaseSha: BASE, + }); + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental.posture).toBe('critical'); + // No seam record: the bound was lifted, so both hunks republish. + expect(report.incremental.scope.interaction).toEqual([ + { path: 'b.ts', importsChanged: ['a.ts'] }, + ]); + const diff = writtenDiff() ?? ''; + expect(diff).toContain('+added();'); + expect(diff).toContain('+heavy 0'); + expect(diff).toContain('+heavy 799'); + // The plan classifies it heavy from the published slice, and the roster + // requires the whole-file invariant agents for it. + const b = (report.files as Array<{ path: string; heavy: boolean }>).find( + (f) => f.path === 'b.ts', + ); + expect(b?.heavy).toBe(true); + const agentKeys = requiredAgents(report).map((a) => a.key); + expect(agentKeys).toContain('invariant-a--b.ts'); + expect(agentKeys).toContain('invariant-b--b.ts'); + expect(agentKeys).toContain('invariant-c--b.ts'); + }); + it('drops a widening candidate whose real path leaves the worktree', async () => { // Wiring, not the rule itself — `worktree-reader.test.ts` proves the rule // against a real filesystem, where the kernel does the resolving. What diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index 8f54ebc3a99..1b7ec7c9cb6 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -28,8 +28,14 @@ import type { CommandModule } from 'yargs'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { clearReviewWorktreeLeaseIfOwned, @@ -39,7 +45,7 @@ import { reviewLeasePath, } from '../../services/review-worktree-lease.js'; import { sanitizedGitEnv } from './lib/worktree.js'; -import { setGhHost } from './lib/gh.js'; +import { resolveGhHost, setGhHost } from './lib/gh.js'; import { getPlatformReader } from './lib/platform/registry.js'; import type { ReviewPlatformReader } from './lib/platform/types.js'; import { EFFORT_OPTION, type ReviewEffort } from './parse-args.js'; @@ -109,6 +115,12 @@ import { prebuildWorktree, type WorktreeDependencies, } from './lib/prebuild.js'; +import { + resolveCriticalPosture, + type CriticalPostureCause, +} from './lib/posture.js'; +import { recordedSeverityFloor } from './lib/authorization.js'; +import { parseRemoteUrl } from './lib/remote-match.js'; interface PrMetadata { headRefName: string; @@ -355,6 +367,18 @@ export interface IncrementalDecision { * at the seam rather than order a from-scratch re-review. */ scope?: IncrementalScope; + /** + * The round's posting posture, when the capture resolved it to + * critical-only (#10104) — present exactly beside an effective scope. It + * is what flips the round to the fix-audit shape: the territory fan-out + * regardless of the narrowed delta's size (`isFixAuditRound` in + * budget.ts), seam-bounded interaction republication, and the + * posture-narrowed reverse-audit schedule. + */ + posture?: 'critical'; + /** Which arm resolved it: the operator's recorded floor, the round + * schedule, or the latched flat-trend streak. */ + postureCause?: CriticalPostureCause; } /** Thrown when a probe could not answer — the git surface, not a verdict. */ @@ -1280,6 +1304,73 @@ async function runFetchPr(args: FetchPrArgs): Promise { `agents will have to fall back to running \`git diff\` themselves.`, ); } + // The round's posture, read at capture time (#10104). Only a round that + // is about to scope to an anchor asks: the fix-audit shape exists for + // the critical-only re-review of the commits since the last round, and + // without a usable anchor there is no "since". The floor is the CLI's + // own recorded invocation (never the orchestrator's account of it), and + // the side file is the same one compose's recovery reads next to this + // plan, so the prediction and the resolution share their facts. + let postureCause: CriticalPostureCause | null = null; + // The previous round's merge base, carried by the same side file the + // posture reads (#10136 R18-3). The seam bound sheds an interaction + // file's hunks on the premise that a prior round published them — + // which holds only while the merge base holds still between rounds: + // a backward base move smuggles hunks NO round ever published into + // the full-range slice, and the bound would drop them from every + // agent's view. `roster.ts` rules its own skip by the same premise + // ("the skip is off until the anchor can prove base continuity"). + // Null — no recorded base, or a malformed one — resolves the gate to + // false: the bound stays off until continuity is provable, and + // whole-section republication remains the floor. + let prevMergeBase: string | null = null; + if (anchor?.diffBase) { + let sideLedger: unknown = null; + try { + sideLedger = JSON.parse( + readFileSync( + join(dirname(out), `qwen-review-pr-${prNumber}-prev-ledger.json`), + 'utf8', + ), + ); + } catch { + sideLedger = null; + } + if ( + sideLedger !== null && + typeof sideLedger === 'object' && + !Array.isArray(sideLedger) + ) { + const carried = (sideLedger as Record)['mergeBaseSha']; + prevMergeBase = + typeof carried === 'string' && carried !== '' ? carried : null; + } + postureCause = resolveCriticalPosture({ + recordedFloor: recordedSeverityFloor({ + callerPr: Number(prNumber), + callerRepo: ownerRepo, + // The SAME evidence chain submit binds the recorded floor's host + // axis to (#10136): the explicit flag, else the host of the + // remote under review (the cwd origin submit's chain reads — the + // one already selected this fetch's platform above), else the gh + // fallback (GH_HOST, else github.com). `resolveGhHost` alone + // never yields a recorded Aone or GHE host, so a flagless capture + // of a URL-shaped record missed the operator's explicit + // `suggestion` — the one miss that spends the narrowed shape + // against an instruction to keep the full one. + callerHost: + (typeof args.host === 'string' && args.host.trim()) || + (remoteUrl ? parseRemoteUrl(remoteUrl)?.host : undefined) || + resolveGhHost(undefined), + defaultSeverityFloor: operatorReviewSettings().severityFloor, + // No `skillArgs` seam here, deliberately: the caller-supplied + // record path is honoured only with no session id present, and + // this command refuses to run without one (the lease needs it) — + // the seam would be dead code wearing a flag. + })?.floor, + sideLedger, + }); + } /** True when the FINAL published diff is the incremental delta. */ let scopedDelta = false; /** The PR's own hunks, narrowed to what changed since the anchor. */ @@ -1376,8 +1467,21 @@ async function runFetchPr(args: FetchPrArgs): Promise { anchor: anchor.diffBase ?? anchor.incremental.since, selection, readWorktree: containedWorktreeReader(wt), + // The bound's second gate (#10136 R18-3): base continuity. The + // posture alone does not prove the hunks it would shed were + // ever published — only a merge base that held still since the + // previous round does. Resolved here at capture, never by a + // later reader (incremental-scope.ts:100-101). + seamBound: + postureCause !== null && + prevMergeBase !== null && + prevMergeBase === mergeBaseSha, })), - (narrowed = assembleSections(selection, widened.paths)) === null) + (narrowed = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )) === null) ) { // `assembleSections` selects nothing only when the widened set names // no section the full capture carries, which the guards above already @@ -1388,6 +1492,35 @@ async function runFetchPr(args: FetchPrArgs): Promise { if (publish(narrowed)) { scopedDelta = true; anchor.incremental.scope = widened.scope; + if (postureCause !== null) { + anchor.incremental.posture = 'critical'; + anchor.incremental.postureCause = postureCause; + const bounded = widened.scope.interaction.filter( + (e) => e.seam !== undefined, + ); + const kept = bounded.reduce((n, e) => n + (e.seam?.kept ?? 0), 0); + const total = bounded.reduce((n, e) => n + (e.seam?.total ?? 0), 0); + const continuityProven = + prevMergeBase !== null && prevMergeBase === mergeBaseSha; + writeStderrLine( + `Critical posture (${postureCause}): fix-audit round shape — ` + + `territory fan-out over the delta, ` + + (bounded.length > 0 + ? `interaction files seam-bounded to ${kept} of ${total} hunk(s).` + : widened.scope.seamOracle === 'unavailable' + ? `interaction files republished in full — the seam ` + + `oracle could not resolve a TypeScript parser at run ` + + `time, so the bound never ran.` + : continuityProven || widened.scope.interaction.length === 0 + ? `no interaction file needed seam-bounding.` + : `interaction files republished in full — merge-base ` + + `continuity with the previous round is unproven (${ + prevMergeBase === null + ? 'no base recorded yet' + : 'the base moved' + }), so the seam bound stayed off.`), + ); + } // The published hunks are byte-identical hunks of // `mergeBaseSha..head`, so that range is what downstream consumers // recomputing their own diffs must probe (Agent 7's test-efficacy @@ -1716,12 +1849,78 @@ async function runFetchPr(args: FetchPrArgs): Promise { ...buildPlanReport(plan, (path) => fileLineCount(fetchedSha, path), { operatorRoundCap: operatorReviewSettings().reverseAuditRounds, hasDeadline: hasReviewDeadline(process.env), + ...(anchor ? { incremental: anchor.incremental } : {}), }), ...planEffortField(args.effort), }; writeFileSync(out, stringifyPlanReport(result), 'utf8'); + // Stamp the merge base this round's published diff was captured over + // into the side file, so the NEXT round's seam bound can prove base + // continuity (#10136 R18-3): the bound sheds an interaction file's + // hunks on the premise a prior round published them, which holds only + // while the merge base holds still between rounds. Stamped exactly + // when a diff was published AND the round is not a retryable refusal + // (#10136 R18-3 round 19): `capture-failed`/`base-untrusted` publish + // a fallback full range that SKILL.md's same-round retry discards + // before any agent launches, so publication there is not a proxy for + // "a round reviewed it" — a stamp off one would let the retry's own + // continuity gate pass on hunks no round ever published. The + // non-retryable refusals (`nothing-to-narrow`, `partition-failed`, + // the deterministic anchor refusals) publish a full range the + // round's agents DO consume, so they stamp. Best-effort and + // write-temp-then-rename like the file's own writer + // (`persistRecoveredLedger`): a torn write must never restart the + // round id space the file carries, and a failed stamp simply keeps + // the next round's bound off. + const retryableRefusal = + anchor !== null && + anchor.incremental.effective === false && + (anchor.incremental.reason === 'capture-failed' || + anchor.incremental.reason === 'base-untrusted'); + if (diffPath !== null && mergeBaseSha !== null && !retryableRefusal) { + const sideFile = join( + dirname(out), + `qwen-review-pr-${prNumber}-prev-ledger.json`, + ); + try { + let carried: Record = {}; + try { + const parsed: unknown = JSON.parse(readFileSync(sideFile, 'utf8')); + if ( + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + carried = parsed as Record; + } + } catch { + // No readable file yet — the stamp is the only field written. + } + if (carried['mergeBaseSha'] !== mergeBaseSha) { + const tmp = `${sideFile}.${process.pid}.tmp`; + writeFileSync( + tmp, + JSON.stringify({ ...carried, mergeBaseSha }, null, 2), + 'utf8', + ); + try { + renameSync(tmp, sideFile); + } catch (err) { + try { + rmSync(tmp, { force: true }); + } catch { + // Debris removal is best-effort. + } + throw err; + } + } + } catch { + // Best-effort: the next round's bound stays off, never wrong. + } + } + // 6. Prebuild the worktree — install and compile it through Agent 7's // own `build-test` — when this run asked for it (CI does; issue // #10108). After the plan write, because `build-test` reads the plan diff --git a/packages/cli/src/commands/review/lib/budget.test.ts b/packages/cli/src/commands/review/lib/budget.test.ts index 76a05a74543..83703fe4946 100644 --- a/packages/cli/src/commands/review/lib/budget.test.ts +++ b/packages/cli/src/commands/review/lib/budget.test.ts @@ -17,6 +17,9 @@ import { reverseAuditRoundTier, cappedRoundTier, reviewBudget as deriveReviewBudget, + isFixAuditRound, + isTerritoryFanOut, + interactionEntryOf, type BudgetContext, type BudgetInput, } from './budget.js'; @@ -1266,3 +1269,169 @@ describe('the huge reduction applies only where there is a wall to fit inside', expect(reverseAuditRoundCap(withClock, false)).toBe(3); // in [3,5], honoured }); }); + +describe('isFixAuditRound and the topology override (#10104)', () => { + const POSTURE = { + incremental: { + since: 'a'.repeat(40), + effective: true, + posture: 'critical', + scope: { anchor: 'a'.repeat(40), deltaFiles: ['x.ts'], interaction: [] }, + }, + }; + + it('reads the posture only from a well-formed effective block', () => { + expect(isFixAuditRound(POSTURE)).toBe(true); + expect(isFixAuditRound({})).toBe(false); + expect(isFixAuditRound({ incremental: null })).toBe(false); + expect( + isFixAuditRound({ + incremental: { ...POSTURE.incremental, effective: false }, + }), + ).toBe(false); + expect( + isFixAuditRound({ + incremental: { ...POSTURE.incremental, scope: undefined }, + }), + ).toBe(false); + expect( + isFixAuditRound({ + incremental: { ...POSTURE.incremental, posture: 'CRITICAL' }, + }), + ).toBe(false); + }); + + it('refuses the scopes the brief builder refuses — one bar across readers', () => { + // `incrementalScopeOf` degrades to full scope when the delta list is + // empty beside an empty interaction list, and when it carries a + // non-string element; the shape readers must refuse the same plans, or + // the roster drops Agent 0 while every brief runs full-scope. + const scope = POSTURE.incremental.scope; + const divergent = [ + { ...scope, deltaFiles: [], interaction: [] }, + { ...scope, deltaFiles: ['x.ts', 42] }, + { ...scope, deltaFiles: [''] }, + ]; + for (const s of divergent) { + expect( + isFixAuditRound({ + incremental: { ...POSTURE.incremental, scope: s }, + }), + ).toBe(false); + } + // …and neither shape flips the topology or the round-cap tier. + const small = { srcDiffLines: 120, diffLines: 400 }; + expect( + isTerritoryFanOut({ + ...small, + incremental: { ...POSTURE.incremental, scope: divergent[0] }, + }), + ).toBe(false); + expect( + reverseAuditRoundTier( + { + ...small, + incremental: { ...POSTURE.incremental, scope: divergent[1] }, + }, + false, + ), + ).toBe(10); + + // …but a delta list mixing a valid path with an empty string is one the + // brief builder ACCEPTS — `incrementalScopeOf` rejects only non-string + // elements and filters '' AFTER admission — so the shape readers must + // accept it too, or the briefs render an incremental frame while every + // isFixAuditRound reader goes full: the exact two-reader disagreement + // this bar exists to eliminate. + expect( + isFixAuditRound({ + incremental: { + ...POSTURE.incremental, + scope: { ...scope, deltaFiles: ['x.ts', ''] }, + }, + }), + ).toBe(true); + expect( + isTerritoryFanOut({ + ...small, + incremental: { + ...POSTURE.incremental, + scope: { ...scope, deltaFiles: ['x.ts', ''] }, + }, + }), + ).toBe(true); + }); + + it('flips a small plan into the territory fan-out', () => { + const small = { srcDiffLines: 120, diffLines: 400 }; + expect(isTerritoryFanOut(small)).toBe(false); + expect(isTerritoryFanOut({ ...small, ...POSTURE })).toBe(true); + }); + + it('prices the round cap at the territory tier it flips to', () => { + const small = { srcDiffLines: 120, diffLines: 400 }; + expect(reverseAuditRoundTier(small, false)).toBe(10); + expect(reverseAuditRoundTier({ ...small, ...POSTURE }, false)).toBe(5); + // The huge finishability ruling still wins where there is a wall. + const huge = { srcDiffLines: 4000, diffLines: 5000, ...POSTURE }; + expect(reverseAuditRoundTier(huge, true)).toBe(3); + }); + + it('stamps the flipped tier into the recorded budget', () => { + const small = { srcDiffLines: 120, diffLines: 400 }; + expect(reviewBudget(small, {}).reverseAuditRounds).toBe(10); + expect( + reviewBudget(small, { incremental: POSTURE.incremental }) + .reverseAuditRounds, + ).toBe(5); + }); +}); + +describe('interactionEntryOf — one admission for every census reader (#10136)', () => { + it('admits a well-formed entry with its census', () => { + expect( + interactionEntryOf({ + path: 'src/b.ts', + importsChanged: ['src/a.ts', '', 7], + seam: { kept: 1, total: 3 }, + }), + ).toEqual({ + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam: { kept: 1, total: 3 }, + }); + }); + + it('keeps the entry and drops a census that cannot be true', () => { + for (const seam of [ + { kept: 5, total: 2 }, + { kept: -1, total: 2 }, + { kept: 1.5, total: 2 }, + { kept: '1', total: 2 }, + null, + 'garbled', + ]) { + expect( + interactionEntryOf({ + path: 'src/b.ts', + importsChanged: ['src/a.ts'], + seam, + }), + ).toEqual({ path: 'src/b.ts', importsChanged: ['src/a.ts'] }); + } + }); + + it('refuses an entry the briefs would not render', () => { + for (const raw of [ + null, + 'src/b.ts', + { importsChanged: ['src/a.ts'] }, + { path: '', importsChanged: ['src/a.ts'] }, + { path: 'src/b.ts' }, + { path: 'src/b.ts', importsChanged: [] }, + { path: 'src/b.ts', importsChanged: ['', 3] }, + ]) { + expect(interactionEntryOf(raw)).toBeNull(); + } + }); +}); diff --git a/packages/cli/src/commands/review/lib/budget.ts b/packages/cli/src/commands/review/lib/budget.ts index 09edefea46b..8db4d136eb5 100644 --- a/packages/cli/src/commands/review/lib/budget.ts +++ b/packages/cli/src/commands/review/lib/budget.ts @@ -65,6 +65,13 @@ export interface BudgetContext { * all; see `HUGE_REVERSE_AUDIT_ROUNDS`. */ hasDeadline?: boolean; + /** + * The capture command's incremental ruling, when it made one — so the + * recorded round cap reads the SAME topology the fix-audit posture flips + * (`isFixAuditRound`), instead of stamping the small tier's ten rounds on + * a plan whose builder will enforce the territory tier's five. + */ + incremental?: unknown; } export interface ReviewBudget { @@ -239,6 +246,114 @@ const FAN_OUT_TOTAL_FLOOR = 3200; export interface DiffSize { srcDiffLines?: unknown; diffLines?: unknown; + /** + * The capture command's incremental ruling, carried so the fix-audit + * posture can reach the topology gate — see `isFixAuditRound`. + */ + incremental?: unknown; +} + +/** + * Is this plan a critical-posture **fix-audit round** — an incremental + * re-review whose capture resolved the round's posting posture to + * critical-only (issue #10104)? + * + * The fact is read from the plan, never from a caller argument, for the + * reason `effort` is: a shape the caller could assert is a shape that gets + * asserted. The capture command (`fetch-pr`) writes `incremental.posture` + * exactly when the posture resolved AND the round scoped to the anchor, and + * this reader additionally requires the scope to be present — a hand-edited + * plan claiming the posture over a full-range diff must not shrink the + * roster below what the full range is owed. Everything malformed reads as + * "not a fix-audit round", which is the ordinary full shape — the safe + * direction. + * + * It lives here beside `isTerritoryFanOut` because this module must stay + * import-free; the topology gate and the round-cap tier read it here, and + * the floor's fix-audit arm plus the brief builder's posture frame read it + * back directly. + */ +export function isFixAuditRound(plan: { incremental?: unknown }): boolean { + const inc = plan?.incremental; + if (typeof inc !== 'object' || inc === null) return false; + const rec = inc as { + posture?: unknown; + effective?: unknown; + scope?: unknown; + }; + if (rec.posture !== 'critical' || rec.effective !== true) return false; + // The scope must pass the bar the brief builder applies — a non-empty + // anchor and a delta list that IS one — or a posture beside a scope + // `incrementalScopeOf` rejects would shrink the roster while the briefs + // degrade to full-scope, two readers disagreeing about one plan. The bar + // meets the builder's OWN reading of that list: `incrementalScopeOf` + // rejects only non-string elements and filters '' AFTER admission, so a + // list mixing a valid path with an empty string renders an incremental + // frame there and must engage the posture here (an all-or-nothing bar let + // the briefs narrow while every reader below went full). The bar is + // stricter than the builder's only where the builder would still render + // an incremental frame (an interaction-only scope): that then reads as + // the ordinary full shape — the safe direction, as always here. + const scope = rec.scope as + | { anchor?: unknown; deltaFiles?: unknown } + | null + | undefined; + return ( + typeof scope === 'object' && + scope !== null && + typeof scope.anchor === 'string' && + scope.anchor !== '' && + Array.isArray(scope.deltaFiles) && + scope.deltaFiles.every((p) => typeof p === 'string') && + scope.deltaFiles.some((p) => typeof p === 'string' && p !== '') + ); +} + +/** + * One admission for a plan's `incremental.scope.interaction[]` entry, shared + * by every reader of the census (#10136): the brief builder (which renders + * the entry's scope class and seam bound) and `compose-review`'s + * round-shape disclosure. Two readers with two bars let the body count a + * seam census on an entry the briefs never rendered. (The roster reads the + * plan through `isFixAuditRound` alone and never opens the entries.) + * + * `null` for anything the briefs would not render: no path, no surviving + * `importsChanged` edge (an entry IS its edge — "because it imports , + * which changed" is a seam pointing at nothing). The seam census rides + * along only when it can be true: integers, `0 <= kept <= total`; a census + * that cannot be ("5 of 2 republished") is dropped, the entry kept. Lives + * here beside `isFixAuditRound` for the same reason it does: this module is + * import-free, so every reader can reach it without a cycle. + */ +export function interactionEntryOf(raw: unknown): { + path: string; + importsChanged: string[]; + seam?: { kept: number; total: number }; +} | null { + if (typeof raw !== 'object' || raw === null) return null; + const e = raw as { path?: unknown; importsChanged?: unknown; seam?: unknown }; + if (typeof e.path !== 'string' || e.path === '') return null; + const importsChanged = Array.isArray(e.importsChanged) + ? e.importsChanged.filter( + (p): p is string => typeof p === 'string' && p !== '', + ) + : []; + if (importsChanged.length === 0) return null; + const rawSeam = e.seam; + const seam = + typeof rawSeam === 'object' && + rawSeam !== null && + Number.isInteger((rawSeam as { kept?: unknown }).kept) && + Number.isInteger((rawSeam as { total?: unknown }).total) && + ((rawSeam as { kept: number }).kept as number) >= 0 && + ((rawSeam as { total: number }).total as number) >= + ((rawSeam as { kept: number }).kept as number) + ? { + kept: (rawSeam as { kept: number }).kept, + total: (rawSeam as { total: number }).total, + } + : undefined; + return { path: e.path, importsChanged, ...(seam ? { seam } : {}) }; } /** @@ -253,6 +368,14 @@ export interface DiffSize { * own, so the direction cannot cycle). */ export function isTerritoryFanOut(plan: DiffSize): boolean { + // A fix-audit round keeps the territory shape whatever its size: the + // narrowed delta is usually 3A-sized, but the round's whole point is one + // accountable auditor per territory of the fix commits, not fourteen + // dimension lenses re-walking a delta the posture defers all but Critical + // findings on. Ruling it HERE keeps the one-predicate contract — the + // roster, the round-cap tier, and the topology-mismatch note all read this + // gate, so none of them can disagree about which fan-out the round owed. + if (isFixAuditRound(plan)) return true; const src = Number(plan?.srcDiffLines ?? 0); const total = Number(plan?.diffLines ?? 0); return !(src <= FAN_OUT_SRC_FLOOR && total <= FAN_OUT_TOTAL_FLOOR); @@ -309,8 +432,13 @@ export function reverseAuditRoundTier( // give the huge gate and the topology gate two independent derivations of // the same two numbers inside one function — which is exactly the shape of // the defect this function was just repaired for, where one derivation - // laundered garbage the other rejected. - return isTerritoryFanOut({ srcDiffLines: src, diffLines: total }) + // laundered garbage the other rejected. The incremental ruling rides along + // untouched: it is not a size, and the gate validates it itself. + return isTerritoryFanOut({ + srcDiffLines: src, + diffLines: total, + incremental: size?.incremental, + }) ? LARGE_REVERSE_AUDIT_ROUNDS : SMALL_REVERSE_AUDIT_ROUNDS; } @@ -465,7 +593,7 @@ export function reviewBudget( // size failed to arrive, where the flat cap recorded five. The tier does // its own usability check precisely so this call can hand it the truth. reverseAuditRounds: cappedRoundTier( - input, + { ...input, incremental: context.incremental }, context.operatorRoundCap, context.hasDeadline === true, ), diff --git a/packages/cli/src/commands/review/lib/import-graph.test.ts b/packages/cli/src/commands/review/lib/import-graph.test.ts index 3435e8a8ef0..0ea77954ca9 100644 --- a/packages/cli/src/commands/review/lib/import-graph.test.ts +++ b/packages/cli/src/commands/review/lib/import-graph.test.ts @@ -10,12 +10,27 @@ // and the fail-quiet misses, because a resolver that silently resolved OUTSIDE // the membership set would widen the scope with files nobody planned. +import { + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; import { describe, it, expect } from 'vitest'; import { scanImportSpecifiers, resolveSpecifier, dependentsOfChanged, discoverWorkspacePackages, + defaultTypeScriptBases, + loadTypeScript, + seamLines, } from './import-graph.js'; describe('scanImportSpecifiers', () => { @@ -355,3 +370,1133 @@ describe('discoverWorkspacePackages', () => { } }); }); + +describe('seamLines', () => { + const changed = new Set(['src/changed.ts']); + + it('marks the import statement and every use of its bindings', () => { + const source = [ + "import { moved, other as alias } from './changed.js';", // 1 + "import { untouched } from './stable.js';", // 2 + 'const a = moved();', // 3 + 'const b = untouched();', // 4 + 'function f() {', // 5 + ' return alias + a;', // 6 + '}', // 7 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 3, 6]); + }); + + it('follows a namespace import through its alias', () => { + const source = [ + "import * as ns from './changed.js';", // 1 + 'const x = 1;', // 2 + 'ns.moved(x);', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 3]); + }); + + it('marks every line a multi-line statement spans', () => { + const source = [ + 'import {', // 1 + ' moved, // a comment inside the clause', // 2 + "} from './changed.js';", // 3 + 'moved();', // 4 + 'const x = 1;', // 5 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3, 4]); + }); + + it('a binding USE marks every line its statement spans — the multi-line call (#10136 R18-1)', () => { + // The fixture from the finding: the import sits on line 1, the call + // on line 3, and the call's arguments span lines 4-9. A single-line + // mark over `moved` leaves [6,12]-shaped hunks inside the call + // shed while the census certifies `kept: 0` — the use must mark the + // whole statement, argument lines included. + const source = [ + "import { moved } from './changed.js';", // 1 + 'const head = 1;', // 2 + 'moved(', // 3 + ' alpha,', // 4 + ' beta,', // 5 + ' {', // 6 + ' gamma: 1,', // 7 + ' delta: 2,', // 8 + ' zeta: 6,', // 9 + ' },', // 10 + ');', // 11 + 'const tail = 2;', // 12 + ].join('\n'); + const got = seamLines('src/imp.ts', source, changed); + expect(got).toEqual([1, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + // …and reverting to the identifier's own line reds exactly here: the + // inner argument lines 6-10 must be in the set, not just the callee's. + for (const line of [6, 7, 8, 9, 10]) { + expect(got).toContain(line); + } + }); + + it('binds a require destructuring, on one line or across lines', () => { + const one = [ + "const { moved, other: alias } = require('./changed.js');", // 1 + 'alias(moved);', // 2 + 'const x = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', one, changed)).toEqual([1, 2]); + const spread = [ + 'const {', // 1 + ' moved,', // 2 + "} = require('./changed.js');", // 3 + 'moved();', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', spread, changed)).toEqual([1, 2, 3, 4]); + }); + + it('bounds $-carrying identifiers by the grammar, in both directions', () => { + const source = [ + "import { store$, $init } from './changed.js';", // 1 + 'store$.x = 1;', // 2 + 'const other = 1;', // 3 + '$init();', // 4 + 'const not$init = 1;', // 5 + 'const $initial = 2;', // 6 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 4]); + }); + + it('a keyword inside a comment or a string is not a keyword (#10136)', () => { + const source = [ + 'import {', // 1 + ' moved, // export me', // 2 + "} from './changed.js'; // import { x } from './changed.js'", // 3 + 'const s = \'import { y } from "./changed.js"\';', // 4 + 'moved();', // 5 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3, 5]); + }); + + it('a specifier inside a string is one reader short of a census — doubt (#10136 R18-1)', () => { + // The parser correctly reads the string as a string — but the + // entrance regex (`scanImportSpecifiers`) does not, and a file + // ENTERS interaction on that regex: a confident `[]` here sheds the + // file on an edge the census never saw. The cross-check exists for + // exactly this disagreement: any specifier the regex can see + // resolving into `changed` that the parser walk never resolved is + // the doubt state, not "no seam". + const source = [ + 'export const note = "} from \'./changed.js\'";', // 1 + 'let unrelated = 1;', // 2 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toBeNull(); + // …and the control: a string naming a file OUTSIDE the change set + // carries no edge either reader can see — no seam, no doubt. + const elsewhere = [ + 'export const note = "} from \'./elsewhere.js\'";', // 1 + 'let unrelated = 1;', // 2 + ].join('\n'); + expect(seamLines('src/imp.ts', elsewhere, changed)).toEqual([]); + }); + + it('a dynamic import received by a declaration binds its names', () => { + const awaited = [ + "const api = await import('./changed.js');", // 1 + 'api.call();', // 2 + 'const x = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', awaited, changed)).toEqual([1, 2]); + const chained = [ + "const mod = (await import('./changed.js'))!.value as Mod;", // 1 + 'mod.run();', // 2 + 'let y = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', chained, changed)).toEqual([1, 2]); + const destructured = [ + "const { moved } = (await import('./changed.js')) ?? fallback;", // 1 + 'moved();', // 2 + ].join('\n'); + expect(seamLines('src/imp.ts', destructured, changed)).toEqual([1, 2]); + // A branch of a conditional still lands in the declaration. + const branch = [ + "const v = cond ? 1 : require('./changed.js').moved;", // 1 + 'let x = v;', // 2 + 'let y = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', branch, changed)).toEqual([1, 2]); + }); + + it('a require or dynamic import whose value escapes into an expression fails closed (#10136)', () => { + for (const source of [ + "foo(require('./changed.js'));\nlet x = 1;", + "export = require('./changed.js');\nlet x = 1;", + // The un-awaited promise hands the module to a callback defined + // anywhere — `handler`'s body is where the uses live. + "import('./changed.js').then(handler);\nlet x = 1;", + "import('./changed.js').then((m) => m.call());\nlet x = 1;", + "const p = import('./changed.js').catch(log);\nlet x = 1;", + // An argument on the way to a declaration is still an escape: the + // value passed through `wrap` before anything received it. + "const mod = wrap(await import('./changed.js')).value;\nlet x = 1;", + "function f() { return require('./changed.js'); }\nlet x = 1;", + "const list = [require('./changed.js')];\nlet x = 1;", + "const o = { m: require('./changed.js') };\nlet x = 1;", + "for (const m of require('./changed.js')) {}\nlet x = 1;", + "obj['m'] = require('./changed.js');\nlet x = 1;", + // A still-wrapped promise STORED by a binding (#10136 R9-1 round + // 16): the callback body that eventually uses the module is + // written anywhere — a use the name read cannot account for, at + // each binding terminal shape. + "const p = import('./changed.js');\np.then(handler);", + "let p;\np = import('./changed.js');\np.then(handler);", + "class A {\n p = import('./changed.js');\n run() {\n this.p.then((m) => {\n m.call();\n });\n }\n}", + // …and the assignment inside an awaited expression binds the + // promise for `n`, not the module: awaiting AROUND the assignment + // does not unwrap what the assignment stored. + "const m = await (n = import('./changed.js'));\nlet x = 1;", + ]) { + expect(seamLines('src/imp.ts', source, changed)).toBeNull(); + } + // The non-binding terminals stay as they are: a promise that binds + // nothing still just marks its own statement. + expect( + seamLines('src/imp.ts', "void import('./changed.js');", changed), + ).toEqual([1]); + }); + + it('a statement that receives nothing marks its own lines and binds nothing', () => { + for (const first of [ + "import './changed.js';", + "require('./changed.js');", + "await import('./changed.js');", + "void import('./changed.js');", + "require('./changed.js').init();", + "(await import('./changed.js')).init();", + ]) { + expect( + seamLines('src/imp.ts', `${first}\nconst moved = 1;`, changed), + ).toEqual([1]); + } + }); + + it('every receiver along an assignment chain binds (#10136 R9-1 round 17)', () => { + const cases: Array<[string[], number[]]> = [ + [ + [ + 'let cache;', // 1 + "const m = cache ?? (cache = require('./changed.js'));", // 2 + 'm.run();', // 3 + 'cache.run();', // 4 + 'let x = 1;', // 5 + ], + [1, 2, 3, 4], + ], + [ + [ + 'let a, b;', // 1 + "a = b = require('./changed.js');", // 2 + 'a.run();', // 3 + 'b.run();', // 4 + 'let x = 1;', // 5 + ], + [1, 2, 3, 4], + ], + [ + [ + 'let n;', // 1 + "const m = cond ? (n = require('./changed.js')) : null;", // 2 + 'm.run(); n.run();', // 3 + 'let x = 1;', // 4 + ], + [1, 2, 3], + ], + [ + [ + "exports.moved = require('./changed.js');", // 1 + 'exports.moved.run();', // 2 + 'let x = 1;', // 3 + ], + [1, 2], + ], + [ + [ + 'class A {', // 1 + " moved = require('./changed.js');", // 2 + ' run() {', // 3 + ' this.moved.go();', // 4 + ' }', // 5 + '}', // 6 + ], + [2, 4], + ], + [ + [ + 'let moved;', // 1 + "moved ??= require('./changed.js');", // 2 + 'moved.run();', // 3 + 'let x = 1;', // 4 + ], + [1, 2, 3], + ], + [ + [ + "const { moved = require('./changed.js') } = opts;", // 1 + 'moved.run();', // 2 + 'let x = 1;', // 3 + ], + [1, 2], + ], + ]; + for (const [lines, expected] of cases) { + expect(seamLines('src/imp.ts', lines.join('\n'), changed)).toEqual( + expected, + ); + } + }); + + it("a JavaScript caller's JSDoc is walked — its types live there (#10136 R9-1 round 17)", () => { + const typed = [ + "/** @type {import('./changed.js').Foo} */", // 1 — the tag, not `let v` + 'let v;', // 2 + "/** @typedef {import('./changed.js').Bar} Local */", // 3 + '/** @param {Local} p */', // 4 — the alias USE (#10136 R17-3) + 'function f(p) {}', // 5 + 'let x = 1;', // 6 + ].join('\n'); + // Line 4 too: the typedef binds a local ALIAS of the imported type, + // so a file that types everything through the alias no longer marks + // nothing past the typedef line. + expect(seamLines('src/imp.js', typed, changed)).toEqual([1, 3, 4]); + // A QUALIFIED alias (`ns.Bar`) binds every identifier of the name — + // dropping it left the alias's uses unmarked beside a specifier the + // cross-check had seen: a confident under-read (#10136 R18-1 round 19). + const qualified = [ + "/** @typedef {import('./changed.js').Bar} ns.Bar */", // 1 + '/** @type {ns.Bar} */', // 2 + 'const w = make();', // 3 + ].join('\n'); + expect(seamLines('src/imp.js', qualified, changed)).toEqual([1, 2]); + const imported = [ + "/** @import { Foo } from './changed.js' */", // 1 + '', // 2 + '/** @type {Foo} */', // 3 + 'export let v;', // 4 + 'let x = 1;', // 5 + ].join('\n'); + expect(seamLines('src/imp.js', imported, changed)).toEqual([1, 3]); + const used = [ + "const changed = require('./changed.js');", // 1 + '/** @type {changed.Foo} */', // 2 + 'let v;', // 3 + '/** @param {typeof changed} c */', // 4 + 'function g(c) {}', // 5 + ].join('\n'); + expect(seamLines('src/imp.js', used, changed)).toEqual([1, 2, 4]); + }); + + it('JSDoc carried by an import or export statement is walked too (round 3)', () => { + const jsChanged = new Set(['src/changed.js']); + const aboveImport = [ + "/** @import { Foo } from './changed.js' */", // 1 + "import { bar } from './bar.js';", // 2 + '', // 3 + '/** @param {Foo} f */', // 4 + 'export function use(f) {}', // 5 + ].join('\n'); + expect(seamLines('src/imp.js', aboveImport, jsChanged)).toEqual([1, 4]); + const aboveReexport = [ + "/** @typedef {import('./changed.js').Options} Options */", // 1 + "export * from './other.js';", // 2 + ].join('\n'); + expect(seamLines('src/imp.js', aboveReexport, jsChanged)).toEqual([1]); + const aboveLocalExport = [ + 'const x = 1;', // 1 + "/** @typedef {import('./changed.js').T} T */", // 2 + 'export { x };', // 3 + ].join('\n'); + expect(seamLines('src/imp.js', aboveLocalExport, jsChanged)).toEqual([2]); + const aboveResolving = [ + "/** @import { Foo } from './changed.js' */", // 1 + "import { bar } from './changed.js';", // 2 + '/** @type {Foo} */', // 3 + 'let v = bar;', // 4 + ].join('\n'); + expect(seamLines('src/imp.js', aboveResolving, jsChanged)).toEqual([ + 1, 2, 3, 4, + ]); + // A parser too old to read `@import` (TS < 5.5) hands the tag over + // unread: doubt, not "no match". + const older = new Proxy(ts, { + get: (target, prop) => + prop === 'isJSDocImportTag' + ? undefined + : (target as unknown as Record)[prop], + }) as unknown as typeof ts; + expect( + seamLines('src/imp.js', aboveImport, jsChanged, [], older), + ).toBeNull(); + }); + + it('a private field receives a require, and its uses are read by name (round 3)', () => { + const source = [ + 'class C {', // 1 + ' #m;', // 2 + ' constructor() {', // 3 + " this.#m = require('./changed.js');", // 4 + ' }', // 5 + ' run() {', // 6 + ' this.#m.run();', // 7 + ' return this.#m;', // 8 + ' }', // 9 + ' static is(o) {', // 10 + ' return #m in o;', // 11 + ' }', // 12 + '}', // 13 + ].join('\n'); + expect( + seamLines('src/imp.js', source, new Set(['src/changed.js'])), + ).toEqual([2, 4, 7, 8, 11]); + const field = [ + 'class D {', // 1 + " #moved = require('./changed.js');", // 2 + ' go() {', // 3 + ' this.#moved.go();', // 4 + ' }', // 5 + '}', // 6 + ].join('\n'); + expect(seamLines('src/imp.ts', field, changed)).toEqual([2, 4]); + }); + + it('an object or array assignment pattern receives a require element by element (round 3)', () => { + const source = [ + 'let moved, other, rest, first;', // 1 + "({ moved, other: other, ...rest } = require('./changed.js'));", // 2 + "[first = 1, , ...rest] = require('./changed.js');", // 3 + 'moved(); other(); rest.x; first;', // 4 + 'let x = 1;', // 5 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3, 4]); + const awaited = [ + 'let moved;', // 1 + "({ moved } = await import('./changed.js'));", // 2 + 'moved();', // 3 + 'export {};', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', awaited, changed)).toEqual([1, 2, 3]); + // A target the name read cannot follow: doubt. + expect( + seamLines( + 'src/imp.ts', + "({ moved: obj['k'] } = require('./changed.js'));\nlet x = 1;", + changed, + ), + ).toBeNull(); + }); + + it('a template or parenthesised specifier is a literal, a computed one is doubt', () => { + for (const source of [ + 'const { moved } = require(`./changed.js`);\nmoved();\nlet x = 1;', + "const { moved } = require(('./changed.js'));\nmoved();\nlet x = 1;", + 'const { moved } = await import(`./changed.js`);\nmoved();\nlet x = 1;', + ]) { + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2]); + } + expect( + seamLines( + 'src/imp.ts', + 'let x: import(`./changed.js`).Foo;\nlet y = 1;', + changed, + ), + ).toEqual([1]); + // A type import nested inside another's type arguments is reached. + expect( + seamLines( + 'src/imp.ts', + "let x: import('./stable.js').Foo;\nlet y = 1;", + changed, + ), + ).toEqual([1]); + }); + + it("a type position's import('…') marks its member, not the class around it", () => { + const source = [ + 'class Big {', // 1 + ' a() {}', // 2 + " b(): import('./changed.js').Foo {", // 3 + ' return make();', // 4 + ' }', // 5 + ' c() {}', // 6 + '}', // 7 + 'interface I {', // 8 + " d: import('./changed.js').Bar;", // 9 + ' e: number;', // 10 + '}', // 11 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([3, 4, 5, 9]); + }); + + it('a parser build missing an entry point makes the read doubt, never throw (#10136)', () => { + // A `satisfies` on the receiver path: the full parser passes through + // it and binds `m`; a build without `isSatisfiesExpression` (TS 4.8 + // and older) cannot follow the path and doubts — never throws. + const source = [ + "const m = require('./changed.js') satisfies X;", // 1 + 'm.run();', // 2 + 'let y = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2]); + const older = new Proxy(ts, { + get: (target, prop) => + prop === 'isSatisfiesExpression' + ? undefined + : (target as unknown as Record)[prop], + }) as unknown as typeof ts; + expect(seamLines('src/imp.ts', source, changed, [], older)).toBeNull(); + // A build whose walk throws mid-way: the doubt state, not a crash. + const throwing = new Proxy(ts, { + get: (target, prop) => + prop === 'isCallExpression' + ? () => { + throw new Error('boom'); + } + : (target as unknown as Record)[prop], + }) as unknown as typeof ts; + expect( + seamLines('src/imp.ts', 'let x = 1;\nlet y = 2;', changed, [], throwing), + ).toBeNull(); + }); + + it('an assignment to an identifier receives a require', () => { + const source = [ + 'let moved;', // 1 + "moved = require('./changed.js');", // 2 + 'moved.run();', // 3 + 'let x = 1;', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3]); + }); + + it('a computed specifier is a read the oracle cannot prove', () => { + for (const source of [ + 'const m = require(name);\nlet x = 1;', + 'const m = await import(`./${name}.js`);\nlet x = 1;', + ]) { + expect(seamLines('src/imp.ts', source, changed)).toBeNull(); + } + }); + + it('require by an alias, a comma sequence, or the property form is still require (#10136 R18-1)', () => { + // Each of these returned a confident `[]` where the plain-`require` + // control correctly marked — the class the round-18 probe filed. + const control = seamLines( + 'src/imp.ts', + "const m = require('./changed.js');\nm.run();", + changed, + ); + expect(control).toEqual([1, 2]); + // A `createRequire` factory's product. + expect( + seamLines( + 'src/imp.js', + [ + 'const req = createRequire(import.meta.url);', // 1 + "const m = req('./changed.js');", // 2 + 'm.run();', // 3 + ].join('\n'), + changed, + ), + ).toEqual([2, 3]); + // The `(0, require)(…)` comma sequence. + expect( + seamLines( + 'src/imp.js', + "const m = (0, require)('./changed.js');\nm.run();", + changed, + ), + ).toEqual([1, 2]); + // The property form — `module.require(…)` is the same function. + expect( + seamLines( + 'src/imp.js', + "const m = module.require('./changed.js');\nm.run();", + changed, + ), + ).toEqual([1, 2]); + // An alias call with a COMPUTED specifier is the same doubt the + // plain form's computed specifier is. + expect( + seamLines( + 'src/imp.js', + 'const req = createRequire(import.meta.url);\nconst m = req(name);', + changed, + ), + ).toBeNull(); + // …and a factory whose own introduction escapes the receiver walk. + expect( + seamLines( + 'src/imp.js', + "register(createRequire(import.meta.url));\nconst m = req('./changed.js');", + changed, + ), + ).toBeNull(); + // A factory bound under a RENAMED local is the same factory — the + // import's/destructuring's/property's ORIGINAL name is + // `createRequire` whatever the local is (#10136 R18-1 round 19). + // None of these may answer a confident `[]`. + expect( + seamLines( + 'src/imp.mjs', + [ + "import { createRequire as cjsRequire } from 'node:module';", // 1 + 'const req = cjsRequire(import.meta.url);', // 2 + "const moved = req('./changed.js');", // 3 + 'moved.run();', // 4 + ].join('\n'), + changed, + ), + ).toEqual([3, 4]); + expect( + seamLines( + 'src/imp.js', + [ + "const { createRequire: mkRequire } = require('node:module');", // 1 + 'const req = mkRequire(import.meta.url);', // 2 + "const moved = req('./changed.js');", // 3 + 'moved.run();', // 4 + ].join('\n'), + changed, + ), + ).toEqual([3, 4]); + expect( + seamLines( + 'src/imp.js', + [ + 'const mkRequire = module.createRequire;', // 1 + 'const req = mkRequire(import.meta.url);', // 2 + "const moved = req('./changed.js');", // 3 + 'moved.run();', // 4 + ].join('\n'), + changed, + ), + ).toEqual([3, 4]); + }); + + it('a property-named binding reads back through its bracket spelling (#10136 R18-1)', () => { + // `exports.moved = require(…)` establishes the binding by property + // name; `exports['moved']` is the same property read back through + // the legal bracket spelling — the establishing side of that exact + // spelling is already a doubt state (`obj['m'] = require(…)`), so + // the read-back side must not mark nothing. + const source = [ + "exports.moved = require('./changed.js');", // 1 + "exports['moved'].run();", // 2 + 'let x = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.js', source, changed)).toEqual([1, 2]); + // …and a bracket read of a name the seam did NOT bind stays silent. + const unbound = [ + "exports.moved = require('./changed.js');", // 1 + "exports['other'].run();", // 2 + ].join('\n'); + expect(seamLines('src/imp.js', unbound, changed)).toEqual([1]); + }); + + it('a JSDoc type spelling the parser erases is a doubt, not a confident miss (#10136 R18-1, R17-2)', () => { + // Each of these TypeScript's JSDoc parser erases into a childless + // node — the specifier never becomes an `ImportType`, no diagnostic + // fires, and the read used to return `[]` while every sibling + // spelling correctly marked. Doubt over the tag's own raw text. + for (const tag of [ + "@callback {import('./changed.js').Foo} MyFn", + "@overload {import('./changed.js').Foo}", + "@func {import('./changed.js').Foo}", + "@function {import('./changed.js').Foo}", + "@see {@link import('./changed.js')}", + // The legal JSDoc `require('…')` type spelling (R17-2) — erased + // where `@type {import('…')}` is read. + "@type {require('./changed.js')}", + ]) { + const source = [`/** ${tag} */`, 'let v = 1;'].join('\n'); + expect(seamLines('src/imp.js', source, changed)).toBeNull(); + } + // …and the control rows the table already reads correctly: readable + // spellings mark, an erased spelling naming a file OUTSIDE the + // change set is no seam and no doubt. + expect( + seamLines( + 'src/imp.js', + ["/** @type {import('./changed.js').Foo} */", 'let v = 1;'].join('\n'), + changed, + ), + ).toEqual([1]); + expect( + seamLines( + 'src/imp.js', + ["/** @see {@link import('./elsewhere.js')} */", 'let v = 1;'].join( + '\n', + ), + changed, + ), + ).toEqual([]); + }); + + it('a syntax error is a tree the oracle cannot certify', () => { + // The error sits BEFORE a real seam the recovered tree would still + // read correctly: trusting the tree would certify `[2, 3]`, and the + // doubt is what covers line 1 too. + const source = [ + 'let a = ;', // 1 + "const { moved } = require('./changed.js');", // 2 + 'moved();', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toBeNull(); + }); + + it('with no parser resolvable every read is the doubt state', () => { + const source = ["import { moved } from './changed.js';", 'moved();'].join( + '\n', + ); + expect(seamLines('src/imp.ts', source, changed, [], null)).toBeNull(); + expect(seamLines('src/imp.ts', 'let x = 1;', changed, [], null)).toBeNull(); + }); + + it('comment-like markers inside strings, templates and regexes do not blank the seam (#10136)', () => { + const url = [ + "const u = 'https://x'; const { moved } = require('./changed.js');", // 1 + 'moved();', // 2 + ].join('\n'); + expect(seamLines('src/imp.ts', url, changed)).toEqual([1, 2]); + const blockPair = [ + "const a = '/*';", // 1 + "const { moved } = require('./changed.js');", // 2 + "const b = '*/';", // 3 + 'moved();', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', blockPair, changed)).toEqual([2, 4]); + const regex = [ + "const URL_RE = /https?:\\/\\//; const { moved } = require('./changed.js');", // 1 + 'moved();', // 2 + ].join('\n'); + expect(seamLines('src/imp.ts', regex, changed)).toEqual([1, 2]); + const nested = [ + 'const a = `${`/*`}`;', // 1 + "const { moved } = require('./changed.js');", // 2 + 'const b = `*/`;', // 3 + 'moved();', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', nested, changed)).toEqual([2, 4]); + }); + + // The lexical shapes six review rounds of a hand-rolled lexer guessed + // wrong (#10136 R9-1). Each reads exactly what the grammar says. + it('regex-versus-division shapes read as the grammar reads them (R9-1)', () => { + const source = [ + "import { moved } from './changed.js';", // 1 + "const per = count! / lines; const sep = '/'; const glob = '**/*.ts';", // 2 + 'moved(per);', // 3 + "const half = opts.in / 2; const a = 'x/y'; const b = 'p/*q';", // 4 + 'const c = arr.with(0, 1) / 2; const d = (y as Array) / 2;', // 5 + 'function f(): void {', // 6 + '}', // 7 + '/[//]/.test(s); moved();', // 8 + 'switch (s) {', // 9 + ' case 1: {', // 10 + ' }', // 11 + ' /[/*]/.test(line);', // 12 + '}', // 13 + 'const q = a / /[/*]/.test(x); moved(q);', // 14 + 'export default /re/;', // 15 + 'let z = moved;', // 16 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 3, 8, 14, 16]); + }); + + it('every line terminator and continuation reads as the grammar reads it (R9-1)', () => { + // LF alone counts as a line: a CR/LS/PS after `//` ends the comment + // but not the line; a CRLF does both. + for (const [terminator, expected] of [ + ['\r', [1, 2]], + ['
', [1, 2]], + ['
', [1, 2]], + ['\r\n', [2, 3]], + ] as const) { + const source = + `// c${terminator}const { moved } = require('./changed.js');\n` + + 'moved();'; + expect(seamLines('src/imp.ts', source, changed)).toEqual([...expected]); + } + // A backslash-CRLF continuation stays inside its string, and a raw + // LS inside a string is a string character (ES2019). + const continued = + "const s = 'a\\\r\n/*';\n" + // 1-2 + "const { moved } = require('./changed.js');\n" + // 3 + "const t = '*/';\n" + // 4 + 'moved(x);'; // 5 + expect(seamLines('src/imp.ts', continued, changed)).toEqual([3, 5]); + const ls = [ + "const s = 'a
/*';", // 1 + "const { moved } = require('./changed.js');", // 2 + "const t = '*/';", // 3 + 'moved(x);', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', ls, changed)).toEqual([2, 4]); + }); + + it('every clause shape binds what the grammar binds (R9-1)', () => { + const source = [ + "import { moved as type, other as default_, type Kind as K, export as ex } from './changed.js';", // 1 + 'type();', // 2 + 'default_();', // 3 + 'let k: K;', // 4 + 'ex();', // 5 + 'moved(); other();', // 6 — the IMPORTED names are not bindings here + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3, 4, 5]); + expect( + seamLines( + 'src/imp.ts', + "import { type } from './changed.js';\ntype();\nlet x = 1;", + changed, + ), + ).toEqual([1, 2]); + expect( + seamLines( + 'src/imp.ts', + "import type from './changed.js';\ntype();\nlet x = 1;", + changed, + ), + ).toEqual([1, 2]); + expect( + seamLines( + 'src/imp.ts', + "import type, { moved } from './changed.js';\ntype();\nmoved();\nlet x = 1;", + changed, + ), + ).toEqual([1, 2, 3]); + expect( + seamLines( + 'src/imp.ts', + "import type { Moved } from './changed.js';\nlet x: Moved;\ntype Other = 1;", + changed, + ), + ).toEqual([1, 2]); + expect( + seamLines( + 'src/imp.ts', + "import moved = require('./changed.js');\nmoved();\nlet x = 1;", + changed, + ), + ).toEqual([1, 2]); + expect( + seamLines( + 'src/imp.ts', + "export { moved as default } from './changed.js';\nconst moved = 1;", + changed, + ), + ).toEqual([1]); + expect( + seamLines( + 'src/imp.ts', + "export * as ns from './changed.js';\nconst ns = 1;", + changed, + ), + ).toEqual([1]); + }); + + it("a type position's import('…') is a seam, used inline", () => { + const source = [ + "let x: import('./changed.js').Foo;", // 1 + 'type T = {', // 2 + " moved: typeof import('./changed.js');", // 3 — the member, not the type + '};', // 4 + "let y: import('./stable.js').Bar;", // 5 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 3]); + }); + + it('non-ASCII identifiers are identifiers (R9-1)', () => { + expect( + seamLines( + 'src/imp.ts', + "import 数 from './changed.js';\n数();\nlet x = 1;", + changed, + ), + ).toEqual([1, 2]); + expect( + seamLines( + 'src/imp.ts', + "const 数import = 1; export { moved } from './changed.js';\nlet x = 数import;", + changed, + ), + ).toEqual([1]); + expect( + seamLines( + 'src/imp.ts', + "import { moved } from './changed.js';\nconst 变量 = { 键: moved };\nlet x = 1;", + changed, + ), + ).toEqual([1, 2]); + }); + + it('JSX is a language the parser reads — a seam inside markup is a seam', () => { + const source = [ + "import { Moved } from './changed.js';", // 1 + 'export const el = (', // 2 + '

// not a comment {"/*"}', // 3 + ' ', // 4 + '

', // 5 + ');', // 6 + 'let x = 1;', // 7 + ].join('\n'); + // The use marks its whole statement (#10136 R18-1): the declaration + // spans lines 2-6, and a hunk carrying any of those lines is one the + // seam must keep — a single-line mark at 4 sheds the closing tags a + // fix commit edits beside the element. + expect(seamLines('src/imp.tsx', source, changed)).toEqual([ + 1, 2, 3, 4, 5, 6, + ]); + }); + + it('a shebang line is a comment', () => { + const source = [ + "#!/usr/bin/env node // import { x } from './changed.js'", // 1 + "const moved = require('./changed.js');", // 2 + 'moved.run();', // 3 + ].join('\n'); + expect(seamLines('src/imp.js', source, changed)).toEqual([2, 3]); + }); + + it('a default import binds its name', () => { + const source = [ + "import moved, { other } from './changed.js';", // 1 + 'moved();', // 2 + 'other();', // 3 + 'const x = 1;', // 4 + ].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([1, 2, 3]); + }); + + it('resolves a workspace-package specifier through the packages argument', () => { + const packages = [{ name: '@w/pkg', dir: 'packages/pkg' }]; + const pkgChanged = new Set(['packages/pkg/src/changed.ts']); + const source = [ + "import { moved } from '@w/pkg/src/changed.js';", // 1 + 'moved();', // 2 + 'const x = 1;', // 3 + ].join('\n'); + expect(seamLines('src/imp.ts', source, pkgChanged)).toEqual([]); + expect(seamLines('src/imp.ts', source, pkgChanged, packages)).toEqual([ + 1, 2, + ]); + }); + + it('marks nothing for a file whose imports all resolve elsewhere', () => { + const source = ["import { a } from './stable.js';", 'a();'].join('\n'); + expect(seamLines('src/imp.ts', source, changed)).toEqual([]); + }); +}); + +describe('the seam oracle and its parser (#10136)', () => { + it('resolves TypeScript at run time, and it is the parser the tests run under', () => { + const loaded = loadTypeScript(); + expect(loaded).not.toBeNull(); + expect(loaded?.version).toBe(ts.version); + }); + + it('looks in the working directory first, then in its own tree', () => { + // A globally installed CLI has no `typescript` beside `dist/`; the + // repository the review runs in is the one base that can serve it. + const bases = defaultTypeScriptBases(); + expect(bases).toHaveLength(2); + expect(bases[0]).toBe(join(process.cwd(), 'package.json')); + expect(bases[1]).toMatch(/^file:.*import-graph\.(ts|js)$/); + }); + + it('refuses a resolvable but unusable parser and falls through to the next base', () => { + // A `typescript` that resolves from the working directory but cannot + // do the oracle's work — the entry points missing, or a probe parse + // that throws or yields no diagnostics field — must be rejected at + // load time, visibly, not accepted and left to doubt every file. + const stub = (body: string): string => { + const dir = mkdtempSync(join(tmpdir(), 'seam-ts-')); + const pkg = join(dir, 'node_modules', 'typescript'); + mkdirSync(pkg, { recursive: true }); + writeFileSync( + join(pkg, 'package.json'), + JSON.stringify({ + name: 'typescript', + version: '0.0.0-stub', + main: 'index.js', + }), + ); + writeFileSync(join(pkg, 'index.js'), body); + writeFileSync(join(dir, 'package.json'), '{}'); + return join(dir, 'package.json'); + }; + const real = fileURLToPath(import.meta.url); + const partial = stub('module.exports = { version: "0.0.0-stub" };'); + const throwing = stub( + 'const ts = require(' + + JSON.stringify( + join( + dirname(real), + '..', + '..', + '..', + '..', + 'node_modules', + 'typescript', + ), + ) + + '); module.exports = { ...ts, version: "0.0.0-throws", createSourceFile() { throw new Error("boom"); } };', + ); + const blind = stub( + 'const ts = require(' + + JSON.stringify( + join( + dirname(real), + '..', + '..', + '..', + '..', + 'node_modules', + 'typescript', + ), + ) + + '); module.exports = { ...ts, version: "0.0.0-blind", createSourceFile: (...a) => { const sf = ts.createSourceFile(...a); delete sf.parseDiagnostics; return sf; } };', + ); + try { + for (const bad of [partial, throwing, blind]) { + expect(loadTypeScript([bad])).toBeNull(); + // …and the next base is used instead. + expect(loadTypeScript([bad, real])?.version).toBe(ts.version); + } + expect( + loadTypeScript([join(tmpdir(), 'nowhere', 'package.json')]), + ).toBeNull(); + } finally { + for (const p of [partial, throwing, blind]) { + rmSync(dirname(p), { recursive: true, force: true }); + } + } + }); + + // Every source file of the CLI package is read against the corpus itself + // as the change set: every one of its relative imports that lands in the + // corpus is a seam, and the parser's own count of those declarations — + // computed here, independently — must be inside what `seamLines` marks. + // The only doubts the corpus may carry are the ones the contract names; + // each is checked against the source that caused it. + it('reads every source file of the CLI package against the corpus, marking every resolvable import', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const root = join(here, '..', '..', '..'); + const files: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + files.push(full); + } + } + }; + walk(root); + expect(files.length).toBeGreaterThan(1500); + // The resolver the corpus feeds is posix-hardcoded (`repoJoin` → + // nodePath.posix), so a win32-spelled relative path would resolve + // nothing into the corpus; normalise the same way paths.ts:499-502 does. + const rels = files.map((f) => + f + .slice(root.length + 1) + .split(sep) + .join('/'), + ); + const corpus = new Set(rels); + const doubted: string[] = []; + const unexplained: string[] = []; + const missing: string[] = []; + let importLines = 0; + let markedLines = 0; + // The doubt causes the contract names (#10136 R18-1): a computed + // specifier or an escaping value at a `require(…)`/`import(…)` call, + // an erased JSDoc subtree carrying one, and the one-reader + // cross-check — a specifier the entrance regex sees resolving into + // the corpus that the parser walk never resolved (a comment or + // string mention). A doubt in a file carrying NONE of these is one + // the contract does not name. + const dynamicLoad = /\b(?:require|import)\s*\(|createRequire\s*\(/; + for (const [i, file] of files.entries()) { + const text = readFileSync(file, 'utf8'); + const rel = rels[i]; + const got = seamLines(rel, text, corpus); + // The parser's own reading of which import/export-from declarations + // land in the corpus — the lines the result must contain. + const sf = ts.createSourceFile( + rel, + text, + ts.ScriptTarget.Latest, + true, + rel.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const expected: number[] = []; + const lfLine = (pos: number): number => + text.slice(0, pos).split('\n').length; + ts.forEachChild(sf, (node) => { + const spec = ts.isImportDeclaration(node) + ? node.moduleSpecifier + : ts.isExportDeclaration(node) + ? node.moduleSpecifier + : undefined; + if ( + spec !== undefined && + ts.isStringLiteral(spec) && + resolveSpecifier(rel, spec.text, corpus) !== null + ) { + expected.push(lfLine(node.getStart(sf))); + } + }); + // …and every type-position `import('…')` anywhere in the tree. + const typeImports = (node: ts.Node): void => { + if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) && + resolveSpecifier(rel, node.argument.literal.text, corpus) !== null + ) { + expected.push(lfLine(node.getStart(sf))); + } + ts.forEachChild(node, typeImports); + }; + typeImports(sf); + if (got === null) { + doubted.push(rel); + const regexSeesCorpus = scanImportSpecifiers(text).some( + (s) => resolveSpecifier(rel, s, corpus) !== null, + ); + if (!dynamicLoad.test(text) && !regexSeesCorpus) { + unexplained.push(rel); + } + continue; + } + importLines += expected.length; + markedLines += got.length; + const marked = new Set(got); + for (const line of expected) { + if (!marked.has(line)) missing.push(`${rel}:${line}`); + } + } + expect(missing).toEqual([]); + expect(unexplained).toEqual([]); + // The corpus imports itself thousands of times over, and every such + // statement was marked together with the lines that use its bindings + // — the census that a disabled resolver, span or mention scan cannot + // produce. + expect(importLines).toBeGreaterThan(2000); + expect(markedLines).toBeGreaterThan(importLines * 3); + // Disclosed, not capped: the corpus carries a handful of lazy + // `import(variable)` loaders and escaping `import(…)` values (a + // `Promise.all([import(…)])`), each republished in full on a fix-audit + // round, and a change in that number is a fact to look at. + expect(doubted.length).toBeLessThan(files.length / 100); + expect(doubted.length).toBeGreaterThan(0); + }, 180_000); +}); diff --git a/packages/cli/src/commands/review/lib/import-graph.ts b/packages/cli/src/commands/review/lib/import-graph.ts index 3e3d7412cd5..4da25d1778d 100644 --- a/packages/cli/src/commands/review/lib/import-graph.ts +++ b/packages/cli/src/commands/review/lib/import-graph.ts @@ -46,6 +46,7 @@ // The scan reads files from the review worktree (post-change state), because // the question is whether the caller AS IT NOW STANDS uses what changed. +import { createRequire } from 'node:module'; import * as nodePath from 'node:path'; /** File-reading seam: the incremental scope passes worktree reads, tests pass a map. */ @@ -267,6 +268,846 @@ export function discoverWorkspacePackages( .map(([dir, name]) => ({ name, dir })); } +/** An identifier a seam binding can be — nothing flag- or operator-shaped. */ +/** The TypeScript module the seam oracle parses with (`typeof import('typescript')`). */ +export type TypeScriptModule = typeof import('typescript'); + +let loadedTypeScript: TypeScriptModule | null | undefined; + +/** + * The parser the seam oracle reads with, resolved at run time and never + * bundled (#10136): TypeScript is a build-time dependency of this package, + * not a runtime one, and shipping it inside the CLI for one scan is not a + * trade this command makes. It is resolved from the process's working + * directory first — the repository the review runs in, whose own + * `typescript` is exactly the parser that reads its sources — then from + * this module's own location (the source tree, the test runner). Where + * neither resolves, or the module does not expose the parser entry points + * the oracle uses, the answer is `null` and every seam read is the doubt + * shape: the round republishes interaction files in full, which is what + * every round did before the seam bound existed. Memoised: one resolution + * per process, whatever the answer. + */ +export function loadTypeScript( + /** + * Where to resolve from, in order — a test's seam. Absent, the default + * bases (the working directory, then this module) and the memo apply. + */ + bases?: readonly string[], +): TypeScriptModule | null { + if (bases === undefined && loadedTypeScript !== undefined) { + return loadedTypeScript; + } + const from = bases ? [...bases] : defaultTypeScriptBases(); + let found: TypeScriptModule | null = null; + for (const base of from) { + try { + const candidate = createRequire(base)('typescript') as unknown; + if (isTypeScriptModule(candidate)) { + found = candidate; + break; + } + } catch { + /* not resolvable or not usable from here — try the next base */ + } + } + if (bases === undefined) loadedTypeScript = found; + return found; +} + +/** + * Where the default resolution looks, in order: the working directory — + * the repository the review runs in, whose own `typescript` is the one + * that reads its sources, and the only base a globally installed CLI has + * (nothing ships beside `dist/`) — then this module's own tree, which + * serves the source checkout and the test runner. A working directory + * that no longer exists contributes no base. + */ +export function defaultTypeScriptBases(): string[] { + const from: string[] = []; + try { + from.push(nodePath.join(process.cwd(), 'package.json')); + } catch { + /* a working directory that no longer exists: only the CLI's own tree */ + } + from.push(import.meta.url); + return from; +} + +/** + * The entry points the oracle calls, present and working: a probe parse of + * a trivial module runs here so a module that resolves but cannot parse + * (a broken install, an incompatible build) is refused now, visibly, rather + * than doubting every file silently. Guards the members added after TS + * 4.8 (`isSatisfiesExpression`) explicitly: a reviewed repository may pin + * an older compiler, and the walk must never throw on its absence. + */ +function isTypeScriptModule(value: unknown): value is TypeScriptModule { + const m = value as Partial | null; + if ( + typeof m !== 'object' || + m === null || + typeof m.createSourceFile !== 'function' || + typeof m.forEachChild !== 'function' || + typeof m.isImportDeclaration !== 'function' || + typeof m.isCallExpression !== 'function' || + typeof m.isStringLiteralLike !== 'function' || + typeof m.SyntaxKind !== 'object' || + typeof m.ScriptKind !== 'object' || + typeof m.ScriptTarget !== 'object' + ) { + return false; + } + try { + const probe = m.createSourceFile( + 'probe.ts', + 'export {};', + m.ScriptTarget.Latest, + true, + m.ScriptKind.TS, + ); + return Array.isArray( + (probe as { parseDiagnostics?: unknown }).parseDiagnostics, + ); + } catch { + return false; + } +} + +/** + * The 1-based lines of `source` that touch its seam with the changed files: + * every import/require statement whose specifier resolves into `changed` + * (every line the statement spans), plus every line mentioning a binding + * such a statement introduces. + * + * This is the seam-bounded widening's oracle (#10104): a fix-audit round + * republishes an interaction file's hunks only where they display one of + * these lines. It reads the file through TypeScript's own parser (#10136) — + * the grammar's reading of strings, templates, regex literals, comments, + * JSX and import clauses, not a hand-rolled approximation of it: six review + * rounds of an in-house lexer each surfaced a new lexical shape it guessed + * wrong (a `/` after a non-null `!`, a keyword-shaped property name, a + * control-word-shaped method, an `import { export as x }` clause), and a + * wrong guess is not line-local — a mis-lexed literal desyncs everything + * after it. The parser carries none of that: an `ImportDeclaration` is an + * import, its clause's bindings are the nodes the grammar says they are. + * + * What it marks: `import`/`export … from` declarations, TypeScript's + * `import x = require(…)`, a type position's `import('…')` and JSDoc's + * `@import`/`@type {import('…')}` (the JSDoc tree is walked too — a JS + * caller's types live there) whose specifier resolves into `changed`; a + * `require(…)` or `import(…)` call whose specifier does, together with + * EVERY receiver of its value on the way to the statement — the declared + * names (`const { a, b: c } = require('x')`, `const m = await import('x')`), + * each identifier or property assigned along a chain (`a = b = require('x')`, + * `cache ?? (cache = require('x'))`, `exports.m = require('x')`), a class + * field — through whatever wraps the call (a property access, an `await`, + * a `?.`, an `as`, a conditional's branch, a `??`); and every line where an + * identifier spelled like one of those bindings appears. That last read is + * by NAME, so a shadowing local or a same-named property marks one line too + * many — over-collection is the budgeted direction; a binding renamed into + * another local after import, or reached through a barrel, marks no line, + * and the file itself always stays in scope with its brief, so the seam + * question is asked even when no hunk survives. A statement that receives + * nothing (`require('x');`, `await import('x');`, `require('x').init();`) + * marks its own lines and binds nothing — the grammar introduces no name. + * A dynamic import's value is a promise until an `await` unwraps it, so a + * method chained onto the un-awaited promise (`import('x').then(handler)`) + * hands the module to a callback the name read cannot follow: an escape. + * + * Every read the oracle cannot prove fails CLOSED — `null`, the doubt + * state, which `widenScope` republishes in full: no parser resolvable + * (`loadTypeScript`), a source the parser reports a syntax error on (the + * tree past the error is a guess), a `require`/`import(…)` whose specifier + * is not a string literal (a computed one may name a changed file the read + * cannot see, so "no match" is no proof), a `require`/`import(…)` whose + * value escapes into an expression the receiver walk does not follow (an + * argument — `foo(require('x'))` — a method chained onto an un-awaited + * `import('x')` promise, an array or object literal, a `return`, an + * `export =`, an element-access target), a `createRequire` alias whose + * introduction escapes the same way, a JSDoc tag whose own text carries a + * specifier its parsed subtree never surfaced as an `ImportType` node + * (TypeScript erases the `@callback`/`@overload`/`@func` family, `@see + * {@link …}` and the JSDoc `require('…')` type spelling into childless + * nodes), a specifier the entrance regex can see resolving into `changed` + * that this walk never resolved (one reader for both halves — a comment + * that merely mentions the path is a doubt, not a confident `kept: 0`), + * and a walk that throws on a parser build missing an entry point. Under- + * collection of the oracle is the one error the seam bound must not make. + * Line numbers count LF alone — the diff's own accounting — never the + * CR/LS/PS breaks the parser also counts. + * + * Returns the sorted 1-based lines, or `null` for the doubt state. + */ +export function seamLines( + fromFile: string, + source: string, + changed: ReadonlySet, + packages: readonly WorkspacePackage[] = [], + ts: TypeScriptModule | null = loadTypeScript(), +): number[] | null { + if (ts === null) return null; + try { + return seamLinesWith(ts, fromFile, source, changed, packages); + } catch { + // A parser build the oracle's walk does not fit (an entry point missing, + // a node shape it did not expect): not a reading, so not a census. + return null; + } +} + +type TSNode = import('typescript').Node; + +/** The read itself; `null` is the doubt state. */ +function seamLinesWith( + ts: TypeScriptModule, + fromFile: string, + source: string, + changed: ReadonlySet, + packages: readonly WorkspacePackage[], +): number[] | null { + // LF-only line accounting, computed once: the parser's own line map + // counts CR, LS and PS as breaks, and a hunk's line numbers do not. + const lineStarts: number[] = [0]; + for (let i = 0; i < source.length; i++) { + if (source.charCodeAt(i) === 10) lineStarts.push(i + 1); + } + const lineOf = (pos: number): number => { + let lo = 0; + let hi = lineStarts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (lineStarts[mid] <= pos) lo = mid; + else hi = mid - 1; + } + return lo + 1; + }; + + const sf = ts.createSourceFile( + `seam${scriptExtension(fromFile)}`, + source, + ts.ScriptTarget.Latest, + true, + scriptKindOf(ts, fromFile), + ); + // The parser is error-tolerant, and a tree built past a syntax error is + // a guess about what the author meant — not a reading the oracle can + // certify. The diagnostics ride on the source file as an internal field; + // a build of TypeScript that hides it is one the oracle cannot vouch for. + const diagnostics = (sf as { parseDiagnostics?: unknown }).parseDiagnostics; + if (!Array.isArray(diagnostics) || diagnostics.length > 0) return null; + + const marked = new Set(); + const bindings = new Set(); + let refused = false; + const markSpan = (node: TSNode): void => { + const from = lineOf(node.getStart(sf)); + const to = lineOf(node.getEnd()); + for (let line = from; line <= to; line++) marked.add(line); + }; + // The statement — or the class/interface/object member — a node sits in: + // every line it spans is the seam, not the node's own line alone (a + // destructuring spread over three lines is one statement), and a member + // rather than its whole class (one typed method must not republish the + // class around it). + const statementOf = (node: TSNode): TSNode => { + let current = node; + while ( + current.parent !== undefined && + !ts.isSourceFile(current.parent) && + // A node inside a JSDoc comment spans its tag, not the declaration + // the comment documents. + !ts.isJSDoc(current.parent) && + !ts.isBlock(current.parent) && + !ts.isModuleBlock(current.parent) && + !ts.isCaseClause(current.parent) && + !ts.isDefaultClause(current.parent) && + !ts.isClassLike(current.parent) && + !ts.isInterfaceDeclaration(current.parent) && + !ts.isTypeLiteralNode(current.parent) && + !ts.isObjectLiteralExpression(current.parent) + ) { + current = current.parent; + } + return current; + }; + // A specifier the read can resolve: a string literal, a template with no + // substitution, either wrapped in parentheses. Anything else — a name, a + // concatenation, a substituting template — is computed. + const literalSpecifier = ( + expr: import('typescript').Expression | undefined, + ): string | null => { + let e = expr; + while (e !== undefined && ts.isParenthesizedExpression(e)) e = e.expression; + return e !== undefined && ts.isStringLiteralLike(e) ? e.text : null; + }; + // Every specifier this walk resolved into `changed`, recorded so the + // post-walk cross-check can prove the two readers agree (#10136 R18-1): + // a file enters `interaction` on `scanImportSpecifiers`' regex, and any + // specifier THAT read can see resolving into `changed` which this walk + // never resolved is an edge the census cannot account for — doubt, not + // a confident miss. + const resolvedSpecs = new Set(); + const resolves = (spec: string): boolean => { + const hit = resolveSpecifier(fromFile, spec, changed, packages) !== null; + if (hit) resolvedSpecs.add(spec); + return hit; + }; + // The callee of a call, unwrapped past the shapes that hide it from a + // plain identifier read: parentheses, and the `(0, require)(…)` comma + // sequence (the right operand is what actually gets called). + const unwrapCallee = (expr: TSNode): TSNode => { + let e = expr; + for (;;) { + if (ts.isParenthesizedExpression(e)) { + e = e.expression; + continue; + } + if ( + ts.isBinaryExpression(e) && + e.operatorToken.kind === ts.SyntaxKind.CommaToken + ) { + e = e.right; + continue; + } + return e; + } + }; + // The names a binding pattern or identifier declares — the LOCAL names, + // whatever property they were taken from. + const declaredNames = (name: import('typescript').BindingName): string[] => { + if (ts.isIdentifier(name)) return [name.text]; + const out: string[] = []; + for (const element of name.elements) { + if (ts.isOmittedExpression(element)) continue; + out.push(...declaredNames(element.name)); + } + return out; + }; + // The names an assignment target receives: an identifier, a property + // (a private `#field` included — its uses are read by the same name), or + // an object/array assignment pattern, element by element. Anything else + // (an element access, a call) is a target the name read cannot follow. + const assignmentTargetNames = ( + target: import('typescript').Expression, + ): string[] | null => { + if (ts.isIdentifier(target)) return [target.text]; + if (ts.isPropertyAccessExpression(target)) return [target.name.text]; + if (ts.isParenthesizedExpression(target)) { + return assignmentTargetNames(target.expression); + } + if (ts.isObjectLiteralExpression(target)) { + const out: string[] = []; + for (const prop of target.properties) { + let names: string[] | null; + if (ts.isShorthandPropertyAssignment(prop)) names = [prop.name.text]; + else if (ts.isPropertyAssignment(prop)) { + names = assignmentTargetNames(prop.initializer); + } else if (ts.isSpreadAssignment(prop)) { + names = assignmentTargetNames(prop.expression); + } else names = null; + if (names === null) return null; + out.push(...names); + } + return out; + } + if (ts.isArrayLiteralExpression(target)) { + const out: string[] = []; + for (const element of target.elements) { + if (ts.isOmittedExpression(element)) continue; + const names = assignmentTargetNames( + ts.isSpreadElement(element) ? element.expression : element, + ); + if (names === null) return null; + out.push(...names); + } + return out; + } + if ( + ts.isBinaryExpression(target) && + target.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) { + // A default inside a pattern: `[a = 1] = …` binds `a`. + return assignmentTargetNames(target.left); + } + return null; + }; + const isPassThrough = (parent: TSNode, node: TSNode): boolean => + ts.isPropertyAccessExpression(parent) || + ts.isElementAccessExpression(parent) || + ts.isAwaitExpression(parent) || + ts.isVoidExpression(parent) || + ts.isParenthesizedExpression(parent) || + ts.isNonNullExpression(parent) || + ts.isAsExpression(parent) || + ts.isTypeAssertionExpression(parent) || + (typeof ts.isSatisfiesExpression === 'function' && + ts.isSatisfiesExpression(parent)) || + (ts.isCallExpression(parent) && parent.expression === node) || + (ts.isConditionalExpression(parent) && parent.condition !== node) || + (ts.isBinaryExpression(parent) && + (parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarToken || + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)); + // Every receiver of a `require(…)`/`import(…)` value on the way up to its + // statement. A declaration or a class field is terminal (it binds and the + // value stops there); an assignment records its target and keeps going — + // the assignment expression's value flows on (`a = b = require('x')`, + // `cache ?? (cache = require('x'))`); an expression statement ends the + // walk with whatever was collected (nothing, for a bare side-effect + // call). Anything else the value flows into is an escape the name read + // cannot follow, and the read fails closed. A still-WRAPPED promise + // reaches no terminal either (#10136 R9-1 round 16): `const p = + // import('x')` stores the promise, and the callback body that eventually + // uses the module (`p.then((m) => …)`) is written anywhere — a use the + // name read cannot account for. So every BINDING terminal refuses while + // `unwrapped` is still false: receiving the value is gated on the + // promise's state, not only chaining onto it (`const api = await + // import('x')` still binds — the await is inside the walk's own path). + const receiverBindings = ( + call: TSNode, + promise: boolean, + ): string[] | null => { + const out: string[] = []; + let node: TSNode = call; + // A dynamic import's value is a PROMISE of the module until an `await` + // unwraps it: a method chained onto the promise (`.then(handler)`, + // `.catch(…)`) hands the module to a callback the name read cannot + // follow, so until the await a call on the path is an escape. + let unwrapped = !promise; + for (;;) { + const parent: TSNode | undefined = node.parent; + if (parent === undefined) return null; + if (ts.isAwaitExpression(parent)) unwrapped = true; + if ( + !unwrapped && + ts.isCallExpression(parent) && + parent.expression === node + ) { + return null; + } + if (!unwrapped) { + // The binding terminals, gated on the promise's state: a + // declaration, a destructuring default, a class field, or an + // assignment that would STORE the wrapped promise. + if ( + (ts.isVariableDeclaration(parent) && parent.initializer === node) || + (ts.isBindingElement(parent) && parent.initializer === node) || + (ts.isPropertyDeclaration(parent) && + parent.initializer === node && + (ts.isIdentifier(parent.name) || + ts.isPrivateIdentifier(parent.name))) || + (ts.isBinaryExpression(parent) && + parent.right === node && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken || + parent.operatorToken.kind === + ts.SyntaxKind.QuestionQuestionEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken || + parent.operatorToken.kind === + ts.SyntaxKind.AmpersandAmpersandEqualsToken)) + ) { + return null; + } + } + if (ts.isVariableDeclaration(parent) && parent.initializer === node) { + out.push(...declaredNames(parent.name)); + return out; + } + if (ts.isBindingElement(parent) && parent.initializer === node) { + out.push(...declaredNames(parent.name)); + return out; + } + if ( + ts.isPropertyDeclaration(parent) && + parent.initializer === node && + (ts.isIdentifier(parent.name) || ts.isPrivateIdentifier(parent.name)) + ) { + out.push(parent.name.text); + return out; + } + if ( + ts.isBinaryExpression(parent) && + parent.right === node && + (parent.operatorToken.kind === ts.SyntaxKind.EqualsToken || + parent.operatorToken.kind === + ts.SyntaxKind.QuestionQuestionEqualsToken || + parent.operatorToken.kind === ts.SyntaxKind.BarBarEqualsToken || + parent.operatorToken.kind === + ts.SyntaxKind.AmpersandAmpersandEqualsToken) + ) { + const names = assignmentTargetNames(parent.left); + if (names === null) return null; + out.push(...names); + node = parent; + continue; + } + if (ts.isExpressionStatement(parent) && parent.expression === node) { + return out; + } + if (isPassThrough(parent, node)) { + node = parent; + continue; + } + return null; + } + }; + const bindImportClause = ( + clause: import('typescript').ImportClause | undefined, + ): void => { + if (clause?.name) bindings.add(clause.name.text); + const named = clause?.namedBindings; + if (named) { + if (ts.isNamespaceImport(named)) bindings.add(named.name.text); + else for (const el of named.elements) bindings.add(el.name.text); + } + }; + // `forEachChild` never enters a node's JSDoc, and a JavaScript caller's + // types live there — `@type {import('./x').T}`, `@import { T } from + // './x'` — so the walk enters it by hand, in every script kind (a `.ts` + // file's JSDoc import marks one line too many at worst). + const eachChild = (node: TSNode, fn: (child: TSNode) => void): void => { + ts.forEachChild(node, fn); + const docs = (node as { jsDoc?: readonly TSNode[] }).jsDoc; + if (Array.isArray(docs)) for (const doc of docs) fn(doc); + }; + const isJSDocImport = (node: TSNode): boolean => + typeof ts.isJSDocImportTag === 'function' && ts.isJSDocImportTag(node); + // A parser too old to know `@import` (TS < 5.5) hands the tag over as an + // unknown one, clause unread: a seam it cannot show is a doubt, not a + // "no match". + const isUnreadableJSDocImport = (node: TSNode): boolean => + typeof ts.isJSDocImportTag !== 'function' && + (node as { tagName?: { text?: unknown } }).tagName?.text === 'import'; + // A JSDoc tag whose own text carries a `require('…')`/`import('…')` + // specifier its parsed subtree never surfaced as an `ImportType` node + // (#10136 R17-2/R18-1). TypeScript's JSDoc parser erases several legal + // type spellings — the `@callback`/`@overload`/`@func`/`@function` + // signatures, `@see {@link …}`, and the JSDoc `require('…')` type — into + // childless nodes (`JSDocSignature`, unknown tags, link text), so the + // specifier never becomes a node the walk can rule on: no + // `ImportTypeNode`, no parse diagnostic, no refusal — a confident miss. + // The doubt is read over the tag's own raw text (`node.pos`/`node.end`), + // and only when the erased specifier actually resolves into `changed`: + // an erased type naming something else is no seam either way. + const jsDocCarriesUnreadSpec = (node: TSNode): boolean => { + // A JSDoc tag by kind range, not by `ts.isJSDocTag`: that guard is + // absent from the public type surface of the parser builds the + // oracle resolves at run time. + if ( + node.kind < ts.SyntaxKind.FirstJSDocTagNode || + node.kind > ts.SyntaxKind.LastJSDocTagNode + ) { + return false; + } + const raw = source.slice(node.pos, node.end); + const specs = new Set(); + for (const re of [ + /\brequire\s*\(\s*['"`]([^'"`\n]+)['"`]/g, + /\bimport\s*\(\s*['"`]([^'"`\n]+)['"`]/g, + ]) { + for (const m of raw.matchAll(re)) specs.add(m[1] ?? ''); + } + if (specs.size === 0) return false; + const readable = new Set(); + const collect = (n: TSNode): void => { + if (ts.isImportTypeNode(n)) { + const arg = n.argument; + if (ts.isLiteralTypeNode(arg) && ts.isStringLiteralLike(arg.literal)) { + readable.add(arg.literal.text); + } + } + ts.forEachChild(n, collect); + }; + collect(node); + for (const spec of specs) { + if (!readable.has(spec) && resolves(spec)) return true; + } + return false; + }; + // `require` under an alias (#10136 R18-1): `const req = + // createRequire(import.meta.url)` makes `req` a require factory's + // product, and a call through it is a require call the plain identifier + // read cannot see — it returned a confident `[]` where the aliased + // control correctly marked. The factory itself is matched by the LOCAL + // names its original name was bound to, not just the literal spelling + // (#10136 R18-1 round 19): `import { createRequire as cr }`, + // `const { createRequire: cr } = …`, and `const cr = x.createRequire` + // all rename it, and a rename the read cannot follow is another + // confident miss. Both sets are collected file-wide BEFORE the walk (a + // hoisted use precedes its declaration in source order), by name — the + // same fuzz the binding read budgets. + const factoryNames = new Set(['createRequire']); + const collectFactories = (node: TSNode): void => { + if (ts.isImportDeclaration(node)) { + const named = node.importClause?.namedBindings; + if (named !== undefined && ts.isNamedImports(named)) { + for (const el of named.elements) { + // `import { createRequire as cr }` — `propertyName` is the + // ORIGINAL name, `name` the local one. + if ((el.propertyName ?? el.name).text === 'createRequire') { + factoryNames.add(el.name.text); + } + } + } + } else if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) + ) { + // `const { createRequire: cr } = …` (and the shorthand form). + for (const el of node.name.elements) { + const prop = el.propertyName ?? el.name; + if ( + ts.isIdentifier(prop) && + prop.text === 'createRequire' && + ts.isIdentifier(el.name) + ) { + factoryNames.add(el.name.text); + } + } + } else if ( + ts.isVariableDeclaration(node) && + node.initializer !== undefined && + ts.isIdentifier(node.name) && + ts.isPropertyAccessExpression(node.initializer) && + node.initializer.name.text === 'createRequire' + ) { + // `const cr = module.createRequire` — the property read binds the + // local to the same factory. + factoryNames.add(node.name.text); + } + ts.forEachChild(node, collectFactories); + }; + collectFactories(sf); + const isFactoryCallee = (callee: TSNode): boolean => + (ts.isIdentifier(callee) && factoryNames.has(callee.text)) || + (ts.isPropertyAccessExpression(callee) && + callee.name.text === 'createRequire'); + const requireAliases = new Set(); + const collectAliases = (node: TSNode): void => { + if (refused) return; + if (ts.isCallExpression(node)) { + const callee = unwrapCallee(node.expression); + if (isFactoryCallee(callee)) { + const received = receiverBindings(node, false); + if (received === null) { + // The alias itself escapes the receiver walk: calls through it + // are unreadable either way. + refused = true; + return; + } + for (const b of received) requireAliases.add(b); + } + } + ts.forEachChild(node, collectAliases); + }; + collectAliases(sf); + if (refused) return null; + // Every branch falls through to the children walk at the bottom: the + // JSDoc a statement carries — an `@import` above an `import`, a + // `@typedef` above an `export … from` — is a child the walk must enter + // whatever the statement itself was. + const visit = (node: TSNode): void => { + if (refused) return; + if (jsDocCarriesUnreadSpec(node)) { + refused = true; + return; + } + if (ts.isImportDeclaration(node)) { + const spec = literalSpecifier(node.moduleSpecifier); + if (spec === null) { + refused = true; + return; + } + if (resolves(spec)) { + markSpan(node); + bindImportClause(node.importClause); + } + } else if (isUnreadableJSDocImport(node)) { + refused = true; + return; + } else if (isJSDocImport(node)) { + const tag = node as import('typescript').JSDocImportTag; + const spec = literalSpecifier(tag.moduleSpecifier); + if (spec === null) { + refused = true; + return; + } + if (resolves(spec)) { + markSpan(node); + bindImportClause(tag.importClause); + } + } else if ( + typeof ts.isJSDocTypedefTag === 'function' && + ts.isJSDocTypedefTag(node) + ) { + // `@typedef {import('./changed.js').Bar} Local` binds a local ALIAS + // of an imported type (#10136 R17-3): the ImportType child marks + // the typedef's own lines, but nothing bound the alias, so a file + // that types everything through it marked nothing past this line. + let aliased = false; + const scan = (n: TSNode): void => { + if (ts.isImportTypeNode(n)) { + const arg = n.argument; + const spec = + ts.isLiteralTypeNode(arg) && ts.isStringLiteralLike(arg.literal) + ? arg.literal.text + : null; + if (spec !== null && resolves(spec)) aliased = true; + } + ts.forEachChild(n, scan); + }; + scan(node); + if (aliased) { + const name = (node as import('typescript').JSDocTypedefTag).fullName; + if (name !== undefined) { + // A QUALIFIED alias (`ns.Bar`) binds every identifier of the + // name, not nothing (#10136 R18-1 round 19): dropping it left + // the alias's uses unmarked beside a specifier the cross-check + // HAD seen — a confident under-read where the sibling + // unreadable-`@import` branch refuses. Over-collecting a + // namespace segment is the direction the name read budgets. + const bindAll = (n: TSNode): void => { + if (ts.isIdentifier(n)) bindings.add(n.text); + ts.forEachChild(n, bindAll); + }; + bindAll(name); + } + } + } else if (ts.isExportDeclaration(node)) { + if (node.moduleSpecifier !== undefined) { + const spec = literalSpecifier(node.moduleSpecifier); + if (spec === null) { + refused = true; + return; + } + // A re-export introduces no local binding: the statement is the seam. + if (resolves(spec)) markSpan(node); + } + } else if (ts.isImportTypeNode(node)) { + // `import('./changed.js').Foo` in a type position: a seam by the + // grammar (the signature it names moved), used inline — no binding. + const arg = node.argument; + const spec = + ts.isLiteralTypeNode(arg) && ts.isStringLiteralLike(arg.literal) + ? arg.literal.text + : null; + if (spec === null) { + refused = true; + return; + } + if (resolves(spec)) markSpan(statementOf(node)); + } else if (ts.isImportEqualsDeclaration(node)) { + const ref = node.moduleReference; + if (ts.isExternalModuleReference(ref)) { + const spec = literalSpecifier(ref.expression); + if (spec === null) { + refused = true; + return; + } + if (resolves(spec)) { + markSpan(node); + bindings.add(node.name.text); + } + } + } else if (ts.isCallExpression(node)) { + const callee = unwrapCallee(node.expression); + // `require` by every spelling the grammar can hide it behind + // (#10136 R18-1): the bare identifier, a `createRequire` alias + // collected above, a `(0, require)(…)` comma sequence, and the + // property form `module.require(…)` — an unwrapped callee the read + // cannot name is left to the doubt states, never guessed. + const isRequire = + (ts.isIdentifier(callee) && + (callee.text === 'require' || requireAliases.has(callee.text))) || + (ts.isPropertyAccessExpression(callee) && + callee.name.text === 'require'); + const isDynamicImport = callee.kind === ts.SyntaxKind.ImportKeyword; + if ((isRequire || isDynamicImport) && node.arguments.length >= 1) { + const spec = literalSpecifier(node.arguments[0]); + if (spec === null) { + refused = true; + return; + } + if (resolves(spec)) { + markSpan(statementOf(node)); + const received = receiverBindings(node, isDynamicImport); + if (received === null) { + refused = true; + return; + } + for (const b of received) bindings.add(b); + } + } + } + eachChild(node, visit); + }; + visit(sf); + if (refused) return null; + // One reader for both halves (#10136 R18-1): a file enters + // `interaction` on `scanImportSpecifiers`' regex, so any specifier the + // regex can see resolving into `changed` that this walk never resolved + // is an edge the census cannot account for — a comment or string that + // merely MENTIONS the changed path, an import the walk's own rules + // refused to see. A confident census over it sheds the file on evidence + // nobody read. + for (const spec of scanImportSpecifiers(source)) { + if (resolveSpecifier(fromFile, spec, changed, packages) !== null) { + if (!resolvedSpecs.has(spec)) return null; + } + } + if (bindings.size > 0) { + const mention = (node: TSNode): void => { + // A binding use marks the whole statement it sits in + // (`markSpan(statementOf(…))`), never the identifier's own line + // (#10136 R18-1): a single-line mark over a multi-line call sheds + // exactly the hunks inside the call — the argument lines a fix + // commit changes — while the plan's census certifies the shed. + if ( + (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) && + bindings.has(node.text) + ) { + markSpan(statementOf(node)); + } else if ( + ts.isElementAccessExpression(node) && + ts.isStringLiteralLike(node.argumentExpression) && + bindings.has(node.argumentExpression.text) + ) { + // The bracket spelling of a property-named binding — + // `exports['moved']` reads back what `exports.moved = require(…)` + // established; the establishing side of the same spelling is + // already a doubt state, so the read-back must see it too. + markSpan(statementOf(node)); + } + eachChild(node, mention); + }; + mention(sf); + } + return [...marked].sort((a, b) => a - b); +} + +function scriptExtension(file: string): string { + const ext = nodePath.extname(file).toLowerCase(); + return EXT_WALK.includes(ext) ? ext : '.ts'; +} + +function scriptKindOf( + ts: TypeScriptModule, + file: string, +): import('typescript').ScriptKind { + switch (scriptExtension(file)) { + case '.tsx': + return ts.ScriptKind.TSX; + case '.jsx': + return ts.ScriptKind.JSX; + case '.js': + case '.mjs': + case '.cjs': + return ts.ScriptKind.JS; + default: + return ts.ScriptKind.TS; + } +} + /** * Which candidates import a changed file — the widening set. * diff --git a/packages/cli/src/commands/review/lib/incremental-scope.test.ts b/packages/cli/src/commands/review/lib/incremental-scope.test.ts index 4f4ef9ad516..d952a49b84e 100644 --- a/packages/cli/src/commands/review/lib/incremental-scope.test.ts +++ b/packages/cli/src/commands/review/lib/incremental-scope.test.ts @@ -8,9 +8,20 @@ // testable without a repository. The selection it widens comes from the real // `selectNarrowing`, so these exercise the pair as the command wires it. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { widenScope } from './incremental-scope.js'; import { assembleSections, selectNarrowing } from './narrow-diff.js'; +import { buildDiffPlan, parseDiff } from './diff-plan.js'; +import { loadTypeScript } from './import-graph.js'; + +// One test below needs the seam oracle unresolvable (#10136 R18-2). The +// mock delegates EVERYTHING to the real module — the delegation is what +// lets this file keep exercising the real scan in every other case — and +// the one test flips `loadTypeScript` alone. +vi.mock('./import-graph.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadTypeScript: vi.fn(actual.loadTypeScript) }; +}); /** A one-hunk section for `path`, as `parseDiff` reads it. */ function section(path: string): string { @@ -106,3 +117,596 @@ describe('widenScope', () => { expect(scope.interaction).toEqual([]); }); }); + +/** The importer's two-hunk section: hunk 1 sits on the seam, hunk 2 does not. */ +const IMP_SECTION = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + '--- a/src/imp.ts', + '+++ b/src/imp.ts', + '@@ -1,3 +1,3 @@', + " import { moved } from './changed.js';", + '-const a = old();', + '+const a = moved();', + ' //', + '@@ -10,3 +10,3 @@', + ' function unrelated() {', + '- return 0;', + '+ return 1;', + ' }', + '', +].join('\n'); + +/** The importer's worktree content — its new side, seam on lines 1-2. */ +const IMP_SOURCE = [ + "import { moved } from './changed.js';", + 'const a = moved();', + '//', + '', + '', + '', + '', + '', + '', + 'function unrelated() {', + ' return 1;', + '}', + '', +].join('\n'); + +function seamSelection() { + const sel = selectNarrowing( + Buffer.from(section('src/changed.ts') + IMP_SECTION, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (sel === null) throw new Error('the narrowing refused this fixture'); + return sel; +} + +describe('widenScope seam bound (#10104)', () => { + it('keeps only the hunks that display a seam line, and says so', () => { + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? IMP_SOURCE : null), + seamBound: true, + }); + + expect(widened.scope.interaction).toEqual([ + { + path: 'src/imp.ts', + importsChanged: ['src/changed.ts'], + seam: { kept: 1, total: 2 }, + }, + ]); + expect([...(widened.hunkKeep?.get('src/imp.ts') ?? [])]).toEqual([0]); + + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('+const a = moved();'); + expect(diff).not.toContain('+ return 1;'); + // The reassembled text is still a well-formed diff the planner tiles. + const parsed = parseDiff(diff ?? ''); + const imp = parsed.files.find((f) => f.path === 'src/imp.ts'); + expect(imp?.hunks).toHaveLength(1); + expect(() => buildDiffPlan(diff ?? '', 400)).not.toThrow(); + }); + + it('keeps the file as a header-only section when no hunk sits near a seam', () => { + // The seam lives outside the file's own diff entirely: the import and + // its uses sit on lines no hunk displays. The file must still publish — + // header only — so a chunk owns it and its brief asks the seam question. + const source = [ + '//', // 1 + '//', // 2 + '//', // 3 + '', + '', + '', + '', + '', + '', + 'function unrelated() {', // 10 + ' return 1;', // 11 + '}', // 12 + '', + "import { moved } from './changed.js';", // 14 + 'const a = moved();', // 15 + '', + ].join('\n'); + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + + expect(widened.scope.interaction[0].seam).toEqual({ kept: 0, total: 2 }); + expect(widened.hunkKeep?.get('src/imp.ts')?.size).toBe(0); + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('diff --git a/src/imp.ts b/src/imp.ts'); + expect(diff).not.toContain('@@ -1,3 +1,3 @@'); + const parsed = parseDiff(diff ?? ''); + const imp = parsed.files.find((f) => f.path === 'src/imp.ts'); + expect(imp).toBeDefined(); + expect(imp?.hunks).toHaveLength(0); + // The planner still tiles the header-only section into a chunk. + const plan = buildDiffPlan(diff ?? '', 400); + expect( + plan.chunks.some((c) => c.files.some((f) => f.path === 'src/imp.ts')), + ).toBe(true); + }); + + it('records a census that kept every hunk, and sheds nothing (#10136)', () => { + // Both hunks display a seam line: the census says so (`kept === total`) + // and the section republishes exactly as the unbounded widening would — + // no `hunkKeep` entry, byte-identical diff. The readers render this + // census as "kept whole", never as a shed. + const source = [ + "import { moved } from './changed.js';", // 1 + 'const a = moved();', // 2 + '//', // 3 + '', + '', + '', + '', + '', + '', + 'function unrelated() {', // 10 + ' return moved(1);', // 11 + '}', // 12 + '', + ].join('\n'); + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + expect(widened.scope.interaction[0].seam).toEqual({ kept: 2, total: 2 }); + expect(widened.hunkKeep).toBeUndefined(); + expect( + assembleSections(selection, widened.paths, widened.hunkKeep)?.toString( + 'utf8', + ), + ).toBe(assembleSections(selection, widened.paths)?.toString('utf8')); + }); + + it('sheds the FIRST hunk and keeps the second — the reassembled section still tiles', () => { + // The seam sits in the second hunk only; the first is shed. The + // reassembly must keep the header, drop hunk 0 and keep hunk 1 with its + // own `@@` line intact — a planner reading it must find one hunk. + const source = [ + 'const a = 1;', // 1 + 'const b = 2;', // 2 + '//', // 3 + '', + '', + '', + '', + '', + "import { moved } from './changed.js';", // 9 + 'function unrelated() {', // 10 + ' return moved(1);', // 11 + '}', // 12 + '', + ].join('\n'); + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + expect(widened.scope.interaction[0].seam).toEqual({ kept: 1, total: 2 }); + expect([...(widened.hunkKeep?.get('src/imp.ts') ?? [])]).toEqual([1]); + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('diff --git a/src/imp.ts b/src/imp.ts'); + expect(diff).not.toContain('+const a = moved();'); + expect(diff).toContain('@@ -10,3 +10,3 @@'); + expect(diff).toContain('+ return 1;'); + const parsed = parseDiff(diff ?? ''); + const imp = parsed.files.find((f) => f.path === 'src/imp.ts'); + expect(imp?.hunks).toHaveLength(1); + expect(() => buildDiffPlan(diff ?? '', 400)).not.toThrow(); + }); + + it('a hunk-less interaction section is a doubt state: no census, nothing shed', () => { + // A section the PR's diff carries with no hunks (a mode change, a + // rename) has nothing to bound; the scan is skipped and the entry + // carries no seam record, exactly like an unreadable source. + const impSection = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + 'old mode 100644', + 'new mode 100755', + '', + ].join('\n'); + const selection = selectNarrowing( + Buffer.from(section('src/changed.ts') + impSection, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (selection === null) + throw new Error('the narrowing refused this fixture'); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? IMP_SOURCE : null), + seamBound: true, + }); + expect(widened.scope.interaction).toEqual([ + { path: 'src/imp.ts', importsChanged: ['src/changed.ts'] }, + ]); + expect(widened.hunkKeep).toBeUndefined(); + }); + + it('a heavy full-range slice is exempt from the bound at the unit level (#10136)', () => { + // The heavy classification reads the FULL-RANGE section — added + + // removed lines against the post-image line count — not the slice the + // bound would leave. A section past the heavy bar keeps every hunk with + // NO census, so `heavyFiles()` and the invariant agents still see it. + // The fixture is dimensioned so that the REMOVED side decides both + // heavy terms: 101 added + 800 removed against a 302-line post-image. + // With removals: preLines 1001 (bar 300), changed 901 (bar 800) — + // heavy. Ignore removals in the pre-image count and preLines is 201; + // ignore them in the changed count and it is 101 at a 0.33 ratio (bar + // 0.4) — either way not heavy, and the census would appear. + const bulk = Array.from({ length: 100 }, (_, i) => `+heavy ${i}`); + const gone = Array.from({ length: 800 }, (_, i) => `-gone ${i}`); + const impSection = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + '--- a/src/imp.ts', + '+++ b/src/imp.ts', + '@@ -1,1 +1,2 @@', + " import { moved } from './changed.js';", + '+const a = moved();', + '@@ -100,802 +101,102 @@', + ' ctx', + ...gone, + ...bulk, + ' ctx2', + '', + ].join('\n'); + const source = + "import { moved } from './changed.js';\nconst a = moved();\n" + + Array.from({ length: 300 }, (_, i) => `// filler ${i}`).join('\n'); + const selection = selectNarrowing( + Buffer.from(section('src/changed.ts') + impSection, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (selection === null) + throw new Error('the narrowing refused this fixture'); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + expect(widened.scope.interaction[0].seam).toBeUndefined(); + expect(widened.hunkKeep).toBeUndefined(); + // The same section below the heavy bar IS bounded — the exemption is + // the heaviness, not the shape. + const light = impSection.replace( + '@@ -100,802 +101,102 @@\n ctx\n' + + gone.join('\n') + + '\n' + + bulk.join('\n') + + '\n ctx2\n', + '@@ -100,2 +101,3 @@\n ctx\n+light\n ctx2\n', + ); + const lightSel = selectNarrowing( + Buffer.from(section('src/changed.ts') + light, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (lightSel === null) + throw new Error('the narrowing refused this fixture'); + const bounded = widenScope({ + anchor: 'a'.repeat(40), + selection: lightSel, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + expect(bounded.scope.interaction[0].seam).toEqual({ kept: 1, total: 2 }); + }); + + it('a KEPT slice that classifies heavy republishes whole with no census (#10136 R17-4)', () => { + // The exemption's second direction. `buildPlanReport` re-derives + // heaviness from the PUBLISHED slice — the kept hunks' +/- counts + // against the whole-file post-image — so a file non-heavy full-range + // can classify heavy once bounded: shedding hunks lowers changedLines + // while the preLines identity RAISES the pre-image count, and the two + // move in opposite directions. The plan's `heavy` would then roster + // three whole-file invariant agents on a file the loop deliberately + // bounded. The two classifications must tell one story: a + // disagreement republishes the file whole, recording NO census. + // + // Dimensioned on the measured shape: a 400-line post-image whose + // full-range section carries 340 added / 200 removed (preLines 260 — + // below the 300 bar, so the bound engages), where the seam keeps only + // the removal-heavy hunk (0 added / 200 removed → preLines 600, + // changedLines/fileLines 0.5 — heavy). + const gone = Array.from({ length: 200 }, (_, i) => `-gone ${i}`); + const added = Array.from({ length: 340 }, (_, i) => `+new ${i}`); + const impSection = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + '--- a/src/imp.ts', + '+++ b/src/imp.ts', + '@@ -1,201 +1,1 @@', + " import './changed.js';", + ...gone, + '@@ -202,1 +2,341 @@', + ' // filler 2', + ...added, + '', + ].join('\n'); + const source = + "import './changed.js';\n" + + Array.from({ length: 399 }, (_, i) => `// filler ${i + 2}`).join('\n'); + const selection = selectNarrowing( + Buffer.from(section('src/changed.ts') + impSection, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (selection === null) + throw new Error('the narrowing refused this fixture'); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + // Sanity: the fixture really is the divergent shape — the full-range + // section is NOT heavy (preLines 260 < 300), the kept slice IS + // (preLines 600, ratio 0.5). + const section400 = selection.sections.find((f) => f.path === 'src/imp.ts'); + expect(section400?.addedLines).toBe(340); + expect(section400?.removedLines).toBe(200); + expect(widened.scope.interaction[0].seam).toBeUndefined(); + expect(widened.hunkKeep).toBeUndefined(); + // The published bytes carry the whole section, both hunks — the file + // is republished exactly as the unbounded widening would have. + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('-gone 0'); + expect(diff).toContain('+new 0'); + }); + + it('an unresolvable parser records the oracle state and republishes whole with no census (#10136 R18-2)', () => { + // A global install resolves no `typescript` (a build-time dependency + // of the CLI; the published package declares no runtime + // dependencies): `seamLines` would answer the doubt shape for every + // file — correctly republishing everything in full — but the plan, + // the capture note and the posted body could not tell "the oracle + // never ran" from "nothing needed bounding". The scope names the + // state instead: no census, no hunkKeep, the pre-bound behaviour + // byte-identical. + vi.mocked(loadTypeScript).mockReturnValueOnce(null); + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? IMP_SOURCE : null), + seamBound: true, + }); + expect(widened.scope.seamOracle).toBe('unavailable'); + expect(widened.hunkKeep).toBeUndefined(); + expect(widened.scope.interaction).toEqual([ + { path: 'src/imp.ts', importsChanged: ['src/changed.ts'] }, + ]); + // Both hunks republish — the file comes out exactly as the unbounded + // widening would have emitted it. + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('+const a = moved();'); + expect(diff).toContain('+ return 1;'); + }); + + it('a hunk inside a multi-line USE of the seam is kept — the census is {kept: 1, total: 1}, not an empty keep set (#10136 R18-1)', () => { + // The finding's fixture, end to end through the real chain: the + // interaction file imports the changed module on line 1 and calls it + // on line 3, with the call's arguments spanning lines 4-11. The only + // hunk covers the inner argument lines [6,12]. A single-line mark + // over `moved` keeps NOTHING and the plan certifies `kept: 0` over a + // hunk that sits inside the call to the changed module; the + // statement-span mark keeps the hunk, so `hunkKeep` stays + // `undefined` (nothing shed) beside `seam: {kept: 1, total: 1}`. + const ARG_SECTION = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + '--- a/src/imp.ts', + '+++ b/src/imp.ts', + '@@ -4,7 +6,7 @@', + ' {', + ' gamma: 1,', + ' delta: 2,', + '- zeta: 6,', + '+ zeta: 7,', + ' },', + ');', + ' const tail = 2;', + '', + ].join('\n'); + const ARG_SOURCE = [ + "import { moved } from './changed.js';", // 1 + 'const head = 1;', // 2 + 'moved(', // 3 + ' alpha,', // 4 + ' beta,', // 5 + ' {', // 6 + ' gamma: 1,', // 7 + ' delta: 2,', // 8 + ' zeta: 7,', // 9 + ' },', // 10 + ');', // 11 + 'const tail = 2;', // 12 + ].join('\n'); + const selection = selectNarrowing( + Buffer.from(section('src/changed.ts') + ARG_SECTION, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (selection === null) + throw new Error('the narrowing refused this fixture'); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? ARG_SOURCE : null), + seamBound: true, + }); + expect(widened.scope.interaction).toEqual([ + { + path: 'src/imp.ts', + importsChanged: ['src/changed.ts'], + seam: { kept: 1, total: 1 }, + }, + ]); + expect(widened.hunkKeep).toBeUndefined(); + // …and the published bytes carry the zeta hunk — the file did not go + // header-only over a hunk inside the call. + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('+ zeta: 7,'); + }); + + it('a seam line on a hunk boundary keeps the hunk — both ends inclusive (#10136)', () => { + // IMP_SECTION's second hunk spans new-side lines 10-12. A seam use on + // line 12 exactly (the last line) must keep it; one on line 10 exactly + // (the first) must too; one on line 13 must not. + const withUse = (line: number): string => { + const rows = Array.from({ length: 14 }, (_, i) => + i + 1 === line ? 'moved();' : `// filler ${i + 1}`, + ); + rows[0] = "import { moved } from './changed.js';"; + return rows.join('\n'); + }; + const keptAt = (line: number): { kept: number; hunks: number[] | null } => { + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection: seamSelection(), + readWorktree: (rel) => (rel === 'src/imp.ts' ? withUse(line) : null), + seamBound: true, + }); + const keep = widened.hunkKeep?.get('src/imp.ts'); + return { + kept: widened.scope.interaction[0].seam?.kept ?? -1, + // No `hunkKeep` entry means nothing was shed (both kept). + hunks: keep === undefined ? null : [...keep], + }; + }; + // Line 1 (the import) always keeps hunk 0 (new-side 1-3). + expect(keptAt(12)).toEqual({ kept: 2, hunks: null }); + expect(keptAt(10)).toEqual({ kept: 2, hunks: null }); + expect(keptAt(13)).toEqual({ kept: 1, hunks: [0] }); + expect(keptAt(9)).toEqual({ kept: 1, hunks: [0] }); + }); + + it('republishes in full when the seam scan cannot read the source', () => { + // The edge was found on the first read; the seam read failing is a doubt + // state, and every doubt state republishes what the unbounded widening + // would have. + const reads = new Map(); + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => { + if (rel !== 'src/imp.ts') return null; + const n = (reads.get(rel) ?? 0) + 1; + reads.set(rel, n); + return n === 1 ? IMP_SOURCE : null; + }, + seamBound: true, + }); + expect(widened.scope.interaction[0].seam).toBeUndefined(); + expect(widened.hunkKeep).toBeUndefined(); + expect( + assembleSections(selection, widened.paths, widened.hunkKeep)?.toString( + 'utf8', + ), + ).toBe(assembleSections(selection, widened.paths)?.toString('utf8')); + }); + + it('the doubt shape keeps a clamped pure-deletion hunk (#10136)', () => { + // The doubt return marks lines 1..total, but `parseDiff` clamps a + // `@@ -1,N +0,0 @@` hunk to new-side [0,0] — no marked line is ever 0, + // so hunk matching in the doubt state shed exactly the hunk the doubt + // promises to keep. The doubt shape is detected before matching: the + // file republishes in full, with NO seam record, exactly like the + // unreadable-source doubt state. + const impSection = [ + 'diff --git a/src/imp.ts b/src/imp.ts', + '--- a/src/imp.ts', + '+++ b/src/imp.ts', + '@@ -1,2 +0,0 @@', + '-deleted line one', + '-deleted line two', + '@@ -10,3 +8,3 @@', + ' function unrelated() {', + '- return 0;', + '+ return 1;', + ' }', + '', + ].join('\n'); + const selection = selectNarrowing( + Buffer.from(section('src/changed.ts') + impSection, 'utf8'), + Buffer.from(section('src/changed.ts'), 'utf8'), + ); + if (selection === null) + throw new Error('the narrowing refused this fixture'); + // The worktree source trips the oracle's doubt: a dynamic import whose + // value escapes into an argument before any declaration receives it. + const source = [ + "const api = wrap(await import('./changed.js'));", + 'api.call();', + 'export {};', + ].join('\n'); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? source : null), + seamBound: true, + }); + expect(widened.scope.interaction[0].seam).toBeUndefined(); + expect(widened.hunkKeep).toBeUndefined(); + const diff = assembleSections( + selection, + widened.paths, + widened.hunkKeep, + )?.toString('utf8'); + expect(diff).toContain('@@ -1,2 +0,0 @@'); + expect(diff).toContain('-deleted line one'); + expect(diff).toContain('+ return 1;'); + }); + + it('records nothing and drops nothing when the bound is off', () => { + const selection = seamSelection(); + const widened = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: (rel) => (rel === 'src/imp.ts' ? IMP_SOURCE : null), + }); + expect(widened.scope.interaction[0]).toEqual({ + path: 'src/imp.ts', + importsChanged: ['src/changed.ts'], + }); + expect(widened.hunkKeep).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/review/lib/incremental-scope.ts b/packages/cli/src/commands/review/lib/incremental-scope.ts index b9a595165b5..b568f4bc76f 100644 --- a/packages/cli/src/commands/review/lib/incremental-scope.ts +++ b/packages/cli/src/commands/review/lib/incremental-scope.ts @@ -42,12 +42,23 @@ import type { NarrowSelection } from './narrow-diff.js'; import { dependentsOfChanged, discoverWorkspacePackages, + loadTypeScript, + seamLines, } from './import-graph.js'; +import { classifyHeavy } from './heavy.js'; /** A still-clean file pulled in because it imports a changed one. */ export interface InteractionFile { path: string; importsChanged: string[]; + /** + * Present exactly when the fix-audit posture seam-bounded this file + * (#10104): of its section's `total` hunks, `kept` republish — the ones + * displaying a line that imports or uses what changed. The rest were + * cleared by the round that reviewed them and are not re-shown; the brief + * and the posted body both disclose the reduction through this record. + */ + seam?: { kept: number; total: number }; } export interface IncrementalScope { @@ -59,6 +70,18 @@ export interface IncrementalScope { interaction: InteractionFile[]; /** Clean source files the widening considered and did NOT pull in. */ contextFileCount: number; + /** + * Set exactly when the seam bound was asked for but no TypeScript + * parser could be resolved at run time (#10136 R18-2): every + * interaction file republished in full with NO census — byte-identical + * to the pre-bound widening — and the capture note and the posted body + * name the oracle's absence instead of reading as "no interaction file + * needed seam-bounding". TypeScript is a build-time dependency of the + * CLI and the published package carries no runtime dependencies, so + * this is the steady state of a global install; the seam bound only + * ever runs where a parser resolves. + */ + seamOracle?: 'unavailable'; } export interface WidenedScope { @@ -66,6 +89,14 @@ export interface WidenedScope { paths: Set; /** The record the plan carries and the chunk briefs read. */ scope: IncrementalScope; + /** + * Per seam-bounded interaction file, the indices (into its section's + * `hunks`) to republish — `assembleSections` reads it. An entry exists only + * where the bound actually dropped something; an empty set is legal and + * means "header only": the file stays in the published diff (and so in a + * chunk, and so in a brief) with none of its already-cleared hunks. + */ + hunkKeep?: Map>; } export interface WidenInput { @@ -75,6 +106,13 @@ export interface WidenInput { selection: NarrowSelection; /** Read a repo-relative file from the worktree; null when unreadable. */ readWorktree: (repoRelPath: string) => string | null; + /** + * Bound each interaction file to the hunks near its import seams (#10104) + * — the fix-audit posture's widening. Off, the widening republishes + * full-range sections exactly as it always has; the flag is resolved by + * the capture command from the posture, never by a later reader. + */ + seamBound?: boolean; } /** @@ -85,7 +123,7 @@ export interface WidenInput { * floor rather than a separate path that could disagree with it. */ export function widenScope(input: WidenInput): WidenedScope { - const { anchor, selection, readWorktree } = input; + const { anchor, selection, readWorktree, seamBound } = input; const touched = new Set(selection.touched); // Test and docs dependents stay out: re-running tests is `build-test`'s job, @@ -104,6 +142,130 @@ export function widenScope(input: WidenInput): WidenedScope { packages, ); + // The seam bound (#10104). Under the critical posture an interaction + // file's full-range republication is what re-entered 89% of a measured + // long-lived diff every round, and everything it re-found below Critical + // was deferred anyway. So each interaction file keeps only the hunks that + // DISPLAY a seam line — an import of a changed file, or a use of a binding + // such an import introduces — and the record says how many were shed. The + // file itself always stays in scope (header at minimum), so its chunk + // agent is still briefed to re-ask the seam question against the worktree. + // Every doubt state republishes in full: an unreadable source, a section + // with no hunks, a scan that keeps everything, a FULL-RANGE slice that + // classifies heavy (#10136), and the oracle's own doubt return — each + // leaves the file exactly as the unbounded widening published it. The + // heavy state is a doubt state because heaviness is classified from the + // PUBLISHED slice: bounding a heavy interaction file would flip it + // non-heavy, `heavyFiles()` would drop it, and the invariant agents that + // read it whole from the worktree — the only auditors of hunks a backward + // base move smuggles into the full-range slice — would never launch on + // exactly the rounds the bound runs. + const hunkKeep = new Map>(); + const seams = new Map(); + // The oracle's unavailable state is named, not doubted through (#10136 + // R18-2). `seamLines` answers the doubt shape for every file when no + // parser resolves, which republishes everything whole correctly — but + // the plan, the capture's note and the posted body could not then tell + // "the oracle never ran" from "nothing needed bounding", and the round + // would certify the narrowed shape while running the full one (a + // global install resolves no `typescript`: it is a build-time + // dependency of the CLI and the published package carries no runtime + // dependencies). Record it instead: every interaction file republishes + // in full with NO census — the pre-bound behaviour, byte-identical — + // and `seamOracle` says why. + const oracleUnavailable = + seamBound === true && interaction.size > 0 && loadTypeScript() === null; + if (seamBound === true && interaction.size > 0 && !oracleUnavailable) { + const byPath = new Map(selection.sections.map((f) => [f.path, f])); + // The full capture's lines, for the kept slice's own +/- counts — the + // second heaviness classification below reads them exactly as a + // re-parse of the emitted hunks would (`assembleSections` emits each + // kept hunk's diff text verbatim). + const diffLines = selection.fullText.split('\n'); + for (const path of interaction.keys()) { + const section = byPath.get(path); + if (!section || section.hunks.length === 0) continue; + const source = readWorktree(path); + if (source === null) continue; + // Heavy exemption (#10136): classify against the FULL-RANGE section, + // not the slice the bound would leave — the same counts + // `buildPlanReport` derives (added+removed, and preLines from the + // post-image line count), so the plan's `heavy` and the roster's + // invariant agents agree with what this loop decided to publish. + const fileLines = + source === '' + ? 0 + : source.split('\n').length - (source.endsWith('\n') ? 1 : 0); + if ( + classifyHeavy({ + preLines: Math.max( + 0, + fileLines - section.addedLines + section.removedLines, + ), + fileLines, + changedLines: section.addedLines + section.removedLines, + binary: section.binary, + kind: section.kind, + }).heavy + ) { + continue; + } + const lines = seamLines(path, source, touched, packages); + // The doubt state — `null`, a read whose bindings cannot be proven + // collected (#10136) — is detected before hunk matching: + // `parseDiff` clamps a pure-deletion hunk at the top of a file + // (`@@ -1,N +0,0 @@`) to new-side [0,0], no marked line is ever 0, + // so matching in the doubt state would shed exactly the hunks the + // doubt state promises to keep. An explicit signal, never a + // count-based guess (#10136 R18-1): span-widened marking can + // legitimately cover nearly every line of a file, so "all lines + // marked" stopped being a shape a detector could read. Leave the + // file unbounded with NO seam record, exactly like the + // unreadable-source doubt state. + if (lines === null) continue; + const kept = new Set(); + section.hunks.forEach((h, i) => { + if (lines.some((ln) => ln >= h.newStart && ln <= h.newEnd)) { + kept.add(i); + } + }); + // The heavy exemption's second direction (#10136 R17-4): the plan + // classifies heaviness from the PUBLISHED slice — the kept hunks' + // own +/- counts against the whole-file post-image, by the same + // identity `buildPlanReport` applies — and the bound is the first + // partial publisher, so a full-range NON-heavy file can classify + // heavy once bounded (shedding hunks lowers changedLines while the + // identity raises preLines; the two move in opposite directions). + // The classifications must tell one story: a disagreement + // republishes the file whole with NO census, or the plan's `heavy` + // would roster three whole-file invariant agents on a file this + // loop deliberately bounded, in the round shape whose purpose is to + // stop spending them. + if (kept.size < section.hunks.length) { + let keptAdded = 0; + let keptRemoved = 0; + section.hunks.forEach((h, i) => { + if (!kept.has(i)) return; + for (let ln = h.diffStart; ln <= h.diffEnd; ln++) { + const ch = diffLines[ln - 1]?.charAt(0); + if (ch === '+') keptAdded++; + else if (ch === '-') keptRemoved++; + } + }); + const keptHeavy = classifyHeavy({ + preLines: Math.max(0, fileLines - keptAdded + keptRemoved), + fileLines, + changedLines: keptAdded + keptRemoved, + binary: section.binary, + kind: section.kind, + }).heavy; + if (keptHeavy) continue; + } + seams.set(path, { kept: kept.size, total: section.hunks.length }); + if (kept.size < section.hunks.length) hunkKeep.set(path, kept); + } + } + const paths = new Set([...touched, ...interaction.keys()]); return { paths, @@ -112,8 +274,14 @@ export function widenScope(input: WidenInput): WidenedScope { deltaFiles: [...touched].sort(), interaction: [...interaction.entries()] .sort(([a], [b]) => a.localeCompare(b)) - .map(([path, importsChanged]) => ({ path, importsChanged })), + .map(([path, importsChanged]) => ({ + path, + importsChanged, + ...(seams.has(path) ? { seam: seams.get(path) } : {}), + })), contextFileCount: candidates.filter((p) => !interaction.has(p)).length, + ...(oracleUnavailable ? { seamOracle: 'unavailable' as const } : {}), }, + ...(hunkKeep.size > 0 ? { hunkKeep } : {}), }; } diff --git a/packages/cli/src/commands/review/lib/narrow-diff.ts b/packages/cli/src/commands/review/lib/narrow-diff.ts index 525c1fdfbac..5e62b03aa8f 100644 --- a/packages/cli/src/commands/review/lib/narrow-diff.ts +++ b/packages/cli/src/commands/review/lib/narrow-diff.ts @@ -205,12 +205,28 @@ export function selectNarrowing( export function assembleSections( selection: NarrowSelection, paths: ReadonlySet, + hunkKeep?: ReadonlyMap>, ): Buffer | null { // 1-based line numbers throughout, matching `parseDiff`'s own coordinates. const lines = selection.fullText.split('\n'); const selected: Array<[number, number]> = []; for (const file of selection.sections) { if (!paths.has(file.path)) continue; + // A seam-bounded file (#10104) emits its header plus only the kept + // hunks. The header range ends where the first hunk begins, and each + // hunk's own `@@` carries its line numbers, so the reassembled section + // stays a well-formed unified diff whatever subset survives — an empty + // subset leaves a header-only section, which `parseDiff` reads as a + // hunk-less file and `planChunks` tiles as one unit, keeping the file in + // a chunk (and so in a brief) with none of its cleared hunks re-shown. + const kept = hunkKeep?.get(file.path); + if (kept !== undefined && file.hunks.length > 0) { + selected.push([file.diffStart, file.hunks[0].diffStart - 1]); + file.hunks.forEach((h, i) => { + if (kept.has(i)) selected.push([h.diffStart, h.diffEnd]); + }); + continue; + } selected.push([file.diffStart, file.diffEnd]); } diff --git a/packages/cli/src/commands/review/lib/posture.test.ts b/packages/cli/src/commands/review/lib/posture.test.ts new file mode 100644 index 00000000000..1e229292259 --- /dev/null +++ b/packages/cli/src/commands/review/lib/posture.test.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The plan-time posture prediction (#10104) must stay INSIDE the compose-time +// resolution: every case below that predicts `critical` is one +// `floorResolvesCritical` resolves `critical` for, and the conservative +// misses (a streak one short of the bar) predict nothing. + +import { describe, it, expect } from 'vitest'; +import { + CRITICAL_FLOOR_ROUND, + FLAT_STREAK_TO_ENGAGE, + resolveCriticalPosture, +} from './posture.js'; + +describe('resolveCriticalPosture', () => { + it('resolves the round arm from the side file alone', () => { + expect( + resolveCriticalPosture({ + sideLedger: { round: CRITICAL_FLOOR_ROUND - 1 }, + }), + ).toBe('round'); + }); + + it('stays off one round before the schedule with no streak', () => { + expect( + resolveCriticalPosture({ + sideLedger: { round: CRITICAL_FLOOR_ROUND - 2 }, + }), + ).toBeNull(); + }); + + it('resolves the flat-trend arm off a latched streak', () => { + // Round 4 with flatRounds 2: compose's latch holds engagement on the + // recorded streak alone, so the prediction may follow it. + expect( + resolveCriticalPosture({ + sideLedger: { round: 4, flatRounds: FLAT_STREAK_TO_ENGAGE }, + }), + ).toBe('flat-trend'); + }); + + it('does not predict a streak one measurement short of the bar', () => { + // Compose MAY advance it this round — the prediction must not outrun it. + expect( + resolveCriticalPosture({ + sideLedger: { round: 4, flatRounds: FLAT_STREAK_TO_ENGAGE - 1 }, + }), + ).toBeNull(); + }); + + it('clamps a planted streak to the honest maximum, as compose does', () => { + // At round 3 no honest run carries more than 1, so a planted 5 must not + // engage the posture a round ahead of the earliest honest engagement. + expect( + resolveCriticalPosture({ sideLedger: { round: 3, flatRounds: 5 } }), + ).toBeNull(); + }); + + it('follows the recorded explicit floor in both directions', () => { + expect( + resolveCriticalPosture({ recordedFloor: 'critical', sideLedger: null }), + ).toBe('explicit'); + // An explicit `suggestion` turns the posture off even where the round + // schedule would have resolved it. + expect( + resolveCriticalPosture({ + recordedFloor: 'suggestion', + sideLedger: { round: 20 }, + }), + ).toBeNull(); + }); + + it('reads every doubt state as no posture', () => { + expect(resolveCriticalPosture({ sideLedger: null })).toBeNull(); + expect(resolveCriticalPosture({ sideLedger: 'garbled' })).toBeNull(); + expect(resolveCriticalPosture({ sideLedger: {} })).toBeNull(); + expect( + resolveCriticalPosture({ sideLedger: { round: 0, flatRounds: 9 } }), + ).toBeNull(); + expect( + resolveCriticalPosture({ sideLedger: { round: '7' } }), + ).toBeNull(); + }); +}); diff --git a/packages/cli/src/commands/review/lib/posture.ts b/packages/cli/src/commands/review/lib/posture.ts new file mode 100644 index 00000000000..7fea8b8f23e --- /dev/null +++ b/packages/cli/src/commands/review/lib/posture.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// The plan-time reading of the round's posting posture (#10104). +// +// The posting floor itself resolves at compose time, where the previous +// posted round's number is in hand (`floorResolvesCritical`). But by then +// the round's SHAPE is spent: a re-review that was always going to post +// Criticals only has already run the full territory fan-out and the +// full-width reverse-audit waves over territory whose sub-Critical yield the +// floor defers wholesale. Measured on one long-lived PR, that shape cost a +// 3h13m round whose entire finder fan-out contributed nothing postable. +// +// So the capture command predicts the resolution from the same facts compose +// will read — the CLI-recorded invocation floor and the side file +// `pr-context` persisted — and predicts ONLY off the monotone arms, so the +// prediction cannot outrun the resolution: +// +// - an explicit/configured `critical` floor resolves `critical` at compose +// unconditionally; +// - the round arm (`thisRound >= CRITICAL_FLOOR_ROUND`) reads the side +// file's round, which is exactly what compose's `prevRound` reads, and +// rounds only grow; +// - the flat-trend arm reads the recorded streak: at or past +// `FLAT_STREAK_TO_ENGAGE` compose's latch holds engagement on the recorded +// value alone ("the pin is the latch"), so a streak at the bar today is a +// floor resolved `critical` at compose. +// +// What it deliberately does NOT predict: a streak one measurement short of +// the bar (compose may advance it this round — this prediction stays +// conservative and the round keeps the full shape), and this round's own +// explicit `--severity-floor suggestion`, which reaches this reading through +// the same recorded invocation and turns the posture off. Every doubt state +// — no side file, an unreadable one, a round it cannot place — reads as "no +// posture", which is the ordinary full round. +// +// The alignment is closed from the other side too: the plan record this +// prediction produces is itself an arm of compose's floor resolution +// (`floorResolvesCritical`'s fix-audit arm), so even where compose cannot +// re-derive the arms this read predicted from — a context-unavailable +// round, a side file rewritten in between — the posting bar follows the +// shape the round already ran, and an explicit `suggestion` floor at +// compose still wins. + +import { streakOf } from './ledger.js'; + +/** + * The `auto` floor's round schedule: from this round on it resolves + * `critical`. One constant shared by compose's resolution and the capture + * command's prediction, so the two cannot disagree about the schedule. + */ +export const CRITICAL_FLOOR_ROUND = 6; + +/** + * How many consecutive rounds of a not-falling first-time-finding rate + * engage the severity floor ahead of the round-6 schedule (#9903). + * + * Two: one flat round is a step, two is the shortest window in which "the + * rate is not falling" is an observation rather than a single step. The bar + * is read off the ledger's `flatRounds` streak, which a round advances when + * its OWN measured trend fires and resets when it falls — so reaching it + * always takes two measured firing rounds; a carried or pinned streak never + * adds. (Moved here from `compose-review` so the plan-time prediction and + * the compose-time latch read one bar.) + */ +export const FLAT_STREAK_TO_ENGAGE = 2; + +/** Why the capture resolved the critical posture, for the plan's record. */ +export type CriticalPostureCause = 'explicit' | 'round' | 'flat-trend'; + +/** + * Resolve the round's posture at capture time, or null for the full shape. + * + * `recordedFloor` is the invocation's floor as `recordedSeverityFloor` + * recovered it from the CLI-written args record — `undefined` when nothing + * was recorded (the `auto` default). `sideLedger` is the parsed side file + * `pr-context` wrote (`qwen-review-pr--prev-ledger.json`), `null` when + * absent or unreadable; it is the same untrusted shape compose's own + * recovery reads, so the round and streak take the same clamps. + */ +export function resolveCriticalPosture(input: { + recordedFloor?: string; + sideLedger: unknown; +}): CriticalPostureCause | null { + if (input.recordedFloor === 'suggestion') return null; + if (input.recordedFloor === 'critical') return 'explicit'; + const prev = input.sideLedger; + if (typeof prev !== 'object' || prev === null) return null; + const rec = prev as { round?: unknown; flatRounds?: unknown }; + const round = + typeof rec.round === 'number' && Number.isInteger(rec.round) && rec.round > 0 + ? rec.round + : 0; + if (round === 0) return null; + if (round + 1 >= CRITICAL_FLOOR_ROUND) return 'round'; + // Clamped to the honest maximum exactly as compose's read is: the signal + // that advances the streak gates on round >= 3, so at round N no honest + // run carries more than N - 2, and a planted file claiming more would + // engage the posture off rounds the signal never measured. + const flat = Math.min(streakOf(rec.flatRounds) ?? 0, Math.max(round - 2, 0)); + return flat >= FLAT_STREAK_TO_ENGAGE ? 'flat-trend' : null; +} diff --git a/packages/cli/src/commands/review/lib/retirement.test.ts b/packages/cli/src/commands/review/lib/retirement.test.ts index 6169798583b..3856a475922 100644 --- a/packages/cli/src/commands/review/lib/retirement.test.ts +++ b/packages/cli/src/commands/review/lib/retirement.test.ts @@ -233,6 +233,7 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { due: [13, 14, 15], coldChecks: [], skipped: [], + narrowed: [], converged: false, // No history yet — nothing is certifiable, so nothing is diagnosed. diagnostics: [], @@ -240,6 +241,383 @@ describe('scheduleReverseAuditRound — the scheduler on its own', () => { expect(schedule(2).due).toEqual([13, 14, 15]); }); + it('posture narrowing drops a dry non-delta chunk, keeps yields and unknowns (#10104)', () => { + // Delta territory: 13. 14 and 15 are interaction-only chunks. + transcript(record(1, 13, 'chunk 13 round 1 territory walk'), YIELD); + transcript(record(2, 13, 'chunk 13 round 2 territory walk'), DRY); + transcript(record(1, 14, 'chunk 14 round 1 territory walk'), DRY); + transcript(record(2, 14, 'chunk 14 round 2 territory walk'), DRY); + transcript(record(1, 15, 'chunk 15 round 1 territory walk'), DRY); + transcript(record(2, 15, 'chunk 15 round 2 territory walk'), YIELD); + record(1, 16, 'chunk 16 round 1 territory walk'); // no transcript: unknown + record(2, 16, 'chunk 16 round 2 territory walk'); + + const r3 = scheduleReverseAuditRound( + plan, + [13, 14, 15, 16], + 3, + process.env, + diff, + { deltaChunkIds: new Set([13]) }, + ); + // 13 is delta and keeps the ordinary rules (yield+dry: hot). 14 is + // non-delta with a dry latest audit: narrowed out, no cold check. 15 + // yielded last wave: due. 16 never certified anything: fails toward + // auditing, due. + expect(r3.due).toEqual([13, 15, 16]); + expect(r3.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + expect(r3.coldChecks).toEqual([]); + expect(r3.converged).toBe(false); + }); + + it('one dry receipt narrows a non-delta chunk that YIELDED the wave before (#10136 R1-4)', () => { + // The distinguishing fixture: chunk 14 yielded in round 1 and returned + // a substantive dry receipt in round 2, each against its OWN findings + // digest (the serial shape — round 2 was built after round 1's + // findings entered the list). Ordinary retirement needs two dry + // audits and would keep it hot; the posture narrowing prices it out + // on the single dry receipt. Delta chunk 13 with the same history + // stays hot — the narrowing never touches a delta territory. + transcript(record(1, 13, 'chunk 13 round 1 territory walk', 'd1'), YIELD); + transcript(record(2, 13, 'chunk 13 round 2 territory walk', 'd2'), DRY); + transcript(record(1, 14, 'chunk 14 round 1 territory walk', 'd1'), YIELD); + transcript(record(2, 14, 'chunk 14 round 2 territory walk', 'd2'), DRY); + + const r3 = scheduleReverseAuditRound(plan, [13, 14], 3, process.env, diff, { + deltaChunkIds: new Set([13]), + }); + expect(r3.due).toEqual([13]); + expect(r3.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + expect(r3.coldChecks).toEqual([]); + // Without the narrowing context the same history keeps BOTH hot — the + // one-receipt bar is the posture's alone. + const plain = scheduleReverseAuditRound( + plan, + [13, 14], + 3, + process.env, + diff, + ); + expect(plain.due).toEqual([13, 14]); + expect(plain.narrowed).toEqual([]); + }); + + it('a non-delta chunk with NO audit history stays in the wave (#10136)', () => { + // The `latest !== undefined` arm alone keeps such a chunk hot: no + // receipt at all is not a dry receipt. Chunk 17 has no record in + // rounds 1-2; it is due at round 3, not narrowed, beside a narrowed + // sibling that holds its single dry receipt. + transcript(record(1, 14, 'chunk 14 round 1 territory walk', 'd1'), DRY); + transcript(record(2, 14, 'chunk 14 round 2 territory walk', 'd2'), DRY); + const r3 = scheduleReverseAuditRound(plan, [14, 17], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([17]); + expect(r3.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + expect(r3.converged).toBe(false); + }); + + it('a dry receipt sharing its digest with a yield does not narrow the chunk out (#10136)', () => { + // The convergence-pair shape: a fix-audit round's first two waves run + // against the SAME findings digest. Chunk 14's round-1 member YIELDED; + // its round-2 member returned a substantive dry receipt that was built + // before round 1's findings entered the cumulative list — the receipt + // never saw them. Pricing the chunk out of the wave on that receipt + // alone would certify convergence over a live finding; the shared + // digest is the proof of staleness, so the chunk falls through to the + // ordinary rules, which see the yield and keep it hot. Chunk 13 shows + // the unaffected shape beside it: dry+dry on one digest, no yield to + // be stale against, narrows out exactly as before. + transcript(record(1, 13, 'chunk 13 round 1 territory walk'), DRY); + transcript(record(2, 13, 'chunk 13 round 2 territory walk'), DRY); + transcript( + record(1, 14, 'chunk 14 round 1 territory walk', 'feed01'), + YIELD, + ); + transcript(record(2, 14, 'chunk 14 round 2 territory walk', 'feed01'), DRY); + + const r3 = scheduleReverseAuditRound(plan, [13, 14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([14]); + expect(r3.narrowed).toEqual([{ chunkId: 13, dryRound: 2 }]); + expect(r3.converged).toBe(false); + }); + + it('a dry receipt sharing its digest with an UNCERTIFIED round does not narrow the chunk out (#10136)', () => { + // The twin of the test above with round 1 uncertified instead of + // yielded: findings merge into the cumulative list unconditionally — + // the yield scan refuses a filed finding whose file line is a + // substring of a listed entry, classifying the receipt `unknown` + // while the orchestrator still merges the finding. A dry member built + // against the SAME digest may never have seen those findings, exactly + // as with a yield; pricing the chunk out on it would certify + // convergence over live findings. Round 1 here left no transcript at + // all — the record's own `unknown` — and the chunk stays hot in `due`, + // not merely outside `narrowed`. + transcript(record(1, 13, 'chunk 13 round 1 territory walk'), DRY); + transcript(record(2, 13, 'chunk 13 round 2 territory walk'), DRY); + record(1, 14, 'chunk 14 round 1 territory walk', 'feed01'); // no transcript + transcript(record(2, 14, 'chunk 14 round 2 territory walk', 'feed01'), DRY); + + const r3 = scheduleReverseAuditRound(plan, [13, 14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([14]); + expect(r3.narrowed).toEqual([{ chunkId: 13, dryRound: 2 }]); + expect(r3.converged).toBe(false); + }); + + it('a dry receipt whose list differs only by cleared verification tags is still stale (#10136 R17-1)', () => { + // The convergence pair's lists legitimately differ by `— [unverified]` + // tag state alone (SKILL.md:771 — the merge clears tags between the + // pair's rounds), so round 2's record carries a NEW digest over the + // SAME entries: chunk 14 yielded in round 1 against list L (digest + // d1); its round-2 record (digest d2) points at L with the tags + // cleared and no new entry. Digest inequality is not freshness — the + // dry receipt never saw round 1's finding, so the chunk falls through + // to the ordinary rules, which see the yield and keep it hot. + const L1 = + '- **File:** src/pay.ts:42 — the double charge — [unverified]\n' + + '- **Severity:** Suggestion\n'; + const L2 = + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n'; + const f1 = writeFindingsFile(plan, 'reverse-audit--round-1--d1', L1); + const f2 = writeFindingsFile(plan, 'reverse-audit--round-2--d2', L2); + transcript( + record( + 1, + 14, + 'chunk 14 round 1 territory walk\n' + + `read_file(file_path="${f1 ?? ''}")`, + 'd1', + ), + YIELD, + ); + transcript( + record( + 2, + 14, + 'chunk 14 round 2 territory walk\n' + + `read_file(file_path="${f2 ?? ''}")`, + 'd2', + ), + DRY, + ); + + const r3 = scheduleReverseAuditRound(plan, [14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([14]); + expect(r3.narrowed).toEqual([]); + expect(r3.converged).toBe(false); + }); + + it('a re-rendered list (reordered or re-wrapped, round 1 uncertified) is still stale (#10136 R17-1 round 19)', () => { + // The convergence pair's lists are model-edited markdown: between the + // pair's builds the orchestrator clears tags AND re-renders — reorders + // entries, re-wraps prose. Whole-text equality normalises neither, + // and an UNCERTIFIED round 1 filed nothing for the entry arm to look + // for. The comparison must hold at entry granularity: same `file:line` + // tokens, set-compared, or the chunk narrows out over a finding the + // dry receipt never saw and the loop certifies a clean convergence. + const L1 = + '- **File:** src/pay.ts:42 — the double charge — [unverified]\n' + + '- **Severity:** Suggestion\n' + + '- **File:** src/other.ts:7 — a stale cache — [unverified]\n' + + '- **Severity:** Suggestion\n'; + const L2_REORDERED = + '- **File:** src/other.ts:7 — a stale cache\n' + + '- **Severity:** Suggestion\n' + + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n'; + const L2_REWRAPPED = + '- **File:** src/pay.ts:42 — the double\n' + + ' charge\n' + + '- **Severity:** Suggestion\n' + + '- **File:** src/other.ts:7 — a stale\n' + + ' cache\n' + + '- **Severity:** Suggestion\n'; + const f1 = writeFindingsFile(plan, 'reverse-audit--round-1--d1', L1); + for (const [name, l2] of [ + ['reordered', L2_REORDERED], + ['re-wrapped', L2_REWRAPPED], + ] as const) { + const f2 = writeFindingsFile(plan, `reverse-audit--round-2--${name}`, l2); + // Round 1 left a record and NO transcript — the record's own + // `unknown` (the orchestrator still merges its finding). + record( + 1, + 14, + 'chunk 14 round 1 territory walk\n' + + `read_file(file_path="${f1 ?? ''}")`, + 'd1', + ); + transcript( + record( + 2, + 14, + 'chunk 14 round 2 territory walk\n' + + `read_file(file_path="${f2 ?? ''}")`, + 'd2', + ), + DRY, + ); + + const r3 = scheduleReverseAuditRound(plan, [14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([14]); + expect(r3.narrowed).toEqual([]); + expect(r3.converged).toBe(false); + } + }); + + it('a dry receipt whose list never carried the yield is stale even with new entries elsewhere (#10136 R17-1)', () => { + // The sibling arm: round 2's list DID change between the rounds — a + // different chunk's finding merged — so neither the digest nor the + // tag-stripped comparison proves staleness. What proves it is the + // yield's own filed file: the list the dry receipt was launched + // against carries no entry for it, so the receipt predates the merge + // of THIS chunk's finding. The control beside it: the same history + // with the yield's entry present in round 2's list narrows — the + // receipt saw the finding. + const L1 = + '- **File:** src/pay.ts:42 — the double charge — [unverified]\n' + + '- **Severity:** Suggestion\n'; + const L2_OTHER_ONLY = + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n' + + '- **File:** src/other.ts:7 — an unrelated finding\n' + + '- **Severity:** Suggestion\n'; + const f1 = writeFindingsFile(plan, 'reverse-audit--round-1--d1', L1); + const f2 = writeFindingsFile( + plan, + 'reverse-audit--round-2--d2', + L2_OTHER_ONLY, + ); + transcript( + record( + 1, + 14, + 'chunk 14 round 1 territory walk\n' + + `read_file(file_path="${f1 ?? ''}")`, + 'd1', + ), + YIELD, + ); + transcript( + record( + 2, + 14, + 'chunk 14 round 2 territory walk\n' + + `read_file(file_path="${f2 ?? ''}")`, + 'd2', + ), + DRY, + ); + + const r3 = scheduleReverseAuditRound(plan, [14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([14]); + expect(r3.narrowed).toEqual([]); + expect(r3.converged).toBe(false); + }); + + it('a dry receipt whose list CARRIES the yield narrows — the serial shape (#10136 R17-1)', () => { + // The control for the entry arm: round 1's yield is IN round 2's + // list, so the receipt was built with the finding in view. Different + // digest, different entries, entry present — nothing is stale, and + // the non-delta chunk narrows out on its single dry receipt exactly + // as the posture intends. + const L1 = + '- **File:** src/pay.ts:42 — the double charge — [unverified]\n' + + '- **Severity:** Suggestion\n'; + const L2_SAW_YIELD = + '- **File:** src/pay.ts:42 — the double charge\n' + + '- **Severity:** Suggestion\n' + + '- **File:** packages/cli/src/commands/review/x.test.ts:12 — [unverified]\n' + + '- **Severity:** Suggestion\n'; + const f1 = writeFindingsFile(plan, 'reverse-audit--round-1--d1', L1); + const f2 = writeFindingsFile( + plan, + 'reverse-audit--round-2--d2', + L2_SAW_YIELD, + ); + transcript( + record( + 1, + 14, + 'chunk 14 round 1 territory walk\n' + + `read_file(file_path="${f1 ?? ''}")`, + 'd1', + ), + YIELD, + ); + transcript( + record( + 2, + 14, + 'chunk 14 round 2 territory walk\n' + + `read_file(file_path="${f2 ?? ''}")`, + 'd2', + ), + DRY, + ); + + const r3 = scheduleReverseAuditRound(plan, [14], 3, process.env, diff, { + deltaChunkIds: new Set([99]), + }); + expect(r3.due).toEqual([]); + expect(r3.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + expect(r3.converged).toBe(true); + }); + + it('a retired DELTA chunk still cold-checks; a narrowed one never does', () => { + dryTwice([13, 14]); + const narrowing = { deltaChunkIds: new Set([13]) }; + const r3 = scheduleReverseAuditRound( + plan, + [13, 14], + 3, + process.env, + diff, + narrowing, + ); + expect(r3.due).toEqual([]); + expect(r3.skipped).toEqual([ + { chunkId: 13, dryRounds: [1, 2], nextColdCheck: 4 }, + ]); + expect(r3.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + // Every chunk left the wave — the audit has converged, and the narrowed + // chunk's exit is the posture's own ruling, disclosed, not a gap. + expect(r3.converged).toBe(true); + + const r4 = scheduleReverseAuditRound( + plan, + [13, 14], + 4, + process.env, + diff, + narrowing, + ); + // The even round: the retired delta chunk takes its cold check; the + // narrowed chunk stays out. + expect(r4.due).toEqual([13]); + expect(r4.coldChecks).toEqual([13]); + expect(r4.narrowed).toEqual([{ chunkId: 14, dryRound: 2 }]); + }); + + it('without a narrowing context the schedule is what it always was', () => { + dryTwice([13, 14, 15]); + const r3 = schedule(3); + expect(r3.due).toEqual([]); + expect(r3.narrowed).toEqual([]); + expect(r3.skipped).toHaveLength(3); + }); + it('a disclosure cannot BE the receipt — but cannot BLOCK a real one either', () => { // Two directions, one rule: the receipt is judged with its // `Budget gap:` lines stripped. A return whose only substance is its diff --git a/packages/cli/src/commands/review/lib/retirement.ts b/packages/cli/src/commands/review/lib/retirement.ts index c858c28652f..5e87e619f67 100644 --- a/packages/cli/src/commands/review/lib/retirement.ts +++ b/packages/cli/src/commands/review/lib/retirement.ts @@ -92,6 +92,12 @@ interface Classification { outcome: AuditOutcome; /** Defined exactly when `outcome` is `unknown`. */ failure: CertificationFailure | null; + /** + * Defined exactly when `outcome` is `yielded`: the first filed finding's + * file — the entry a later round's findings list must carry to prove it + * was built after the yield merged. + */ + filedFile?: string; } /** A retired chunk skipped this round, with the receipts that earned it. */ @@ -110,7 +116,27 @@ export interface RoundSchedule { coldChecks: number[]; /** Retired chunks NOT due this round — the retirement note names these. */ skipped: RetiredChunk[]; - /** Every chunk is retired and none is due: the audit has converged. */ + /** + * Chunks the fix-audit posture narrowed out of the wave (#10104): not a + * delta territory, and the most recent audit on record is a substantive + * dry receipt not stale against a yield or uncertified receipt — same + * digest, same entries modulo verification tags, or a filed finding the + * receipt's list never carried. Unlike a retired chunk they get no + * alternating cold check — + * on a critical-posture round the wave re-launches the delta territories + * under the ordinary retirement rules and every non-delta chunk the + * previous waves could not certify dry: a yield, an uncertified receipt + * or no history keeps the chunk in the wave, and a stale dry receipt + * returns it to the ordinary rules (hot until twice dry, then + * cold-checked). The note disclosing the narrowing IS this list. Empty + * whenever the caller passed no narrowing context. + */ + narrowed: Array<{ chunkId: number; dryRound: number }>; + /** + * No chunk is due: retired chunks are between cold checks, posture-narrowed + * chunks have left the wave (#10104 — on a fix-audit round a non-delta + * chunk converges on its single dry receipt), and the audit has converged. + */ converged: boolean; /** * One line per chunk whose two most recent audits are NEITHER dry enough @@ -128,7 +154,7 @@ export interface RoundSchedule { * loosely on purpose: its width is the digest function's business, and a key * this regex misses is merely history this module cannot see — fail-open. */ -const RECORD_KEY_RE = /^reverse-audit--chunk-(\d+)--round-(\d+)--[0-9a-f]+$/; +const RECORD_KEY_RE = /^reverse-audit--chunk-(\d+)--round-(\d+)--([0-9a-f]+)$/; /** * Every launch the builder emits for this loop carries the literal role id — @@ -454,18 +480,49 @@ function substantiveClause(clause: string): boolean { * pairing walk reads each round's list once, not once per record. */ +/** + * A finding entry's trailing `— [unverified]` tag — the marker the merge + * adds at admission and removes once the verdict lands (SKILL.md:789). + * Whitespace-tolerant like compose-review's own reader of the same tag. + */ +const UNVERIFIED_FINDING_TAG_RE = /—\s*\[unverified\]/gi; + +/** + * The `file:line` tokens of a list's entries, as a SET — the granularity + * the staleness ruling compares at (#10136 R17-1 round 19). `FILE_LINE_RE` + * captures the rest of the line, which a re-wrap of the orchestrator's + * model-edited markdown rewrites; the first whitespace-delimited token is + * the `file:line` the entry names, which re-wraps and re-orders cannot + * change — so the comparison is insensitive to both by construction, and + * tag state is stripped per token. Null for a NON-EMPTY list no entry + * extracts from: that list cannot be compared, and the fail direction is + * stale, never a fresh reading of a list nobody could parse. + */ +function entryTokensOf(list: string): ReadonlySet | null { + const out = new Set(); + for (const m of list.matchAll(FILE_LINE_RE)) { + const token = (m[1] ?? '') + .replace(UNVERIFIED_FINDING_TAG_RE, ' ') + .trim() + .split(/\s+/)[0]; + if (token !== undefined && token !== '') out.add(token); + } + return out.size === 0 && list.trim() !== '' ? null : out; +} + function findingsListFor( prompt: string, recordDir: string, memo: Map, -): string { +): { content: string; fromFile: boolean } { const pointer = findingsPointerOf(prompt); - if (pointer === null) return prompt; + if (pointer === null) return { content: prompt, fromFile: false }; const root = resolve(recordDir); const target = resolve(pointer); - if (target !== root && !target.startsWith(root + sep)) return prompt; + if (target !== root && !target.startsWith(root + sep)) + return { content: prompt, fromFile: false }; const cached = memo.get(pointer); - if (cached !== undefined) return cached; + if (cached !== undefined) return { content: cached, fromFile: true }; try { const content = readFileSync(target, 'utf8'); // Memoize ONLY a successful read: the pointer is shared by every chunk of @@ -474,9 +531,10 @@ function findingsListFor( // other chunk's findings list. On a miss each record falls back to its // OWN prompt (no entry matches there → stays hot), uncached. memo.set(pointer, content); - return content; + return { content, fromFile: true }; } catch { - return prompt; // Fall back to this record's own prompt. + // Fall back to this record's own prompt. + return { content: prompt, fromFile: false }; } } @@ -558,7 +616,7 @@ function classifyReturn( const file = (m[1] ?? '').trim(); if (file === '' || /^N\/A\b/i.test(file)) continue; if (findingsList.includes(`**File:** ${file}`)) continue; - return { outcome: 'yielded', failure: null }; + return { outcome: 'yielded', failure: null, filedFile: file }; } } // The receipt is judged WITHOUT its budget-gap disclosure lines. Two @@ -693,6 +751,17 @@ function mergeOutcomes(outcomes: AuditOutcome[]): AuditOutcome { * the two-most-recent-dry rule and is due every round again; no state is * kept anywhere, the history IS the state. * + * Narrowing (#10104): on a fix-audit round the caller passes the delta + * territories, and from round 3 a NON-delta chunk leaves the wave after ONE + * substantive dry audit — no alternating cold check brings it back. That is + * the posture's deliberate recall trade ("re-launch only the chunks that + * produced findings in the previous wave, plus the delta chunks"), and it + * narrows the wave's WIDTH instead of lowering the round cap, because the + * late waves are where measured fix-induced Criticals kept surfacing. The + * failure directions are unchanged: an `unknown` outcome still reads as + * hot, a yield still re-launches, and with no narrowing context the + * schedule is exactly what it always was. + * * Throws whatever the transcript or record readers throw * (`TranscriptsUnavailableError` included): the CALLER owns the fail-open, * because the right degradation — build every chunk — is a build decision, @@ -704,6 +773,7 @@ export function scheduleReverseAuditRound( round: number, env: NodeJS.ProcessEnv = process.env, diffPath?: string, + narrowing?: { deltaChunkIds: ReadonlySet } | null, ): RoundSchedule { // Rounds 1 and 2 establish each chunk's record; retirement needs two // consecutive dry audits, so nothing can retire before round 3. @@ -712,6 +782,7 @@ export function scheduleReverseAuditRound( due: [...chunkIds], coldChecks: [], skipped: [], + narrowed: [], converged: false, diagnostics: [], }; @@ -750,9 +821,12 @@ export function scheduleReverseAuditRound( const records: Array<{ chunkId: number; round: number; + digest: string; lines: string[]; territory: Array<[number, number]>; findings: string; + /** The list was read back from its `.findings.md` file, not the prompt fallback. */ + findingsFromFile: boolean; pointer: string | null; }> = []; for (const [key, prompt] of built) { @@ -760,15 +834,18 @@ export function scheduleReverseAuditRound( if (!m) continue; const r = Number(m[2]); if (r >= round) continue; + const list = findingsListFor(prompt, recordDir, findingsMemo); records.push({ chunkId: Number(m[1]), round: r, + digest: m[3], // Flattened ONCE per record, beside the once-per-transcript flatten // below: the pairing walk pays neither half per (record, transcript) // pair. lines: promptLines(prompt), territory: bakedRanges(prompt, diffPath), - findings: findingsListFor(prompt, recordDir, findingsMemo), + findings: list.content, + findingsFromFile: list.fromFile, pointer: findingsPointerOf(prompt), }); } @@ -867,7 +944,16 @@ export function scheduleReverseAuditRound( // it dry. const history = new Map< number, - Map + Map< + number, + { + outcomes: AuditOutcome[]; + failures: CertificationFailure[]; + digests: Set; + fileLists: Set; + filedFiles: Set; + } + > >(); records.forEach((rec, i) => { let byRound = history.get(rec.chunkId); @@ -875,15 +961,27 @@ export function scheduleReverseAuditRound( byRound = new Map(); history.set(rec.chunkId, byRound); } - const entry = byRound.get(rec.round) ?? { outcomes: [], failures: [] }; + const entry = byRound.get(rec.round) ?? { + outcomes: [], + failures: [], + digests: new Set(), + fileLists: new Set(), + filedFiles: new Set(), + }; entry.outcomes.push(...classificationsByRecord[i].map((c) => c.outcome)); entry.failures.push(...failuresByRecord[i]); + for (const c of classificationsByRecord[i]) { + if (c.filedFile !== undefined) entry.filedFiles.add(c.filedFile); + } + entry.digests.add(rec.digest); + if (rec.findingsFromFile) entry.fileLists.add(rec.findings); byRound.set(rec.round, entry); }); const due: number[] = []; const coldChecks: number[] = []; const skipped: RetiredChunk[] = []; + const narrowed: Array<{ chunkId: number; dryRound: number }> = []; const diagnostics: string[] = []; for (const chunkId of chunkIds) { const audits = [...(history.get(chunkId)?.entries() ?? [])] @@ -891,8 +989,88 @@ export function scheduleReverseAuditRound( round: r, outcome: mergeOutcomes(entry.outcomes), failures: entry.failures, + digests: [...entry.digests], + fileLists: [...entry.fileLists], + filedFiles: [...entry.filedFiles], })) .sort((a, b) => a.round - b.round); + // The posture narrowing, ruled before retirement so a non-delta chunk + // never earns a cold-check slot the posture does not run: its most + // recent audit being provably dry is the whole bar. Everything less + // certain — no history, an unknown, a yield — falls through to the + // ordinary rules and stays hot, the same fail-toward-auditing floor as + // every other refusal in this file. One dry receipt is NOT decisive + // when it shares its findings digest with ANY round the record does + // not certify dry — a yield, or an uncertified `unknown`: fix-audit + // rounds run their first two waves as a convergence pair against the + // SAME cumulative list (#10136), and findings merge into that list + // unconditionally — the yield scan refuses a filed finding whose file + // line is a substring of a listed entry, classifying the receipt + // `unknown` while the orchestrator still merges the finding — so the + // dry member was built before those findings entered it. It never saw + // them, and pricing the chunk out of the wave on it would certify + // convergence over live findings. Digest inequality does NOT lift the + // doubt (#10136 R17-1): the pair's two lists may differ by tag state + // alone, so staleness is ruled on the lists themselves below, with the + // digest kept as the same-bytes arm. + if (narrowing != null && !narrowing.deltaChunkIds.has(chunkId)) { + const latest = audits[audits.length - 1]; + if (latest !== undefined && latest.outcome === 'dry') { + // A dry receipt is stale against a non-dry round it shows no + // evidence of having been launched after (#10136 R17-1). Digest + // INEQUALITY alone is not freshness: the convergence pair's two + // lists legitimately differ by `— [unverified]` tag state alone + // (SKILL.md:771 — the merge clears tags between the pair's + // rounds), so a receipt built against the same entries under a + // different digest never saw the round's findings. Three arms, + // each its own evidence: + const staleAgainstYield = audits.some((a) => { + if (a.outcome === 'dry') return false; + // Same digest: built against the same list bytes. + if (a.digests.some((d) => latest.digests.includes(d))) return true; + // Same entries, compared as a SET of `file:line` tokens — the + // pair's two lists legitimately differ by tag state alone + // (SKILL.md:771), and the orchestrator's list is model-edited + // markdown whose re-wraps and re-orders must not read as a + // different list either (#10136 R17-1 round 19: whole-text + // equality normalised neither). Only a list read back from its + // findings file is evidence — a prompt fallback is not a list + // — and a non-empty one no entry extracts from reads as stale + // (fail closed), never fresh. + if ( + a.fileLists.length > 0 && + latest.fileLists.length > 0 && + a.fileLists.some((l) => + latest.fileLists.some((ll) => { + const ea = entryTokensOf(l); + const eb = entryTokensOf(ll); + if (ea === null || eb === null) return true; + return ea.size === eb.size && [...ea].every((x) => eb.has(x)); + }), + ) + ) + return true; + // A yield filed a finding; a receipt whose list carries no + // entry for that file was built before the finding merged. + // Only a list read back from its findings file is evidence here + // — a prompt fallback names no entries either way (the serial + // shape narrows on it exactly as before). File-line + // granularity, the same bar the yield scan itself applies to + // tell a filing from a quotation. + return ( + latest.fileLists.length > 0 && + a.filedFiles.some( + (f) => + !latest.fileLists.some((l) => l.includes(`**File:** ${f}`)), + ) + ); + }); + if (!staleAgainstYield) { + narrowed.push({ chunkId, dryRound: latest.round }); + continue; + } + } + } const lastTwo = audits.slice(-2); const retired = lastTwo.length === 2 && lastTwo.every((a) => a.outcome === 'dry'); @@ -953,11 +1131,14 @@ export function scheduleReverseAuditRound( due, coldChecks, skipped, + narrowed, // An empty `chunkIds` empties `due` vacuously — nothing was ever under // audit, so nothing has proven itself cold. `runAllChunks` refuses a // chunkless plan long before scheduling, but this function is exported, // and convergence is an exit-5 termination rule: it must not be - // reachable from nothing. + // reachable from nothing. A narrowed-out chunk does not block + // convergence: leaving the wave is what the posture ruled for it, and + // the note that discloses the narrowing is the record of the trade. converged: chunkIds.length > 0 && due.length === 0, diagnostics, }; diff --git a/packages/cli/src/commands/review/lib/roster.test.ts b/packages/cli/src/commands/review/lib/roster.test.ts index f26ebb7bfc1..a752ec2ce89 100644 --- a/packages/cli/src/commands/review/lib/roster.test.ts +++ b/packages/cli/src/commands/review/lib/roster.test.ts @@ -671,6 +671,111 @@ describe('a heavy file in a Step-3A-sized diff', () => { }); }); +describe('the fix-audit roster (#10104)', () => { + const posture = { + since: 'a'.repeat(40), + effective: true, + posture: 'critical' as const, + scope: { + anchor: 'a'.repeat(40), + deltaFiles: ['src/a.ts'], + interaction: [], + }, + }; + + it('keeps Agent 0 and the territory shape on a small delta', () => { + const plan = { + prNumber: '123', + ownerRepo: 'o/r', + worktreePath: '/wt', + srcDiffLines: 120, + diffLines: 300, + chunks: [{ id: 1 }, { id: 2 }], + files: [{ path: 'src/a.ts', removedLines: 1, fileLines: 10 }], + incremental: posture, + }; + const keys = requiredAgents(plan).map((a) => a.key); + // The posture's first draft dropped Agent 0 here on the claim that the + // one Critical-grade fidelity regression — a fix commit removing + // behaviour the issue required — is the removed-behavior audit's + // territory. A probe through the real pipeline disproved the claim (see + // the roster's gate comment): the class is invisible to every + // diff-bounded auditor, so the round keeps the one pass that re-checks + // head against the issue. + expect(keys).toContain('0'); + // The chunk fan-out, not the 3A dimension set — the posture flips the + // shared predicate whatever the narrowed delta's size says. + expect(keys).toContain('chunk-1'); + expect(keys).toContain('chunk-2'); + expect(keys).toContain('test-matrix'); + expect(keys).not.toContain('1a'); + expect(keys).not.toContain('6a'); + // The cross-chunk safety nets stay. + expect(keys).toContain('1b'); + expect(keys).toContain('1c'); + expect(keys).toContain('7'); + }); + + it('keeps Agent 0 on a fix-audit round whose published scope shows no deletion', () => { + // The canonical hole the exclusion rationale claimed covered: behaviour + // the PR itself added is absent at the merge base and, once a fix + // commit removes it, absent at head — `base..head` displays it on + // NEITHER side, `removedLines` stays 0, `hasDeletions` drops 1b, and no + // chunk territory shows the removal. Issue fidelity reads head against + // the issue whatever the diff displays, so the roster must require it + // exactly on this shape. + const plan = { + prNumber: '123', + ownerRepo: 'o/r', + worktreePath: '/wt', + srcDiffLines: 120, + diffLines: 300, + chunks: [{ id: 1 }], + files: [{ path: 'src/a.ts', removedLines: 0, fileLines: 10 }], + incremental: posture, + }; + const keys = requiredAgents(plan).map((a) => a.key); + expect(keys).toContain('0'); + }); + + it('keeps Agent 0 on the same plan without the posture', () => { + const plan = { + prNumber: '123', + ownerRepo: 'o/r', + worktreePath: '/wt', + srcDiffLines: 120, + diffLines: 300, + chunks: [{ id: 1 }], + files: [{ path: 'src/a.ts', removedLines: 1, fileLines: 10 }], + incremental: { ...posture, posture: undefined }, + }; + const keys = requiredAgents(plan).map((a) => a.key); + expect(keys).toContain('0'); + // …and reads as 3A: the dimension set, no chunk agents. + expect(keys).toContain('1a'); + expect(keys).not.toContain('chunk-1'); + }); + + it('gives a heavy file its invariant agents under the posture', () => { + const plan = { + prNumber: '123', + ownerRepo: 'o/r', + worktreePath: '/wt', + srcDiffLines: 120, + diffLines: 300, + chunks: [{ id: 1 }], + files: [ + { path: 'src/a.ts', removedLines: 0, fileLines: 10, heavy: true }, + ], + incremental: posture, + }; + const keys = requiredAgents(plan).map((a) => a.key); + expect(keys).toContain('invariant-a--src/a.ts'); + expect(keys).toContain('invariant-b--src/a.ts'); + expect(keys).toContain('invariant-c--src/a.ts'); + }); +}); + describe('isPromptPath — the instruction-file detector', () => { it.each([ // Skills, agent definitions, prompt directories, and prompt/brief-named files. diff --git a/packages/cli/src/commands/review/lib/roster.ts b/packages/cli/src/commands/review/lib/roster.ts index 0ee6d077cc7..b38067b5981 100644 --- a/packages/cli/src/commands/review/lib/roster.ts +++ b/packages/cli/src/commands/review/lib/roster.ts @@ -30,7 +30,7 @@ import { pathTool } from '../script-lint.js'; // The topology gate lives in `budget.ts` — it is a size ruling, and the round // cap needs the same one. Re-exported here because this file was its home and // the roster is where a reader looks for "which fan-out was owed". -export { isTerritoryFanOut } from './budget.js'; +export { isTerritoryFanOut, isFixAuditRound } from './budget.js'; import { isTerritoryFanOut } from './budget.js'; /** @@ -86,6 +86,12 @@ export interface RosterPlan { */ effort?: unknown; repositoryContext?: unknown; + /** + * The capture command's incremental ruling. The roster reads it through + * `isTerritoryFanOut` — a critical-posture round keeps the territory + * shape and the full agent set whatever the narrowed delta's size says. + */ + incremental?: unknown; } /** One agent this review must launch. */ @@ -292,6 +298,18 @@ export function requiredAgents(plan: RosterPlan): RequiredAgent[] { // issue-fidelity pass regardless of whether it has a worktree. Both halves of // the identity, because the brief builder needs both — requiring an agent // nobody could build would wedge the run. + // …and on a fix-audit round too (#10104). The posture's first draft + // dropped Agent 0 here on the claim that the one Critical-grade fidelity + // regression — a fix commit removing behaviour the issue required — is + // the removed-behavior audit's territory (1b), which the round keeps. A + // probe through the real pipeline disproved the claim in its canonical + // shape: the published scope is assembled from `base..head`, and + // behaviour the PR itself added is absent at the merge base and — once a + // fix commit removes it — absent at head, so the removal appears on + // NEITHER side: `removedLines` stays 0, `hasDeletions` drops 1b, and no + // chunk territory displays it. Issue fidelity re-checks head against the + // issue whatever the diff displays, so it is the one auditor that can + // still see the class, and the round keeps it. if (isPositivePrNumber(plan.prNumber) && typeof plan.ownerRepo === 'string') { add('0'); } diff --git a/packages/cli/src/commands/review/pr-context-persist.test.ts b/packages/cli/src/commands/review/pr-context-persist.test.ts index 62c63572912..c47e3e74ce9 100644 --- a/packages/cli/src/commands/review/pr-context-persist.test.ts +++ b/packages/cli/src/commands/review/pr-context-persist.test.ts @@ -110,6 +110,63 @@ describe('persistRecoveredLedger', () => { } }); + it("carries the fetch's merge-base stamp through the identity-known rewrite (#10136 R18-3)", () => { + // The stamp is a fact about THIS machine's capture, not a marker + // field: `fetch-pr` writes it when a round publishes, and the seam + // bound's continuity gate reads it next round. A wholesale rewrite + // that dropped it would keep the gate permanently unprovable; a file + // with no stamp writes none (the bound simply stays off). + const dir = mkdtempSync(join(tmpdir(), 'prev-ledger-')); + const side = join(dir, 'side.json'); + try { + writeFileSync( + side, + JSON.stringify({ + round: 1, + findings: [], + mergeBaseSha: 'b'.repeat(40), + }), + ); + persistRecoveredLedger( + side, + { + ledger, + commitId: 'a'.repeat(40), + reviewId: 42, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + const written = JSON.parse(readFileSync(side, 'utf8')) as Record< + string, + unknown + >; + expect(written['mergeBaseSha']).toBe('b'.repeat(40)); + // …and a stamp-less predecessor yields a stamp-less file: the field + // is never invented, only carried. + writeFileSync(side, JSON.stringify({ round: 1, findings: [] })); + persistRecoveredLedger( + side, + { + ledger, + commitId: 'a'.repeat(40), + reviewId: 43, + foreign: false, + merged: false, + }, + { noOwnReview: false, identityKnown: true }, + ); + const unstamped = JSON.parse(readFileSync(side, 'utf8')) as Record< + string, + unknown + >; + expect('mergeBaseSha' in unstamped).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('a FOREIGN winner carries no planted churn state — and own streak still restores across the round gap', () => { // The round trip for the recovery seam, both halves: any account that // can submit a review can post a marker carrying `churnRounds`, and diff --git a/packages/cli/src/commands/review/pr-context.ts b/packages/cli/src/commands/review/pr-context.ts index cbbe7b2afee..5ac139e0afc 100644 --- a/packages/cli/src/commands/review/pr-context.ts +++ b/packages/cli/src/commands/review/pr-context.ts @@ -1683,6 +1683,18 @@ export function persistRecoveredLedger( { ...recoveredOut, ...carryFileChurn, + // The fetch's merge-base stamp (#10136 R18-3), carried through + // the rewrite: it is a fact about THIS machine's capture — the + // base this round's published diff was captured over — not a + // marker field, so the recovery walk has nothing to say about + // it and a wholesale write would drop it every round, keeping + // the seam bound's continuity gate permanently unprovable. + // Like every carried field it rides the fail-safe direction: a + // file with no stamp simply keeps the next round's bound off. + ...(typeof existing?.['mergeBaseSha'] === 'string' && + existing['mergeBaseSha'] !== '' + ? { mergeBaseSha: existing['mergeBaseSha'] } + : {}), ...(recovered.commitId ? { commitId: recovered.commitId } : {}), // The grafted anchor's provenance — the round that CERTIFIED it. // Persisted beside the pair so compose-review's `prevLedgerFacts` diff --git a/packages/core/src/skills/bundled/review/DESIGN.md b/packages/core/src/skills/bundled/review/DESIGN.md index e3625f9d6a4..7f99b645b77 100644 --- a/packages/core/src/skills/bundled/review/DESIGN.md +++ b/packages/core/src/skills/bundled/review/DESIGN.md @@ -609,6 +609,20 @@ It cannot be folded into the narrowing, because the file it adds is one the delt The local flow anchors on content, not on a commit, because it has no commit to anchor on and is forbidden from making one: the reviewed state is a dirty working tree, and `local-diff.ts`'s standing constraint — nothing on the capture path writes to the index, the worktree, or any ref — rules out snapshot commits and stashes. `git hash-object` without `-w` computes the blob id of the current bytes and writes nothing, so the anchor is the hashed per-file state of exactly what the plan covered, plus the HEAD the diff was measured against. The identity is `:`, not the blob alone — an exec-bit flip or a file↔symlink typechange is its own diff lines, so identical bytes under a different mode are not an identical change; symlinks hash their link text at 120000, exactly what `git diff` renders, never the resolved target's bytes. Whatever cannot be captured faithfully — a submodule gitlink (the pinned diff flags deliberately keep those visible), a FIFO, a path git C-quoted out of an invalid-UTF-8 filename — is marked `unhashable`, which never compares equal, not even to itself: "could not capture it twice" is not "unchanged", and each of those shapes was measured comparing stable under the naive scheme, silently leaving incremental scope forever. The capture also re-snapshots the diff after hashing and withholds the candidate unless the two captures are byte-identical — the one race where the anchor could certify bytes no round reviewed, closed by refusing to anchor rather than by pretending the window is empty. HEAD is hashed into the state id AND checked as a separate hard gate — the redundancy is for legibility's sake — "HEAD moved since the last local round" is a reason a user can act on, where a mere state-id mismatch is not — and it is load-bearing: the captured diff is HEAD-vs-worktree, so under a moved HEAD identical worktree bytes describe a different change under review (a reset exposes commits no round ever read). The candidate/cache split mirrors the PR flow's marker rules: the capture writes this round's anchor deterministically on every run, and only Step 8's clean-high-effort gate promotes it to `.qwen/review-cache/`, so a fail-closed round can never anchor the next round's skip past scope nobody reviewed. +## Why the critical posture changes the round's shape (#10104) + +Once a multi-round review settles into the critical-only posting posture, the round's shape and its signal come apart. Measured on PR #9729's round 15: the `review-pr` step ran 3h13m and ~131M input tokens; all three findings the round posted were Critical, all sat in code the fix loop had just written, and all three **first surfaced in the reverse-audit waves** (r1, r3, r5) — under the critical floor the entire 18-agent finder fan-out contributed nothing postable, because everything it found below Critical was deferred by construction. Meanwhile the one-hop widening re-entered 5,110 of ~5,762 diff lines (89%), because every fix commit touches hub files the rest of the PR imports. Sixteen such rounds cost ~50 runner-hours without converging. + +So when the capture command can prove the posture is coming, it changes the round's shape — three levers, one condition: + +- **The fix-audit fan-out.** `fetch-pr` predicts the floor's compose-time resolution from the same facts compose reads (the side file's round and `flatRounds`, or the CLI-recorded explicit floor), and only off the monotone arms — a round at or past the schedule, a streak at or past the latch bar — so the prediction can never claim a posture the resolution then declines. The residual divergences — a compose-time `contextUnavailable` disengaging the auto arms, or a side file rewritten between capture and compose — are closed from the other side: the plan's posture record is itself an arm of the floor resolution (`floorResolvesCritical`'s fix-audit arm), so a round that ran the narrow shape defers sub-Critical findings whatever the re-derivation says, and the one combination nothing licenses — narrowed coverage posting in full — is unreachable on the `auto` path. An explicit `--severity-floor suggestion` this round still wins over a stale plan record (the arm is gated on `auto` like its siblings), and the posted body's round-shape sentence then states that the floor resolved open rather than claiming a deferral. The prediction lands in the plan as `incremental.posture`, and the topology gate reads it: a fix-audit round is a territory fan-out whatever its narrowed sizes say. That single ruling in `isTerritoryFanOut` is the whole mechanism — the roster, the round-cap tier, the #9242 note and `check-coverage` all read that one predicate, so no consumer can disagree about which fan-out the round owed. The roster keeps every agent the full shape runs. The posture's first draft dropped Agent 0 on the claim that its one Critical-grade regression — a fix removing required behaviour — belongs to the removed-behavior audit the round keeps, but the published `base..head` scope never displays a removal of behaviour the PR itself added (absent at the merge base, absent at head), so `removedLines` stays 0, `hasDeletions` drops 1b, and no territory shows it; issue fidelity re-checks head against the issue whatever the diff displays, and the round keeps the one auditor that can still see the class. +- **The seam-bounded widening.** The full-range republication exists because "clean" is a verdict about the code as it stood — but under the posture, everything a re-read of an interaction file's unrelated hunks can yield below Critical is deferred anyway. So each interaction file keeps only the hunks that display a seam line (an import of a changed file, or a use of a binding such an import introduces), the entry records a `seam: {kept, total}` census, and a file with no seam hunk publishes header-only — still chunked, still briefed, so the seam question ("do your uses of what changed still hold") is asked from the worktree even when no diff byte republishes. The floor argument survives intact: the unwidened round never republished these files at all, so the seam-bounded round sits strictly between the unwidened floor and the full widening, and every doubt state (unreadable source, hunk-less section, a scan that keeps everything, a full-range slice that classifies heavy — or a kept slice that classifies heavy where the full range did not, the plan's heaviness being derived from the published slice) republishes in full — the heavy ones because heaviness is classified from the published slice, and bounding such an interaction file would drop the invariant agents that read it whole. What it knowingly gives up is the caller-side re-read of hunks whose text does not touch the seam — the same regex-heuristic recall trade the widening already made for its edges, now disclosed per file in the plan, the brief, and the posted body. The seam oracle reads the file through TypeScript's own parser (#10136), never a hand-rolled approximation of the grammar: six review rounds of an in-house lexer each surfaced a new lexical shape it guessed wrong — a `/` after a non-null `!`, a keyword-shaped property name, a control-word-shaped method, an `import { export as x }` clause — and a wrong guess is not line-local, because a mis-lexed literal desyncs everything after it. The parser is a build-time dependency of the CLI, not a runtime one, so it is resolved at run time — from the repository the review runs in (whose own `typescript` is the parser that reads its sources), else from the CLI's own tree — and never bundled; where it does not resolve (the steady state of a global install, which is also the review workflow's deployment) the bound does not run at all: every interaction file republishes in full, the plan records `seamOracle: 'unavailable'`, and the capture note and the posted round-shape sentence name the oracle's absence rather than reading as "nothing needed seam-bounding". The reads that doubt are the ones the walk cannot certify: a syntax error (the tree past it is a guess), a `require`/`import(…)` with a computed specifier (it may name a changed file no read can see), a `require`/`import(…)` whose value escapes into an expression no declaration or assignment receives, a still-wrapped promise stored by a binding (the callback body that uses the module is written anywhere), a `createRequire` alias whose own introduction escapes, a JSDoc tag whose own text carries a specifier TypeScript's JSDoc parser erased before it became a node (the `@callback`/`@overload` family, `@see {@link …}`, the `require('…')` type spelling), and a specifier the entrance regex can see resolving into the change set that the parser walk never resolved — one reader for both halves, so a comment that merely mentions the path is a doubt rather than a confident `kept: 0`. The doubt state is an explicit `null` from the oracle, never a count a later reader must re-derive. Measured over the CLI package's own sources, the doubts are the handful of lazy `import(variable)` loaders plus a few comment-mention files, and a test pins that census. +- **The narrowed reverse-audit waves.** Lowering the wave cap would trade recall exactly where this shape's signal lives — R15-3 surfaced in wave 5. So the cap stays and the wave's _width_ narrows instead: from round 3, a chunk holding no delta file leaves the schedule after one substantive dry audit — one not stale against a yield or uncertified receipt it shows no evidence of having seen (same list bytes, same entries modulo `— [unverified]` tags, or a filed finding its list never carried: the convergence pair's members run against the same findings list, and a dry member's receipt predates its pair member's findings entering it, so it cannot price the chunk out) — and takes no cold checks, while delta territories keep the full retirement rules (two consecutive dry receipts, alternating cold checks) and anything `unknown` stays hot, unchanged. The waves keep running to the same cap over a shrinking front — delta chunks under the ordinary retirement rules plus every non-delta chunk the waves could not certify dry (a yield, an uncertified receipt or no history keeps it in the wave; a stale dry receipt returns it to the ordinary rules) — which is where all three R15 findings were. The residual recall this knowingly trades: an interaction-only territory holds postable seam Criticals too, and it earns only the waves until its first proven dry receipt — a seam breakage subtle enough to evade the seam-briefed finder pass and the first dry wave is missed. That territory is exactly where the seam bound has already focused the finder and waves 1-2, the trade is the issue's own stated rule ("re-launch only the chunks that produced findings in the previous wave plus the delta chunks"), and the `posture narrowing:` note names every chunk it costs. + +What deliberately did not change: the floor's own resolution and enforcement (compose-time, marker-stamped, exactly as before — the plan-time half only predicts it), finding severities (the posture governs posting, never finding, and the fix-audit brief says so), the standing-blocker re-check and the ledger rulings Step 6 owes, the two-consecutive-dry stop rule for delta territories (the narrowing lever above is the only change to when a non-delta chunk leaves the wave), and the verdict semantics. The disclosures are the price of every reduction: the plan records posture, cause and census; the chunk brief names its file's bound; the round output carries a `posture narrowing:` note beside the retirement note; and `compose-review` puts one round-shape sentence in the posted body, so the reduced coverage is a fact on the record rather than a diff of agent counts. + +The backward base-move smuggle (see the roster's heavy-interaction-file comment) is closed at the gate rather than priced as exposure: a seam-bounded round sheds hunks on the premise a prior round published them, and that premise holds only while the merge base holds still between rounds. Each published round's capture stamps its merge base into the side file the next round's posture read already touches, and the bound engages only when the stamp matches the round's own base — a retarget mid-loop (or no recorded base at all, which is every round until the first stamp) keeps the bound off and the full-range republication is the floor, so smuggled hunks outside the seam are shown once and re-anchored past. The heavy-file invariant agents keep their coverage regardless, as before. + ## Why three more mutation operators, and why each is shaped the way it is Statement deletion with a safety-verb filter was the first operator because it has the cleanest survivor semantics. But a live maintainer re-verification produced a survivor list the deletion operator cannot express — and every entry mapped to one of three shapes, each with equally crisp semantics: diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index 9dd0ee9ccdf..a937b02950e 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -168,7 +168,7 @@ Based on the parsed `target.type`: Worktree isolation: all subsequent steps (agents, build/test) operate inside `worktreePath`, not the user's working tree. Cache and reports (Step 8) are written to the **main project directory**, not the worktree. - **Incremental review check** (high effort only — neither low nor medium consults or updates the cache): read `.qwen/review-cache/pr-.json` **before** `fetch-pr` (it is a local file; nothing about it needs the fetch) and, when it holds a `lastCommitSha`, pass BOTH fields to the fetch verbatim: `--since --since-model ` (omit `--since-model` when the cache has no `lastModelId`; do not substitute anything for it). **Copy them; do not compare them to anything.** The same-model gate is ruled inside `fetch-pr`, over the identity the runtime published — "clean up to `lastCommitSha`" is the recorded identity's verdict, and the command validates an anchor against the HISTORY, never against who certified it, so an anchor from another identity is ancestrally perfect and would scope this round past code it never reviewed. A hand-applied version of that gate was wrong every time it was written, because `{{model}}` interpolates the BARE model id while every identity the CLI records is provider-qualified: two provider configurations exposing one model name compared equal and passed each other's gate. When the gate refuses, the report says `cross-model-anchor` and the round reviews the full diff. Read the cache's `findings` ledger either way (Step 6 owes each entry a ruling; the work list carries across models, only the anchor does not). **You never run `git` against an anchor yourself** — no `git diff ..HEAD`, no `cat-file`, no `merge-base --is-ancestor`: the command validates the anchor against the fetched history and computes the scoped diff and chunk plan in one pass, because a hand-run check is one a run can skip, and the hand-computed delta was exactly the shape this skill forbids everywhere else (the diff is a file the CLI writes, never a command you run). The report's `incremental` field is the decision; act on it with `lastModelId` from the cache and the current model ID (`{{model}}`): - - `effective: true` (no `upToDate`) → the report's diff and plan ARE the incremental scope (`since..head`); continue with them exactly as with a full plan. The file set is **widened by one import hop**: a still-clean source file that imports a changed one re-enters the scope with its own full-range hunks, because the round before cleared it against the callee's OLD shape. `incremental.scope` names each file's class — `deltaFiles` (touched since the anchor), `interaction[]` (widened back in, each with the edges that did it), `contextFileCount` (weighed and passed over) — and a chunk brief built for an interaction file points its agent at that seam instead of a from-scratch re-review. **Also read the cache's `findings` ledger** (older caches have none — then there is nothing to track): these are the previous round's findings with their ids, and Step 6 owes each of them a ruling this round. (Reachable only under a matching identity: the gate inside the command is what keeps a cross-model anchor from scoping anything.) + - `effective: true` (no `upToDate`) → the report's diff and plan ARE the incremental scope (`since..head`); continue with them exactly as with a full plan. The file set is **widened by one import hop**: a still-clean source file that imports a changed one re-enters the scope with its own full-range hunks, because the round before cleared it against the callee's OLD shape. `incremental.scope` names each file's class — `deltaFiles` (touched since the anchor), `interaction[]` (widened back in, each with the edges that did it), `contextFileCount` (weighed and passed over) — and a chunk brief built for an interaction file points its agent at that seam instead of a from-scratch re-review. On a round the capture resolved to the **critical posting posture** the report also carries `incremental.posture: "critical"` with its `postureCause` (`round`, `flat-trend`, or `explicit` — the same facts Step 6's floor resolution reads, predicted from the side file and the CLI-recorded invocation), and the round runs the **fix-audit shape**: see Step 1's topology note and Step 5's posture narrowing. On such a round the interaction files re-enter **seam-bounded** — each `interaction[]` entry that the seam scan could run carries a `seam: {kept, total}` census of the hunks republished (the ones displaying a line that imports or uses what changed; the rest — when any were shed — were cleared by an earlier round and are not re-shown, and a census that kept every hunk republishes the file whole and says so); a file whose worktree source could not be read (or whose section holds no hunks, or whose seam read TypeScript's parser cannot certify — the parser is resolved at run time from the reviewed repository and never bundled, so no parser, a syntax error, a computed `require`/`import(` specifier, or a required value that escapes into an expression each read as doubt) re-enters with its full-range hunks and no census — every doubt state fails toward full republication. Two deployment conditions gate the bound itself, each recorded rather than silent: **the oracle must resolve** — where no TypeScript resolves at all (the steady state of a global install, where `typescript` is a build-time dependency of the CLI and the published package carries no runtime dependencies — including the review workflow's runners, which install nothing into the base checkout) the bound never runs, every interaction file republishes in full with no census, the plan records `incremental.scope.seamOracle: "unavailable"`, and the capture note and the posted Round-shape sentence say so instead of reading as "nothing needed seam-bounding"; and **the merge base must hold still** — the bound sheds hunks on the premise a prior round published them, so the capture compares its merge base against the previous round's stamp (carried by the same side file, written by each published round's fetch) and a moved or unrecorded base keeps the bound off, republication whole and the reason printed. A file whose census is `kept: 0` arrives as a header-only section that still lands in a chunk, so its agent is still briefed to answer the seam question from the worktree. **Also read the cache's `findings` ledger** (older caches have none — then there is nothing to track): these are the previous round's findings with their ids, and Step 6 owes each of them a ruling this round. (Reachable only under a matching identity: the gate inside the command is what keeps a cross-model anchor from scoping anything.) - `upToDate: true` **and** `comment.effective` is false (no `--comment` flag, and `review.comment` not enabled in settings) → inform the user "No new changes since last review" (this branch consumes no plan, so it holds even when `diffPath` is null). **Before the cleanup, write the stop sidecar** so `qwen review run` reads the round as DECIDED instead of exiting 1 "Review did not complete" over it: when the environment carries `QWEN_REVIEW_RUN_ID`, write `.qwen/tmp/qwen-review-pr--stop.json` containing exactly `{"reason": "", "runId": ""}` — the same reason+runId contract `capture-local` writes for local stops, runId copied verbatim (the parent's reader is nonce-fenced and discards any other stamp); without that variable no parent is reading and the file is not written. `cleanup` deliberately KEEPS this run's sidecar (it spares a `stop.json` whose `runId` matches the environment) so the parent can still read the decision after the child exits — do not remove it by hand; the next run's cleanup collects it. Then run `"${QWEN_CODE_CLI:-qwen}" review cleanup pr-` to remove the worktree just created, and stop. **This branch does not apply on a resumed run** (`resumed: true` from the resume branch below): a continuation's `incremental` field is the interrupted attempt's history, not this run's decision, and taking the stop/cleanup here would destroy the very state `--resume` reused. - `upToDate: true` **but** `comment.effective` is true (the `--comment` flag or the `review.comment` setting) → run the full review anyway — the report already holds the full-range diff and plan for exactly this flow, unless `diffPath` is null, which is the ordinary degraded state (partial coverage, disclosed) rather than a scoping fact. Inform the user: "No new code changes. Running review to post inline comments." - `reason: cross-model-anchor` → the cached anchor was certified by another identity, so it was not used. Continue on the full-range plan (or, when `diffPath` is null, on the degraded state its siblings name). The command already said which identity certified it and which is running; repeat that to the user rather than restating it from the cache. @@ -244,7 +244,7 @@ Read from it: - `diffLines`, `diffChars`, and `srcDiffLines` / `testDiffLines` / `docsDiffLines` / `generatedDiffLines` - `chunks[]` — contiguous, non-overlapping line ranges tiling the whole diff. Each entry has `id`, `startLine`, `endLine` (1-based, inclusive), `lines`, `chars`, an `oversized` flag, and `files[]` naming the source files and new-side line ranges it covers. A chunk with `oversized: true` may exceed what one `read_file` call returns. - `files[]` — per-file `kind` (`source` / `test` / `generated`), `hunks[]` new-side ranges (Step 7 validates comment anchors against these), `addedRanges[]` and `diffRange` (present only on `heavy` files — the exact lines the PR wrote, and where that file's own diff lives, so an invariant agent can see what was deleted), change counts, and the `heavy` flag -- `budget` — how much walking the **size-elastic** parts of this run owe, sized from `srcDiffLines` except that an all-non-source diff (docs, lockfiles) counts its total lines at an eighth rate, so the size these tiers read is `effective = max(srcDiffLines, floor(diffLines / 8))`; recorded here rather than passed as a flag so every reader sees one number. `inlineAngles`, `sweep`, and `candidateFloor` scope Step 3C's low pass; `candidateFloor` is `min(changed files, 4)` and triggers one deterministic re-pass rather than forcing findings. `specialistCap` is the Agent 8 ceiling (**0** below 80 source lines — "one domain dominates the diff" is a judgement, and a judgement made about forty lines finds a dominant domain every time, because forty lines are usually all one thing — **and 0 again for a huge diff (effective ≥ 3000)**, where an Agent 8 whole-diff pass on top of the base fan-out is the marginal cost that tips a review too big to finish into posting nothing); `verifyShard` is Step 4's findings-per-verifier; `reverseAuditRounds` is the reverse-audit loop's round cap, **one value per topology**: **10** on a Step 3A diff, **5** on a Step 3B one, **3 for a huge diff** (effective ≥ 3000 lines) — but the huge reduction applies **only when the run has a deadline** (`QWEN_REVIEW_DEADLINE_EPOCH`); without a clock a huge diff is just a large 3B diff and gets 5. One number cannot price all three, because what is being capped is a _round_ and a round costs one auditor on 3A, one auditor per non-retired chunk on 3B, and ~90 minutes on a 4,000-line PR — where five rounds (450 min) alone exceed the six-hour ceiling before the fan-out and tail are counted, and the 6-hour timeouts that posted nothing were 4,000-5,300-line PRs (measured; DESIGN.md — The six-hour timeouts). Ten on 3A because the marginal round there is a single agent against a whole review of 20-31 calls: five was the 3B arithmetic applied where it does not hold, and it stopped loops that were still confirming Criticals to save ~5 calls. Three when huge is not a claim that a huge diff converges sooner — it plainly does not, and on recall it deserves more rounds than a small one, not fewer; it is a claim that five ~90-minute rounds do not fit a six-hour ceiling, and a review killed mid-flight posts nothing at all. Where there is no ceiling the premise is absent and so is the reduction. Three is one audit round above the convergence floor of two — the all-dry rounds-1-and-2 shape converges under any cap of two or more, since the convergence check runs before the cap gate; the extra round buys hot chunks one more pass. An operator may LOWER the tier for every review through the `review.reverseAuditRounds` setting (honoured from the User, System and SystemDefaults scopes — never from the repository's own `.qwen/settings.json`; a value below 3, or above the tier, is ignored rather than clamped, so it leaves the tier alone) — the capture command resolves it into this field, so you read one number here either way and never learn that a setting was involved; it can never RAISE a tier. The `agent-prompt` builder enforces the cap itself (a `ROUND CAP:` refusal, exit 4, that writes a marker `compose-review` caps on — same contract as the deadline gate below), so you never count rounds yourself. `agentToolBudget` is the base rate of the soft tool-call ceiling `agent-prompt` bakes into every finder and auditor brief — not the verifier's, not Agent 7's, and not Agent 0's (whose mandatory work scales with the linked issues rather than the diff), not the counter-frame audit 6d's (its mandated PR-context read is discussion-sized) and not the prose-execution audit's (its work is recipe-sized) — five exemptions, the set `agent-prompt` computes from the briefs' own `budgetExempt`. The ceiling is per **launch**: a scoped agent (a chunk, a heavy file) gets an allowance derived from its own territory — never above the plan's recorded allowance, which is clamped into the budget's own band in both directions, so the plan stays the one number every launch answers to — and every launch's assigned reads ride on top of the allowance rather than inside it, so a huge diff's mandatory chunk reads can never exhaust the exploration a whole-diff role owes — because a wave's wall clock is its slowest agent and the slowest agent is reliably one that kept exploring past any recall gain: the same 14-agent fan-out has measured 11.7 and 41 minutes on comparable diffs, the difference being individual agents spending 40-100 calls walking the tree (measured; DESIGN.md — The forty-one minute wave). The ceiling is soft and the briefs restate the recall rule beside it: at the budget an agent stops **exploring**, never reporting — findings in hand are filed, and each stopped check is disclosed on its own line in the fixed form `Budget gap: `, which `check-coverage` parses out of the transcripts (its report's `budgetGaps`) — see Step 3D for the ruling each gap is owed. **It never scales a dimension away** — which agents a review owes is the roster's answer and the roster reads `effort`, so a size input cannot become a back door into shrinking coverage. Nothing here is yours to override: a budget the caller can inflate is a budget that gets inflated. A plan whose `budget` predates `candidateFloor` uses `min(plan.files.length, 4)` for that field. **A plan with no `budget` field** (written by an older CLI — the version-skew this skill has already measured once) falls back to the pre-budget flat behaviour: walk all six angles, run the sweep, use that same candidate-floor fallback, cap Agent 8 at 2, shard verification at 8. Those five err toward more coverage, never less. The round cap is the one exception and is worth naming rather than lumping in: **in a run that has a deadline**, a field-less **huge** plan reads 3 where the flat fallback read 5 — deliberately _less_, because that tier is a finishability ruling and the reviews it exists for are the ones that ran six hours and posted nothing. Without a deadline it reads 5, the same as the flat fallback. +- `budget` — how much walking the **size-elastic** parts of this run owe, sized from `srcDiffLines` except that an all-non-source diff (docs, lockfiles) counts its total lines at an eighth rate, so the size these tiers read is `effective = max(srcDiffLines, floor(diffLines / 8))`; recorded here rather than passed as a flag so every reader sees one number. `inlineAngles`, `sweep`, and `candidateFloor` scope Step 3C's low pass; `candidateFloor` is `min(changed files, 4)` and triggers one deterministic re-pass rather than forcing findings. `specialistCap` is the Agent 8 ceiling (**0** below 80 source lines — "one domain dominates the diff" is a judgement, and a judgement made about forty lines finds a dominant domain every time, because forty lines are usually all one thing — **and 0 again for a huge diff (effective ≥ 3000)**, where an Agent 8 whole-diff pass on top of the base fan-out is the marginal cost that tips a review too big to finish into posting nothing); `verifyShard` is Step 4's findings-per-verifier; `reverseAuditRounds` is the reverse-audit loop's round cap, **one value per topology**: **10** on a Step 3A diff, **5** on a Step 3B one (a fix-audit round below the huge floor reads this tier whatever its narrowed sizes say — the posture flips the same gate the tier reads — but the huge finishability ruling below still wins where there is a wall), **3 for a huge diff** (effective ≥ 3000 lines) — but the huge reduction applies **only when the run has a deadline** (`QWEN_REVIEW_DEADLINE_EPOCH`); without a clock a huge diff is just a large 3B diff and gets 5. One number cannot price all three, because what is being capped is a _round_ and a round costs one auditor on 3A, one auditor per non-retired chunk on 3B, and ~90 minutes on a 4,000-line PR — where five rounds (450 min) alone exceed the six-hour ceiling before the fan-out and tail are counted, and the 6-hour timeouts that posted nothing were 4,000-5,300-line PRs (measured; DESIGN.md — The six-hour timeouts). Ten on 3A because the marginal round there is a single agent against a whole review of 20-31 calls: five was the 3B arithmetic applied where it does not hold, and it stopped loops that were still confirming Criticals to save ~5 calls. Three when huge is not a claim that a huge diff converges sooner — it plainly does not, and on recall it deserves more rounds than a small one, not fewer; it is a claim that five ~90-minute rounds do not fit a six-hour ceiling, and a review killed mid-flight posts nothing at all. Where there is no ceiling the premise is absent and so is the reduction. Three is one audit round above the convergence floor of two — the all-dry rounds-1-and-2 shape converges under any cap of two or more, since the convergence check runs before the cap gate; the extra round buys hot chunks one more pass. An operator may LOWER the tier for every review through the `review.reverseAuditRounds` setting (honoured from the User, System and SystemDefaults scopes — never from the repository's own `.qwen/settings.json`; a value below 3, or above the tier, is ignored rather than clamped, so it leaves the tier alone) — the capture command resolves it into this field, so you read one number here either way and never learn that a setting was involved; it can never RAISE a tier. The `agent-prompt` builder enforces the cap itself (a `ROUND CAP:` refusal, exit 4, that writes a marker `compose-review` caps on — same contract as the deadline gate below), so you never count rounds yourself. `agentToolBudget` is the base rate of the soft tool-call ceiling `agent-prompt` bakes into every finder and auditor brief — not the verifier's, not Agent 7's, and not Agent 0's (whose mandatory work scales with the linked issues rather than the diff), not the counter-frame audit 6d's (its mandated PR-context read is discussion-sized) and not the prose-execution audit's (its work is recipe-sized) — five exemptions, the set `agent-prompt` computes from the briefs' own `budgetExempt`. The ceiling is per **launch**: a scoped agent (a chunk, a heavy file) gets an allowance derived from its own territory — never above the plan's recorded allowance, which is clamped into the budget's own band in both directions, so the plan stays the one number every launch answers to — and every launch's assigned reads ride on top of the allowance rather than inside it, so a huge diff's mandatory chunk reads can never exhaust the exploration a whole-diff role owes — because a wave's wall clock is its slowest agent and the slowest agent is reliably one that kept exploring past any recall gain: the same 14-agent fan-out has measured 11.7 and 41 minutes on comparable diffs, the difference being individual agents spending 40-100 calls walking the tree (measured; DESIGN.md — The forty-one minute wave). The ceiling is soft and the briefs restate the recall rule beside it: at the budget an agent stops **exploring**, never reporting — findings in hand are filed, and each stopped check is disclosed on its own line in the fixed form `Budget gap: `, which `check-coverage` parses out of the transcripts (its report's `budgetGaps`) — see Step 3D for the ruling each gap is owed. **It never scales a dimension away** — which agents a review owes is the roster's answer and the roster reads `effort`, so a size input cannot become a back door into shrinking coverage. Nothing here is yours to override: a budget the caller can inflate is a budget that gets inflated. A plan whose `budget` predates `candidateFloor` uses `min(plan.files.length, 4)` for that field. **A plan with no `budget` field** (written by an older CLI — the version-skew this skill has already measured once) falls back to the pre-budget flat behaviour: walk all six angles, run the sweep, use that same candidate-floor fallback, cap Agent 8 at 2, shard verification at 8. Those five err toward more coverage, never less. The round cap is the one exception and is worth naming rather than lumping in: **in a run that has a deadline**, a field-less **huge** plan reads 3 where the flat fallback read 5 — deliberately _less_, because that tier is a finishability ruling and the reviews it exists for are the ones that ran six hours and posted nothing. Without a deadline it reads 5, the same as the flat fallback. A chunk is read with `read_file(file_path=diffPathAbsolute, offset=startLine - 1, limit=endLine - startLine + 1)` — `offset` is 0-based. For **local-diff and file-path reviews**, capture and plan in one command: @@ -342,6 +342,7 @@ If `diffPath` is `null` (merge-base could not be resolved), fall back to giving **Choose the topology from `srcDiffLines`, not from `diffLines`.** +- **the plan carries `incremental.posture: "critical"`** beside `effective: true` and a well-formed `scope` (a non-empty `anchor` and a `deltaFiles` list — the same `isFixAuditRound` bar every CLI reader applies; a posture beside a malformed scope is the ordinary full round) — a **fix-audit round** (#10104): use Step 3B whatever the sizes say. The narrowed delta is usually 3A-sized, but the posture defers everything below Critical that the floor takes — deterministic `[build]`/`[test]`/`[probe]` findings are excluded from the floor by the tag on their claim line (the fact a typed deferral entry carries as its `source` field) and post as usual — so the round owes one accountable reader per territory of the fix commits, not fourteen dimension lenses re-walking a delta whose sub-Critical yield is deferred. The CLI's own gate reads the same plan field, so the roster, the round-cap tier (the 3B tier of 5, or the huge tier's 3 where a deadline-bound run's delta is itself huge — the huge gate is checked first) and the #9242 stderr note all agree with this routing; the roster the plan builds already reflects the shape. Tell the user: "Critical posture (): fix-audit round over the commits since the last round." - **`srcDiffLines` ≤ 500 and `diffLines` ≤ 3200** — use the dimension fan-out in Step 3A. - **otherwise** — use the territory × dimension fan-out in Step 3B, and inform the user: "This is a large changeset (N source lines of M total, K chunks). The review may take a few minutes." @@ -447,7 +448,7 @@ Everything below still governs what the agent is asked to do; the command builds **Whole-diff agents — launched alongside the chunk agents, in the same response.** -**Their blocks are already in the `--roster` output above — you have them.** Roles there: `0` (PR reviews), `1b` (when the diff removes anything, or a repository context requires it), `1c`, `test-matrix`, `6d` (PR reviews, high effort), `prose-exec` (when the diff touches an instruction file, when the plan's file list is unknown, or a repository context requires it — same-repo only, like `7`: it needs a tree), `7` (same-repo), and for a **heavy** file three more, one per checklist slice (their blocks are labelled `Invariant agent A|B|C: … — `). Pass each **verbatim**. To rebuild one for a relaunch: `--role ` (an invariant agent adds `--file `). `check-coverage` derives the same list from the plan and will name any role that did not run. +**Their blocks are already in the `--roster` output above — you have them.** Roles there: `0` (PR reviews — kept on a fix-audit round too: issue fidelity re-checks head against the issue whatever the diff displays, and it is the one auditor that can see a fix commit removing behaviour the issue required when the removal appears on neither side of `base..head`), `1b` (when the diff removes anything, or a repository context requires it), `1c`, `test-matrix`, `6d` (PR reviews, high effort), `prose-exec` (when the diff touches an instruction file, when the plan's file list is unknown, or a repository context requires it — same-repo only, like `7`: it needs a tree), `7` (same-repo), and for a **heavy** file three more, one per checklist slice (their blocks are labelled `Invariant agent A|B|C: … — `). Pass each **verbatim**. To rebuild one for a relaunch: `--role ` (an invariant agent adds `--file `). `check-coverage` derives the same list from the plan and will name any role that did not run. Why: **the chunk agents got the diff and these did not.** In one real 3B run every one of them was launched with no diff path — and these own exactly the classes a chunk agent is structurally blind to (measured; DESIGN.md — The whole-diff agents launched without the diff). @@ -767,7 +768,7 @@ After deduplication, run reverse audit **iteratively** — the first launch ride - **Small diffs (Step 3A path):** one reverse audit agent per round, reading the whole diff — except rounds 1 and 2, which are **the convergence pair** and launch together (below). - **Large diffs (Step 3B path):** one reverse audit agent **per chunk** per round, launched together in a single response — and rounds 1 and 2 are **the convergence pair** here too, their per-chunk auditors launched together (below). A single agent asked to re-read a 5 800-line diff with a growing finding list appended is the most context-starved agent in the pipeline — precisely on the PRs where the reverse audit matters most. Each per-chunk auditor gets the same territory as its Step 3B counterpart, plus the cumulative finding list for the **whole** diff (so it knows what is already covered elsewhere). -- **The builder schedules the 3B fan-out; you do not.** Rounds 1 and 2 audit every chunk — they are what establishes each territory's record. From round 3 on, `--all-chunks` reads the harness transcripts and **retires** any chunk whose own last two audits were substantively dry (the receipt named what it examined AND the transcript shows the diff was opened): a retired chunk is cold-checked on alternating rounds instead of every round, and a cold check that yields anything returns it to every-round auditing. The savings land on the odd rounds — every retired chunk cold-checks together on the even ones, so an even round's fan-out is unchanged; expect the odd rounds to shrink, not the even ones (under the 3-round huge-diff cap — the reduction a run earns only when it has a deadline — only round 3 can shrink, because the cap ends the loop before round 5). The blocks it prints are the round; the `retirement:` note after the `end of round` line names each skipped chunk and its certificate — relay that note in your narration, and do not hand-build an auditor for a chunk the builder skipped. Why, measured: on a real 6-chunk run, two chunks were dry in **all five rounds** — a third of the loop's auditors re-certifying territories that had already converged, while the three hot chunks were where every finding came from. Attention follows evidence; the certificate a retired chunk holds (two consecutive substantive dry audits) is exactly the one the whole loop used to end on. +- **The builder schedules the 3B fan-out; you do not.** Rounds 1 and 2 audit every chunk — they are what establishes each territory's record. From round 3 on, `--all-chunks` reads the harness transcripts and **retires** any chunk whose own last two audits were substantively dry (the receipt named what it examined AND the transcript shows the diff was opened): a retired chunk is cold-checked on alternating rounds instead of every round, and a cold check that yields anything returns it to every-round auditing. The savings land on the odd rounds — every retired chunk cold-checks together on the even ones, so an even round's fan-out is unchanged; expect the odd rounds to shrink, not the even ones (under the 3-round huge-diff cap — the reduction a run earns only when it has a deadline — only round 3 can shrink, because the cap ends the loop before round 5). The blocks it prints are the round; the `retirement:` note after the `end of round` line names each skipped chunk and its certificate — relay that note in your narration, and do not hand-build an auditor for a chunk the builder skipped. On a **fix-audit round** the schedule additionally narrows the wave (#10104): from round 3, a chunk holding no delta file leaves the schedule after **one** substantive dry audit (one not stale against a same-digest yield or uncertified receipt — the pair's dry member was built before its pair member's findings entered the list) and takes no cold checks — the wave re-launches the delta territories under the ordinary retirement rules (a twice-dry one only on its cold-check rounds) and every non-delta chunk the previous waves could not certify dry (one that yielded, one whose latest receipt is uncertified, or one with no audit history stays in the wave; one whose dry receipt is stale against a same-digest yield or uncertified receipt returns to the ordinary retirement rules), which trades the cold-check recall the posture has already priced (everything below Critical defers, except the pre-confirmed deterministic findings the floor leaves inline by the tag on their claim line) for waves that keep running to the same cap over a shrinking front; on such a round the even-round parity claim above holds for delta territories only — a narrowed non-delta chunk never returns, on any parity. The `posture narrowing:` note after the end-of-round line names each narrowed chunk; relay it exactly as you relay the retirement note, and hand-build nothing it skipped. Why, measured: on a real 6-chunk run, two chunks were dry in **all five rounds** — a third of the loop's auditors re-certifying territories that had already converged, while the three hot chunks were where every finding came from. Attention follows evidence; the certificate a retired chunk holds (two consecutive substantive dry audits) is exactly the one the whole loop used to end on. One anomaly the builder flags but does not refuse (#9242): a per-chunk build on a plan whose own `srcDiffLines`/`diffLines` say Step 3A prints a stderr note — the plan's numbers price one whole-diff auditor per round (the reverse-audit round cap reads them), yet per-chunk auditors were built. It fires on `--all-chunks` and on a `--chunk` build of a round that has no admission stamp yet; a stamped round's `--chunk` rebuilds are exempt — their fan-out was ruled on at admission. If the note fires and the fan-out is deliberate — you decided against the plan's numbers (the routing is yours, as Step 1 says), or this is a whole-round `--all-chunks` rebuild of an already-admitted round on a hand-maintained plan — say so in the round; if it was not deliberate, stop and re-derive the topology from Step 1 instead of spending a fan-out the plan never owed. @@ -821,7 +822,7 @@ On a resumed run (Step 1's `--resume`), the loop re-enters at `latestReverseAudi - A round is **dry** only when _every_ agent in it returned zero new findings **with** the evidence-bearing receipt (`No issues found — `). A round containing a twice-whiffed agent is **not dry** — silence is not convergence evidence — so the loop continues (the hard cap below still bounds it). - **When the loop ends with any scope still outstanding** (by cap, or by dry rounds elsewhere), terminal prose is not enough: add one self-explained entry per scope to `unreviewedDimensions` — e.g. `reverse audit of chunk 3 — the auditor returned nothing substantive twice` — so compose-review serializes it and caps a would-be Approve at `COMMENT`. The primary Step 3 pass did read that scope (its receipt stands), but this run's contract includes the reverse audit, and a verdict must not silently claim an audit that never ran. - Stop after **two consecutive dry rounds** (the 3A criterion — one auditor, so round-dry and territory-dry are the same thing). One dry round is not evidence of convergence: on PR #6457 the review returned "no blockers" twice and the very next round surfaced five Criticals, three of them in code that had been in the diff since the first commit. A single lazy agent must not be able to end the loop. A dry convergence pair satisfies this rule in one launch — its two members are exactly the two independent audits the rule demands; what the pair removes is the wall clock between them, not either audit. When the loop ends on this rule, the last reporting round's verifiers are already in flight (they launched with the next round's auditors) — wait for their verdicts and apply them in the final merge before Step 6. -- **On the 3B path the builder is also the convergence ledger**: when every chunk holds two consecutive substantive dry audits and none is due a cold check, `--all-chunks` builds nothing, prints a `CONVERGED` explanation to stderr and exits **5**. Stop the loop and proceed to Step 6 — this is a **clean** convergence, not a gap: no `unreviewedDimensions` entry is owed, because each chunk holds the two-dry rule's evidence chunk by chunk — two consecutive dry **audits**, though not necessarily in consecutive rounds (a chunk dry in rounds 1 and 2 skips round 3 and cold-checks dry in round 4, holding rounds 2 and 4). If an earlier round-cap or budget refusal told you to add its stop entry to `unreviewedDimensions`, remove it now — this convergence supersedes that stop (the marker on disk is cleared the same way). Exit 5 is mainly the CLI enforcing the stop the two-dry-rounds rule above used to leave to orchestrator discretion; the new savings are the odd-round skips and a convergence at the cap round (round 5 on a 3B diff, round 3 under the huge-diff cap when the run has a deadline and round 5 when it does not — this ledger is 3B's, so the 3A tier's ten never applies here). (It cannot owe a verification launch: a reporting round makes its chunk hot, so every verifier launched with a later round that did run.) +- **On the 3B path the builder is also the convergence ledger**: when every chunk holds two consecutive substantive dry audits and none is due a cold check — or, on a fix-audit round, when every non-delta chunk has been posture-narrowed out on its single dry receipt or retired under the ordinary rules and every delta chunk is retired — `--all-chunks` builds nothing, prints a `CONVERGED` explanation to stderr (naming any chunk narrowed out that round, since no round output carries the `posture narrowing:` note for it) and exits **5**. Stop the loop and proceed to Step 6 — this is a **clean** convergence, not a gap: no `unreviewedDimensions` entry is owed, because each chunk holds the two-dry rule's evidence chunk by chunk — two consecutive dry **audits**, though not necessarily in consecutive rounds (a chunk dry in rounds 1 and 2 skips round 3 and cold-checks dry in round 4, holding rounds 2 and 4). If an earlier round-cap or budget refusal told you to add its stop entry to `unreviewedDimensions`, remove it now — this convergence supersedes that stop (the marker on disk is cleared the same way). Exit 5 is mainly the CLI enforcing the stop the two-dry-rounds rule above used to leave to orchestrator discretion; the new savings are the odd-round skips and a convergence at the cap round (round 5 on a 3B diff, round 3 under the huge-diff cap when the run has a deadline and round 5 when it does not — this ledger is 3B's, so the 3A tier's ten never applies here). (It cannot owe a verification launch: a reporting round makes its chunk hot, so every verifier launched with a later round that did run.) - Stop at the plan's **`reverseAuditRounds` cap** — 10 on a 3A diff, 5 on a 3B one, and 3 for a huge diff (effective ≥ 3000 lines) **when the run has a deadline**, 5 when it does not (the huge reduction answers a six-hour ceiling, so it applies only where there is one) — and say so in the output rather than implying convergence. The cap is per topology because it prices a round, and a 3A round is one auditor where a huge-diff round is ~90 minutes; you never work this out yourself, the builder reads the plan's tier. The builder enforces this itself: a round past the cap gets a `ROUND CAP:` refusal on stderr and exit **4**, and — like the time-budget gate — writes a marker `compose-review` caps the verdict on whether or not you relay anything; still add the entry the message names to `unreviewedDimensions` so the terminal report agrees. If the cap round reported findings, its verifiers have NOT launched — that launch rides the next round's build, which the cap forbids — so verify them before Step 6 through `agent-prompt --role verify` **only** (never a hand-rolled agent), under the same bounded tail as the budget stop below: that builder is gated on the compose floor and refuses once too little time remains, and when the deadline is within the floor you stop waiting on any verifier batch still out and compose with the tags in hand — no fresh re-verification pass, and nothing already confirmed re-verified. This matters most on exactly the huge diffs the cap targets: a time-budgeted CI run that stops at the cap with ~30-90 minutes left must not spend it on an unbounded tail and die before compose. The tag backstop below (and `compose-review`'s machine-read of it) is what catches a miss. - Findings **reported** by each round are merged into the cumulative list **before** the next round begins, so each round sees an updated baseline. **The merge runs unconditionally — before every round build and before Step 6, whether or not the previous round reported findings**: under the pipelined loop below, round _k_'s verdicts land during round _k+1_, and every termination mode (two dry rounds, CONVERGED, budget stop, the round cap) can arrive with the final rounds dry — a merge keyed to "some round reported something" would never apply the last verdicts that landed. Each merge applies every Step 4 verdict that has landed: confirmed removes the tag, rejected removes the entry. Verification status does not gate the merge — the list exists so auditors do not re-report what is already filed, and an unverified entry serves that purpose exactly as well as a confirmed one. The trade, named: an entry a verifier later rejects will have suppressed one round of rediscovery in its neighbourhood — the window is one round in one location, and the plan's round cap still bounds the loop. The tag is what keeps this mechanical rather than remembered: an entry enters the list tagged `— [unverified]`; the merge after its Step 4 verdict removes the tag (confirmed) or the entry (rejected). Step 6's confirmed-only read then has something to key on — anything still tagged is left out of the confirmed set — instead of a memory of which round each entry arrived in. The tag rides inside the findings file, which is hashed into the record key and copied to the digest-named list file each block points at — so a launch that drops the pointer matches no record, and the delivery floor counts the agent's read of that file exactly as it counts the brief's. - **A reporting round whose every finding the verifier rejected is retroactively dry.** The merge already removes a rejected entry from the cumulative list; from the merge that applies the last of a round's rejections, the round also stops counting as a reporting round, and the two-consecutive-dry rule reads rounds' **effective** status. Rejected means rejected — an entry confirmed at low confidence keeps its round a reporting round. Under the pipelined loop a round's verdicts land while the next round runs, so the upgrade usually arrives one round late, and that is still one round saved: a measured run held round 2 dry, watched round 3's sole finding be rejected, and then ran rounds 4 **and 5** — round 4's dry return plus the rejection already in hand was the two-dry evidence, and the fifth round audited nothing the loop had not already answered (measured; DESIGN.md — The rounds a rejected finding bought (PR #8353)). The rule leans on the rejection bar the verifier's brief already enforces — a rejection claims direct counter-evidence, never mere unverifiability — so a round retired by rejections is retired on evidence, not on doubt. **It pairs forward only, and is consulted when a round returns**: on round _k_'s dry return, first apply every verdict that has landed (the unconditional merge — the retirement takes effect at this application, not at some earlier moment), then end the loop if round _k−1_ was dry or is now retired. Round _k−1_ counts **launches, not labels**: the convergence pair is one round here — a pair member is never round _k−1_ on its own (the pair bullet's not-carried-forward rule stands), and a reporting pair retires only when every finding from **both** members is rejected. The upgrade never ends the loop by itself — a preceding dry round plus a freshly-retired round stops nothing while the next round is already in flight: that round was launched, and its return is taken whatever it says, because a launched auditor can be carrying a real Critical. This is the measured shape (round 4's return is where the loop closes under this rule — the measured run, which predates it, ran a fifth round; a cap-5 shape — under the 3-round huge-diff tier, which a run only gets when it has a deadline, the upgrade can only ever retire rounds 1–2, since the cap round's verdicts land during its solo verification, after the loop has already ended) and the only pairing licensed here. It softens nothing else: a whiffed scope stays not-audited whatever the verdicts say, and on 3B the retirement ledger's per-chunk certificates are untouched — this rule reads at the level the round counter reads. @@ -911,7 +912,7 @@ Render the rulings as a short table at the top of the Findings section — id, o **A re-review that keeps posting new non-Critical findings is the motor of a feedback loop this pipeline has measured from the outside**: every push triggers a fresh review, the review files findings on code the previous round just added, the next push implements them, and the diff widens — which allocates more agents, which file more findings. One managed PR rode that loop to +13k lines across 8 rounds with its per-round Critical count flat, and was closed unmerged; the growth was 78–86% test lines. Bug-finding never converges a loop — only the **posting bar** can, and it must rise as rounds accumulate, exactly the discipline a senior reviewer applies by hand ("after ~5 rounds, only blockers; defer the rest, on the record"). This posture is that discipline, made the default. It governs **what posts to the PR**, never what is found, verified, or reported in the terminal: `RECALL` still binds every finder, Step 4 still verifies, the artifact and the terminal report still carry everything. -**Resolve the floor first.** The Step 1 verdict's `severityFloor` is `critical`, `suggestion`, or `auto`. Explicit values are the operator's call: `critical` applies the Critical-only posture from round 1; `suggestion` turns the posture **off** — every round posts Suggestions, and the code-age rule below does not run. `auto` — the default — resolves here, where the round is known: **this review is round `prev ledger round + 1`**, and the round that decides the posture is the SIDE FILE's — the same read `compose-review` stamps into the marker and the deferral clause; the local cache's round scopes the diff but never decides the posture, or the body and the marker would disagree about which round ran (no recovered ledger → round 1 → no posture). Through round 5 the floor is `suggestion`; **from round 6 it is `critical`** — **and it is `critical` from ANY round once the side file's `flatRounds` is at its bar of 2**. That streak is the signal-driven early trigger: `compose-review` measures each round's first-time-finding rate against the previous round's, stamps the consecutive not-falling count into the marker as `flatRounds`, and engages the floor ahead of schedule when the count reaches 2 — acting on the convergence paragraph's own "drop to `--severity-floor critical`" advice instead of only printing it. You cannot evaluate that trend yourself (it is a deterministic join over the ledger, which is exactly why the module owns it), so your routing follows the **marker**: `flatRounds >= 2` in the side file means the floor is `critical` for this round and every later round of this PR — route Suggestions to the deferral channel accordingly. On the round the streak first reaches the bar you will usually have drafted under the open posture; the enforcement backstop below moves those Suggestions mechanically and the posted body discloses the move with the streak that armed it — that is the trigger working, not a lost finding. Once engaged the trigger **latches**: the streak is pinned in the marker rather than re-measured (the floor itself quiets the posted-set trend it reads), so it does not release on a quiet round — an explicit `--severity-floor suggestion` remains the only way back to full posting. In the **context-unavailable** state the round is unknowable — the ledger this rule counts from could not be recovered by a run that could not read the PR — so treat `auto` as round 1: no posture, full posting, and say so in the terminal report (the deterministic marker still stamps its own count from the side file; a posting bar in doubt fails open, bookkeeping does not). Carry the **verdict's `severityFloor` into the compose state UNRESOLVED** — explicit values as they are, and `auto` as the literal string `auto`, never as the level it resolved to this round: the module licenses `auto` by the round it derives itself, and a round-resolved `suggestion` is indistinguishable from the operator's explicit posture-off override — passing it would turn every legal rounds-2–5 age-rule deferral into an unlicensed one. The resolution in this paragraph decides what YOU post; the state field carries the policy. **The module also enforces the floor itself**: a Suggestion still drafted inline past a resolved `critical` floor is moved into the deferral list mechanically by `compose-review`/`submit` (the composed result's `floorEnforced` names the moved indices, the posted body discloses the move, and `submit` drops those comments from the write). Your Step 6 routing stays the primary path — the enforcement is the backstop that keeps the posted set lawful when the routing drifts, so a submit report showing fewer inline comments than you drafted under a critical floor is the floor working, not a lost finding. Three consequences of it being mechanical: the backstop classifies by the drafted severity MARKER alone — it cannot re-derive confidence or a Nice-to-have, so keeping low-confidence and Nice-to-have findings OUT of the drafted comments (as this step already mandates) is what keeps them out of the published deferral list too; **leave moved comments IN the comments file and the submit payload** — the CLI removes them from the write itself, and hand-removing them "to match" makes both boundaries recompute over the reduced set and erases the deferral record the move exists to keep; and the floor it enforces is the RESOLVED one (an explicit `critical`, `auto` from round 6, or `auto` with the `flatRounds` streak at its bar), recovered where possible from the CLI's own record of the invocation rather than the state field alone. +**Resolve the floor first.** The Step 1 verdict's `severityFloor` is `critical`, `suggestion`, or `auto`. Explicit values are the operator's call: `critical` applies the Critical-only posture from round 1; `suggestion` turns the posture **off** — every round posts Suggestions, and the code-age rule below does not run. `auto` — the default — resolves here, where the round is known: **this review is round `prev ledger round + 1`**, and the round that decides the posture is the SIDE FILE's — the same read `compose-review` stamps into the marker and the deferral clause; the local cache's round scopes the diff but never decides the posture, or the body and the marker would disagree about which round ran (no recovered ledger → round 1 → no posture — unless the plan's own fix-audit record resolves it; see the plan-time half below). Through round 5 the floor is `suggestion`; **from round 6 it is `critical`** — **and it is `critical` from ANY round once the side file's `flatRounds` is at its bar of 2**. Since #10104 this resolution also has a **plan-time half**: `fetch-pr` predicts it from the same facts (the side file's round and `flatRounds`, or the invocation's recorded explicit floor — only the monotone arms) and, when it resolves critical beside a usable anchor, runs the round in the fix-audit shape and records `incremental.posture` in the plan. The plan record is then itself an arm of THIS resolution — a fix-audit plan resolves the `auto` floor to critical even where the two arms above cannot re-derive it (a context-unavailable compose, a rewritten side file), so the posting bar can never disagree with the shape the round already ran; an explicit `suggestion` floor still wins. `compose-review` discloses the reduced shape (and, on the explicit-suggestion divergence, the open floor) in the posted body on its own — you never write that sentence by hand. That streak is the signal-driven early trigger: `compose-review` measures each round's first-time-finding rate against the previous round's, stamps the consecutive not-falling count into the marker as `flatRounds`, and engages the floor ahead of schedule when the count reaches 2 — acting on the convergence paragraph's own "drop to `--severity-floor critical`" advice instead of only printing it. You cannot evaluate that trend yourself (it is a deterministic join over the ledger, which is exactly why the module owns it), so your routing follows the **marker**: `flatRounds >= 2` in the side file means the floor is `critical` for this round and every later round of this PR — route Suggestions to the deferral channel accordingly. On the round the streak first reaches the bar you will usually have drafted under the open posture; the enforcement backstop below moves those Suggestions mechanically and the posted body discloses the move with the streak that armed it — that is the trigger working, not a lost finding. Once engaged the trigger **latches**: the streak is pinned in the marker rather than re-measured (the floor itself quiets the posted-set trend it reads), so it does not release on a quiet round — an explicit `--severity-floor suggestion` remains the only way back to full posting. In the **context-unavailable** state the round is unknowable — the ledger this rule counts from could not be recovered by a run that could not read the PR — so treat `auto` as round 1: no posture, full posting, and say so in the terminal report — unless the plan carries its own fix-audit record, which resolves the floor to critical even here (the deterministic marker still stamps its own count from the side file; a posting bar in doubt fails open, bookkeeping does not). Carry the **verdict's `severityFloor` into the compose state UNRESOLVED** — explicit values as they are, and `auto` as the literal string `auto`, never as the level it resolved to this round: the module licenses `auto` by the round it derives itself, and a round-resolved `suggestion` is indistinguishable from the operator's explicit posture-off override — passing it would turn every legal rounds-2–5 age-rule deferral into an unlicensed one. The resolution in this paragraph decides what YOU post; the state field carries the policy. **The module also enforces the floor itself**: a Suggestion still drafted inline past a resolved `critical` floor is moved into the deferral list mechanically by `compose-review`/`submit` (the composed result's `floorEnforced` names the moved indices, the posted body discloses the move, and `submit` drops those comments from the write). Your Step 6 routing stays the primary path — the enforcement is the backstop that keeps the posted set lawful when the routing drifts, so a submit report showing fewer inline comments than you drafted under a critical floor is the floor working, not a lost finding. Three consequences of it being mechanical: the backstop classifies by the drafted severity MARKER alone — it cannot re-derive confidence or a Nice-to-have, so keeping low-confidence and Nice-to-have findings OUT of the drafted comments (as this step already mandates) is what keeps them out of the published deferral list too; **leave moved comments IN the comments file and the submit payload** — the CLI removes them from the write itself, and hand-removing them "to match" makes both boundaries recompute over the reduced set and erases the deferral record the move exists to keep; and the floor it enforces is the RESOLVED one (an explicit `critical`, `auto` from round 6, `auto` with the `flatRounds` streak at its bar, or a literal `auto` beside the plan's fix-audit record — the enforcement reading fails open on an ABSENT floor field even beside that record, and the posted body then says the floor resolved open while the plan record alone still licenses the deferral list), recovered where possible from the CLI's own record of the invocation rather than the state field alone. **At floor `critical`, a non-Critical finding that would otherwise post is recorded, not requested.** The deferrable set is exactly the set the floor takes away: **high-confidence Suggestions** — the findings a `suggestion`-floor round would have drafted inline — plus, at floor `critical` only, the fails-closed/new-surface Criticals described below. Low-confidence findings and Nice-to-haves were never posted at any floor and **stay terminal-only exactly as before**: routing them through the deferral list would _publish_ to the PR what the review contract keeps out of it, and inflate the list the posture exists to keep small. A deferred finding has been through Step 4 like any posted one — the deferral list publishes its one-line claims in the body, so `compose-review`'s verifier-delivery floor counts deferred findings exactly as posted ones; an unverified claim does not become publishable by being deferred. (Deterministic findings are the exception on the verifier's side, and for Suggestions on the floor's side too: a `[build]`/`[test]`/`[probe]` finding is pre-confirmed, Step 4 launches no verifier for it, and the floor's source exclusion leaves a deterministic Suggestion inline — by its `source` field; a deterministic Critical the axes classify defers like any other axes-Critical, its source riding the entry.) Each deferred finding stays in the findings artifact and the terminal report under its own grouping — "Deferred (convergence posture)" — and enters the compose state's `deferredSuggestions` as a **TYPED entry, one object per finding, copied from the artifact's own fields**: `{"file": "src/a.ts", "line": 42, "source": "test", "severity": "Suggestion", "title": "mutation survivor on the retry guard"}` (`line` optional; a pattern aggregate adds `"locations": N` for its further locations). This is a data field, not a sentence: `compose-review` derives deterministic from `source`, relocates a `severity: "Critical"` entry into the body Criticals unless it is the fails-closed, new-surface shape at floor `critical` (below), refuses a `"Nice to have"` (terminal-only) or any malformed entry, and RENDERS the human line `file:line — [source] title` itself — never write that line into the state, and never re-type the fields: read them out of the findings artifact you just wrote. It is **not** drafted into the `comments` array, **not** counted toward `S`, and casts no vote on the event: `compose-review` renders the list as a disclosed, non-capping paragraph — up to 20 entries, each capped at 240 characters, with an overflow count pointing at the run report — so the deferral is on the PR record without opening a thread that regenerates a round, and anything past the rendered cap survives in full in the findings artifact and the terminal report (say so there when the cap trims the list). A previous-round **non-Critical** ledger entry that still stands is ruled in the status table as `still stands — deferred (convergence posture)` and is likewise not re-posted; it leaves the machine ledger (`buildLedger` ingests only posted findings), and the deferral list plus the original round's thread remain its record. **A Critical is deferred by its axes, never by its severity — and only at floor `critical`.** The severity bit alone carried three decisions in one — which way the defect fails, what it is measured against, how often it triggers — and past the convergence rounds everything that mattered still landed on the floor, so the floor filtered nothing and the loop oscillated instead of settling (measured; DESIGN.md — The floor that could not floor (#9659)). Two of those axes now travel with the finding (Step 4's verifier states them off its witness; the artifact carries them as `direction` and `baseline`), and the floor reads them: a Critical whose artifact entry carries `direction: fails-closed` AND `baseline: new-surface` — the change narrows what works, in a surface the merge base never had, so merging it certifies nothing false and regresses nothing — is recorded, not requested, exactly like a Suggestion: a typed `deferredSuggestions` entry with `severity: "Critical"` and both axes copied from the artifact, under its own `D-` artifact id, its `title` opening with the original `R-` id when it carries a still-standing entry forward (the closure mint reads the id there — an id-less re-post silences that round's lineage). Every other Critical posts: `certifies-falsely` at either baseline (the code lies — that is the core promise broken, whatever surface it lives in), `regression` in either direction (the merge base did it right, and a merge gate grades against the merge base), a Critical with either axis missing or self-contradicting (the floor cannot classify it, and a blocker in doubt posts), and every Critical at any floor below `critical` — the rounds-2–5 code-age rule never touches a Critical. `compose-review` holds the same rule in code: a `Critical` entry that is not both `fails-closed` and `new-surface`, or that arrives when the floor is not in effect, is relocated into the body Criticals and posts; and the enforcement backstop moves a drafted `**[Critical]**` comment whose claim line carries both the `[fails-closed]` and `[new-surface]` tags (Step 7 puts them there from the artifact) exactly as it moves a Suggestion, naming the move by severity in the disclosure. The deferred Critical's record is the same as a deferred Suggestion's — the posted deferral line (which names it `Critical` and shows its tags), the findings artifact entry, the terminal report — and it is follow-up work the author files as an issue, not work this round requests; no issue is filed by the review. Everything else about Criticals is unchanged: new Criticals that post still post, still-standing ledger Criticals re-post under their original ids, and every Critical ruling above runs unchanged — with one addition to the routing: the side file's work-list table shows a carried Critical's recorded axes beside its severity (`Critical (fails-closed, new-surface)`), so a still-standing entry of that shape at a `critical` floor goes to the deferral channel rather than being re-posted. An APPROVE composed over a non-empty deferral list opens "No blocking issues" instead of "No issues found" — `compose-review` owns that wording.