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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 07:39:26 +00:00
1 parent 11a9a24906
commit 49421ee874
8 files changed
+70 -34

No files matched your search

@@ -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 <run dir>` (it finds every `review-ledger.jsonl` under it), `--ledger <f>`, 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`.
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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 @<file>`): `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=<n> blocking_found=<k> deferred=<m>`, so a scorecard can track
passes per PR.
+2 -2
View File
@@ -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<string, number>; 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<string, number>; 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 … @<tree> 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 } },
+3 -2
View File
@@ -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 }));
+29 -16
View File
@@ -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, any>): 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<string, any>[], since: string, ledgers: readonly (readonly LedgerBlocker[])[] = []): ReviewLoopSummary {
const sinceMs = Date.parse(since);
const inRun = (r: Record<string, any>) => 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<string, boolean>();
const rowCriticals = new Set<string>();
type Seen = { locus: string | null; classified: boolean; blocks: boolean; candidate: boolean; disposed: boolean };
const seen = new Map<string, Seen>();
const deferred = new Set<string>();
const noted = new Set<string>();
const fixed = new Set<string>();
@@ -387,19 +388,31 @@ export function summarizeReviewLoop(rows: readonly Record<string, any>[], 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<string>();
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<string, number> | 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}` : '');
}
+1 -1
View File
@@ -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<string, unknown>)
.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<string, number>;
+31 -9
View File
@@ -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', () => {