#!/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 [--pr ] [--repo ] [--branch ] [--out ] [--dry-run] [--json] * gstack-review-scope render --deferred [--findings ] * gstack-review-scope summary --since [--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`, files `defer` as issues and writes `note` into the PR body. * `defer` files one GitHub issue per deferred finding through gh, reusing an * open issue that already carries the finding's marker; without gh the finding * stays deferred as "unfiled" with the reason. `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 path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLineWithDocs } from '../lib/result-codes'; import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; import { EXIT as POST_EXIT, runPost } from '../lib/gstack-post'; import { parseHunks, renderDeferredIssue, renderDeferredSection, reviewLoopLine, scopeFindings, scopeLine, summarizeReviewLoop, ALREADY_FILED_REASON, DEFERRED_MARKER, findingIdentity, findingWhere, REVIEW_SCOPE_POLICIES, type DeferredRecord, type ReviewScopePolicy, type ScopeFinding, type ScopedFinding, } from '../lib/review-scope'; 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 [--pr ] [--repo ] [--branch ] [--out ] [--dry-run] file one issue per scope=defer finding through gh (reuses an open issue carrying the finding's marker); 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 [--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) 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; --branch the current branch. ${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() }; } function gh(args: string[], cwd: string): { ok: boolean; out: string; err: string } { const r = spawnSync('gh', args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); if (r.error) return { ok: false, out: '', err: (r.error as NodeJS.ErrnoException).code === 'ENOENT' ? 'gh is not installed' : r.error.message }; return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; } /** * File one deferred issue through the publication boundary the PR body uses * (lib/gstack-post: the exact title and body bytes are scanned for secrets and * private values before anything reaches gh). Reviewer text is untrusted here: a * security finding may quote the value it found. A HIGH finding refuses, a MEDIUM * one needs a human and is never confirmed unattended; both leave the finding * deferred and unfiled with the reason in the record. */ function publishIssue(repoRoot: string, repo: string | undefined, title: string, body: string): { ok: boolean; out: string; err: string } { const dir = fs.mkdtempSync(path.join(process.env.TMPDIR ?? '/tmp', 'gstack-deferred-')); const titleFile = path.join(dir, 'title.txt'); const bodyFile = path.join(dir, 'issue.md'); fs.writeFileSync(titleFile, title, { mode: 0o600 }); fs.writeFileSync(bodyFile, body, { mode: 0o600 }); let out = ''; let err = ''; try { const code = runPost(['issue-create', '--title-file', titleFile, '--body-file', bodyFile], { run(cmd, args, input) { // --repo names the destination: the remote is that GitHub repository and every gh call targets it. // `gh repo view` takes the repository as its positional argument and rejects --repo, so the visibility read // (public-strict scan or not) names it that way; every other gh command takes --repo. if (repo && cmd === 'git' && args[0] === 'remote') return { status: 0, stdout: `https://github.com/${repo}\n`, stderr: '' }; const argv = cmd === 'gh' && repo ? (args[0] === 'repo' && args[1] === 'view' ? [...args.slice(0, 2), repo, ...args.slice(2)] : [...args, '--repo', repo]) : args; const r = spawnSync(cmd, argv, { cwd: repoRoot, input: input ?? '', encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }); if (r.error) return { status: 1, stdout: '', stderr: (r.error as NodeJS.ErrnoException).code === 'ENOENT' ? `${cmd} is not installed` : r.error.message }; return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }; }, readFile: p => fs.readFileSync(p, 'utf8'), out: t => { out += t; }, err: t => { err += t; }, }, path.join(import.meta.dir, 'gstack-config')); if (code === POST_EXIT.posted) return { ok: true, out, err }; const rules = [...new Set([...out.matchAll(/RULE: (\S+)/g)].map(m => m[1]))].join(', '); const reason = code === POST_EXIT.refused ? `publication scan refused: HIGH ${rules || 'finding'}; remove the value from the finding text` : code === POST_EXIT.confirm ? `publication scan: MEDIUM ${rules || 'finding'} needs a human before this text is posted` : `gh issue create failed: ${(err.trim().split('\n').find(l => l.trim()) ?? 'unknown').slice(0, 160)}`; return { ok: false, out, err: reason }; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } /** One `gh issue list` page; a page exactly this full may hide older markers, so the run says so and the records carry it. */ const DEFERRED_SEARCH_LIMIT = 200; 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', 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 = git(['diff', ...DIFF_ARGS, 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 = git(['diff', ...DIFF_ARGS, 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 { const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd()).out.trim() || process.cwd(); 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 toDefer = findings.filter(f => f.scope === 'defer'); const prFlag = flag(rest, '--pr'); if (prFlag !== undefined && !/^[1-9]\d*$/.test(prFlag)) return refuse('defer', 'REVIEW_SCOPE_INPUT', `--pr must be a PR number, got ${JSON.stringify(prFlag.slice(0, 40))}`); const repo = flag(rest, '--repo'); const repoArgs = repo ? ['--repo', repo] : []; const ctx = { repo, pr: prFlag ? Number(prFlag) : undefined, branch: flag(rest, '--branch') ?? (git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).out.trim() || undefined), head: git(['rev-parse', 'HEAD'], repoRoot).out.trim() || undefined, }; const dryRun = rest.includes('--dry-run'); // One search for every marker, before any create: a failed search files nothing, so a rate-limited rerun cannot duplicate issues. let filed: Array<{ number: number; url: string; body: string }> | null = null; let searchError = ''; let truncated = false; if (!dryRun && toDefer.length) { const existing = gh(['issue', 'list', ...repoArgs, '--state', 'open', '--search', `"${DEFERRED_MARKER}" in:body`, '--json', 'number,url,body', '--limit', String(DEFERRED_SEARCH_LIMIT)], repoRoot); if (existing.ok) { try { filed = JSON.parse(existing.out); } catch { searchError = 'gh issue list returned no JSON'; } } else searchError = `gh issue list failed: ${(existing.err || 'unknown').split('\n')[0]!.slice(0, 160)}`; if (!Array.isArray(filed)) filed = null; // An exactly-full page is read as truncated: an older issue with this finding's marker may sit past it, so a new issue may duplicate one. truncated = (filed?.length ?? 0) >= DEFERRED_SEARCH_LIMIT; if (truncated) process.stderr.write(`DEFERRED_SEARCH: truncated at ${DEFERRED_SEARCH_LIMIT} open issues; an existing issue for a finding may not have been found\n`); } const records: DeferredRecord[] = []; for (const f of toDefer) { const { title, body, marker } = renderDeferredIssue(f, ctx); const record: DeferredRecord = { fingerprint: findingIdentity(f), path: findingWhere(f), severity: String(f.severity ?? 'unrated'), summary: String(f.summary ?? f.problem ?? f.category ?? ''), issue: null }; records.push(record); if (dryRun) { record.reason = 'dry run'; continue; } if (!filed) { record.reason = searchError; continue; } const hit = filed.find(i => typeof i.body === 'string' && i.body.includes(marker)); if (hit) { record.issue = hit.number; record.url = hit.url; record.reason = ALREADY_FILED_REASON; continue; } const created = publishIssue(repoRoot, repo, title, body); if (created.ok) { const url = created.out.split('\n').map(l => l.trim()).filter(l => /\/issues\/\d+$/.test(l)).pop() ?? ''; const n = Number(/\/issues\/(\d+)$/.exec(url)?.[1]); if (Number.isInteger(n)) { record.issue = n; record.url = url; if (truncated) record.reason = `filed; the marker search was truncated at ${DEFERRED_SEARCH_LIMIT}, so an older issue may duplicate it`; } else record.reason = `gh returned no issue url: ${url.slice(0, 120)}`; } else record.reason = created.err; } // Unfiled is the documented degraded path, not a failure: the finding stays deferred and the PR body names the reason. for (const r of records) process.stderr.write(`DEFERRED: ${r.issue ? `#${r.issue}` : `unfiled (${r.reason ?? 'no gh'})`} ${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 = spawnSync(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.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 summary = summarizeReviewLoop(rows, since); 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));