diff --git a/changelog.d/gst-951-receipt-counts-blockers.md b/changelog.d/gst-951-receipt-counts-blockers.md index a7175c3f7..ed0cce59a 100644 --- a/changelog.d/gst-951-receipt-counts-blockers.md +++ b/changelog.d/gst-951-receipt-counts-blockers.md @@ -1,2 +1,2 @@ -- **The /ship receipt counts every blocker its readers found** (GST-951). `gstack-review-scope summary` read only the review-log rows, but /ship's readers report through the dispatch ledgers (`gstack-review-dispatch collect` and `judge`). So a blocker that only the native adversarial pass raised, a CRITICAL logged fixed without classify's blocking stamp, and findings logged before they were classified all left `blocking_found` at 0. That happened on #3841, #3838 and #3837. The summary now also reads each ledger's collected and judged CRITICAL findings, pairs each one with at most one row finding at the same path:line so a blocker held in both places counts once and one classified below the floor does not, and counts an unclassified CRITICAL row as blocking. `unrecorded=N` names blockers no row gives a disposition, and `ledgers_read=N` says how many it read. +- **The /ship receipt counts every blocker its readers found** (GST-951). `gstack-review-scope summary` read only the review-log rows, but /ship's readers report through the dispatch ledgers (`gstack-review-dispatch collect` and `judge`). So a blocker that only the native adversarial pass raised, a CRITICAL logged fixed without classify's blocking stamp, and findings logged before they were classified all left `blocking_found` at 0. That happened on #3841, #3838 and #3837. The summary now also reads each ledger's collected and judged CRITICAL findings, pairs each one with at most one row finding from the same reviewer at the same path:line so a blocker held in both places counts once and one classified below the floor does not, and counts an unclassified CRITICAL row as blocking. `unrecorded=N` names blockers no row gives a disposition, and `ledgers_read=N` says how many it read. - **The summary refuses rather than undercount.** It now needs `--run ` (it finds every `review-ledger.jsonl` under it), `--ledger `, or `--no-ledger` when no reader ran through `gstack-review-dispatch`. A missing choice, a run directory with no ledger, or an unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER` with nothing written. /ship on Capy (`ship/DRIVER.md`) passes `--run`; the Claude Code flow passes `--no-ledger`. diff --git a/docs/review-policy.md b/docs/review-policy.md index 8b75ecb3d..b3660c34c 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -395,7 +395,7 @@ puts in the receipt: | Field | Meaning | |---|---| | `review_loop.passes` | full review passes this ship ran (1 or 2; up to 3 under `review_scope: all`) | -| `review_loop.blocking_found` | every blocker found (GST-951): the rows' blocking findings (classified, or an unclassified CRITICAL), plus each ledger CRITICAL (as collected or judged) that no row finding at the same path:line accounts for (each row finding pairs with one), so an adversarial, outside-voice or not-yet-classified blocker counts and one whose newest classification is below the floor does not. An unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER`, never a count | +| `review_loop.blocking_found` | every blocker found (GST-951): the rows' blocking findings (classified, or an unclassified CRITICAL), plus each ledger CRITICAL (as collected or judged) that no row finding from the same reviewer at the same path:line accounts for (each row finding pairs with one per ledger), so an adversarial, outside-voice or not-yet-classified blocker counts and one whose newest classification is below the floor does not. An unreadable ledger, merged file or judged set, or a judged set changed since `judge`, is `REVIEW_SCOPE_LEDGER`, never a count | | `review_loop.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 | | `review_loop.ledgers_read` | dispatch ledgers the count read; absent under `--no-ledger` | | `review_loop.deferred` | blocking findings in unchanged code filed as issues (a Step 11 late defer, logged as a `review-late-defer` row so it never displaces the newest `review` pass, counts here and is not a pass) | diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index 7c3a1ff3e..5f2973f66 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -240,7 +240,8 @@ export function recordJudgment(ledger: string, merged: MergedLine[], judged: Jud * The blocking findings a run's ledger holds (GST-951): every collected line whose label ranks CRITICAL, raised to its judged * severity when the newest judge accepted a judged set that is unchanged since; lead judgment's additions count and a folded * duplicate does not. Every reader counts the same way, whatever it is and whether or not Step 9.4 classified its findings. - * Each carries its locus (a JSON finding's path:line, else the first file:line its text names) so the summary can find its row. + * Each carries its locus (a JSON finding's path:line, else the first path:line its text names, an extensionless bin/ path + * included) so the summary can find its row. * A ledger, merged file or judged set it cannot read, or a judged set changed since judge, throws: the count is never a guess. */ export interface LedgerBlocker { id: string; locus: string | null } @@ -249,7 +250,7 @@ function lineLocus(raw: string): string | null { const o = JSON.parse(raw); if (o && typeof o.path === 'string' && Number.isInteger(o.line)) return `${o.path.replace(/^\.\//, '')}:${o.line}`; } catch { /* a prose finding */ } - return LOCUS.exec(raw)?.[0]?.replace(/^\.\//, '') ?? null; + return /(?:[\w.-]+\/)+[\w.-]+:\d+|[\w.-]+\.\w+:\d+/.exec(raw)?.[0]?.replace(/^\.\//, '') ?? null; } export function ledgerBlockers(ledger: string): LedgerBlocker[] { const fail = (why: string): never => { throw new Error(`${ledger}: ${why}`); }; diff --git a/lib/review-scope.ts b/lib/review-scope.ts index e5e86c453..6ab96923f 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -357,6 +357,12 @@ function rowDispatches(row: Record): Array<{ role: string; bytes: n const DISPOSITIONS = ['fixed', 'auto-fixed', 'deferred', 'noted', 'skipped', 'kept', 'restored', 'open']; /** A row finding that blocks: classified blocking, or an unclassified CRITICAL defect (logged before Step 9.4's classify). */ const rowBlocks = (f: Record) => f.blocking === true || (f.blocking === undefined && f.advisory !== true && rank(f.severity) === 4); +/** The reviewer behind a row's raised_by or a ledger id: every specialist is one; the lead's additions and unknowns name none. */ +function raiserOf(raiser: unknown): string | null { + const role = typeof raiser === 'string' ? raiser.split(/[/#]/)[0]!.trim() : ''; + if (!role || role === 'unknown' || role === 'lead' || role === 'core') return null; + return ['qa', 'red-team', 'adversarial-native', 'outside-voice', 'fix-check', 'targeted'].includes(role) ? role : 'specialist'; +} /** A row finding's path:line, from its path and line or its fingerprint (`path:line:category`). */ function rowLocus(f: Record): string | null { if (typeof f.path === 'string' && Number.isInteger(f.line)) return `${f.path.replace(/^\.\//, '')}:${f.line}`; @@ -366,18 +372,21 @@ function rowLocus(f: Record): string | null { /** * The loop summary. blocking_found counts every blocker found (GST-951). Each CRITICAL the dispatch ledgers hold - * (`ledgerBlockers`, one list per ledger) is paired with at most one row finding at the same path:line that may be it (no - * severity yet, CRITICAL as logged or before a mapper lowered it, or blocking). A paired finding blocks unless its newest + * (`ledgerBlockers`, one list per ledger) is paired with at most one row finding per ledger at the same path:line, raised + * by the same reviewer (or naming none; a fix-check re-reads every reviewer's fixes), that may be it (no severity yet, CRITICAL as logged or before a mapper lowered + * it, or blocking), so one finding re-raised by a later pass's ledger pairs again. A paired finding blocks unless its newest * classification put it below the floor (blocking: false), and its row's action is its disposition. An unpaired ledger * CRITICAL blocks and is unrecorded. A row finding blocks by its newest classification, else as an unclassified CRITICAL. - * A ledger CRITICAL with no locus, or a second one at a paired line, counts on its own: the count may run high, never low. + * A ledger CRITICAL with no locus, or a second one at a paired line, counts on its own. A different defect the same reviewer + * raises at the exact line of a finding a row classified below the floor pairs with that finding: path:line is the only + * identity rows and ledgers share until rows carry the ledger id. */ export function summarizeReviewLoop(rows: readonly Record[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = []): ReviewLoopSummary { const sinceMs = Date.parse(since); const inRun = (r: Record) => r && r.via === 'ship' && Number.isFinite(rowTime(r)) && (!Number.isFinite(sinceMs) || rowTime(r) >= sinceMs); const loopRows = rows.filter(r => inRun(r) && (r.skill === 'review' || r.skill === LATE_DEFER_SKILL || r.skill === FIX_CHECK_SKILL)); const passes = loopRows.filter(r => r.skill === 'review'); - type Seen = { locus: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean }; + type Seen = { locus: string | null; raiser: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean }; const seen = new Map(); const deferred = new Set(); const noted = new Set(); @@ -392,6 +401,7 @@ export function summarizeReviewLoop(rows: readonly Record[], since: const classified = typeof f.blocking === 'boolean'; seen.set(id, { locus: rowLocus(f) ?? prior?.locus ?? null, + raiser: prior?.raiser ?? raiserOf(f.raised_by), classified: classified || !!prior?.classified, blocks: classified ? f.blocking : prior?.classified ? prior.blocks : !!prior?.blocks || rowBlocks(f), candidate: !!prior?.candidate || f.severity === undefined || rank(f.severity) === 4 || rank(f.raw_severity) === 4 || f.blocking === true, @@ -404,11 +414,15 @@ export function summarizeReviewLoop(rows: readonly Record[], since: } const paired = new Set(); let ledgerOnly = 0; - for (const b of ledgers.flat()) { - const id = b.locus ? [...seen].find(([k, s]) => s.candidate && s.locus === b.locus && !paired.has(k))?.[0] : undefined; - if (id) paired.add(id); else ledgerOnly++; + const pairedIds = new Set(); + for (const [n, list] of ledgers.entries()) { + for (const b of list) { + const by = b.id.startsWith('fix-check') ? null : raiserOf(b.id); + const id = b.locus ? [...seen].find(([k, s]) => s.candidate && s.locus === b.locus && (!s.raiser || !by || s.raiser === by) && !paired.has(`${n}\0${k}`))?.[0] : undefined; + if (id) { paired.add(`${n}\0${id}`); pairedIds.add(id); } else ledgerOnly++; + } } - const blockers = [...seen].filter(([k, s]) => s.blocks || (paired.has(k) && !s.classified)); + const blockers = [...seen].filter(([k, s]) => s.blocks || (pairedIds.has(k) && !s.classified)); const unrecorded = ledgerOnly + blockers.filter(([, s]) => !s.disposed).length; const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blockers.length + ledgerOnly, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope }; if (unrecorded) out.unrecorded = unrecorded; diff --git a/ship/DRIVER.md b/ship/DRIVER.md index 549a005fd..d799205cf 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -14,7 +14,7 @@ gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names the next act. Read a section only when a phase or the list at the end sends you, never up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it there, come back. Unattended, take a question's recommended option (log it); gate -dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1 and fill each as its phase ends: **Checks** +dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks** (Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line, start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7: @@ -28,7 +28,7 @@ file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` r small model at low effort; their findings go to the PR body. **Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's -section read or a fix-check, plus a keep-alive per 200 s waited. Fixes redo 3-4. +section read or a fix-check, plus a keep-alive per 200 s waited. **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program @@ -119,7 +119,7 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an ## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22 - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. -- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. +- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: the metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index 7d4abf27d..9670902dd 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -12,7 +12,7 @@ gates and the report. `STOP:` (exit 3) means nothing after it ran; `NEXT:` names the next act. Read a section only when a phase or the list at the end sends you, never up front. A STOP or decision this file does not cover: read `ship/SKILL.md` at that step, finish it there, come back. Unattended, take a question's recommended option (log it); gate -dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1 and fill each as its phase ends: **Checks** +dispositions and approvals stay pending and end the run. Keep a private invocation record (`--record`): write its six headings at Phase 1, fill each as its phase ends: **Checks** (Phases 2-4: lanes, pregate, evals, `Coverage:`, the plan checks, `Exploratory QA:`, `Scope Check:`), **Decisions** (any phase: each answered or auto-taken question, verbatim), **Reviews** (Phases 3-5: the plan line, start tokens, `Pre-Landing Review:`, `Gate edits:`, the reviewed tree), **Release** (Phases 5-7: @@ -26,7 +26,7 @@ file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` r small model at low effort; their findings go to the PR body. **Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's -section read or a fix-check, plus a keep-alive per 200 s waited. Fixes redo 3-4. +section read or a fix-check, plus a keep-alive per 200 s waited. **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program @@ -117,7 +117,7 @@ STALE or MISSING reruns once), `PREGATE:`; print them, the last `Gate edits:` an ## 8. Publish: Steps 18, 19, 19-C, 20, 21, 22 - Step 18: the open-PR lookup; a title file starting `v `. `UNATTENDED_SHIP:` with no open PR at HEAD takes Step 18-H. -- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; a draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. +- Step 19 body: the receipt block, then the sections `sections/pr-body.md` lists (Pre-Landing Review: `REVIEW_LOOP:`, `Gate edits:`, deferred, notes), led under `CAPY_PR: on` (Step 19-C) by What changed, Why, Blast radius and Approvals (`gstack-approvals render`). `gstack-prose-lint --sections 'Why,Summary,Blast radius' --gate`; `gstack-issue-links`; `gstack-review-scope summary --since "$REVIEW_SINCE" --run && gstack-ship-receipt write --pr --base --gate @ --pregate --issues @ --review-loop @ --docs @ --body `; `gstack-post pr-body` and `pr-title` (a new PR via the host's PR tool; draft while a gate finding is `open`). `SHIP_RECEIPT:` opens your report. - Step 20: the metrics row via `gstack-review-log`. - Step 22: `gstack-ship-budget close --reading R --credits C` (report `BUDGET_FINAL:`, `BUDGET_TIMELINE:` (tokens, credits), any `METER:` line), then `gstack-ship-receipt result --pr `: `GSTACK_RESULT: skill=ship status=complete run=`. Under a merge queue, `gstack-ship-queue ready --pr [--screen|--no-screen]` at the pushed head; paste its output. diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 4d02514cb..79af0c5d9 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -233,10 +233,9 @@ When the owner accepted a stop after a fix-check edit, add and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its -`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). When Step 11 -rendered the targeted reader, replace `--no-ledger` with `--ledger /review-ledger.jsonl` -(a run whose passes all ran through `gstack-review-dispatch` passes `--run `), so -every reader's CRITICAL findings count. The `&&` +`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every +finding reaches a row through Step 9.4; a run whose readers were collected by +`gstack-review-dispatch` (ship/DRIVER.md) passes `--run ` instead. The `&&` matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write` record a stale or empty loop file. diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 68b4c4ffc..611c1e553 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -216,10 +216,9 @@ When the owner accepted a stop after a fix-check edit, add and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its -`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). When Step 11 -rendered the targeted reader, replace `--no-ledger` with `--ledger /review-ledger.jsonl` -(a run whose passes all ran through `gstack-review-dispatch` passes `--run `), so -every reader's CRITICAL findings count. The `&&` +`REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). Here every +finding reaches a row through Step 9.4; a run whose readers were collected by +`gstack-review-dispatch` (ship/DRIVER.md) passes `--run ` instead. The `&&` matters: a summary that refuses (no pass matched `REVIEW_SINCE`) must not let `write` record a stale or empty loop file. diff --git a/test/review-scope-ledger-count.test.ts b/test/review-scope-ledger-count.test.ts index 08b835bbd..955d57a61 100644 --- a/test/review-scope-ledger-count.test.ts +++ b/test/review-scope-ledger-count.test.ts @@ -56,21 +56,39 @@ const CLEAN = 'NO FINDINGS'; const OUTSIDE_CLEAN = 'No issues found in the diff.\nRecommendation: ship because no finding reaches the floor'; describe('GST-951: the receipt counts every blocker the readers found', () => { - // #3841 (reconstruction): the native adversarial pass's FIXABLE (CRITICAL) finding was fixed, but no row recorded it; the - // receipt's blocking_found=1 fixed=1 was entered by hand. Its two other findings were noted in pass 2. - test('#3841: an adversarial-only blocker counts', () => { + // #3841 (reconstruction; GSTA-203 PR 8 trail rows 9 and 14): Step 11's native adversarial FIXABLE (CRITICAL) finding was + // fixed in round 2, but no row recorded it (pass 2's row held no findings), so summary printed blocking_found=0 fixed=0 and + // the lane hand-set blocking_found=1 fixed=1. Its two other findings are the noted pair. + const ADVERSARIAL_3841 = [ + '1. lib/pregate-clear.ts:140 The receipt stores the raw run URL, not the attempt it judged. FIXABLE', + '2. lib/pregate.ts:88 The refusal text does not name the attempt. INVESTIGATE', + '3. docs/pregate.md:12 The docs do not say a rerun replaces the attempt. INVESTIGATE', + 'Recommendation: fix 1 because a later attempt would be judged by the first', + ].join('\n'); + test('#3841 as /ship logs it: the queued adversarial blocker in pass 2\'s row counts as 1 found and 1 fixed', () => { + const repo = branchWith([ + review(0, 1, []), + review(40, 2, [{ fingerprint: 'lib/pregate-clear.ts:140:security', action: 'fixed', raised_by: 'adversarial-native' }, + note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); + pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]); + pass(path.join(run, 'p2'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); + expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 1 }); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 1, ledgers_read: 3, rounds: 2 }); + expect(s.unrecorded).toBeUndefined(); + }); + + test('#3841 as the lane logged it: an adversarial-only blocker counts, and its missing fix record shows as unrecorded', () => { const repo = branchWith([ review(0, 1, []), review(40, 2, [note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]), ]); const run = path.join(repo.root, 'run'); pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]); - pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', [ - '1. lib/pregate-clear.ts:140 The receipt stores the raw run URL, not the attempt it judged. FIXABLE', - '2. lib/pregate.ts:88 The refusal text does not name the attempt. INVESTIGATE', - '3. docs/pregate.md:12 The docs do not say a rerun replaces the attempt. INVESTIGATE', - 'Recommendation: fix 1 because a later attempt would be judged by the first', - ].join('\n')]]); + pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]); expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 0 }); const s = loop(summary(repo, '--run', run)); expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 0, unrecorded: 1, ledgers_read: 2, rounds: 2 }); @@ -148,7 +166,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { review(0, 1, [ row('src/auth.ts:42:testing', { path: 'src/auth.ts', line: 42, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' }), row('src/m.ts:5:state-matrix', { path: 'src/m.ts', line: 5, severity: 'INFORMATIONAL', raw_severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'state-machines' }), - row('src/f.ts:3:logic', { action: 'fixed', raised_by: 'red-team' }), + row('src/f.ts:3:logic', { action: 'fixed', raised_by: 'outside-voice' }), row('src/n.ts:8:logic', { severity: 'CRITICAL', raised_by: 'red-team' }), row('src/g.ts:7:logic', { action: 'fixed', raised_by: 'core' }), ]), @@ -164,6 +182,26 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { expect(s).toMatchObject({ passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, ledgers_read: 1 }); }); + test('round 2 cases: another reviewer at a below-floor line, an extensionless path, one finding in two ledgers, no locus', () => { + const repo = branchWith([ + review(0, 1, [ + { fingerprint: 'src/auth.ts:42:testing', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'testing' }, + { fingerprint: 'bin/gstack-review-scope:262:logic', path: 'bin/gstack-review-scope', line: 262, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }, + { fingerprint: 'x.ts:5:logic', path: 'x.ts', line: 5, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }, + ]), + fixCheck(30, []), + ]); + const run = path.join(repo.root, 'run'); + pass(path.join(run, 'p1'), [ + ['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test'], + ['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Blocks: yes\nRecommendation: fix 1 because it is open'], + ['red-team', '[CRITICAL] bin/gstack-review-scope:262 the refusal is skipped\n[CRITICAL] x.ts:5 the guard is inverted'], + ]); + pass(path.join(run, 'fix-check'), [['fix-check', '[CRITICAL] x.ts:5 the guard is still inverted\n[CRITICAL] the retry loop never ends']]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ blocking_found: 4, fixed: 2, noted: 1, unrecorded: 2, ledgers_read: 2 }); + }); + test('judgment decides: a folded duplicate counts once, a lead addition counts, a rejected judgment keeps the collected labels', () => { const repo = branchWith([review(0, 1, [])]); const run = path.join(repo.root, 'run'); diff --git a/test/size-ledger/ship/gst-951-receipt-counts-blockers.json b/test/size-ledger/ship/gst-951-receipt-counts-blockers.json index 5adfe051c..7509a6666 100644 --- a/test/size-ledger/ship/gst-951-receipt-counts-blockers.json +++ b/test/size-ledger/ship/gst-951-receipt-counts-blockers.json @@ -1,4 +1,4 @@ { - "bytes": 263, - "why": "GST-951: summary names its ledgers (--run in DRIVER, --ledger or --no-ledger in pr-body)" + "bytes": 177, + "why": "GST-951: summary names its ledgers (--run in DRIVER, --no-ledger in pr-body)" }