mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* review: targeted read-only pass on money, gate, auth and secret diffs (GST-257 part 1, report-only) lib/review-targeted.ts classifies a diff's added and deleted lines into money, gate, auth and secret (token-bounded, docs and tests excluded, fails closed to all four). gstack-review-plan prints TARGETED and dispatches one read-only targeted reader at full effort when a class hits; review/specialists/targeted.md is its brief. The receipt records review_targeted bound to product_patch_id, a carry keeps it, and gstack-ship-queue ready / train build print a report-only REVIEW_TARGETED line that refuses nothing. No-loss evidence: the dispatch-superset test in test/review-targeted.test.ts runs gstack-review-plan on 18 fixture diffs and requires every reviewer main dispatched at8be7f21, unchanged, plus only the targeted reader. Brief identity: gstack-review-dispatch render on this tree and origin/main2356792gives 13 of 13 existing reviewer briefs byte-identical (handoffs/GSTA-160/gst257-briefcmp/SUMMARY.txt). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (targeted patch id covers tests, record binds the diff it read, role list pin) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: targeted reader reads the merge base on every pass; dirty-tree record test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: targeted render takes --base and any spelling of the merge base; Step 11 says the merge base; docs gate-path list Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: type the fail-closed class list (typecheck:test ratchet) 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>
171 lines
14 KiB
TypeScript
Executable File
171 lines
14 KiB
TypeScript
Executable File
#!/usr/bin/env -S bun --no-env-file --config=/dev/null
|
|
/**
|
|
* gstack-review-plan — how much review the next /ship step dispatches (docs/review-policy.md#review-depth).
|
|
*
|
|
* gstack-review-plan --base <ref> [--pass <full passes run>] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below]
|
|
* [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged <n>] [--gated a,b] [--force a,b|all]
|
|
* [--reviewed-numbers <fingerprint>] [--depth-floor standard|full] [--range-base <sha>] [--pass1-depth light|standard|full] [--no-tiers]
|
|
* [--targeted-ran <targeted_patch_id>] [--json]
|
|
* gstack-review-plan --fix-check-prompt --fix-list <file> --hunks <file> --tests <file> [--callers <file>]
|
|
*
|
|
* Reads the diff against the merge base with <ref> (committed, tracked uncommitted
|
|
* edits and untracked files, through lib/review-collect.ts collectLive), runs
|
|
* gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints
|
|
* REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and
|
|
* result codes), GATE_SCAN:, TARGETED: (the money, gate, auth and secret classes, lib/review-targeted.ts),
|
|
* FIX_RANGE: (with --range-base), REVIEW_PLAN:, one DEPTH_ERROR: per input that failed
|
|
* the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per
|
|
* reviewer and one SKIP: per reviewer left out. A plan that fails closed to full
|
|
* exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts.
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { EXIT, exitTableHelp } from '../lib/headless-artifacts';
|
|
import { runScriptSync } from '../lib/script-invocation';
|
|
import { collectCommit, collectLive } from '../lib/review-collect';
|
|
import { classifyDiff, diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, SCOPED_RANGE_LINES, type Depth, type History } from '../lib/review-depth';
|
|
import { classList, targetedBriefSha, targetedClasses, targetedPatchId } from '../lib/review-targeted';
|
|
import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check';
|
|
|
|
const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth)
|
|
|
|
--base <ref> the base branch (merge base with HEAD is the diff base); required
|
|
--pass <n> full review passes already run in this invocation (default 0)
|
|
--last none|full|fix-check the step that just finished (default none: plan pass 1)
|
|
--fixed-blocking the finished step fixed a blocking finding (raises light to standard)
|
|
--fixed-below the finished step fixed findings below the floor (ignored after a fix-check: below-floor
|
|
fixes never repeat review, so only --fixed-blocking or --blocking-open stops there)
|
|
--blocking-open a blocking finding is still open in fix scope
|
|
--touched-gate a fix touched a gate-class path
|
|
--critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard)
|
|
--adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it)
|
|
--gate-tagged <n> gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable)
|
|
--gated a,b specialists the hit-rate stats tag GATE_CANDIDATE
|
|
--force a,b|all specialists forced by prompt flags (--all-specialists is all): at standard and full depth they join the
|
|
batched specialist's checklists, where maintainability, performance and simplification are read only when
|
|
forced; light runtime-0 and scoped passes dispatch them one reader each
|
|
--reviewed-numbers <fp> the numeric-claim fingerprint the outside voice already reviewed
|
|
--depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run
|
|
--range-base <sha> the fix range's base (this pass's PASS_HEAD): after a fixed blocker on a diff below full depth, a
|
|
committed range of at most ${SCOPED_RANGE_LINES} runtime lines plans a scoped pass; anything unread plans the full pass
|
|
--no-tiers specialists, audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does)
|
|
--json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude)
|
|
--pass1-depth <d> pass 1's REVIEW_DEPTH depth: full, or absent, keeps pass 2 full
|
|
--targeted-ran <id> the TARGETED line's targeted_patch_id (the whole committed diff, tests included) when the targeted
|
|
reader last read it: the same patch skips it; any other id, none, or uncommitted edits dispatch it again
|
|
--fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files
|
|
|
|
Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). TARGETED: the money,
|
|
gate, auth and secret classes the diff touches; any class dispatches the targeted reader at every depth. NEXT: full, scoped
|
|
(a full pass on the range: fix-check, Red Team, native adversarial), fix-check, stop or done. A full plan
|
|
from an error exits 0; exit 1 means no plan could be built (git could not list the diff).
|
|
${exitTableHelp()}`;
|
|
|
|
const args = process.argv.slice(2);
|
|
const flag = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
|
const has = (n: string) => args.includes(n);
|
|
const list = (n: string) => (flag(n) ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
const git = (a: string[]) => { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...a], { encoding: 'utf8', timeout: 60_000, maxBuffer: 64 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; };
|
|
|
|
function main(): number {
|
|
if (has('--help') || has('-h')) { process.stdout.write(HELP + '\n'); return EXIT.ok; }
|
|
if (has('--fix-check-prompt')) {
|
|
const read = (n: string, required: boolean) => {
|
|
const f = flag(n);
|
|
if (!f) return required ? null : '';
|
|
try { return fs.readFileSync(f, 'utf8'); } catch { return null; }
|
|
};
|
|
const [fixList, touchedHunks, testResults, callers] = [read('--fix-list', true), read('--hunks', true), read('--tests', true), read('--callers', false)];
|
|
if (fixList === null || touchedHunks === null || testResults === null || callers === null) { process.stderr.write('gstack-review-plan: --fix-check-prompt needs readable --fix-list, --hunks and --tests files (--callers optional)\n'); return EXIT.usage; }
|
|
process.stdout.write(renderFixCheckPrompt({ fixList, touchedHunks, testResults, callers }));
|
|
return EXIT.ok;
|
|
}
|
|
const base = flag('--base');
|
|
if (!base) { process.stderr.write(`gstack-review-plan: --base <ref> is required\n${HELP}\n`); return EXIT.usage; }
|
|
const last = (flag('--last') ?? 'none') as History['last'];
|
|
if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; }
|
|
const pass1Depth = (flag('--pass1-depth') ?? 'full') as Depth;
|
|
if (!['light', 'standard', 'full'].includes(pass1Depth)) { process.stderr.write('gstack-review-plan: --pass1-depth must be light, standard or full\n'); return EXIT.usage; }
|
|
const pass = Number(flag('--pass') ?? '0');
|
|
if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; }
|
|
const floor = flag('--depth-floor') as Depth | undefined;
|
|
if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; }
|
|
const rangeBase = flag('--range-base');
|
|
if (has('--range-base') && (!rangeBase || rangeBase.startsWith('-'))) { process.stderr.write('gstack-review-plan: --range-base needs the fix range\'s base commit (this pass\'s PASS_HEAD)\n'); return EXIT.usage; }
|
|
const root = git(['rev-parse', '--show-toplevel'])?.trim();
|
|
if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; }
|
|
process.chdir(root);
|
|
const collected = collectLive(root, base);
|
|
if (collected.errors.length) {
|
|
for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`);
|
|
return EXIT.fail;
|
|
}
|
|
const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown';
|
|
const binDir = path.dirname(Bun.fileURLToPath(import.meta.url));
|
|
const scopeOf = (args: string[]): Record<string, string> => {
|
|
const run = runScriptSync(path.join(binDir, 'gstack-diff-scope'), args, { encoding: 'utf8', timeout: 60_000 });
|
|
const s: Record<string, string> = {};
|
|
for (const l of (run.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) s[m[1]!] = m[2]!; }
|
|
if (run.status !== 0 && !s.SCOPE_ERROR) s.SCOPE_ERROR = `exit ${run.status}`;
|
|
return s;
|
|
};
|
|
const gateTagsOf = (args: string[]): number | null => {
|
|
const g = runScriptSync(path.join(binDir, 'gstack-gate-diff'), args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g.stdout ?? '');
|
|
return g.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null;
|
|
};
|
|
const scope = scopeOf([base]);
|
|
let gateTagged = flag('--gate-tagged') !== undefined ? Number(flag('--gate-tagged')) : gateTagsOf([base]);
|
|
if (gateTagged !== null && !Number.isFinite(gateTagged)) gateTagged = null;
|
|
const force = flag('--force') === 'all' ? 'all' as const : list('--force');
|
|
const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 });
|
|
const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light';
|
|
const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected;
|
|
let fixRange: { base: string; runtime_lines: number | null; error?: string; touched_gate?: true } | undefined;
|
|
if (rangeBase) {
|
|
const unread = (error: string) => ({ base: rangeBase, runtime_lines: null, error });
|
|
const resolved = git(['rev-parse', '--verify', '--quiet', `${rangeBase}^{commit}`])?.trim();
|
|
if (!resolved) fixRange = unread('not a commit in this repository');
|
|
else if (git(['merge-base', '--is-ancestor', resolved, 'HEAD']) === null) fixRange = unread('not an ancestor of HEAD');
|
|
else if (git(['diff', '--quiet', 'HEAD', '--']) === null || collected.untracked.length) fixRange = unread('uncommitted or untracked edits: commit the fixes first');
|
|
else {
|
|
const commit = collectCommit(root, resolved, 'HEAD');
|
|
const range = classifyDiff({ ...commit, registry, scope: scopeOf([resolved, '--head', 'HEAD']), gateTagged: gateTagsOf([resolved]) });
|
|
fixRange = range.errors.length ? unread(range.errors.map(e => `${e.code} ${e.input}`).join('; '))
|
|
: !commit.paths.length ? unread('the fix range is empty: commit the fix after the range base')
|
|
: { base: rangeBase, runtime_lines: range.runtimeLines, ...(range.class === 'gate' ? { touched_gate: true as const } : {}) };
|
|
}
|
|
}
|
|
const targeted = targetedClasses({ ...collected, scope, gateTagged });
|
|
const patch = collected.mergeBase ? targetedPatchId(process.cwd(), collected.mergeBase) : 'none';
|
|
const brief = targetedBriefSha();
|
|
const plan = planReview({
|
|
...collected, scope, gateTagged, targeted: { classes: targeted.classes, ran: !['uncommitted', 'none'].includes(patch) && flag('--targeted-ran') === patch }, numbers: scanNumbers(added), force, gated: list('--gated'), floor, tiers: auditEffort !== 'host',
|
|
criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'),
|
|
history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below') && last !== 'fix-check', blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') || !!fixRange?.touched_gate, pass1Depth, rangeRuntimeLines: fixRange?.runtime_lines },
|
|
});
|
|
const gateScan = { tagged: gateTagged, head };
|
|
if (has('--json')) {
|
|
const { runtimeLines, stepDepth, depthReasons, ...rest } = plan;
|
|
process.stdout.write(JSON.stringify({ ...rest, targeted: { ...targeted, brief, targeted_patch_id: patch }, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n');
|
|
return EXIT.ok;
|
|
}
|
|
const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`,
|
|
`TARGETED: classes=${classList(targeted.classes)} rules=${targeted.rules} brief=${brief} targeted_patch_id=${patch}${targeted.reasons.length ? ` reasons=${targeted.reasons.join('; ')}` : ''}`];
|
|
if (fixRange) out.push(`FIX_RANGE: ${fixRange.runtime_lines === null ? `unread (${fixRange.error})` : `runtime_lines=${fixRange.runtime_lines}`} base=${fixRange.base}`);
|
|
out.push(reviewPlanLine(plan, pass + (plan.next.step === 'full' || plan.next.step === 'scoped' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`);
|
|
for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`);
|
|
for (const d of plan.dispatches) {
|
|
const key = d.name.split(' ')[0];
|
|
out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`);
|
|
if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`);
|
|
}
|
|
for (const s of plan.skipped) out.push(`SKIP: ${s}`);
|
|
out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above run at their effort=)`);
|
|
process.stdout.write(out.join('\n') + '\n');
|
|
return EXIT.ok;
|
|
}
|
|
|
|
process.exit(main());
|