train S4: merge #3845 at b593334e44 v1.92.75.0

This commit is contained in:
garrytan committed 2000-01-01 00:00:00 +00:00
commit 5a57ff129e
25 files changed
+463 -41

No files matched your search

+1 -1
View File
@@ -190,7 +190,7 @@ function reviewRows(repo: string, env: NodeJS.ProcessEnv) {
}
function loopSummary(repo: string, env: NodeJS.ProcessEnv, since: string) {
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--since', since, '--json']).stdout.trim());
return JSON.parse(command(repo, env, path.join(ROOT, 'bin/gstack-review-scope'), ['summary', '--no-ledger', '--since', since, '--json']).stdout.trim());
}
export async function shipFrozenScopeFailures(fixture: ReturnType<typeof createShipFrozenScopeFixture>, result: Pick<AgentSdkResult, 'exitReason'>) {
+249
View File
@@ -0,0 +1,249 @@
/**
* GST-951: the /ship receipt's review_loop counters (gstack-review-scope summary) missed real blockers three times on
* 2026-11-02, because summary read only the review-log rows while /ship's readers report through the dispatch ledgers
* (gstack-review-dispatch collect and judge). Each case below is a minimal reconstruction of that PR's rows and ledgers,
* built from its public PR body and receipt (the rows and ledgers themselves stayed on the lanes' machines): the rows
* reproduce the receipt's published counters on the old summary, and the ledgers hold what the PR body says the readers found.
* Ledgers are written with the real collect and judge, so the parser and judge rules apply as in a run.
*/
import { afterEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXIT } from '../lib/headless-artifacts';
import { collect, readJsonl, recordJudgment, type JudgedLine, type MergedLine } from '../lib/review-ledger';
import { parseReviewLoop } from '../lib/ship-receipt';
import { cleanup, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/restamp-fixture';
setDefaultTimeout(60_000);
const repos: FixtureRepo[] = [];
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
const SINCE = '2026-11-02T01:00:00Z';
const at = (min: number) => new Date(Date.parse(SINCE) + min * 60_000).toISOString();
const review = (min: number, pass: number, findings: object[]) => ({ skill: 'review', via: 'ship', timestamp: at(min), status: findings.length ? 'issues_found' : 'clean', review_scope: 'frozen', pass, findings });
const fixCheck = (min: number, findings: object[]) => ({ skill: 'review-fix-check', via: 'ship', timestamp: at(min), review_scope: 'frozen', findings });
const note = (fp: string, raised_by: string) => ({ fingerprint: fp, severity: 'INFORMATIONAL', action: 'noted', blocking: false, raised_by });
/** One pass directory: each reader's output collected into its ledger, then lead judgment (default: every label kept). */
function pass(dir: string, readers: Array<[string, string]>, judgeWith = (merged: MergedLine[]): JudgedLine[] => merged.map(m => ({ id: m.id, severity: m.label }))): string {
fs.mkdirSync(dir, { recursive: true });
const ledger = path.join(dir, 'review-ledger.jsonl');
const mergedFile = path.join(dir, 'merged.jsonl');
fs.writeFileSync(mergedFile, '');
for (const [reader, output] of readers) expect(collect(ledger, reader, output, mergedFile).review).toBe(true);
const merged = readJsonl<MergedLine>(mergedFile);
const judged = judgeWith(merged);
const judgedFile = path.join(dir, 'judged.jsonl');
fs.writeFileSync(judgedFile, judged.map(j => JSON.stringify(j) + '\n').join(''));
expect(recordJudgment(ledger, merged, judged, judgedFile, mergedFile).ok).toBe(true);
return ledger;
}
function branchWith(rows: object[]): FixtureRepo {
const repo = makeFixtureRepo({ prefix: 'gst951-' });
repos.push(repo);
repo.git('checkout', '-qb', 'feat');
const slug = runBin(repo, 'gstack-slug', ['--get', 'SLUG']).out.trim();
const logDir = path.join(repo.home, 'projects', slug);
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r) + '\n').join(''));
return repo;
}
const summary = (repo: FixtureRepo, ...args: string[]) => runBin(repo, 'gstack-review-scope', ['summary', '--since', SINCE, '--json', ...args]);
const loop = (r: { status: number | null; out: string; err: string }) => { expect(r.err).not.toContain('(REVIEW_SCOPE_'); expect(r.status).toBe(EXIT.ok); return JSON.parse(r.out); };
const CLEAN = 'NO FINDINGS';
const OUTSIDE_CLEAN = 'No issues found in the diff.\nRecommendation: ship because no finding reaches the floor';
describe('GST-951: the receipt counts every blocker the readers found', () => {
// #3841 (reconstruction; GSTA-203 PR 8 trail rows 9 and 14): Step 11's native adversarial FIXABLE (CRITICAL) finding was
// fixed in round 2, but no row recorded it (pass 2's row held no findings), so summary printed blocking_found=0 fixed=0 and
// the lane hand-set blocking_found=1 fixed=1. Its two other findings are the noted pair.
const ADVERSARIAL_3841 = [
'1. lib/pregate-clear.ts:140 The receipt stores the raw run URL, not the attempt it judged. FIXABLE',
'2. lib/pregate.ts:88 The refusal text does not name the attempt. INVESTIGATE',
'3. docs/pregate.md:12 The docs do not say a rerun replaces the attempt. INVESTIGATE',
'Recommendation: fix 1 because a later attempt would be judged by the first',
].join('\n');
test('#3841 as /ship logs it: the queued adversarial blocker in pass 2\'s row counts as 1 found and 1 fixed', () => {
const repo = branchWith([
review(0, 1, []),
review(40, 2, [{ fingerprint: 'lib/pregate-clear.ts:140:security', action: 'fixed', raised_by: 'adversarial-native' },
note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]);
pass(path.join(run, 'p2'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 1 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 1, ledgers_read: 3, rounds: 2 });
expect(s.unrecorded).toBeUndefined();
});
test('#3841 as the lane logged it: an adversarial-only blocker counts, and its missing fix record shows as unrecorded', () => {
const repo = branchWith([
review(0, 1, []),
review(40, 2, [note('lib/pregate.ts:88:docs', 'adversarial-native'), note('docs/pregate.md:12:docs', 'adversarial-native')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/testing', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['outside-voice', OUTSIDE_CLEAN], ['adversarial-native', ADVERSARIAL_3841]]);
expect(loop(summary(repo, '--no-ledger'))).toMatchObject({ passes: 2, blocking_found: 0, noted: 2, fixed: 0 });
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ passes: 2, blocking_found: 1, noted: 2, fixed: 0, unrecorded: 1, ledgers_read: 2, rounds: 2 });
});
// #3838 (reconstruction): the rows reproduce its receipt (passes=2 blocking_found=0 noted=8 fixed=4 fix_checks=3). Pass 2's
// red-team CRITICAL was logged fixed without classify's blocking stamp; Step 11's native adversarial CRITICAL (CSS
// image-set loading an outside image) was fixed in 0f61362c1 and no row holds it.
test('#3838: CRITICALs fixed without a classified row count', () => {
const repo = branchWith([
review(0, 1, ['a', 'b', 'c'].map(x => ({ fingerprint: `lib/review-depth.ts:1${x}:style`, severity: 'INFORMATIONAL', action: 'fixed', blocking: false, raised_by: 'security' }))),
review(50, 2, [{ fingerprint: 'lib/review-depth.ts:212:security', severity: 'CRITICAL', action: 'fixed', raised_by: 'red-team' },
note('lib/review-depth.ts:220:style', 'red-team'), note('lib/review-depth.ts:230:style', 'red-team')]),
fixCheck(60, [note('lib/review-depth.ts:240:style', 'fix-check'), note('lib/review-depth.ts:241:style', 'fix-check')]),
fixCheck(90, [note('lib/review-depth.ts:250:style', 'fix-check')]),
fixCheck(120, [note('lib/review-depth.ts:260:perf', 'fix-check'), note('lib/review-depth.ts:261:perf', 'fix-check'), note('lib/review-depth.ts:262:perf', 'fix-check')]),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist/security', '- [INFORMATIONAL] lib/review-depth.ts:1a a CSS escape\n- [INFORMATIONAL] lib/review-depth.ts:1b a zero-padded reference\n- [INFORMATIONAL] lib/review-depth.ts:1c a split handler line'], ['red-team', CLEAN]]);
pass(path.join(run, 'p2'), [['red-team', '[CRITICAL] lib/review-depth.ts:212 a stray quote hides an external href from the drawing check']]);
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_read: 3 });
});
// #3837 (reconstruction): pass 1's row was logged before the outside voice's seven findings were classified, so they sit in the
// row with no severity, blocking stamp or action; the fix batch under the Captain's exception fixed them, and the receipt said
// blocking_found=0 fixed=0 noted=11 fix_checks=3.
test('#3837: blockers logged before classification count', () => {
const seven = Array.from({ length: 7 }, (_, i) => ({ fingerprint: `lib/inventory.ts:${10 + i}:logic`, raised_by: 'outside-voice' }));
const repo = branchWith([
review(0, 1, [...seven, ...Array.from({ length: 9 }, (_, i) => note(`docs/inventory.md:${i + 1}:docs`, 'specialist'))]),
review(60, 2, [note('lib/inventory.ts:90:docs', 'red-team'), note('lib/inventory.ts:91:docs', 'red-team')]),
fixCheck(80, []), fixCheck(100, []), fixCheck(120, []),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [['specialist-batched/1', CLEAN], ['red-team', CLEAN]]);
pass(path.join(run, 'p1-voices'), [['adversarial-native', CLEAN], ['outside-voice', [
...seven.map((f, i) => `${i + 1}. ${f.fingerprint.split(':').slice(0, 2).join(':')} finding ${i + 1} Severity: High Blocks: yes`),
'Recommendation: fix 1-6 because a self-grandfathered row skips every plan rule',
].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_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_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', () => {
const repo = branchWith([review(0, 1, [{ fingerprint: 'src/a.ts:3:logic', severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' }])]);
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_read: 1 });
expect(s.unrecorded).toBeUndefined();
});
test('a CRITICAL a row classified below the floor is not a blocker; distinct blockers from one reviewer each count', () => {
const repo = branchWith([review(0, 1, [
{ fingerprint: 'src/a.ts:3:testing', path: 'src/a.ts', line: 3, severity: 'CRITICAL', kind: 'test-gap', blocking: false, action: 'noted', raised_by: 'testing' },
{ fingerprint: 'src/b.ts:9:security', path: 'src/b.ts', line: 9, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'security' },
{ fingerprint: 'src/c.ts:1:logic', severity: 'CRITICAL', advisory: true, action: 'noted', raised_by: 'security' },
])]);
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_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: 'outside-voice' }),
row('src/n.ts:8:logic', { severity: 'CRITICAL', raised_by: 'red-team' }),
row('src/g.ts:7:logic', { action: 'fixed', raised_by: 'core' }),
]),
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('round 2 cases: another reviewer at a below-floor line, an extensionless path, one finding in two ledgers, no locus', () => {
const repo = branchWith([
review(0, 1, [
{ fingerprint: 'src/auth.ts:42:testing', path: 'src/auth.ts', line: 42, severity: 'CRITICAL', blocking: false, action: 'noted', raised_by: 'testing' },
{ fingerprint: 'bin/gstack-review-scope:262:logic', path: 'bin/gstack-review-scope', line: 262, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' },
{ fingerprint: 'x.ts:5:logic', path: 'x.ts', line: 5, severity: 'CRITICAL', blocking: true, action: 'fixed', raised_by: 'red-team' },
]),
fixCheck(30, []),
]);
const run = path.join(repo.root, 'run');
pass(path.join(run, 'p1'), [
['specialist/testing', '[CRITICAL] src/auth.ts:42 the denied path has no test'],
['outside-voice', '1. src/auth.ts:42 an unauthenticated caller reaches the handler. Blocks: yes\nRecommendation: fix 1 because it is open'],
['red-team', '[CRITICAL] bin/gstack-review-scope:262 the refusal is skipped\n[CRITICAL] x.ts:5 the guard is inverted'],
]);
pass(path.join(run, 'fix-check'), [['fix-check', '[CRITICAL] x.ts:5 the guard is still inverted\n[CRITICAL] the retry loop never ends']]);
const s = loop(summary(repo, '--run', run));
expect(s).toMatchObject({ blocking_found: 4, fixed: 2, noted: 1, unrecorded: 2, ledgers_read: 2 });
});
test('judgment decides: a folded duplicate counts once, a lead addition counts, a rejected judgment keeps the collected labels', () => {
const repo = branchWith([review(0, 1, [])]);
const run = path.join(repo.root, 'run');
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_read: 1 });
const rejected = path.join(repo.root, 'rejected');
fs.mkdirSync(rejected);
const ledger = path.join(rejected, 'review-ledger.jsonl');
const mergedFile = path.join(rejected, 'merged.jsonl');
fs.writeFileSync(mergedFile, '');
collect(ledger, 'red-team', '[CRITICAL] src/a.ts:3 the guard is inverted', mergedFile);
const merged = readJsonl<MergedLine>(mergedFile);
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_read: 1 });
});
test('summary refuses REVIEW_SCOPE_LEDGER instead of counting without a ledger it needs, and writes nothing', () => {
const repo = branchWith([review(0, 1, [])]);
const run = path.join(repo.root, 'run');
const ledger = pass(path.join(run, 'p1'), [['red-team', '[CRITICAL] src/a.ts:3 the guard is inverted']]);
const out = path.join(repo.root, 'loop.json');
const refused = (r: { status: number | null; err: string }, why: string) => { expect(r.status).toBe(EXIT.fail); expect(r.err).toContain('(REVIEW_SCOPE_LEDGER;'); expect(r.err).toContain(why); expect(fs.existsSync(out)).toBe(false); };
refused(summary(repo, '--out', out), '--no-ledger when no reader ran through gstack-review-dispatch');
fs.mkdirSync(path.join(repo.root, 'empty'));
refused(summary(repo, '--run', path.join(repo.root, 'empty'), '--out', out), 'holds no review-ledger.jsonl');
refused(summary(repo, '--ledger', path.join(run, 'nope.jsonl'), '--out', out), 'is unreadable');
const judged = path.join(run, 'p1', 'judged.jsonl');
const kept = fs.readFileSync(judged, 'utf8');
fs.writeFileSync(judged, kept.replace('CRITICAL', 'INFORMATIONAL'));
refused(summary(repo, '--ledger', ledger, '--out', out), 'changed since judge');
fs.writeFileSync(judged, kept);
fs.rmSync(path.join(run, 'p1', 'merged.jsonl'));
refused(summary(repo, '--run', run, '--out', out), 'is unreadable');
refused(summary(repo, '--run', path.join(repo.root, 'nonesuch'), '--out', out), 'ENOENT');
const bare = summary(repo, '--out', out, '--ledger');
expect(bare.err).toContain('(REVIEW_SCOPE_INPUT;');
expect(bare.err).toContain('--ledger needs a file');
const both = summary(repo, '--run', run, '--no-ledger');
expect(both.status).toBe(EXIT.fail);
expect(both.err).toContain('(REVIEW_SCOPE_INPUT;');
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ test('summary reads the review log through gstack-review-read (#3541)', () => {
];
fs.writeFileSync(path.join(state, 'projects', SLUG, 'feat-reviews.jsonl'), rows.map(r => JSON.stringify(r)).join('\n') + '\n');
const out = path.join(tmp, 'review-loop.json');
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], {
const r = runScriptSync(path.join(ROOT, 'bin', 'gstack-review-scope'), ['summary', '--no-ledger', '--since', '2026-10-26T09:00:00Z', '--out', out, '--json'], {
cwd: repo, env, encoding: 'utf8', timeout: 120_000,
});
expect(r.error).toBeUndefined();
+5 -5
View File
@@ -423,30 +423,30 @@ describe('gstack-review-scope (bin)', () => {
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', '--since', '2026-10-26T09:30:00Z', '--out', out, '--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', '--since', '2026-10-26T08:00:00Z']);
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', '--since', 'yesterday']);
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', '--since', '2026-10-26T09:30:00Z', '--out', noneOut, '--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', '--since', '2026-10-27T00:00:00Z']);
const late = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-27T00:00:00Z']);
expect(late.status).toBe(EXIT.fail);
});
});
+1 -1
View File
@@ -324,7 +324,7 @@ describe('the targeted role end to end on a fixture repository', () => {
fs.mkdirSync(logDir, { recursive: true });
const row = { skill: 'review', via: 'ship', timestamp: '2026-10-30T10:00:00Z', status: 'clean', review_scope: 'frozen', findings: [], dispatches: [{ role: 'specialist' }, { role: 'adversarial-native' }, { role: 'targeted' }] };
fs.writeFileSync(path.join(logDir, 'feat-reviews.jsonl'), JSON.stringify(row) + '\n');
const summary = runBin(repo, 'gstack-review-scope', ['summary', '--since', '2026-10-30T09:00:00Z', '--json']);
const summary = runBin(repo, 'gstack-review-scope', ['summary', '--no-ledger', '--since', '2026-10-30T09:00:00Z', '--json']);
expect(summary.status, summary.err).toBe(0);
expect(JSON.parse(summary.out).dispatches).toEqual({ specialist: 1, 'adversarial-native': 1, targeted: 1 });
@@ -0,0 +1,4 @@
{
"bytes": 177,
"why": "GST-951: summary names its ledgers (--run in DRIVER, --no-ledger in pr-body)"
}