Files
gstack/bin/gstack-gate-diff
T
Garry Tanandcapy-ai[bot] d69ba2c6fb v1.94.13.0 fix(review): bare --base reads origin/<base> in review and ship bins; diff-scope matches auth as a word (GST-1036, GST-1065) (#3906)
* fix(review): bare --base reads origin/<base> in review and ship bins; diff-scope matches auth as a word (GST-1036, GST-1065)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: fragment for GST-1036 and GST-1065

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(diff-scope): a token that starts or ends with auth is an auth path; author* is not (review fix, GST-1065)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(diff-scope): authoriz/authenticat mid-word and a leading oauth are auth paths (granted fix round, GST-1065)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(review-policy): REVIEW_BASE line and SCOPE_AUTH word matching (GST-1036, GST-1065)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* diff-scope: fail-safe auth match; resolveBase never re-qualifies a remote-qualified base (GST-1065, GST-1036)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Revert the resolveBase remote-prefix guard (regressed alice/base stacked bases); fringe case stays an open note

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-11-03 10:52:35 +00:00

205 lines
9.2 KiB
TypeScript
Executable File

#!/usr/bin/env -S bun --no-env-file --config=/dev/null
/**
* 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 <sha>] [--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=<csv|none>
* candidate=<merge-base-short>+<wtree-short> artifact=<absolute path>
*
* 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/<slug>/gate-diff/<candidate>.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=<ref> fix=<git command>` as the only stdout line;
* `ref=<remote>/HEAD target=<remote>/<branch>` when no base is given, that
* remote's HEAD names the checked-out branch and no other default resolves),
* when the base is HEAD or already contains it while the branch has commits
* its real base lacks (`GATE_ERROR=base_contains_head ref=<ref>`, the
* BASE_CONTAINS_HEAD line on stderr), 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 {
baseNote, commitInfo, commitsInRange, defaultBase, diffCommit, diffWorkingTree, firstParent, headSha, mergeBase, noBaseFix, readClaudeMd, repoRoot,
resolveBase, resolvesToCommit, treePaths, untrackedAsAdded,
} from '../lib/gate-diff/git';
import { baseContainsHead, noDefaultBaseLine } from '../lib/default-base';
import { resultLine } from '../lib/result-codes';
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 <sha>] [--help]
Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the
candidate (merge-base of <base-ref> and HEAD -> working tree + untracked files;
a branch name resolves to origin/<base-ref>, the local ref only when no remote has it),
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 <sha>
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 <sha>';
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 found = parsed.base !== undefined ? { ref: parsed.base } : defaultBase(root);
if ('skipped' in found) {
const { remote, branch } = found.skipped;
console.error(`gstack-gate-diff: ${noDefaultBaseLine(found.skipped)}`);
process.stdout.write(`GATE_ERROR=no_base ref=${remote}/HEAD target=${remote}/${branch} fix=git remote set-head ${remote} <base>\n`);
return 2;
}
const baseRef = found.ref;
const head = headSha(root);
const base = resolveBase(baseRef, root, head ?? undefined);
if (!base || !head) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const note = baseNote(baseRef, base);
if (note) console.error(`gstack-gate-diff: ${note}`);
const mb = mergeBase(base.sha, head, root);
if (!mb) {
process.stdout.write(`GATE_ERROR=no_base ref=${baseRef} fix=${noBaseFix(baseRef, root)}\n`);
return 2;
}
const self = mb === head ? baseContainsHead(root, base.ref) : null;
if (self) {
console.error(resultLine('gstack-gate-diff', 'BASE_CONTAINS_HEAD', self));
process.stdout.write(`GATE_ERROR=base_contains_head ref=${baseRef}\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;
}