mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
* fix(retro): keep retro history in the state root, shared across worktrees (#3749) /retro saved snapshots under .context/retros inside the working tree, so a run in a linked worktree saw none of the main checkout's history, reported "First retro recorded", and wrote a snapshot that git worktree remove deletes. gstack-retro-metrics --history [--next <date>] now resolves the history directory as <state root>/projects/<slug>/retros (the slug is keyed on the git common dir, so every worktree shares it), moves any legacy .context/retros from this worktree and the main checkout into it, lists prior snapshots and prints the next snapshot path. Step 12/13 and the gbrain prior-retros query use it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(gate-diff): read a bare base as origin/<base> in gate-diff and diff-scope (#3650) gstack-gate-diff and gstack-diff-scope resolved a bare base such as main to the local branch whenever it shared history with HEAD. A local main that is behind origin/main (a linked worktree, or a Capy clone, where local main never moves) then set the merge base, so /ship's gate scan and specialist scope also read commits already upstream. Both now prefer origin/<base> when it resolves, the base /ship fetches and merges against. Fixes #3650 Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com> Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): make history migration safe against symlinks and concurrent runs (#3749 review) Review pass 1 findings: a .context/retros that resolves outside its worktree (or to the shared history itself) is left alone, so migration can no longer delete the history it points at; moves are no-clobber with retry; a copy identical to any same-date snapshot collapses; RETRO_FILE is reserved as {} so overlapping runs in two worktrees never share a path; --next must be a YYYY-MM-DD date; a missing option value no longer loops forever. Step 12 names RETRO_HISTORY_ERROR instead of reading it as a stale helper. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): fragment for the #3749 retro history fix Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (diff-scope reads slash-named bases through origin too) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): trim Step 12/13 prose back under the parity skeleton cap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (a fork branch, a colliding tag or another remote never replaces the base) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(retro): keep today="<today>" in Step 12 so the judge excerpt keeps both date inputs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): describe the fork, tag and remote guards (#3650) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(retro): compare #3749 history paths with forward slashes so the Windows shard passes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): review-plan and gate-diff plan the same with a stale local main or a shadowing tag (GST-883, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): gate-diff and review-plan resolve a branch base to origin/<base> by full ref name; review-plan scans the merge base its own diff used (GST-883) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-883 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-risk): hostile-input time bounds for every risk pattern; the R51 rm and rmSync rules fail (GST-898) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-risk): the R51 rm and rmSync rules run in linear time (GST-898) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review-packets-removed): repin lib/review-risk.ts on the GST-898 verdict-identity replay Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-risk): padding cannot push recursive: true out of the rmSync window; repin on the GST-898 no-loss replay Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-898 fragment wording Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): planner misreads: SVG chart as runtime, session data as auth, bare base in diff-scope and receipt depth (GST-911, GST-884, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a drawing-only SVG is data and a scripted one is security; session data files are not auth; diff-scope and the receipt depth read origin/<base> by full ref name (GST-911, GST-884) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): the SVG drawing check refuses CSS escapes and reads zero-padded character references; the security class reads a file's changed SVG lines joined (GST-911 review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): a stray quote cannot hide an external href in a drawing SVG (GST-911 review pass 2, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): the SVG drawing check reads every name= without consuming its value, so a stray quote cannot hide an external href (GST-911 review pass 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): CSS in a drawing SVG holds only declarations and url(#id): image-set(), image(), element() keep it code (GST-911 Step 11, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a drawing SVG's CSS holds declarations only, no @ rule and no function but url(#id), which closes image-set(), image(), element() and every other CSS load (GST-911 Step 11) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): a style element's CSS is read past comments and CDATA, unclosed style tags are refused in linear time, and every drawing verdict holds at 1 MB and 8 MB (GST-911, failing first) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): walk a style element's CSS with indexOf, dropping comments and reading CDATA, so no hidden </style> ends it early and unclosed style tags refuse in linear time (GST-911) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): the linear-time claim covers R51's rm and rmSync rules (GST-898) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): state the rmSync padding rule exactly (GST-898) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review-depth): linear url() refusal in svgDrawing (wave review-2, red-team CRITICAL) SVG_REFUSED's url() entry backtracked quadratically on url( followed by a long whitespace run (80k spaces: 820 ms; 1 MB: over 230 s), and svgDrawing runs in collectLive and in collectCommit, which ship-queue ready, train build and land call with no timeout. The rewritten entry refuses exactly what #3838's pattern refused. Repin lib/review-depth.ts under no-loss replay GST-911-redos (verdict identity: 60,000 seeded strings, the edges and every SVG blob in history, zero differences). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (wave review-2, pass 1) - bin/gstack-diff-scope: _resolve_base takes a related local <base> before another remote's twin, the order resolveBase uses, so the two tools pick the same base for a bare name when origin/<base> is unrelated. - lib/review-depth.ts: a drawing's DOCTYPE names no document but W3C's SVG DTD; SYSTEM and other PUBLIC identifiers keep the SVG code. Repin lib/review-depth.ts under no-loss replay GST-911-redos (the DOCTYPE change only keeps more SVGs code, so it drops no reviewer). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11) Exported only so test/review-risk-redos.test.ts can reach the url() entry; a frozen readonly copy keeps any importer from changing verdicts while DEPTH_RULES still reports the original rules. Repin lib/review-depth.ts under no-loss replay GST-911-redos (no pattern changed). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "fix: freeze the exported SVG_REFUSED list (wave review-2, Step 11)" The fix-check (wave review-2) showed Object.freeze is shallow: each RegExp can still be changed in place, so the freeze did not close adversarial-native#1, and it moved the tree past the one the Step 11 voices reviewed. The finding ships as an open note (its reviewer rated it Low, not blocking; no production caller mutates the list). Restores the lib/review-depth.ts pin under no-loss replay GST-911-redos. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): wave review-2 entry for the integrator fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(review-policy): a drawing's DOCTYPE names no outside document (wave review-2 docs audit) 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> Co-authored-by: exGeni <135786749+exGeni@users.noreply.github.com>
186 lines
8.5 KiB
TypeScript
Executable File
186 lines
8.5 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)
|
|
* 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,
|
|
resolveBase, 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 <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 baseRef = parsed.base ?? defaultBase(root);
|
|
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;
|
|
}
|
|
if (base.local === 'fallback') console.error(`gstack-gate-diff: ${base.unrelatedRemote ? `${base.unrelatedRemote} shares no history with HEAD` : `no remote-tracking branch for ${baseRef}`}; base is the local ${baseRef}, which may lag its remote`);
|
|
else if (base.local) console.error(`gstack-gate-diff: ${base.local === 'unrelated' ? `local ${baseRef} shares no history with HEAD` : base.local === 'differs' ? `local ${baseRef} is not ${base.ref}` : `no local branch ${baseRef}`}; base is ${base.ref}`);
|
|
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;
|
|
}
|
|
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;
|
|
}
|