#!/usr/bin/env bun /** * gstack-gate-diff — list the gate edits in a candidate so a human can * disposition each one (W1 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md). * * gstack-gate-diff [base-ref] [--format table|jsonl] [--commit ] [--help] * * The tool produces the inventory and the tags; it never judges and never * suppresses. stdout, first line always: * * GATE_SUMMARY: listed=N eligible=N inspected=N unread=N tagged={RH-1:2,RH-15:1} * unmatched=N coverage=RH-1,… languages_unlisted= * candidate=+ artifact= * * then the read-level listing (table) or one JSON object per listed hunk * (jsonl). Diagnostics go to stderr. The full jsonl is always written to * $GSTACK_STATE_ROOT/projects//gate-diff/.jsonl (mode 0600, * never inside the working tree: an untracked file under the repo would flip * /review's review_binding and make /ship loop). * * Exit: 0 with or without hunks. 2 only when the base is unresolvable * (`GATE_ERROR=no_base ref= fix=` as the only stdout line) * or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are * counted, never an exit 2. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { scanCandidate, parseGateIntegrityBlock, specDirectoryHoldsSpecs, type FileDiff, type CommitInfo } from '../lib/gate-diff/index'; import { commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot, resolvesToCommit, treePaths, untrackedAsAdded, } from '../lib/gate-diff/git'; import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render'; import { resolveStateRoot } from '../lib/state-root'; import { runScriptSync, wtreeFingerprint } from '../lib/script-invocation'; const BIN_DIR = path.dirname(Bun.fileURLToPath(import.meta.url)); const USAGE = `usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit ] [--help] Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the candidate (merge-base of and HEAD -> working tree + untracked files), tags the gate edits it recognises (RH-1 RH-3 RH-4 RH-12 RH-13 RH-14 RH-15 RH-16), and prints the read-level listing after one GATE_SUMMARY: line. --commit scans that commit's first-parent delta instead of the working tree. Exit 0 with or without hunks; 2 only for an unresolvable base (GATE_ERROR=no_base) or a tool failure (GATE_ERROR=internal). Full jsonl artifact path is in the summary.`; interface Args { base?: string; format: 'table' | 'jsonl'; commit?: string; help: boolean; } function parseArgs(argv: string[]): Args | string { const args: Args = { format: 'table', help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--help' || a === '-h') args.help = true; else if (a === '--format') { const v = argv[++i]; if (v !== 'table' && v !== 'jsonl') return `--format must be table or jsonl, got ${JSON.stringify(v ?? '')}`; args.format = v; } else if (a.startsWith('--format=')) { const v = a.slice(9); if (v !== 'table' && v !== 'jsonl') return `--format must be table or jsonl, got ${JSON.stringify(v)}`; args.format = v; } else if (a === '--commit') { const v = argv[++i]; if (!v || v.startsWith('-')) return '--commit requires a '; args.commit = v; } else if (a.startsWith('-')) return `unknown option ${a}`; else if (args.base !== undefined) return `unexpected extra argument ${a}`; else args.base = a; } return args; } function projectSlug(cwd: string): string { try { const r = runScriptSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 }); if (r.error) console.error(`gstack-gate-diff: gstack-slug failed (${r.error.message}); artifact filed under projects/unknown`); const m = (r.stdout || '').match(/^SLUG=(.+)$/m); return m ? m[1].trim() : 'unknown'; } catch (e: any) { console.error(`gstack-gate-diff: gstack-slug failed (${e?.message ?? e}); artifact filed under projects/unknown`); return 'unknown'; } } /** The artifact directory: the state root, or the OS temp dir when no home resolves (never the working tree). */ function artifactDir(cwd: string): string { const root = resolveStateRoot(); const base = root === '.gstack' ? path.join(os.tmpdir(), 'gstack-gate-diff') : root; if (root === '.gstack') console.error(`gstack-gate-diff: no HOME/GSTACK_HOME — artifact goes to ${base} instead of the state root`); return path.join(base, 'projects', projectSlug(cwd), 'gate-diff'); } function writeArtifact(file: string, body: string): void { fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); fs.writeFileSync(file, body, { mode: 0o600 }); fs.chmodSync(file, 0o600); } function main(argv: string[]): number { const parsed = parseArgs(argv); if (typeof parsed === 'string') { console.error(`gstack-gate-diff: ${parsed}\n${USAGE}`); process.stdout.write(`GATE_ERROR=internal reason=usage detail=${JSON.stringify(parsed)}\n`); return 2; } if (parsed.help) { console.log(USAGE); return 0; } const cwd = process.cwd(); const root = repoRoot(cwd); if (!root) { process.stdout.write(`GATE_ERROR=internal reason=not_a_git_repository cwd=${cwd}\n`); return 2; } let files: FileDiff[]; let commits: CommitInfo[]; let candidate: string; let specIsTestPath: boolean; if (parsed.commit) { const sha = resolvesToCommit(parsed.commit, root); if (!sha) { process.stdout.write(`GATE_ERROR=no_base ref=${parsed.commit} fix=${noBaseFix(parsed.commit, root)}\n`); return 2; } if (!firstParent(sha, root)) { process.stdout.write(`GATE_ERROR=no_base ref=${sha.slice(0, 12)}^ fix=${noBaseFix(`${sha}^`, root)}\n`); return 2; } files = diffCommit(sha, root); const info = commitInfo(sha, root); commits = info ? [info] : []; candidate = sha.slice(0, 12); specIsTestPath = specDirectoryHoldsSpecs(treePaths(root, sha)); } else { const baseRef = parsed.base ?? defaultBase(root); const baseSha = resolvesToCommit(baseRef, root); const head = headSha(root); if (!baseSha || !head) { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); return 2; } const mb = mergeBase(baseSha, head, root); if (!mb) { process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`); return 2; } files = [...diffWorkingTree(mb, root), ...untrackedAsAdded(root, root)]; commits = commitsInRange(mb, head, root); const fp = wtreeFingerprint({ cwd: root }); if ('error' in fp) console.error(`gstack-gate-diff: working-tree fingerprint unavailable (${fp.error}); candidate recorded without it`); candidate = `${mb.slice(0, 12)}+${'tree' in fp ? fp.tree.slice(0, 12) : 'nowtree'}`; specIsTestPath = specDirectoryHoldsSpecs(treePaths(root)); } const artifact = path.join(artifactDir(root), `${candidate}.jsonl`); const result = scanCandidate({ files, commits, candidate, artifact, specIsTestPath, project: parseGateIntegrityBlock(readClaudeMd(root)), }); writeArtifact(artifact, renderJsonlBody(result)); for (const line of renderDiagnostics(result)) console.error(line); process.stdout.write(parsed.format === 'jsonl' ? renderJsonl(result) : renderTable(result)); return 0; } try { // exitCode, not process.exit(): a large listing may still be in flight on the stdout pipe. process.exitCode = main(process.argv.slice(2)); } catch (e: any) { console.error(`gstack-gate-diff: ${e?.stack ?? e}`); process.stdout.write(`GATE_ERROR=internal reason=exception detail=${JSON.stringify(String(e?.message ?? e))}\n`); process.exitCode = 2; }