Files
gstack/test/review-scope.test.ts
T
Garry Tanandcapy-ai[bot] a7968dc77d Keep deferred review findings on the PR instead of filing issues (#4208)
* Keep deferred review findings on the PR instead of filing issues

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Repin the ship review section after the deferred-findings lead edit

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Keep the deferred record's severity as the plain label

The reviewer's stated severity and Blocks text now sit in their own field, so a review-log row built from a deferred record keeps a severity the ledger can rank. The PR body renders the same line as before.

Review-Finding: p1/adversarial-native#2 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Say deferred findings stay on the PR in the unattended docs

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-09 11:28:20 +00:00

515 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Harness lane E: the /ship review loop's finding scope (lib/review-scope.ts,
* bin/gstack-review-scope). Five Captains hit a loop that re-read the whole
* diff each pass and fixed neighbours in pre-existing code until the cycle
* cap. Under `review_scope: frozen` pass 1 fixes the diff and defers blocking
* findings in unchanged code to the PR body; pass 2+ fixes only blocking findings
* in the range since the previous pass (or the diff) and notes the rest.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import {
deferredRecord, isBlocking, kindOf, locusOf, mapStateMatrixOutput, parseHunks, raisedBy, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
UNTESTED_CELL, type ScopedFinding, type ScopeFinding,
} from '../lib/review-scope';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
const ROOT = path.resolve(import.meta.dir, '..');
const repos: FixtureRepo[] = [];
const fixture = () => { const r = makeFixtureRepo({ prefix: 'review-scope-' }); repos.push(r); return r; };
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const DIFF = [
'diff --git a/src/auth.ts b/src/auth.ts', 'index 1..2 100644', '--- a/src/auth.ts', '+++ b/src/auth.ts',
'@@ -10,2 +10,4 @@ export function check() {', '+ const a = 1;', '+ const b = 2;', '@@ -40 +42,0 @@', '- old();',
'diff --git a/src/new.ts b/src/new.ts', 'new file mode 100644', '--- /dev/null', '+++ b/src/new.ts', '@@ -0,0 +1,3 @@', '+a', '+b', '+c',
'diff --git a/src/gone.ts b/src/gone.ts', 'deleted file mode 100644', '--- a/src/gone.ts', '+++ /dev/null', '@@ -1,3 +0,0 @@', '-a', '-b', '-c',
].join('\n');
describe('hunk parsing and locus', () => {
test('parseHunks reads new-side ranges, whole added files, untracked files, and keeps a deleted file as a whole changed location', () => {
const hunks = parseHunks(DIFF, ['scratch/untracked.ts']);
expect(hunks['src/auth.ts']).toEqual([[10, 13], [42, 42]]);
expect(hunks['src/new.ts']).toBe('whole');
expect(hunks['scratch/untracked.ts']).toBe('whole');
expect(hunks['src/gone.ts']).toBe('whole');
});
// A removed export is a changed location: a caller it breaks reports the caller's path with a cause
// in the deleted file, and the pass-4 outside review showed that anchor landed outside and deferred.
test('a cause anchored in a deleted file is the diff, so the broken caller is fixed here, not deferred', () => {
const diff = parseHunks(DIFF);
expect(locusOf({ path: 'src/consumer.ts', line: 80, cause: { path: 'src/gone.ts', line: 2 } }, diff, null)).toBe('diff');
const [scoped] = scopeFindings([{ fingerprint: 'src/consumer.ts:80:api', path: 'src/consumer.ts', line: 80, severity: 'CRITICAL', cause: { path: 'src/gone.ts' } }], { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: null });
expect(scoped!.locus).toBe('diff');
expect(scoped!.scope).toBe('fix');
});
test('locusOf: inside a hunk or within 3 lines is the diff, farther is outside, an added file is whole, the range wins over the diff', () => {
const diff = parseHunks(DIFF);
expect(locusOf({ path: 'src/auth.ts', line: 11 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/auth.ts', line: 16 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/auth.ts', line: 17 }, diff, null)).toBe('outside');
expect(locusOf({ path: 'src/auth.ts', line: 200 }, diff, null)).toBe('outside');
expect(locusOf({ path: 'src/auth.ts' }, diff, null)).toBe('diff');
expect(locusOf({ path: 'src/new.ts', line: 999 }, diff, null)).toBe('diff');
expect(locusOf({ path: 'lib/other.ts', line: 1 }, diff, null)).toBe('outside');
expect(locusOf({ fingerprint: 'src/auth.ts:11:security' }, diff, null)).toBe('diff');
expect(locusOf({ fingerprint: 'shared-libs:abc' }, diff, null)).toBe('outside');
expect(locusOf({ severity: 'CRITICAL', summary: 'somewhere' }, diff, null)).toBe('outside');
// a defect the diff causes in an unchanged caller names the changed line in `cause`; the locus follows it
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 11 } }, diff, null)).toBe('diff');
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/auth.ts', line: 300 } }, diff, null)).toBe('outside');
expect(locusOf({ path: 'lib/caller.ts', line: 80, cause: { path: 'src/new.ts' } }, diff, null)).toBe('diff');
const range = parseHunks('diff --git a/src/auth.ts b/src/auth.ts\n--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -11 +11 @@\n-x\n+y\n');
expect(locusOf({ path: 'src/auth.ts', line: 11 }, diff, range)).toBe('fix-range');
expect(locusOf({ path: 'src/auth.ts', line: 42 }, diff, range)).toBe('diff');
});
test('kind and the blocking floor: CRITICAL defects block; advisories, test gaps and INFORMATIONAL items never do', () => {
expect(kindOf({ advisory: true, severity: 'INFORMATIONAL' })).toBe('advisory');
expect(kindOf({ category: 'test-gap', severity: 'CRITICAL' })).toBe('test-gap');
// the Testing specialist's schema (review/specialists/testing.md) emits category "testing" for every finding it returns
const real = { severity: 'CRITICAL', confidence: 7, path: 'src/auth.ts', line: 42, category: 'testing', summary: 'denied path has no test', fingerprint: 'src/auth.ts:42:testing', specialist: 'testing' };
expect(kindOf(real)).toBe('test-gap');
expect(isBlocking(real)).toBe(false);
expect(fs.readFileSync(path.join(ROOT, 'review/specialists/testing.md'), 'utf8')).toContain('\"category\":\"testing\"');
expect(kindOf({ category: 'security', severity: 'CRITICAL' })).toBe('defect');
expect(isBlocking({ category: 'security', severity: 'CRITICAL' })).toBe(true);
expect(isBlocking({ category: 'security', severity: 'critical' })).toBe(true);
expect(isBlocking({ category: 'security', severity: 'INFORMATIONAL' })).toBe(false);
expect(isBlocking({ category: 'test-coverage', severity: 'CRITICAL' })).toBe(false);
expect(isBlocking({ advisory: true, severity: 'CRITICAL' })).toBe(false);
});
});
describe('scope decisions', () => {
const diff = parseHunks(DIFF);
const range = parseHunks('diff --git a/src/auth.ts b/src/auth.ts\n--- a/src/auth.ts\n+++ b/src/auth.ts\n@@ -11 +11 @@\n-x\n+y\n');
const findings = [
{ fingerprint: 'src/auth.ts:11:security', path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'in range' },
{ fingerprint: 'src/auth.ts:42:logic', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', category: 'logic', summary: 'in diff' },
{ fingerprint: 'src/auth.ts:42:naming', path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'in diff, informational' },
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'pre-existing' },
{ fingerprint: 'lib/legacy.ts:9:style', path: 'lib/legacy.ts', line: 9, severity: 'INFORMATIONAL', category: 'style', summary: 'pre-existing wording' },
{ fingerprint: 'shared-libs:deadbeef', severity: 'INFORMATIONAL', advisory: true, category: 'shared-libs', summary: 'advice' },
];
const by = (scoped: ScopedFinding[]) => Object.fromEntries(scoped.map(f => [f.fingerprint, `${f.locus}/${f.kind}/${f.blocking ? 'blocking' : 'below'}/${f.scope}`]));
test('all: every finding is fixed, whatever its locus (the pre-frozen loop)', () => {
const scoped = scopeFindings(findings, { policy: 'all', pass: 2, diffHunks: diff, rangeHunks: range });
expect(scoped.every(f => f.scope === 'fix')).toBe(true);
expect(by(scoped)['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
});
test('frozen pass 1: the diff is fixed, blocking findings outside are deferred, the rest are notes', () => {
const scoped = scopeFindings(findings, { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range });
expect(by(scoped)).toEqual({
'src/auth.ts:11:security': 'diff/defect/blocking/fix',
'src/auth.ts:42:logic': 'diff/defect/blocking/fix',
'src/auth.ts:42:naming': 'diff/defect/below/fix',
'lib/legacy.ts:7:data-loss': 'outside/defect/blocking/defer',
'lib/legacy.ts:9:style': 'outside/defect/below/note',
'shared-libs:deadbeef': 'outside/advisory/below/note',
});
expect(scopeLine('frozen', 1, scoped)).toBe('REVIEW_SCOPE: policy=frozen pass=1 findings=6 fix=3 defer=1 note=2 blocking=3');
// a blocking finding nobody could place is fixed now, never filed against '(no path)'
const unplaced = scopeFindings([{ severity: 'CRITICAL', category: 'logic', summary: 'somewhere' }], { policy: 'frozen', pass: 2, diffHunks: diff, rangeHunks: range });
expect(by(unplaced)['undefined']).toBe('outside/defect/blocking/fix');
});
// Value: protects=below-floor findings from Step 11's voices or a fix-check are notes even when classified at --pass 1, so they never send the ship back through Steps 9-11; blocking ones stay fix; fails_when=Step 11 scopes its queue at PASS=1 and four below-floor notes come back `scope: fix` (GSTA-167 replay 8G on #34; CREW 23 and 30a);
// why_new=the frozen pass-1 case above has no raised_by; seam=scopeFindings; no_claim=that the parent tags raised_by correctly
test('frozen pass 1: below-floor findings raised after pass 1 (Step 11, fix-check) are notes, blocking ones are fixed', () => {
const late = (raised_by: string) => [
{ fingerprint: `src/auth.ts:42:naming:${raised_by}`, path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'wording', raised_by },
{ fingerprint: `src/auth.ts:11:security:${raised_by}`, path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'real', raised_by },
];
for (const who of ['adversarial-native', 'adversarial', 'outside-voice', 'fix-check']) {
const [below, blocker] = scopeFindings(late(who), { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range });
expect([who, below!.scope, below!.locus]).toEqual([who, 'note', 'diff']);
expect([who, blocker!.scope]).toEqual([who, 'fix']);
expect(scopeFindings(late(who), { policy: 'diff-plus-blocking', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('note');
}
for (const who of ['core', 'red-team', 'qa', 'security']) expect(scopeFindings(late(who), { policy: 'frozen', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('fix');
expect(scopeFindings(late('outside-voice'), { policy: 'all', pass: 1, diffHunks: diff, rangeHunks: range })[0]!.scope).toBe('fix');
});
test('diff-plus-blocking (the documented fallback, not the default): a blocking finding outside the diff is fixed here instead of filed; everything else is as frozen', () => {
const p1 = by(scopeFindings(findings, { policy: 'diff-plus-blocking', pass: 1, diffHunks: diff, rangeHunks: range }));
expect(p1['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
expect(p1['lib/legacy.ts:9:style']).toBe('outside/defect/below/note');
expect(p1['src/auth.ts:42:naming']).toBe('diff/defect/below/fix');
const p2 = by(scopeFindings(findings, { policy: 'diff-plus-blocking', pass: 2, diffHunks: diff, rangeHunks: range }));
expect(p2['lib/legacy.ts:7:data-loss']).toBe('outside/defect/blocking/fix');
expect(p2['src/auth.ts:42:naming']).toBe('diff/defect/below/note');
expect(Object.values(p2).some(v => String(v).endsWith('/defer'))).toBe(false);
});
test('frozen pass 2+: only blocking findings in the range or the diff are fixed; everything below the floor is a note', () => {
const scoped = scopeFindings(findings, { policy: 'frozen', pass: 2, diffHunks: diff, rangeHunks: range });
expect(by(scoped)).toEqual({
'src/auth.ts:11:security': 'fix-range/defect/blocking/fix',
'src/auth.ts:42:logic': 'diff/defect/blocking/fix',
'src/auth.ts:42:naming': 'diff/defect/below/note',
'lib/legacy.ts:7:data-loss': 'outside/defect/blocking/defer',
'lib/legacy.ts:9:style': 'outside/defect/below/note',
'shared-libs:deadbeef': 'outside/advisory/below/note',
});
});
});
describe('deferral rendering', () => {
const finding: ScopedFinding = { fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'Unbounded delete\nwithout a where clause', fix: 'add the tenant filter', specialist: 'security', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' };
test('a deferred record keeps the collected label as its severity and the reviewer\'s stated severity and Blocks beside it (GST-766)', () => {
expect(deferredRecord({ ...finding, stated: { severity: 'Low', blocks: 'no' } })).toMatchObject({ severity: 'CRITICAL', stated: 'Severity: Low, Blocks: no' });
expect(deferredRecord({ ...finding, stated: { blocks: 'yes\n- **Injected:** x' } })).toMatchObject({ severity: 'CRITICAL', stated: 'Blocks: yes - **Injected:** x' });
expect(deferredRecord(finding)).toEqual({ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete\nwithout a where clause' });
});
test('deferred findings stay on the PR: the body section lists them, says no issue was filed, and never closes anything (R85)', () => {
const section = renderDeferredSection(
[{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete' },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts:1', severity: 'CRITICAL', stated: 'Blocks: no', summary: 'Missing owner\ncheck' }],
[{ path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', summary: 'rename b', advisory: true }]);
expect(section).toContain('## Deferred findings');
expect(section).toContain('not filed as issues');
expect(section).toContain('- Deferred: CRITICAL `lib/legacy.ts:7` Unbounded delete');
expect(section).toContain('- Deferred: CRITICAL (the reviewer wrote Blocks: no) `lib/old.ts:1` Missing owner check');
expect(section).not.toMatch(/#\d|unfiled/);
expect(section).toContain('## Review notes');
expect(section).toContain('- INFORMATIONAL [ADVISORY] `src/auth.ts:42` rename b');
expect(section).not.toMatch(/\b(fixes|closes|resolves)\b/i);
expect(renderDeferredSection([], [])).toBe('');
});
test('no gstack instruction files a deferred review or /ship finding as an issue (R85)', () => {
const files = ['ship/sections/review-army.md.tmpl', 'ship/sections/pr-body.md.tmpl', 'captain/templates/READY.md', 'docs/review-policy.md', 'docs/ship-policy.md', 'docs/unattended.md', 'crew/docs/captain.md', 'capy/skill/gstack/factory.md', 'lib/ship-policy.ts', 'lib/review-scope.ts', 'bin/gstack-review-scope'];
const filing = /\b(fil(e|es|ed|ing)|defers?)\b[^.;:,]{0,80}\b(as|to) (a |an |one )?(github )?issues?\b|\bone issue per\b|\bdeferred[- ]issues?\b|gstack-deferred-finding/gi;
const negated = (text: string, at: number) => /\b(never|not|no)\b[^.;:,]*$/i.test(text.slice(Math.max(0, at - 40), at));
const hits = files.flatMap(f => fs.readFileSync(path.join(ROOT, f), 'utf8').split(/\n\s*\n/).map(para => para.replace(/\s+/g, ' '))
.filter(para => /defer/i.test(para))
.flatMap(para => [...para.matchAll(filing)].filter(m => !negated(para, m.index!)).map(m => `${f}: ${m[0]}`)));
expect(hits).toEqual([]);
for (const f of ['capy/skill/gstack/factory.md', 'crew/docs/captain.md']) {
const text = fs.readFileSync(path.join(ROOT, f), 'utf8').replace(/\s+/g, ' ');
expect(text, f).toMatch(/[Dd]eferred findings[^.]*never (become|filed as) issues/);
expect(text, f).toMatch(/one issue with (a named |an )owner/);
}
});
});
describe('loop summary', () => {
test('counts this invocation\'s passes and unique blocking, deferred, noted and fixed findings', () => {
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', review_scope: 'frozen', findings: [
{ fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T09:00:00Z', findings: [{ fingerprint: 'old:1:q', blocking: true, action: 'fixed' }] },
{ skill: 'adversarial-review', timestamp: '2026-10-26T10:15:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true }] },
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true }] },
];
const s = summarizeReviewLoop(rows, '2026-10-26T09:30:00Z');
expect(s).toEqual({ passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 });
expect(reviewLoopLine(s)).toBe('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
});
// A pass logged without the template's timestamp (the logger stamps review_binding.started_at, never
// timestamp) is still this invocation's pass; dropping it silently under-reported passes on #3481.
test('a row dates from the logger\'s binding start before the writer\'s timestamp; a row with neither is not counted', () => {
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', review_scope: 'frozen', findings: [{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }] },
{ skill: 'review', via: 'ship', review_scope: 'frozen', review_binding: { state: 'changed', started_at: '2026-10-26T10:30:00.000Z' }, findings: [{ fingerprint: 'c:1:z', action: 'noted' }] },
// A writer-typed date-only timestamp parses as midnight, before --since; the logger's start keeps the pass.
{ skill: 'review', via: 'ship', timestamp: '2026-10-26', review_binding: { state: 'verified', started_at: '2026-10-26T10:45:00.000Z' }, findings: [{ fingerprint: 'd:1:w', action: 'noted' }] },
{ skill: 'review', via: 'ship', review_binding: { state: 'changed', started_at: '2026-10-26T09:00:00.000Z' }, findings: [{ fingerprint: 'old:1:q', action: 'noted' }] },
{ skill: 'review', via: 'ship', findings: [{ fingerprint: 'undated:1:q', action: 'noted' }] },
];
expect(summarizeReviewLoop(rows, '2026-10-26T09:30:00Z')).toEqual({ passes: 3, blocking_found: 1, deferred: 0, noted: 2, fixed: 1, scope: 'frozen', rounds: 3,
unbound: ['row review pass=?: no start token and no readable timestamp, so it cannot be placed in this run'] });
});
});
describe('gstack-review-scope (bin)', () => {
test('classify reads the branch diff and the pass range from git, honours the policy default, and refuses bad input with REVIEW_SCOPE_INPUT / REVIEW_SCOPE_RANGE', () => {
const repo = fixture();
const src = path.join(repo.work, 'src');
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'legacy.ts'), Array.from({ length: 30 }, (_, i) => `legacy ${i + 1}`).join('\n') + '\n');
fs.writeFileSync(path.join(src, 'auth.ts'), Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n') + '\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
const lines = fs.readFileSync(path.join(src, 'auth.ts'), 'utf8').split('\n');
lines[9] = 'line 10 changed';
fs.writeFileSync(path.join(src, 'auth.ts'), lines.join('\n'));
repo.git('commit', '-qam', 'change line 10');
const pass1Head = repo.git('rev-parse', 'HEAD');
const findings = JSON.stringify([
{ fingerprint: 'src/auth.ts:10:logic', path: 'src/auth.ts', line: 10, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
{ fingerprint: 'src/auth.ts:25:logic', path: 'src/auth.ts', line: 25, severity: 'CRITICAL', category: 'logic', summary: 'neighbour' },
{ fingerprint: 'src/legacy.ts:3:security', path: 'src/legacy.ts', line: 3, severity: 'CRITICAL', category: 'security', summary: 'old' },
{ fingerprint: 'src/legacy.ts:4:style', path: 'src/legacy.ts', line: 4, severity: 'INFORMATIONAL', category: 'style', summary: 'wording' },
]);
const f = path.join(repo.root, 'findings.json');
fs.writeFileSync(f, findings);
// diff.mnemonicPrefix prints c/ and w/ instead of a/ and b/; classify pins the prefixes, so the user's git config cannot send every finding outside.
repo.git('config', 'diff.mnemonicPrefix', 'true');
const p1 = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
expect(p1.status).toBe(EXIT.ok);
expect(p1.err.trim()).toBe('REVIEW_SCOPE: policy=frozen pass=1 findings=4 fix=1 defer=2 note=1 blocking=3');
const j1 = JSON.parse(p1.out);
expect(j1.policy).toBe('frozen');
expect(j1.findings.map((x: ScopedFinding) => `${x.fingerprint}=${x.locus}/${x.scope}`)).toEqual([
'src/auth.ts:10:logic=diff/fix', 'src/auth.ts:25:logic=outside/defer', 'src/legacy.ts:3:security=outside/defer', 'src/legacy.ts:4:style=outside/note']);
// pass 1 "fixed" line 10 and touched line 25 (a regression in the fix range)
lines[9] = 'line 10 fixed'; lines[24] = 'line 25 touched by the fix';
fs.writeFileSync(path.join(src, 'auth.ts'), lines.join('\n'));
repo.git('commit', '-qam', 'fix: pre-landing review fixes');
const p2 = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', pass1Head, '--findings', f, '--json']);
expect(p2.status).toBe(EXIT.ok);
expect(JSON.parse(p2.out).findings.map((x: ScopedFinding) => `${x.fingerprint}=${x.locus}/${x.scope}`)).toEqual([
'src/auth.ts:10:logic=fix-range/fix', 'src/auth.ts:25:logic=fix-range/fix', 'src/legacy.ts:3:security=outside/defer', 'src/legacy.ts:4:style=outside/note']);
const all = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', pass1Head, '--policy', 'all', '--findings', f, '--json']);
expect(JSON.parse(all.out).findings.every((x: ScopedFinding) => x.scope === 'fix')).toBe(true);
const noRange = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--findings', f]);
expect(noRange.status).toBe(EXIT.fail);
expect(noRange.err).toContain('(REVIEW_SCOPE_INPUT;');
const badRange = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '2', '--range-base', 'f'.repeat(40), '--findings', f]);
expect(badRange.status).toBe(EXIT.fail);
expect(badRange.err).toContain('(REVIEW_SCOPE_RANGE;');
// a --range-base on pass 1 is not applied, so the envelope does not claim it
const p1Range = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--range-base', pass1Head, '--findings', f, '--json']);
expect(JSON.parse(p1Range.out).range_base).toBeNull();
for (const args of [['--pass', '0'], ['--pass', '1', '--policy', 'loose'], ['--pass', '2', '--range-base', '--output=/tmp/x']]) {
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', ...args, '--findings', f]);
expect(r.status).toBe(EXIT.fail);
expect(r.err).toContain('(REVIEW_SCOPE_INPUT;');
}
// the reviewed policy is the base's; an unresolvable base is refused by name, never read from the working tree
const noBase = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'nonesuch', '--pass', '1', '--findings', f]);
expect(noBase.status).toBe(EXIT.refused);
expect(noBase.err).toContain('(POLICY_SOURCE_UNAVAILABLE)');
expect(noBase.err).toContain('pass --policy to classify without the base');
fs.writeFileSync(f, '{"not":"an array"}');
const bad = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f]);
expect(bad.status).toBe(EXIT.fail);
expect(bad.err).toContain('(REVIEW_SCOPE_INPUT;');
const d = path.join(repo.root, 'deferred.json'); fs.writeFileSync(d, '{}');
const badRender = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(badRender.status).toBe(EXIT.fail);
expect(badRender.err).toContain('expected the JSON array defer --out writes');
fs.writeFileSync(d, '{"deferred":[]}');
const wrapped = runBin(repo, 'gstack-review-scope', ['render', '--deferred', d]);
expect(wrapped.status, wrapped.err).toBe(EXIT.ok);
expect(wrapped.out).toBe('');
});
test('classify sees a non-ASCII changed path under its own name, so a CRITICAL finding there is fixed, not deferred to an issue', () => {
const repo = fixture();
fs.mkdirSync(path.join(repo.work, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 1;\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.writeFileSync(path.join(repo.work, 'src', 'café.ts'), 'export const a = 2;\n');
repo.git('commit', '-qam', 'change');
fs.writeFileSync(path.join(repo.work, 'src', 'naïve.ts'), 'export const n = 1;\n');
const f = path.join(repo.root, 'findings.json');
fs.writeFileSync(f, JSON.stringify([
{ path: 'src/café.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
{ path: 'src/naïve.ts', line: 1, severity: 'CRITICAL', category: 'logic', summary: 'untracked' },
]));
const r = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
expect(r.status).toBe(EXIT.ok);
expect(JSON.parse(r.out).findings.map((x: ScopedFinding) => `${x.path}=${x.locus}/${x.scope}`)).toEqual(['src/café.ts=diff/fix', 'src/naïve.ts=diff/fix']);
});
test('defer keeps each deferred finding on the PR and never runs gh, so no issue is filed (R85)', () => {
const repo = fixture();
const shim = path.join(repo.root, 'gh-shim');
fs.mkdirSync(shim);
const log = path.join(shim, 'calls.log');
fs.writeFileSync(path.join(shim, 'gh'), `#!/bin/sh\necho "$*" >> "${log}"\necho https://example.test/acme/app/issues/10\n`, { mode: 0o755 });
const classified = path.join(repo.root, 'classified.json');
fs.writeFileSync(classified, JSON.stringify([
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts', line: 7, severity: 'CRITICAL', category: 'data-loss', summary: 'Unbounded delete', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
{ fingerprint: 'src/auth.ts:42:naming', path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', category: 'naming', summary: 'rename b', locus: 'diff', kind: 'defect', blocking: false, scope: 'note' },
{ fingerprint: 'src/auth.ts:11:security', path: 'src/auth.ts', line: 11, severity: 'CRITICAL', category: 'security', summary: 'fixed', locus: 'diff', kind: 'defect', blocking: true, scope: 'fix' },
]));
const out = path.join(repo.root, 'deferred.json');
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--out', out, '--json'], { PATH: `${shim}:${process.env.PATH}` });
expect(r.status, r.err).toBe(EXIT.ok);
expect(JSON.parse(r.out)).toEqual([{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete' }]);
expect(r.err).toMatch(/^DEFERRED: CRITICAL lib\/legacy\.ts:7$/m);
expect(fs.existsSync(log)).toBe(false);
const rendered = runBin(repo, 'gstack-review-scope', ['render', '--deferred', out, '--findings', classified]);
expect(rendered.status).toBe(EXIT.ok);
expect(rendered.out).toContain('- Deferred: CRITICAL `lib/legacy.ts:7` Unbounded delete');
expect(rendered.out).toContain('## Review notes');
expect(rendered.out).toContain('- INFORMATIONAL `src/auth.ts:42` rename b');
expect(rendered.out).not.toContain('src/auth.ts:11');
});
// Generated by /ship Step 7 (coverage audit of the harness lane E diff).
// Value: protects=summary reads this branch's review log through gstack-review-read and prints the REVIEW_LOOP counters that
// `gstack-ship-receipt write --review-loop` consumes, counting only this invocation's ship-via review passes and tolerating a
// malformed row and a branch with no log; fails_when=the row filter, the ---CONFIG--- split, the --since guard or the --out
// write breaks, or a missing log is treated as an error instead of zero passes; why_new=only the pure summarizeReviewLoop is
// tested, never the bin against a review log on disk; seam=none; no_claim=review-log writing (rows are written directly, not
// through gstack-review-log)
test('summary reads the branch review log through gstack-review-read, counts this invocation\'s passes, writes --out, and refuses a missing --since', () => {
const repo = fixture();
repo.git('checkout', '-qb', 'feat');
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
const branch = runBin(repo, 'gstack-slug', ['--get', 'BRANCH']).out.trim();
expect(slug).not.toBe('');
expect(branch).toBe('feat');
const logDir = path.join(repo.home, 'projects', slug);
fs.mkdirSync(logDir, { recursive: true });
const rows = [
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T09:00:00Z', status: 'clean', review_scope: 'frozen', findings: [{ fingerprint: 'old:1:q', blocking: true, action: 'fixed' }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:00:00Z', status: 'issues_found', review_scope: 'frozen', findings: [
{ fingerprint: 'a:1:x', blocking: true, action: 'auto-fixed' }, { fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'adversarial-review', via: 'ship', timestamp: '2026-10-26T10:15:00Z', findings: [{ fingerprint: 'd:1:w', blocking: true }] },
{ skill: 'review', via: 'ship', timestamp: '2026-10-26T10:30:00Z', status: 'clean', review_scope: 'frozen', findings: [
{ fingerprint: 'b:1:y', blocking: true, action: 'deferred', issue: 5 }, { fingerprint: 'c:1:z', action: 'noted' }] },
{ skill: 'review', timestamp: '2026-10-26T10:40:00Z', findings: [{ fingerprint: 'e:1:v', blocking: true, action: 'fixed' }] },
];
fs.writeFileSync(path.join(logDir, `${branch}-reviews.jsonl`), rows.map(r => JSON.stringify(r)).join('\n') + '\n{not json\n');
const expected = { passes: 2, blocking_found: 2, deferred: 1, noted: 1, fixed: 1, scope: 'frozen', rounds: 2 };
const out = path.join(repo.root, 'review-loop.json');
const r = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', out, '--json']);
expect(r.status).toBe(EXIT.ok);
expect(r.err).toContain('REVIEW_LOOP: passes=2 blocking_found=2 deferred=1 noted=1 fixed=1 scope=frozen');
expect(JSON.parse(r.out)).toEqual(expected);
expect(JSON.parse(fs.readFileSync(out, 'utf8'))).toEqual(expected);
const plain = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T08:00:00Z']);
expect(plain.status).toBe(EXIT.ok);
expect(plain.out.trim()).toBe('REVIEW_LOOP: passes=3 blocking_found=3 deferred=1 noted=1 fixed=2 scope=frozen');
const noSince = runBin(repo, 'gstack-review-scope', ['summary', '--json']);
expect(noSince.status).toBe(EXIT.fail);
expect(noSince.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(noSince.out).toBe('');
const badSince = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', 'yesterday']);
expect(badSince.status).toBe(EXIT.fail);
expect(badSince.err).toContain('(REVIEW_SCOPE_INPUT;');
// zero matching passes is a wrong --since or a log written elsewhere, never a loop to record: refused, nothing written
const bare = fixture();
const noneOut = path.join(bare.root, 'review-loop.json');
const none = runBin(bare, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--json']);
expect(none.status).toBe(EXIT.fail);
expect(none.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(none.err).toContain('REVIEW_LOOP: passes=0');
expect(fs.existsSync(noneOut)).toBe(false);
const late = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-27T00:00:00Z']);
expect(late.status).toBe(EXIT.fail);
});
});
describe('raised_by: which reviewer raised a classified finding (GSTA-152 D6)', () => {
test.each([
[{ raised_by: 'red-team' }, 'red-team'], [{ raised_by: 'QA' }, 'qa'], [{ raised_by: 'core' }, 'core'], [{ raised_by: 'fix-check' }, 'fix-check'],
[{ specialist: 'State Machines' }, 'state-machines'], [{ specialist: 'red-team' }, 'red-team'], [{ specialist: 'adversarial' }, 'adversarial-native'],
[{ specialist: 'outside-voice' }, 'outside-voice'], [{ raised_by: 'nonsense\n', specialist: 'testing' }, 'testing'], [{ specialist: 42 }, 'unknown'], [{}, 'unknown'],
])('%j -> %s', (f, want) => expect(raisedBy(f as any)).toBe(want));
test('classify stamps raised_by on every finding, and an older row without it still summarizes', () => {
const scoped = scopeFindings([{ fingerprint: 'src/a.ts:3:logic', path: 'src/a.ts', line: 3, severity: 'CRITICAL', specialist: 'security' }, { fingerprint: 'src/a.ts:9:logic', path: 'src/a.ts', line: 9, severity: 'CRITICAL' }], { policy: 'frozen', pass: 1, diffHunks: { 'src/a.ts': 'whole' }, rangeHunks: null });
expect(scoped.map(f => f.raised_by)).toEqual(['security', 'unknown']);
const old = { skill: 'review', via: 'ship', timestamp: '2026-10-28T10:00:00Z', findings: [{ fingerprint: 'src/a.ts:3:logic', blocking: true, action: 'fixed' }] };
expect(summarizeReviewLoop([old], '2026-10-28T09:00:00Z')).toMatchObject({ passes: 1, blocking_found: 1, fixed: 1 });
});
});
describe('GSTA-167 part 4b: untested state-matrix cells map below the floor per matrix cell, before the merge', () => {
const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'state-matrix-6792', 'captured-output.jsonl');
const parse = (text: string) => text.split('\n').filter(Boolean).map(l => JSON.parse(l)).filter(o => !o.matrix);
const scope = (findings: ScopeFinding[], policy: 'frozen' | 'all' = 'frozen') => scopeFindings(findings, { policy, pass: 1, diffHunks: { 'src/takes/proposals.ts': 'whole', 'src/m.ts': 'whole' }, rangeHunks: null });
const matrix = (test: string | null) => JSON.stringify({ specialist: 'state-machines', matrix: { machine: 'm (src/m.ts)', kinds: ['hold'], actions: ['release'], cells: [{ kind: 'hold', action: 'release', row: 'hold released', test }] } });
const untested = { severity: 'CRITICAL', confidence: 7, path: 'src/m.ts', line: 9, category: 'state-matrix', summary: 'untested cell: hold × release → hold released', fingerprint: 'src/m.ts:state-matrix:hold:release', specialist: 'state-machines', cell: { kind: 'hold', action: 'release' } };
// Value: protects=the captured #6792 review keeps its wrong-row and collapsed-kinds defects blocking while each untested cell
// alone on its cell becomes a pass-1 note; matrix records pass through byte for byte; fails_when=a defect is demoted or a
// pure test gap blocks; seam=mapStateMatrixOutput before Step 9.3's merge
test('#6792: four lone untested cells become notes; wrong rows, collapsed kinds and cells they share stay blocking', () => {
const raw = fs.readFileSync(FIXTURE, 'utf8');
const out = mapStateMatrixOutput(raw);
expect(out.mapped).toBe(4);
expect(out.text.split('\n')[0]).toBe(raw.split('\n')[0]);
const scoped = scope(parse(out.text));
const blocking = scoped.filter(f => f.blocking).map(f => f.summary!.split(':')[0]);
expect(blocking.sort()).toEqual(['collapsed kinds', 'untested cell', 'untested cell', 'wrong row', 'wrong row']);
const notes = scoped.filter(f => f.state_matrix === UNTESTED_CELL);
expect(notes.map(f => [f.kind, f.blocking, f.scope, f.severity, f.raw_severity])).toEqual(Array(4).fill(['test-gap', false, 'note', 'INFORMATIONAL', 'CRITICAL']));
});
// Value: protects=F3, a wrong-row finding that reuses the untested cell's fingerprint can never be folded into a demoted record;
// fails_when=the merge keeps the demoted summary and the data-loss row drops to Review notes; seam=the merge keeps CRITICAL if either
test('a wrong row sharing the untested cell and its fingerprint keeps both CRITICAL, before and after a merge', () => {
const wrong = { ...untested, summary: 'wrong row: hold × release deletes the hold', line: 12 };
const out = mapStateMatrixOutput([matrix(null), JSON.stringify(untested), JSON.stringify(wrong)].join('\n'));
expect(out.mapped).toBe(0);
const merged = { ...parse(out.text)[0], severity: 'CRITICAL' };
expect(scope([merged])[0]).toMatchObject({ kind: 'defect', blocking: true, scope: 'fix' });
const demoted = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text)[0];
expect(scope([{ ...demoted, severity: 'CRITICAL' }])[0]).toMatchObject({ kind: 'defect', blocking: true });
});
test('no matrix record, a cell the matrix maps to a test, or a lone wrong row on an untested cell: every one stays CRITICAL', () => {
const wrongAlone = { ...untested, summary: 'wrong row: hold × release deletes the hold' };
for (const text of [JSON.stringify(untested), [matrix('test/m.test.ts:4'), JSON.stringify(untested)].join('\n'), [matrix(null), JSON.stringify(wrongAlone)].join('\n')]) {
const out = mapStateMatrixOutput(text);
expect([out.mapped, out.text]).toEqual([0, text]);
expect(scope(parse(text))[0]!.blocking).toBe(true);
}
});
test('policy all fixes a mapped cell; other policies note it from pass 1 on, in or out of the diff', () => {
const [f] = parse(mapStateMatrixOutput([matrix(null), JSON.stringify(untested)].join('\n')).text);
expect(scope([f], 'all')[0]!.scope).toBe('fix');
expect(scope([f])[0]!.scope).toBe('note');
expect(scopeFindings([f], { policy: 'diff-plus-blocking', pass: 2, diffHunks: {}, rangeHunks: {} })[0]!.scope).toBe('note');
});
test('gstack-state-matrix findings prints the mapped output and the count on stderr; check still reads the raw file', () => {
const r = spawnSync('bun', ['--no-env-file', '--config=/dev/null', path.join(ROOT, 'bin', 'gstack-state-matrix'), 'findings', FIXTURE], { encoding: 'utf8', timeout: 60_000 });
expect(r.status).toBe(0);
expect(r.stderr).toContain('STATE_MATRIX_FINDINGS: mapped=4');
expect(r.stdout).toBe(mapStateMatrixOutput(fs.readFileSync(FIXTURE, 'utf8')).text);
expect(fs.readFileSync(path.join(ROOT, 'review', 'specialists', 'state-machines.md'), 'utf8')).toContain('- **Untested cell (CRITICAL; this rule\'s High tier).**');
});
});
describe('gstack-review-scope classify on a diff past 64 MB (GST-1855)', () => {
// Value: protects=a results PR whose branch diff passes 64 MB still gets its findings placed; fails_when=classify reads git diff
// through a bounded pipe again and refuses harness-evals #320's 67 MB diff with an empty reason; seam=gstack-review-scope classify
test('classify places findings in a 70 MB branch diff', () => {
const repo = fixture();
const src = path.join(repo.work, 'src');
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, 'auth.ts'), Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n') + '\n');
repo.git('add', '.'); repo.git('commit', '-qm', 'seed'); repo.git('push', '-q', 'origin', 'main');
repo.git('checkout', '-qb', 'feat');
fs.mkdirSync(path.join(repo.work, 'results'));
fs.writeFileSync(path.join(repo.work, 'results', 'trials.jsonl'), `{"trial":"${'x'.repeat(1010)}"}\n`.repeat(70_000));
const lines = fs.readFileSync(path.join(src, 'auth.ts'), 'utf8').split('\n');
lines[9] = 'line 10 changed';
fs.writeFileSync(path.join(src, 'auth.ts'), lines.join('\n'));
repo.git('add', '.'); repo.git('commit', '-qm', 'results and a fix');
const f = path.join(repo.root, 'findings.json');
fs.writeFileSync(f, JSON.stringify([
{ fingerprint: 'src/auth.ts:10:logic', path: 'src/auth.ts', line: 10, severity: 'CRITICAL', category: 'logic', summary: 'wrong' },
{ fingerprint: 'src/auth.ts:25:logic', path: 'src/auth.ts', line: 25, severity: 'CRITICAL', category: 'logic', summary: 'neighbour' },
{ fingerprint: 'results/trials.jsonl:3:data', path: 'results/trials.jsonl', line: 3, severity: 'INFORMATIONAL', category: 'data', summary: 'row' },
]));
const p1 = runBin(repo, 'gstack-review-scope', ['classify', '--base', 'main', '--pass', '1', '--findings', f, '--json']);
expect(p1.status, p1.err).toBe(EXIT.ok);
expect(JSON.parse(p1.out).findings.map((x: ScopedFinding) => `${x.fingerprint}=${x.locus}`)).toEqual([
'src/auth.ts:10:logic=diff', 'src/auth.ts:25:logic=outside', 'results/trials.jsonl:3:data=diff']);
}, 120_000);
});