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

273 lines
19 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> [--pr <n>] [--repo <owner/repo>] [--branch <name>] [--out <deferred.json>] [--dry-run] [--json]
* gstack-review-scope render --deferred <deferred.json> [--findings <classified.json>]
* gstack-review-scope summary --since <iso-timestamp> [--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`, 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 <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> [--pr <n>] [--repo <r>] [--branch <b>] [--out <file>] [--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 <file> [--findings <classified.json>]
the PR-body sections: ## Deferred findings (references, never closing keywords)
and ## Review notes (scope=note findings)
summary --since <iso> [--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)
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 <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 = 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 <ISO-8601 timestamp> (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<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 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));