#!/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 --pass [--range-base ] [--policy frozen|diff-plus-blocking|all] [--findings |-] [--json] * gstack-review-scope defer --findings [--out ] [--json] * gstack-review-scope render --deferred [--findings ] * gstack-review-scope summary --since (--run | --ledger ... | --no-ledger) [--out ] [--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 --pass [--range-base ] [--policy frozen|diff-plus-blocking|all] [--findings |-] 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 [--out ] record each scope=defer finding for the PR body (no issue is filed); prints DEFERRED: lines and the deferred records render --deferred [--findings ] the PR-body sections: ## Deferred findings (references, never closing keywords) and ## Review notes (scope=note findings) summary --since (--run | --ledger ... | --no-ledger) [--out ] 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 (/) and each fix commit's Review-Finding: reader= trailer bind by id, and what cannot bind prints REVIEW_SCOPE_UNBOUND: : (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 ` 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 (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 (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[] = []; 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 or --ledger ), or --no-ledger when no reader ran through gstack-review-dispatch'); // A row's ledger_id and a fix trailer name a finding as /: 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));