Files
gstack/bin/gstack-review-scope
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

244 lines
17 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* gstack-review-scope — the finding scope of /ship's review loop (docs/ship-policy.md#review-scope).
*
* gstack-review-scope classify --base <ref> --pass <n> [--range-base <sha>] [--policy frozen|diff-plus-blocking|all] [--findings <file>|-] [--json]
* gstack-review-scope defer --findings <classified.json> [--out <deferred.json>] [--json]
* gstack-review-scope render --deferred <deferred.json> [--findings <classified.json>]
* gstack-review-scope summary --since <iso-timestamp> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>] [--json]
*
* `classify` reads a JSON array of findings (the Step 9.3 result) and stamps
* each with `locus` (diff | fix-range | outside), `kind` (defect | test-gap |
* advisory), `blocking` and `scope` (fix | defer | note); the loop fixes only
* `fix` and writes `defer` and `note` into the PR body. `defer` records each
* deferred finding for the PR and files no issue. `render` prints the PR-body
* sections. `summary` reads this branch's review log for the invocation's
* passes and prints the REVIEW_LOOP line the ship receipt carries.
* Logic: lib/review-scope.ts.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { StringDecoder } from 'node:string_decoder';
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
import { resultLineWithDocs } from '../lib/result-codes';
import { runScriptSync } from '../lib/script-invocation';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import {
deferredRecord, ledgerPass, parseFixTrailers, parseHunks, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop,
REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding,
} from '../lib/review-scope';
import { ledgerBlockers, type LedgerBlocker } from '../lib/review-ledger';
const HELP = `gstack-review-scope — finding scope for /ship's review loop (docs/ship-policy.md#review-scope)
classify --base <ref> --pass <n> [--range-base <sha>] [--policy frozen|diff-plus-blocking|all] [--findings <file>|-]
stamp each finding with locus, kind, blocking and scope (fix | defer | note);
prints REVIEW_SCOPE: … and the classified JSON array (--json: JSON only)
defer --findings <classified.json> [--out <file>]
record each scope=defer finding for the PR body (no issue is filed);
prints DEFERRED: lines and the deferred records
render --deferred <file> [--findings <classified.json>]
the PR-body sections: ## Deferred findings (references, never closing keywords)
and ## Review notes (scope=note findings)
summary --since <iso> (--run <dir> | --ledger <f>... | --no-ledger) [--out <file>]
the invocation's passes from this branch's review log: REVIEW_LOOP: passes=…
blocking_found=… deferred=… noted=… (the ship receipt's --review-loop input);
blocking_found also counts every CRITICAL the dispatch ledgers collected or judged
(--run finds each review-ledger.jsonl under the run directory); unrecorded=N names
blockers no row disposes; a row finding's ledger_id (<pass dir>/<merged id>) and each
fix commit's Review-Finding: <id> reader=<role> trailer bind by id, and what cannot bind
prints REVIEW_SCOPE_UNBOUND: <what>: <why> (unbound=N). An unreadable ledger: REVIEW_SCOPE_LEDGER
Defaults: --policy from the base branch's .gstack/ship-policy.json review_scope (frozen); an unresolvable base refuses
(POLICY_SOURCE_UNAVAILABLE) unless --policy is passed; --findings - reads stdin.
${exitTableHelp()}`;
function flag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
}
const DIFF_ARGS = ['-U0', '--no-color', '--no-ext-diff', '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/'];
function git(args: string[], cwd: string): { ok: boolean; out: string; err: string } {
// quotePath off: a non-ASCII path must come back as itself, not as "caf\303\251.ts", or the hunk map never matches the finding.
const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
const HUNK_LINE = /^(diff --git |--- |\+\+\+ |@@)/;
/**
* `git diff <from>` reduced to the lines parseHunks reads (file headers and @@ lines). git writes the diff to a scratch file that
* is read back a megabyte at a time, so no pipe buffer bounds the diff's size (GST-1855: harness-evals #320's 67 MB results diff
* passed the 64 MB buffer and classify refused with an empty reason). A failure names git's stderr, its error code or the timeout.
*/
function diffHunkLines(from: string, cwd: string): { ok: boolean; out: string; err: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-review-scope-'));
try {
const file = path.join(dir, 'diff');
const r = spawnSync('git', ['-c', 'core.quotePath=false', 'diff', ...DIFF_ARGS, `--output=${file}`, from], { cwd, encoding: 'utf8', timeout: 120_000 });
const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
if (r.status !== 0) return { ok: false, out: '', err: (r.stderr ?? '').trim() || (code === 'ETIMEDOUT' ? 'timed out after 120 s' : code ?? `exit ${r.status ?? r.signal}`) };
const keep: string[] = [];
const decoder = new StringDecoder('utf8');
const buf = Buffer.alloc(1024 * 1024);
const fd = fs.openSync(file, 'r');
let rest = '';
try {
for (let n = fs.readSync(fd, buf); n > 0; n = fs.readSync(fd, buf)) {
const lines = (rest + decoder.write(buf.subarray(0, n))).split('\n');
rest = lines.pop()!;
for (const l of lines) if (HUNK_LINE.test(l)) keep.push(l);
}
} finally { fs.closeSync(fd); }
rest += decoder.end();
if (HUNK_LINE.test(rest)) keep.push(rest);
return { ok: true, out: keep.join('\n'), err: '' };
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
}
function readFindings(spec: string | undefined, what: string): ScopeFinding[] {
const text = spec === undefined || spec === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(spec, 'utf8');
const parsed = JSON.parse(text.trim() || '[]');
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.findings) ? parsed.findings : null;
if (!list || !list.every((f: unknown) => f && typeof f === 'object' && !Array.isArray(f))) throw new Error(`${what}: expected a JSON array of finding objects`);
return list as ScopeFinding[];
}
function refuse(cmd: string, code: 'REVIEW_SCOPE_INPUT' | 'REVIEW_SCOPE_RANGE' | 'REVIEW_SCOPE_LEDGER', detail: string): number {
process.stderr.write(resultLineWithDocs(`gstack-review-scope ${cmd}`, code, detail) + '\n');
return EXIT.fail;
}
function cmdClassify(rest: string[], json: boolean): number {
const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd()).out.trim() || process.cwd();
const base = flag(rest, '--base') ?? resolveBaseBranch(repoRoot);
const pass = Number(flag(rest, '--pass') ?? '1');
if (!Number.isInteger(pass) || pass < 1) return refuse('classify', 'REVIEW_SCOPE_INPUT', '--pass must be a positive integer');
const policyFlag = flag(rest, '--policy');
let policy: ReviewScopePolicy;
if (policyFlag !== undefined) {
if (!REVIEW_SCOPE_POLICIES.includes(policyFlag as ReviewScopePolicy)) return refuse('classify', 'REVIEW_SCOPE_INPUT', `--policy must be one of ${REVIEW_SCOPE_POLICIES.join(' | ')}, got ${JSON.stringify(policyFlag)}`);
policy = policyFlag;
} else {
const load = loadPolicyFromBase(repoRoot, base);
// The reviewed policy is the base's; the working tree's copy is never read silently (pass --policy to override).
if (isUnavailable(load)) { process.stderr.write(policyUnavailableLine('gstack-review-scope classify', base, `${load.detail}; pass --policy to classify without the base`) + '\n'); return EXIT.refused; }
policy = load.policy.review_scope;
}
let findings: ScopeFinding[];
try { findings = readFindings(flag(rest, '--findings'), 'findings'); } catch (e: any) { return refuse('classify', 'REVIEW_SCOPE_INPUT', e.message); }
const baseRef = /^[0-9a-f]{7,64}$/i.test(base) ? base : `origin/${base.replace(/^origin\//, '')}`;
const mergeBase = git(['merge-base', baseRef, 'HEAD'], repoRoot);
if (!mergeBase.ok) return refuse('classify', 'REVIEW_SCOPE_RANGE', `cannot resolve the merge base of ${baseRef} and HEAD: ${mergeBase.err}`);
// Prefixes pinned as lib/pregate/checks/secrets.ts pins them: diff.mnemonicPrefix would print c/ and w/ and nothing would match a finding.
const diff = diffHunkLines(mergeBase.out.trim(), repoRoot);
if (!diff.ok) return refuse('classify', 'REVIEW_SCOPE_RANGE', `git diff ${mergeBase.out.trim()} failed: ${diff.err}`);
const untracked = git(['ls-files', '--others', '--exclude-standard'], repoRoot).out.split('\n').filter(Boolean);
const diffHunks = parseHunks(diff.out, untracked);
let rangeHunks = null;
const rangeBase = flag(rest, '--range-base');
if (rangeBase !== undefined && !/^[0-9a-f]{7,64}$/i.test(rangeBase)) return refuse('classify', 'REVIEW_SCOPE_INPUT', `--range-base must be a commit id, got ${JSON.stringify(rangeBase.slice(0, 40))}`);
if (pass > 1) {
if (!rangeBase) return refuse('classify', 'REVIEW_SCOPE_INPUT', '--range-base <sha> (HEAD at the start of the previous pass) is required from pass 2 on');
const ancestor = git(['merge-base', '--is-ancestor', rangeBase, 'HEAD'], repoRoot);
if (!ancestor.ok) return refuse('classify', 'REVIEW_SCOPE_RANGE', `--range-base ${rangeBase.slice(0, 12)} is not an ancestor of HEAD (rewritten history? start a full pass)`);
const range = diffHunkLines(rangeBase, repoRoot);
if (!range.ok) return refuse('classify', 'REVIEW_SCOPE_RANGE', `git diff ${rangeBase.slice(0, 12)} failed: ${range.err}`);
rangeHunks = parseHunks(range.out, untracked);
}
const scoped = scopeFindings(findings, { policy, pass, diffHunks, rangeHunks });
const line = scopeLine(policy, pass, scoped);
process.stderr.write(line + '\n');
process.stdout.write(json ? JSON.stringify({ policy, pass, range_base: pass > 1 ? rangeBase : null, findings: scoped }) + '\n' : line + '\n' + JSON.stringify(scoped, null, 2) + '\n');
return EXIT.ok;
}
function cmdDefer(rest: string[], json: boolean): number {
let findings: ScopedFinding[];
try { findings = readFindings(flag(rest, '--findings'), 'classified findings') as ScopedFinding[]; } catch (e: any) { return refuse('defer', 'REVIEW_SCOPE_INPUT', e.message); }
const records: DeferredRecord[] = findings.filter(f => f.scope === 'defer').map(deferredRecord);
for (const r of records) process.stderr.write(`DEFERRED: ${r.severity} ${r.path}\n`);
if (!records.length) process.stderr.write('DEFERRED: none\n');
const out = flag(rest, '--out');
if (out) fs.writeFileSync(out, JSON.stringify(records, null, 2) + '\n');
process.stdout.write(json ? JSON.stringify(records) + '\n' : renderDeferredSection(records));
return EXIT.ok;
}
function cmdRender(rest: string[]): number {
const deferredFile = flag(rest, '--deferred');
let deferred: DeferredRecord[] = [];
let noted: ScopedFinding[] = [];
try {
if (deferredFile) deferred = JSON.parse(fs.readFileSync(deferredFile, 'utf8'));
const wrapped = deferred as unknown as { deferred?: unknown };
if (!Array.isArray(deferred) && Array.isArray(wrapped?.deferred)) deferred = wrapped.deferred as DeferredRecord[];
const findingsFile = flag(rest, '--findings');
if (findingsFile) noted = (readFindings(findingsFile, 'classified findings') as ScopedFinding[]).filter(f => f.scope === 'note');
} catch (e: any) { return refuse('render', 'REVIEW_SCOPE_INPUT', e.message); }
if (!Array.isArray(deferred)) return refuse('render', 'REVIEW_SCOPE_INPUT', `${deferredFile}: expected the JSON array defer --out writes`);
process.stdout.write(renderDeferredSection(deferred, noted));
return EXIT.ok;
}
function cmdSummary(rest: string[], json: boolean): number {
const since = flag(rest, '--since');
if (!since || !Number.isFinite(Date.parse(since))) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--since <ISO-8601 timestamp> (the invocation\'s first Step 9 entry) is required');
const read = runScriptSync(path.join(path.dirname(Bun.fileURLToPath(import.meta.url)), 'gstack-review-read'), [], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
if (read.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `gstack-review-read failed: ${read.error?.message ?? (read.stderr ?? '').trim().split('\n')[0] ?? ''}`);
const rows: Record<string, unknown>[] = [];
for (const line of (read.stdout ?? '').split('---CONFIG---')[0]!.split('\n')) {
if (!line.trim() || line === 'NO_REVIEWS') continue;
try { rows.push(JSON.parse(line)); } catch { /* malformed row: gstack-review-read already warned */ }
}
const files: string[] = [];
for (let i = 0; i < rest.length; i++) {
if (rest[i] !== '--ledger') continue;
if (!rest[i + 1] || rest[i + 1]!.startsWith('--')) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--ledger needs a file');
files.push(rest[++i]!);
}
const run = flag(rest, '--run');
if (run) {
let found: string[];
try { found = (fs.readdirSync(run, { recursive: true }) as string[]).filter(p => path.basename(p) === 'review-ledger.jsonl').sort().map(p => path.join(run, p)); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run}: ${e.code ?? e.message}`); }
if (!found.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', `--run ${run} holds no review-ledger.jsonl: name the run directory the passes wrote, or each --ledger`);
files.push(...found);
}
const noLedger = rest.includes('--no-ledger');
if (noLedger && files.length) return refuse('summary', 'REVIEW_SCOPE_INPUT', '--no-ledger contradicts --run and --ledger');
// The ledgers hold every reader's findings, the voices' too, before any row classifies them; a summary without them can undercount.
if (!noLedger && !files.length) return refuse('summary', 'REVIEW_SCOPE_LEDGER', 'name the review\'s dispatch ledgers (--run <run dir> or --ledger <f>), or --no-ledger when no reader ran through gstack-review-dispatch');
// A row's ledger_id and a fix trailer name a finding as <pass dir>/<merged id>: the ids restart in every pass's ledger.
const keyOf = (file: string) => (id: string) => `${path.basename(path.dirname(path.resolve(file)))}/${id}`;
let ledgers: LedgerBlocker[][];
try { ledgers = files.map(f => ledgerBlockers(f).map(b => ({ ...b, key: keyOf(f)(b.id) }))); } catch (e: any) { return refuse('summary', 'REVIEW_SCOPE_LEDGER', e.message); }
const log = spawnSync('git', ['log', '--first-parent', '--no-merges', `--since=${since}`, '--format=%H%x00%(trailers:key=Review-Finding,valueonly,separator=%x1f)%x1e', 'HEAD'], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
if (log.status !== 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `git log for Review-Finding trailers failed: ${(log.stderr ?? '').trim().split('\n')[0] ?? ''}`);
const trailers = log.stdout.split('\x1e').flatMap(rec => { const [sha, values] = rec.trim().split('\0'); return sha && values ? parseFixTrailers(sha, values.split('\x1f').map(v => v.trim()).filter(Boolean)) : []; });
const summary = summarizeReviewLoop(rows, since, ledgers, files.map(f => ledgerPass(f, keyOf(f))), trailers);
for (const u of summary.unbound ?? []) process.stderr.write(`REVIEW_SCOPE_UNBOUND: ${u}\n`);
for (const v of summary.voided ?? []) process.stderr.write(`REVIEW_SCOPE_VOIDED: ${v}\n`);
const line = reviewLoopLine(summary);
// A /ship invocation always runs Step 9 once; zero matching rows means the wrong --since or a review log written elsewhere, never a loop to record.
if (summary.passes === 0) return refuse('summary', 'REVIEW_SCOPE_INPUT', `no review-log row of this branch is a via:ship review at or after ${since}; check REVIEW_SINCE (${line})`);
process.stderr.write(line + '\n');
const out = flag(rest, '--out');
if (out) fs.writeFileSync(out, JSON.stringify(summary) + '\n');
process.stdout.write(json ? JSON.stringify(summary) + '\n' : line + '\n');
return EXIT.ok;
}
function main(argv: string[]): number {
const [cmd, ...rest] = argv;
const json = rest.includes('--json');
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; }
try {
if (cmd === 'classify') return cmdClassify(rest, json);
if (cmd === 'defer') return cmdDefer(rest, json);
if (cmd === 'render') return cmdRender(rest);
if (cmd === 'summary') return cmdSummary(rest, json);
} catch (e: any) { process.stderr.write(`gstack-review-scope ${cmd}: ${e.message}\n`); return EXIT.fail; }
process.stderr.write(`gstack-review-scope: unknown command ${cmd}\n${HELP}\n`);
return EXIT.usage;
}
process.exitCode = main(process.argv.slice(2));