Files
gstack/test/review-scope.test.ts
Garry Tanandcapy-ai[bot] b6cb70aea1 v1.91.355.0 ship: poll readers in-program, 32 KB returns, gated fix program, unattended push defers the guard offer, calls meter (GSTA-167 calls PR 1) (#3662)
* ship: poll readers in-program, never wait on wakes; 32 KB returns; gated fix program; unattended push defers the guard offer; calls meter (GSTA-167 calls PR 1)

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

* fix: pre-landing review fixes (inventory lib/ship-release.ts as a GSTACK_SESSION_KIND reader)

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

* docs(ship-budget): document calls and dotted --step notes

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

* ship: judge treats a missing severity as invalid; Capy passes rendered briefs via cat (replay C1-34 breaks)

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

* fix: keep the Capy skill under its context budget after the main merge

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-10-31 07:22:56 +00:00

519 lines
41 KiB
TypeScript
Raw Permalink 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 issues; 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 {
isBlocking, kindOf, locusOf, mapStateMatrixOutput, parseHunks, raisedBy, renderDeferredIssue, 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('the issue names the origin PR, the finding and a marker that keeps a rerun from filing twice; the body section references and never closes', () => {
const issue = renderDeferredIssue(finding, { repo: 'acme/app', pr: 42, branch: 'feat/x', head: 'a'.repeat(40) });
expect(issue.title).toBe('Deferred review finding: lib/legacy.ts:7 — Unbounded delete without a where clause');
expect(issue.body).toContain('acme/app#42 (head aaaaaaaaaaaa)');
expect(issue.body).toContain('- **Severity:** CRITICAL');
expect(issue.body).toContain('- **Suggested fix:** add the tenant filter');
expect(issue.body).toContain('- **Found by:** security');
expect(issue.marker).toBe('<!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss -->');
expect(issue.body.trimEnd().endsWith(issue.marker)).toBe(true);
const section = renderDeferredSection(
[{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 77 },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts:1', severity: 'CRITICAL', summary: 'Missing owner check', issue: null, reason: 'gh is not installed' }],
[{ path: 'src/auth.ts', line: 42, severity: 'INFORMATIONAL', summary: 'rename b', advisory: true }]);
expect(section).toContain('## Deferred findings');
expect(section).toContain('- Deferred: #77 — CRITICAL `lib/legacy.ts:7` Unbounded delete');
expect(section).toContain('- Deferred: unfiled (gh is not installed) — CRITICAL `lib/old.ts:1` Missing owner check');
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('');
});
});
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' });
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' });
});
});
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 without gh keeps the finding deferred as unfiled and still exits 0 (the PR body carries the reason), searches once before creating, and files nothing when the search fails; --dry-run files nothing', () => {
const repo = fixture();
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 dry = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--dry-run', '--json']);
expect(dry.status).toBe(EXIT.ok);
expect(JSON.parse(dry.out)).toEqual([{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: null, reason: 'dry run' }]);
const noGh = path.join(repo.root, 'nogh');
fs.mkdirSync(noGh);
fs.writeFileSync(path.join(noGh, 'gh'), '#!/bin/sh\necho "gh: not logged in" >&2; exit 4\n', { mode: 0o755 });
const out = path.join(repo.root, 'deferred.json');
const unfiled = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--out', out], { PATH: `${noGh}:${process.env.PATH}` });
expect(unfiled.status).toBe(EXIT.ok);
expect(unfiled.err).toMatch(/^DEFERRED: unfiled \(gh issue list failed: gh: not logged in\) CRITICAL lib\/legacy\.ts:7$/m);
expect(unfiled.out).toContain('- Deferred: unfiled (gh issue list failed: gh: not logged in) — CRITICAL `lib/legacy.ts:7` Unbounded delete');
// the search failed, so nothing was created: a rate-limited rerun cannot duplicate the issue the marker exists to find
const listFails = path.join(repo.root, 'listfails');
fs.mkdirSync(listFails);
const created = path.join(listFails, 'created');
fs.writeFileSync(path.join(listFails, 'gh'), `#!/bin/sh\nif [ "$1" = issue ] && [ "$2" = list ]; then echo "gh: HTTP 403 rate limit" >&2; exit 1; fi\nif [ "$1" = issue ] && [ "$2" = create ]; then touch "${created}"; echo https://example.test/o/r/issues/1; exit 0; fi\nexit 1\n`, { mode: 0o755 });
const rateLimited = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--json'], { PATH: `${listFails}:${process.env.PATH}` });
expect(rateLimited.status).toBe(EXIT.ok);
expect(JSON.parse(rateLimited.out)[0]).toMatchObject({ issue: null, reason: 'gh issue list failed: gh: HTTP 403 rate limit' });
expect(fs.existsSync(created)).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 findings');
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');
});
test('defer through gh creates one issue per deferred finding and reuses an open issue that already carries the marker', () => {
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', `echo "$*" >> "${log}"`,
'if [ "$1" = issue ] && [ "$2" = list ]; then',
` echo '[{"number":9,"url":"https://example.test/acme/app/issues/9","body":"x <!-- gstack-deferred-finding: lib/legacy.ts:7:data-loss --> y"},{"number":3,"url":"https://example.test/acme/app/issues/3","body":"<!-- gstack-deferred-finding: other:1:x -->"}]'; exit 0; fi`,
'if [ "$1" = issue ] && [ "$2" = create ]; then echo "https://example.test/acme/app/issues/10"; exit 0; fi',
'echo "unsupported $*" >&2; exit 1', '',
].join('\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: 'lib/old.ts:1:auth', path: 'lib/old.ts', line: 1, severity: 'CRITICAL', category: 'auth', summary: 'Missing owner check', fix: 'check owner', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
// Reviewer text is untrusted: a security finding that quotes the credential it found must not reach a public issue.
{ fingerprint: 'lib/keys.ts:3:security', path: 'lib/keys.ts', line: 3, severity: 'CRITICAL', category: 'security', summary: 'Hardcoded token ghp_' + 'Qm4zR7tK2wX9pL5vN8sD1fG6hJ3yB0cA7eU2', locus: 'outside', kind: 'defect', blocking: true, scope: 'defer' },
]));
const r = runBin(repo, 'gstack-review-scope', ['defer', '--findings', classified, '--pr', '42', '--repo', 'acme/app', '--json'], { PATH: `${shim}:${process.env.PATH}` });
expect(r.status).toBe(EXIT.ok);
const records = JSON.parse(r.out);
expect(records.slice(0, 2)).toEqual([
{ fingerprint: 'lib/legacy.ts:7:data-loss', path: 'lib/legacy.ts:7', severity: 'CRITICAL', summary: 'Unbounded delete', issue: 9, url: 'https://example.test/acme/app/issues/9', reason: 'already filed' },
{ fingerprint: 'lib/old.ts:1:auth', path: 'lib/old.ts:1', severity: 'CRITICAL', summary: 'Missing owner check', issue: 10, url: 'https://example.test/acme/app/issues/10' },
]);
expect(records[2].issue).toBeNull();
expect(records[2].reason).toMatch(/^publication scan refused: HIGH /);
expect(r.err).toContain('DEFERRED: #9 CRITICAL lib/legacy.ts:7');
expect(r.err).toContain('DEFERRED: #10 CRITICAL lib/old.ts:1');
expect(r.err).toContain('DEFERRED: unfiled (publication scan refused: HIGH ');
const calls = fs.readFileSync(log, 'utf8');
expect(calls.match(/^issue create /gm)?.length).toBe(1);
expect(calls.match(/^issue list /gm)?.length).toBe(1);
expect(calls).toContain('--search "gstack-deferred-finding" in:body');
expect(calls).toContain('issue create --title=Deferred review finding: lib/old.ts:1 — Missing owner check --body-file=- --repo acme/app');
expect(calls).not.toContain('Qm4zR7tK2wX9pL5vN8sD1fG6hJ3yB0cA7eU2');
});
// 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' };
const out = path.join(repo.root, 'review-loop.json');
const r = runBin(repo, 'gstack-review-scope', ['summary', '--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', '--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', '--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', '--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', '--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).**');
});
});