From 49421ee874e84e362c30665f8f51f99ace343820 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 07:39:26 +0000 Subject: [PATCH] fix(ship): pair each ledger blocker with one row finding; newest classification wins; ledgers_read (GST-951 Step 11 findings) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../gst-951-receipt-counts-blockers.md | 2 +- docs/review-policy.md | 4 +- docs/ship-policy.md | 2 +- lib/headless-artifacts.ts | 4 +- lib/review-ledger.ts | 5 ++- lib/review-scope.ts | 45 ++++++++++++------- lib/ship-receipt.ts | 2 +- test/review-scope-ledger-count.test.ts | 40 +++++++++++++---- 8 files changed, 70 insertions(+), 34 deletions(-) diff --git a/changelog.d/gst-951-receipt-counts-blockers.md b/changelog.d/gst-951-receipt-counts-blockers.md index 6cc425d33..a7175c3f7 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, matches each one to its row by 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=N` says how many ledgers 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 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 7bc1067fe..8b75ecb3d 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -395,9 +395,9 @@ 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 logged as a CRITICAL at the same path:line, so an adversarial, outside-voice or not-yet-classified blocker counts and one classify put 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 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.unrecorded` | blockers found that no row gives a disposition (fixed, deferred, noted, skipped, a gate answer); absent when 0 | -| `review_loop.ledgers` | dispatch ledgers the count read; absent under `--no-ledger` | +| `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) | | `review_loop.noted` | below-floor findings listed in the PR body | | `review_loop.fixed` | findings fixed in this PR | diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 016af873d..b0c58c716 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1272,7 +1272,7 @@ construction: rows for this invocation and its dispatch ledgers, and writes the counters the ship receipt carries (`gstack-ship-receipt write … --review-loop @`): `review_loop: { passes, blocking_found, deferred, noted, fixed, scope }`, plus `fix_checks`, `dispatches`, -`est_input_tokens`, `unrecorded` and `ledgers` when the run recorded them, printed on the receipt summary +`est_input_tokens`, `unrecorded` and `ledgers_read` when the run recorded them, printed on the receipt summary line as `passes= blocking_found= deferred=`, so a scorecard can track passes per PR. diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index be03c0010..06d9f5d93 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -106,7 +106,7 @@ export interface ShipReceipt { // Related open PRs by other authors (Lane D): `supersedes` / `credit` only on the owner's confirmation. issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> }; /** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */ - review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record; est_input_tokens?: number; unrecorded?: number; ledgers?: number }; + review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; rounds?: number; dispatches?: Record; est_input_tokens?: number; unrecorded?: number; ledgers_read?: number }; carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ head_tree?: string; product_patch_id?: string; @@ -238,7 +238,7 @@ export const ARTIFACT_SCHEMAS = { thread: str, tree: { enum: ['same-modulo-stamps', 'changed', 'not-compared'] }, gate_reuse: str, policy: str, queue_mode: str, preregistration: str, history: str, pregate: { ...str, description: 'pass|warn|requires-remote cleared … @ from pregate.json, current for this head' }, requires_remote: strList, approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' }, - review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers: { type: 'integer', minimum: 0 } } }, + review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, rounds: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 }, unrecorded: { type: 'integer', minimum: 0 }, ledgers_read: { type: 'integer', minimum: 0 } } }, carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, head_tree: str, product_patch_id: str, gstack_version: str, base_red: { type: 'object', required: ['cases', 'files', 'base', 'lane', 'expires_at'], properties: { cases: strList, files: strList, base: str, lane: str, expires_at: str } }, diff --git a/lib/review-ledger.ts b/lib/review-ledger.ts index 84e5dc3ab..7c3a1ff3e 100644 --- a/lib/review-ledger.ts +++ b/lib/review-ledger.ts @@ -274,8 +274,9 @@ export function ledgerBlockers(ledger: string): LedgerBlocker[] { const text = read(verdict.judged, 'judged set'); if (promptHash(text) !== verdict.judged_sha256) fail(`judged set ${String(verdict.judged)} changed since judge`); for (const j of jsonLines(text, String(verdict.judged)) as JudgedLine[]) { - if (j.duplicate_of) ranks.delete(j.id); - else ranks.set(j.id, Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0)); + if (j.duplicate_of) { ranks.delete(j.id); continue; } + ranks.set(j.id, Math.max(ranks.get(j.id) ?? 0, rank(j.severity) ?? 0)); + if (!loci.has(j.id)) loci.set(j.id, lineLocus(j.reason ?? '')); } } return [...ranks].filter(([, r]) => r >= 4).map(([id]) => ({ id, locus: loci.get(id) ?? null })); diff --git a/lib/review-scope.ts b/lib/review-scope.ts index 8ecfe3f54..e5e86c453 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -310,7 +310,7 @@ export interface ReviewLoopSummary { /** Blocking findings no row gives a disposition (fixed, deferred, noted, skipped or a gate answer): found, outcome unrecorded. */ unrecorded?: number; /** Dispatch ledgers whose collected and judged findings were counted (gstack-review-dispatch). */ - ledgers?: number; + ledgers_read?: number; } /** Review-log rows of one /ship invocation (skill review, via ship, at or after `since`) → the loop summary. */ @@ -365,19 +365,20 @@ function rowLocus(f: Record): string | null { } /** - * The loop summary. blocking_found counts every blocker found (GST-951): the rows' blockers, plus each CRITICAL the dispatch - * ledgers hold (`ledgerBlockers`, one list per ledger) that no row logged as a CRITICAL at the same path:line. So a blocker - * only a ledger holds (an adversarial or outside-voice finding, one logged before classification) counts, and unrecorded, - * while one a row classified below the floor (blocking: false) does not. A ledger blocker with no locus, or one raised again - * in another pass's ledger with no row, counts each time: the count may run high, never low. + * 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 + * 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. */ 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'); - const blocking = new Map(); - const rowCriticals = new Set(); + type Seen = { locus: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean }; + const seen = new Map(); const deferred = new Set(); const noted = new Set(); const fixed = new Set(); @@ -387,19 +388,31 @@ export function summarizeReviewLoop(rows: readonly Record[], since: for (const f of Array.isArray(row.findings) ? row.findings : []) { if (!f || typeof f !== 'object') continue; const id = findingIdentity(f); - if (rowBlocks(f)) blocking.set(id, blocking.get(id) === true || DISPOSITIONS.includes(f.action)); - const locus = rowLocus(f); - if (locus && (rowBlocks(f) || rank(f.severity) === 4)) rowCriticals.add(locus); + const prior = seen.get(id); + const classified = typeof f.blocking === 'boolean'; + seen.set(id, { + locus: rowLocus(f) ?? prior?.locus ?? null, + 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, + disposed: !!prior?.disposed || DISPOSITIONS.includes(f.action), + }); if (f.action === 'deferred') deferred.add(id); if (f.action === 'noted') noted.add(id); if (f.action === 'fixed' || f.action === 'auto-fixed') fixed.add(id); } } - const ledgerOnly = ledgers.flat().filter(b => !b.locus || !rowCriticals.has(b.locus)).length; - const unrecorded = ledgerOnly + [...blocking.values()].filter(disposed => !disposed).length; - const out: ReviewLoopSummary = { passes: passes.length, blocking_found: blocking.size + ledgerOnly, deferred: deferred.size, noted: noted.size, fixed: fixed.size, scope }; + 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 blockers = [...seen].filter(([k, s]) => s.blocks || (paired.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; - if (ledgers.length) out.ledgers = ledgers.length; + if (ledgers.length) out.ledgers_read = ledgers.length; const fixChecks = loopRows.filter(r => r.skill === FIX_CHECK_SKILL).length; if (fixChecks) out.fix_checks = fixChecks; out.rounds = passes.length + fixChecks; @@ -420,6 +433,6 @@ export function dispatchTotal(d: Record | undefined): number | u export function reviewLoopLine(s: ReviewLoopSummary): string { const total = dispatchTotal(s.dispatches); return `REVIEW_LOOP: passes=${s.passes} blocking_found=${s.blocking_found} deferred=${s.deferred} noted=${s.noted} fixed=${s.fixed} scope=${s.scope}` - + (s.unrecorded ? ` unrecorded=${s.unrecorded}` : '') + (s.ledgers ? ` ledgers=${s.ledgers}` : '') + + (s.unrecorded ? ` unrecorded=${s.unrecorded}` : '') + (s.ledgers_read ? ` ledgers_read=${s.ledgers_read}` : '') + (s.fix_checks ? ` fix_checks=${s.fix_checks}` : '') + (total !== undefined ? ` dispatches=${total}` : '') + (s.est_input_tokens !== undefined ? ` est_input_tokens=${s.est_input_tokens}` : ''); } diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 4c58e0404..b628a0aba 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -329,7 +329,7 @@ export function parseReviewLoop(value: unknown): ReviewLoopReceipt | undefined { if (n('rounds') !== undefined) out.rounds = n('rounds'); if (n('est_input_tokens') !== undefined) out.est_input_tokens = n('est_input_tokens'); if (n('unrecorded')) out.unrecorded = n('unrecorded'); - if (n('ledgers')) out.ledgers = n('ledgers'); + if (n('ledgers_read')) out.ledgers_read = n('ledgers_read'); if (v.dispatches && typeof v.dispatches === 'object' && !Array.isArray(v.dispatches)) { const d = Object.fromEntries(Object.entries(v.dispatches as Record) .filter(([k, c]) => /^[a-z][a-z0-9-]{0,39}$/.test(k) && typeof c === 'number' && Number.isInteger(c) && c >= 0).slice(0, 12)) as Record; diff --git a/test/review-scope-ledger-count.test.ts b/test/review-scope-ledger-count.test.ts index 048a64823..08b835bbd 100644 --- a/test/review-scope-ledger-count.test.ts +++ b/test/review-scope-ledger-count.test.ts @@ -73,7 +73,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { ].join('\n')]]); 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: 2, rounds: 2 }); + expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 0, unrecorded: 1, ledgers_read: 2, rounds: 2 }); }); // #3838 (reconstruction): the rows reproduce its receipt (passes=2 blocking_found=0 noted=8 fixed=4 fix_checks=3). Pass 2's @@ -94,7 +94,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { pass(path.join(run, 'p2-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', 'lib/review-depth.ts:300 CSS image-set() loads an outside image. FIXABLE\nRecommendation: fix because the drawing loads a remote URL']]); expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 1, noted: 8, fixed: 4, fix_checks: 3, rounds: 5 }); const s = loop(summary(repo, '--run', run)); - expect(s).toMatchObject({ passes: 2, blocking_found: 2, deferred: 0, noted: 8, fixed: 4, fix_checks: 3, rounds: 5, unrecorded: 1, ledgers: 3 }); + expect(s).toMatchObject({ passes: 2, blocking_found: 2, deferred: 0, noted: 8, fixed: 4, fix_checks: 3, rounds: 5, unrecorded: 1, ledgers_read: 3 }); }); // #3837 (reconstruction): pass 1's row was logged before the outside voice's seven findings were classified, so they sit in the @@ -115,10 +115,10 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { ].join('\n')]]); expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 11, fixed: 0, fix_checks: 3, rounds: 5 }); const s = loop(summary(repo, '--run', run)); - expect(s).toMatchObject({ passes: 2, blocking_found: 7, noted: 11, fixed: 0, unrecorded: 7, ledgers: 2, rounds: 5 }); + expect(s).toMatchObject({ passes: 2, blocking_found: 7, noted: 11, fixed: 0, unrecorded: 7, ledgers_read: 2, rounds: 5 }); const line = runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--run', run]); - expect(line.out.trim()).toBe('REVIEW_LOOP: passes=2 blocking_found=7 deferred=0 noted=11 fixed=0 scope=frozen unrecorded=7 ledgers=2 fix_checks=3'); - expect(parseReviewLoop(s)).toMatchObject({ blocking_found: 7, unrecorded: 7, ledgers: 2, rounds: 5 }); + expect(line.out.trim()).toBe('REVIEW_LOOP: passes=2 blocking_found=7 deferred=0 noted=11 fixed=0 scope=frozen unrecorded=7 ledgers_read=2 fix_checks=3'); + expect(parseReviewLoop(s)).toMatchObject({ blocking_found: 7, unrecorded: 7, ledgers_read: 2, rounds: 5 }); }); test('a blocker both sources hold counts once, and a disposed one is not unrecorded', () => { @@ -126,7 +126,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { const run = path.join(repo.root, 'run'); pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]); const s = loop(summary(repo, '--run', run)); - expect(s).toMatchObject({ blocking_found: 1, fixed: 1, ledgers: 1 }); + expect(s).toMatchObject({ blocking_found: 1, fixed: 1, ledgers_read: 1 }); expect(s.unrecorded).toBeUndefined(); }); @@ -139,7 +139,29 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { const run = path.join(repo.root, 'run'); pass(path.join(run, 'p1'), [['specialist/testing', '[CRITICAL] src/a.ts:3 the denied path has no test\n[CRITICAL] src/d.ts:7 the retry loop never ends']]); const s = loop(summary(repo, '--run', run)); - expect(s).toMatchObject({ blocking_found: 2, fixed: 1, noted: 2, unrecorded: 1, ledgers: 1 }); + expect(s).toMatchObject({ blocking_found: 2, fixed: 1, noted: 2, unrecorded: 1, ledgers_read: 1 }); + }); + + test('Step 11 review cases: a row finding pairs with one ledger CRITICAL; the newest classification and a mapped severity decide; a bare fixed row is a disposition', () => { + const row = (fp: string, f: object) => ({ fingerprint: fp, ...f }); + const repo = branchWith([ + 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/n.ts:8:logic', { severity: 'CRITICAL', raised_by: 'red-team' }), + row('src/g.ts:7:logic', { action: 'fixed', raised_by: 'core' }), + ]), + review(30, 2, [row('src/n.ts:8:logic', { severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'red-team' })]), + ]); + 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\n[CRITICAL] src/m.ts:5 untested cell'], + ['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Severity: High Blocks: yes\n2. src/f.ts:3 the guard is inverted. Blocks: yes\nRecommendation: fix 1 because it is an open handler'], + ['red-team', '[CRITICAL] src/n.ts:8 looked wrong, classified below the floor later'], + ], m => [...m.map(l => ({ id: l.id, severity: l.label })), { id: 'lead#1', severity: 'CRITICAL', reason: 'src/g.ts:7 the lead found an unchecked write' }]); + const s = loop(summary(repo, '--run', run)); + expect(s).toMatchObject({ passes: 2, blocking_found: 3, fixed: 2, unrecorded: 1, ledgers_read: 1 }); }); test('judgment decides: a folded duplicate counts once, a lead addition counts, a rejected judgment keeps the collected labels', () => { @@ -147,7 +169,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { const run = path.join(repo.root, 'run'); pass(path.join(run, 'fold'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted\n[CRITICAL] src/a.ts:4 the guard is inverted again']], m => [{ id: m[0]!.id, severity: 'CRITICAL' }, { id: m[1]!.id, severity: 'CRITICAL', duplicate_of: m[0]!.id }, { id: 'lead#1', severity: 'CRITICAL', reason: 'src/e.ts:2 the lead found an unchecked write' }]); - expect(loop(summary(repo, '--run', run))).toMatchObject({ blocking_found: 2, unrecorded: 2, ledgers: 1 }); + expect(loop(summary(repo, '--run', run))).toMatchObject({ blocking_found: 2, unrecorded: 2, ledgers_read: 1 }); const rejected = path.join(repo.root, 'rejected'); fs.mkdirSync(rejected); const ledger = path.join(rejected, 'review-ledger.jsonl'); @@ -158,7 +180,7 @@ describe('GST-951: the receipt counts every blocker the readers found', () => { const judgedFile = path.join(rejected, 'judged.jsonl'); fs.writeFileSync(judgedFile, JSON.stringify({ id: merged[0]!.id, severity: 'INFORMATIONAL' }) + '\n'); expect(recordJudgment(ledger, merged, [{ id: merged[0]!.id, severity: 'INFORMATIONAL' }], judgedFile, mergedFile).ok).toBe(false); - expect(loop(summary(repo, '--ledger', ledger))).toMatchObject({ blocking_found: 1, unrecorded: 1, ledgers: 1 }); + expect(loop(summary(repo, '--ledger', ledger))).toMatchObject({ blocking_found: 1, unrecorded: 1, ledgers_read: 1 }); }); test('summary refuses REVIEW_SCOPE_LEDGER instead of counting without a ledger it needs, and writes nothing', () => {