mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-10 02:30:39 +01:00
Merge remote-tracking branch 'origin/garrytan/hwgi-task-a' into garrytan/honest-work-gate-integrity
This commit is contained in:
commit
934e5b7ed6
55 files changed
+3069
-8
No files matched your search
+128
-7
@@ -10,7 +10,15 @@
|
||||
* `run` is a TRANSPARENT wrapper: it streams the child's output through
|
||||
* unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and
|
||||
* appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree,
|
||||
* dirty, wtree, log_path} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
* dirty, wtree, log_path, tests_ran} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
|
||||
*
|
||||
* `tests_ran` (zero-run green, W3 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md)
|
||||
* is parsed from a bounded tail of the streamed output for the supported
|
||||
* runners only: bun (shard-engine's summary parser where importable; executed =
|
||||
* pass + fail, so an all-skip run is 0), jest, vitest, pytest, rspec, and Go's
|
||||
* zero-only case (every package `[no test files]`). The last summary per
|
||||
* runner wins; anything else is "unknown". Three states, never merged:
|
||||
* a count, 0 (ZERO-RUN, graded like STALE by `check`) and unknown (non-blocking).
|
||||
*
|
||||
* TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the
|
||||
* wrapper's exit code. Every bookkeeping failure — ledger append, log dir,
|
||||
@@ -59,6 +67,96 @@ interface EvidenceRecord {
|
||||
wtree?: string;
|
||||
log_path?: string;
|
||||
redacted?: boolean;
|
||||
/** Executed tests per the runner's own summary; "unknown" when no supported summary was seen. */
|
||||
tests_ran?: number | "unknown";
|
||||
}
|
||||
|
||||
/** Bytes of streamed output kept for the tests_ran parse (the 2MB log would truncate the summary). */
|
||||
const TAIL_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/** Ring of the last TAIL_MAX_BYTES of combined child output, decoded per stream. */
|
||||
class OutputTail {
|
||||
private readonly decoders = { stdout: new TextDecoder("utf-8"), stderr: new TextDecoder("utf-8") };
|
||||
private text = "";
|
||||
push(chunk: Uint8Array, origin: "stdout" | "stderr"): void {
|
||||
this.text += this.decoders[origin].decode(chunk, { stream: true });
|
||||
if (this.text.length > TAIL_MAX_BYTES * 2) this.text = this.text.slice(-TAIL_MAX_BYTES);
|
||||
}
|
||||
end(): string {
|
||||
this.text += this.decoders.stdout.decode() + this.decoders.stderr.decode();
|
||||
return this.text.length > TAIL_MAX_BYTES ? this.text.slice(-TAIL_MAX_BYTES) : this.text;
|
||||
}
|
||||
}
|
||||
|
||||
const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, "");
|
||||
const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0);
|
||||
|
||||
/** Bun: the last "Ran N tests across M files" block; executed = its " N pass" + " N fail" lines. */
|
||||
async function bunTestsRan(lines: string[]): Promise<{ at: number; ran: number } | null> {
|
||||
let isTerminal = (l: string) => /^Ran \d+ tests? across \d+ files?\. \[/.test(l);
|
||||
try {
|
||||
const engine = await import("../scripts/lib/shard-engine");
|
||||
isTerminal = (l: string) => engine.parseBunTerminalSummary(l) !== null;
|
||||
} catch {
|
||||
warn("shard-engine classifier not importable; using the built-in bun summary parser");
|
||||
}
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (!isTerminal(lines[i])) continue;
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
for (let j = i - 1; j >= 0 && j >= i - 12; j--) {
|
||||
if (isTerminal(lines[j])) break;
|
||||
const p = /^\s*(\d+) pass$/.exec(lines[j]);
|
||||
const f = /^\s*(\d+) fail$/.exec(lines[j]);
|
||||
if (p) pass = Number(p[1]);
|
||||
if (f) fail = Number(f[1]);
|
||||
}
|
||||
return { at: i, ran: pass + fail };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function otherRunnersTestsRan(lines: string[]): { at: number; ran: number } | null {
|
||||
const hits: { at: number; ran: number }[] = [];
|
||||
const last = (re: RegExp) => {
|
||||
for (let i = lines.length - 1; i >= 0; i--) if (re.test(lines[i])) return i;
|
||||
return -1;
|
||||
};
|
||||
// jest: "Tests: 1 failed, 2 passed, 3 total" / "No tests found"
|
||||
let i = last(/^Tests:\s+.*\d+ total/);
|
||||
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "passed") + count(lines[i], "failed") });
|
||||
i = last(/^No tests found/);
|
||||
if (i >= 0) hits.push({ at: i, ran: 0 });
|
||||
// vitest: " Tests 3 passed | 1 skipped (4)" / "No test files found"
|
||||
i = last(/^\s*Tests\s+\d+ .*\(\d+\)\s*$/);
|
||||
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "passed") + count(lines[i], "failed") });
|
||||
i = last(/^No test files found/);
|
||||
if (i >= 0) hits.push({ at: i, ran: 0 });
|
||||
// pytest: "===== 3 passed, 1 skipped in 0.12s =====" / "no tests ran in 0.01s"
|
||||
i = last(/^=+ .* in [\d.]+s(?: \([^)]*\))? =+$/);
|
||||
if (i >= 0) {
|
||||
const t = lines[i];
|
||||
hits.push({ at: i, ran: /\bno tests ran\b/.test(t) ? 0 : count(t, "passed") + count(t, "failed") + count(t, "errors?") + count(t, "xfailed") + count(t, "xpassed") });
|
||||
}
|
||||
// rspec: "12 examples, 0 failures, 2 pending"
|
||||
i = last(/^\d+ examples?, \d+ failures?/);
|
||||
if (i >= 0) hits.push({ at: i, ran: count(lines[i], "examples?") - count(lines[i], "pending") });
|
||||
// go test: zero-only — every package "[no test files]" and no ok/FAIL package line
|
||||
const goNoTests = lines.filter((l) => /^\?\s+\S+\s+\[no test files\]$/.test(l));
|
||||
const goRan = lines.some((l) => /^(?:ok|FAIL|---)\s/.test(l));
|
||||
if (goNoTests.length > 0 && !goRan) hits.push({ at: last(/\[no test files\]$/), ran: 0 });
|
||||
if (hits.length === 0) return null;
|
||||
return hits.reduce((a, b) => (b.at > a.at ? b : a));
|
||||
}
|
||||
|
||||
/** The executed-test count from the output tail, or "unknown". Never throws into the caller's exit path. */
|
||||
async function parseTestsRan(tail: string): Promise<number | "unknown"> {
|
||||
if (process.env.GSTACK_EVIDENCE_FAULT === "tests_ran") throw new Error("injected tests_ran parser fault");
|
||||
const lines = tail.split("\n").map(strip);
|
||||
const candidates = [await bunTestsRan(lines), otherRunnersTestsRan(lines)].filter((c): c is { at: number; ran: number } => c !== null);
|
||||
if (candidates.length === 0) return "unknown";
|
||||
return candidates.reduce((a, b) => (b.at > a.at ? b : a)).ran;
|
||||
}
|
||||
|
||||
function warn(msg: string): void {
|
||||
@@ -298,6 +396,7 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
}
|
||||
|
||||
// Stream-tee: forward chunks as they arrive (never buffer — E2E logs are MBs).
|
||||
const tail = new OutputTail();
|
||||
let logBytes = 0;
|
||||
let truncated = false;
|
||||
const teeToLog = (chunk: Uint8Array) => {
|
||||
@@ -319,7 +418,7 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined, out: NodeJS.WriteStream) => {
|
||||
const pump = async (stream: ReadableStream<Uint8Array> | undefined, out: NodeJS.WriteStream, origin: "stdout" | "stderr") => {
|
||||
if (!stream) return;
|
||||
for await (const chunk of stream) {
|
||||
// Honor backpressure: when the console consumer is slower than the child
|
||||
@@ -339,10 +438,15 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
});
|
||||
}
|
||||
teeToLog(chunk);
|
||||
try {
|
||||
tail.push(chunk, origin);
|
||||
} catch (e: any) {
|
||||
warn(`output tail failed (${e?.message ?? e}) — tests_ran will be unknown`);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
await Promise.all([pump(proc.stdout as any, process.stdout), pump(proc.stderr as any, process.stderr)]);
|
||||
await Promise.all([pump(proc.stdout as any, process.stdout, "stdout"), pump(proc.stderr as any, process.stderr, "stderr")]);
|
||||
exitCode = await proc.exited;
|
||||
if (exitCode === null || exitCode === undefined) exitCode = 1;
|
||||
} catch (e: any) {
|
||||
@@ -360,7 +464,15 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore);
|
||||
// TRANSPARENCY INVARIANT: the parse runs AFTER the exit code is fixed and
|
||||
// can only change the receipt, never the code this wrapper returns.
|
||||
let testsRan: number | "unknown" = "unknown";
|
||||
try {
|
||||
testsRan = await parseTestsRan(tail.end());
|
||||
} catch (e: any) {
|
||||
warn(`tests_ran parse failed (${e?.message ?? e}) — recorded as unknown; the command result stands`);
|
||||
}
|
||||
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore, testsRan);
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
@@ -373,6 +485,7 @@ function record(
|
||||
exitCode: number,
|
||||
startedMs: number,
|
||||
wtreeBefore: string | undefined,
|
||||
testsRan: number | "unknown" = "unknown",
|
||||
): void {
|
||||
if (!paths) return;
|
||||
try {
|
||||
@@ -384,6 +497,7 @@ function record(
|
||||
cmd_sha256: cmdSha,
|
||||
exit: exitCode,
|
||||
duration_s: Math.round((Date.now() - startedMs) / 100) / 10,
|
||||
tests_ran: testsRan,
|
||||
};
|
||||
if (redacted) rec.redacted = true;
|
||||
const commit = git(["rev-parse", "HEAD"]);
|
||||
@@ -408,7 +522,7 @@ function record(
|
||||
} catch {}
|
||||
// Summary line on stderr so calling agents get the exit + log path even
|
||||
// when the lane ran backgrounded. Never on stdout (stays transparent).
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=${exitCode} log=${logPath ?? "-"}`);
|
||||
console.error(`gstack-evidence: recorded label=${label} exit=${exitCode} tests_ran=${testsRan} log=${logPath ?? "-"}`);
|
||||
} catch (e: any) {
|
||||
warn(`ledger append failed (${e?.message ?? e}) — the command result stands`);
|
||||
}
|
||||
@@ -472,12 +586,19 @@ function cmdCheck(argv: string[]): number {
|
||||
allFresh = false;
|
||||
continue;
|
||||
}
|
||||
const detail = `label=${label} exit=${latest.exit} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
|
||||
let verdict: "FRESH" | "STALE" = "FRESH";
|
||||
const testsRan = typeof latest.tests_ran === "number" ? latest.tests_ran : "unknown";
|
||||
const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
|
||||
let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH";
|
||||
let reason = "";
|
||||
if (latest.exit !== 0) {
|
||||
verdict = "STALE";
|
||||
reason = "recorded run failed";
|
||||
} else if (testsRan === 0) {
|
||||
// Zero-run green: exit 0 with nothing executed (all skipped, empty
|
||||
// selection, "No tests found"). Graded like STALE under its own label;
|
||||
// "unknown" (no recognised runner summary) stays non-blocking.
|
||||
verdict = "ZERO-RUN";
|
||||
reason = "runner reported 0 executed tests";
|
||||
} else if (maxAgeHours !== undefined) {
|
||||
const ageMs = Date.now() - Date.parse(latest.ts);
|
||||
if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) {
|
||||
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/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 <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 { spawnSync } from 'node:child_process';
|
||||
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, wtreeFingerprint,
|
||||
} from '../lib/gate-diff/git';
|
||||
import { renderDiagnostics, renderJsonl, renderJsonlBody, renderTable } from '../lib/gate-diff/render';
|
||||
import { resolveStateRoot } from '../lib/state-root';
|
||||
|
||||
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),
|
||||
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 = spawnSync(path.join(BIN_DIR, 'gstack-slug'), [], { cwd, encoding: 'utf-8', timeout: 30_000 });
|
||||
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 wtree = wtreeFingerprint(BIN_DIR, root);
|
||||
if (!wtree) console.error('gstack-gate-diff: working-tree fingerprint unavailable (gstack-wtree failed); candidate recorded without it');
|
||||
candidate = `${mb.slice(0, 12)}+${wtree ? wtree.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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# gate-diff calibration — tool inventory over gstack's last 50 merged PRs
|
||||
|
||||
Generated by `bun scripts/gate-diff-calibrate.ts --ref origin/main --count 50 --markdown`
|
||||
on 2026-10-10 against `origin/main` at `20eb620` (v1.91.68.0), scanning each
|
||||
commit's first-parent delta exactly as `gstack-gate-diff --commit <sha>` does.
|
||||
gstack squash-merges, so the 50 first-parent commits are the 50 landed PRs.
|
||||
The clone was shallow; `git fetch --deepen=60 origin main` was needed first
|
||||
(the tool reports a missing parent as `GATE_ERROR=no_base … fix=git fetch --deepen`).
|
||||
|
||||
## What this measures, and what it does not
|
||||
|
||||
This is the **tool's inventory**: how many hunks the floor lists per PR, how
|
||||
many are eligible for the read level, how often the 50-hunk / 200 KB read cap
|
||||
makes a result partial, and how often each tag fires. Tags are factual labels
|
||||
(a test deleted beside its deleted source is still RH-1), so a tag count is not
|
||||
a count of problems.
|
||||
|
||||
It does **not** measure agent findings, kept/restored/open dispositions, or
|
||||
whether a reviewer would have wanted to read a tagged hunk. The design's
|
||||
10-PR hand read of tagged hunks is a human step this table only supports; the
|
||||
agent-level signal is collected after ship from the persisted review records.
|
||||
No artifact was written: calibration never touches the state root.
|
||||
|
||||
## Observations from the numbers
|
||||
|
||||
- **Listed per PR:** median 40.5, p75 267, max 1,276 (the v1.91.8.0 test-audit
|
||||
wave); eligible median 31.5. This matches the design's own measurement
|
||||
(median 23–45) and confirms per-hunk disposition is unworkable; the three
|
||||
levels are the right unit.
|
||||
- **Partial results:** the read cap was hit in 20 of 50 PRs. For gstack's
|
||||
test-heavy PRs, "partial (K of M eligible read)" will be the common state,
|
||||
not the exception.
|
||||
- **RH-15 (paired, 35 hunks / 14 PRs)** reads well by inspection: real
|
||||
`timeout-minutes` bumps in workflows, `timeout 5000 -> 30000` in tests, a
|
||||
judge threshold `4 -> 3`, an exact-count assertion relaxed to a bound, plus
|
||||
tightenings labelled as such. The direction note makes the read fast.
|
||||
- **RH-15? (unpaired, 848 hunks / 41 PRs)** is dominated by `spawnSync(…,
|
||||
{ timeout: 30_000 })` in new or moved test code; this repository requires a
|
||||
timeout on every synchronous spawn (`test/spawnsync-timeout-tripwire.test.ts`),
|
||||
so nearly every test PR carries dozens. As specified ("unpaired values are
|
||||
tagged RH-15? and read") the tag is noise here. Candidates for the human
|
||||
decision the design reserves: demote `RH-15?` to inventory, or keep it only
|
||||
for CI/config files and test-framework timeout options. The detector was
|
||||
tightened to key-value assignments (`key: N`, `key = N`, `key(N)`, `--key N`,
|
||||
and a gate key opening a nested structure) and to numeric literals with `_`
|
||||
separators; prose strings and `setTimeout` calls no longer fire.
|
||||
- **RH-1 (411 hunks / 18 PRs):** 343 of them are the v1.91.8.0 test-retirement
|
||||
PR; 29 are skip/only idioms (several are conditional
|
||||
`process.platform === 'linux' ? describe : describe.skip`). Factual, and
|
||||
exactly the inventory a reviewer of that PR would want listed.
|
||||
- **RH-16 (80 hunks / 19 PRs):** gstack's many best-effort `catch {}` blocks.
|
||||
Overlaps slop-scan as the design states; listed under the RH id.
|
||||
- **RH-12, RH-13, RH-14:** rare (6 / 8 / 3 hunks), plausible on inspection.
|
||||
- **RH-3 / RH-3?:** did not fire; gstack has no `__snapshots__`/`.golden`
|
||||
fixtures in this window (its SKILL.md goldens are `.md`, listed by path only).
|
||||
- Prose (`.md`, `.md.tmpl`) and generated/vendored paths (`dist/`, `vendor/`,
|
||||
lockfiles, `.min.js`) are floor inventory only; without that rule the
|
||||
design document itself tagged every RH id it describes.
|
||||
|
||||
## Table
|
||||
|
||||
| commit | subject | listed | eligible | read | unread | unmatched | unsupported | tags |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|---|
|
||||
| 20eb6202fa8e | v1.91.68.0 fix(cso-eval): Claude producers keep the source sealed, run t | 18 | 16 | 16 | 0 | 6 | 0 | RH-15?:1 |
|
||||
| 54efba6dd5a6 | v1.91.67.0 feat(hosts): Antigravity CLI (agy) host (#3089) | 14 | 9 | 9 | 0 | 15 | 0 | RH-15?:2 |
|
||||
| 8bb6d6527076 | v1.91.66.0 fix: /autoplan without permission prompts after background re | 133 | 99 | 50 | 49 | 119 | 0 | RH-1:2 RH-15?:13 RH-16:2 |
|
||||
| b357b503780f | v1.91.65.0 fix(install): skills only for the agents you installed, every | 12 | 11 | 11 | 0 | 19 | 0 | RH-15?:3 |
|
||||
| 9e9e674b1efa | v1.91.62.0 fix(cso): Rails verification fits two arm64 offline preparati | 1 | 1 | 1 | 0 | 5 | 0 | - |
|
||||
| 4dfd83bf8c5c | v1.91.60.0 fix(windows): setup degrades instead of dying on a CSO failur | 25 | 14 | 14 | 0 | 17 | 0 | RH-15:1 RH-15?:6 RH-16:2 |
|
||||
| 4e5ffbb857c4 | v1.91.59.0 fix(autoplan): bind init through symlinked plan directories a | 6 | 4 | 4 | 0 | 8 | 0 | - |
|
||||
| 9a1dc81a2b96 | v1.91.54.0 fix: office-hours first sessions get the first-session closin | 13 | 8 | 8 | 0 | 18 | 0 | RH-15?:2 |
|
||||
| d082eccc50d2 | v1.91.52.0 fix(qa): annotations limits shape; qa-quick does less work in | 2 | 2 | 2 | 0 | 7 | 0 | RH-15?:1 |
|
||||
| c7edf46bcf6e | v1.91.47.0 fix(careful,freeze): safety hooks cover PowerShell and Notebo | 11 | 7 | 7 | 0 | 22 | 0 | - |
|
||||
| 92cfd07a79ed | v1.91.45.0 feat: configurable model tiers and advisory freshness (#3073) | 52 | 34 | 34 | 0 | 60 | 0 | RH-15?:11 RH-16:1 |
|
||||
| 52eec18c6347 | v1.91.42.0 fix(cso): lift the snapshot file-count ceiling, bound report | 2 | 2 | 2 | 0 | 9 | 0 | RH-15?:1 RH-16:1 |
|
||||
| c7554824cbd1 | v1.91.38.0 fix(ios-qa): iPads work, a dropped route no longer relaunches | 17 | 12 | 12 | 0 | 13 | 0 | RH-15?:2 |
|
||||
| 1d1ce39b4e2b | v1.91.36.0 fix(cso): Python and Rails repairs replay, Rails verification | 9 | 4 | 4 | 0 | 11 | 0 | - |
|
||||
| f67c478f05d8 | v1.91.34.0 fix: /autoplan on current Claude Code and long sessions, quie | 194 | 140 | 50 | 90 | 150 | 0 | RH-1:1 RH-15:1 RH-15?:19 RH-16:1 |
|
||||
| db745675bdf9 | v1.91.33.0 feat(ship): measurement bar with explained-red and pooled dec | 26 | 20 | 20 | 0 | 16 | 0 | RH-15?:4 |
|
||||
| 28f1385eac65 | v1.91.32.0 fix: Codex works from zsh, free text never enters shell comma | 120 | 84 | 50 | 34 | 155 | 0 | RH-15:1 RH-15?:22 RH-16:1 |
|
||||
| 7697fb298173 | fix(cso): install Rails gems on one thread so exports stop failing (v1.9 | 2 | 0 | 0 | 0 | 7 | 0 | - |
|
||||
| 55f8151fbe77 | v1.91.30.0 fix: honest eval failure reports, Bun floor with safe auto-up | 414 | 234 | 50 | 184 | 103 | 0 | RH-1:2 RH-13:4 RH-15?:20 |
|
||||
| 5885157a90e5 | v1.91.29.0 fix(cso): native runtime qualification passes for Node, Bun, | 21 | 12 | 12 | 0 | 14 | 0 | - |
|
||||
| c285d88b90d3 | Promote qualified CSO scanner catalog (#3042) | 0 | 0 | 0 | 0 | 1 | 0 | - |
|
||||
| 10315cf44d1e | v1.91.27.0 fix(cso): runtime staging can finish and a qualified scanner | 11 | 8 | 8 | 0 | 7 | 0 | RH-15?:1 |
|
||||
| 857466ff8b93 | v1.91.25.0 feat(cso): private two-architecture release scoring + self-ch | 55 | 35 | 35 | 0 | 19 | 0 | RH-15?:6 |
|
||||
| 55fbe77195b1 | v1.91.24.0 test: audit wave — PR evals keep finished work, honest ceilin | 761 | 649 | 38 | 611 | 66 | 0 | RH-1:14 RH-14:2 RH-15:1 RH-15?:40 RH-16:1 |
|
||||
| f97aefe654fb | v1.91.22.0 feat(cso): reviewed, attested scanner build inputs + fixes so | 5 | 3 | 3 | 0 | 10 | 0 | - |
|
||||
| 2db0b3adc84b | v1.91.19.0 fix: severe fix wave (truthful gates, no lost memory, every h | 577 | 376 | 31 | 345 | 219 | 0 | RH-1:1 RH-12:1 RH-15?:66 RH-16:11 |
|
||||
| 4015c2870b06 | v1.91.18.0 feat: Opus 5.5 cleanup follow-ups — scoped transcript consent | 423 | 129 | 50 | 79 | 82 | 0 | RH-1:2 RH-15?:21 RH-16:1 |
|
||||
| fe6d1ae62a42 | v1.91.17.0 fix: mvanhorn wave (checkpoint provenance, never-overwrite de | 24 | 13 | 13 | 0 | 37 | 0 | RH-15?:5 RH-16:3 |
|
||||
| ac20ef1e1380 | v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, tru | 420 | 315 | 50 | 265 | 228 | 0 | RH-15:2 RH-15?:45 RH-16:1 |
|
||||
| 74512c20a7ac | v1.91.15.0 v1.91.15.0: Opus 5.5 prompt cleanup, behavior-level skill tes | 538 | 455 | 39 | 416 | 186 | 0 | RH-1:8 RH-15?:14 |
|
||||
| f30b7b788a21 | v1.91.13.0 fix: severe bug wave (autoplan guard, land-and-deploy CI gate | 115 | 76 | 50 | 26 | 56 | 0 | RH-15?:11 RH-16:1 |
|
||||
| 7fca42ad8b6c | v1.91.12.0 v1.91.12.0: audit fix wave, ~11-minute paid eval lanes, eval | 712 | 533 | 11 | 522 | 109 | 0 | RH-1:4 RH-12:1 RH-13:1 RH-15:7 RH-15?:41 RH-16:23 |
|
||||
| df89475b1713 | v1.91.11.0 refactor: one state-root rule, browse route table, shared sha | 310 | 240 | 15 | 225 | 236 | 0 | RH-1:5 RH-13:3 RH-15:1 RH-15?:23 RH-16:3 |
|
||||
| 96764e80a641 | v1.91.9.0 feat: test value bar in plan-eng-review, review, qa and ship, | 45 | 36 | 36 | 0 | 37 | 0 | RH-15?:4 |
|
||||
| 943105f1099c | v1.91.8.0 test: smaller suite, derived paid selection, retired never-gre | 905 | 863 | 36 | 827 | 30 | 3 | RH-1:344 RH-15:1 RH-15?:94 |
|
||||
| dcaea5280055 | v1.91.7.0 feat: add functional QA and pre-publication docs checks (#2983 | 670 | 537 | 17 | 520 | 138 | 0 | RH-1:7 RH-15:4 RH-15?:67 RH-16:1 |
|
||||
| 65bfb0ce49da | v1.91.6.0 feat: balance PR eval slices by recorded duration without weak | 2 | 0 | 0 | 0 | 7 | 0 | - |
|
||||
| 080a655791d4 | v1.91.5.0 feat: balanced free-suite shards, test:ubicloud, and faster PR | 36 | 29 | 29 | 0 | 12 | 0 | RH-1:1 RH-15:1 RH-15?:9 |
|
||||
| d2a0bbcf4c11 | v1.91.4.0 fix: Windows Opera cookie import wave (#2980, refs #2957) (#29 | 12 | 6 | 6 | 0 | 14 | 0 | RH-15?:1 |
|
||||
| 01593aa67c94 | v1.91.2.0 fix: consolidate gstack reliability wave (#2959) | 269 | 194 | 35 | 159 | 97 | 0 | RH-1:3 RH-15:5 RH-15?:37 RH-16:4 |
|
||||
| 2a113ae7e623 | v1.91.1.0 fix: harden Impeccable plugin discovery (#2978) | 27 | 11 | 11 | 0 | 6 | 0 | RH-15?:8 |
|
||||
| 7b534d3e9010 | v1.90.2.0 perf: halve local free-suite time and preserve coverage (#2972 | 99 | 61 | 50 | 11 | 21 | 0 | RH-1:1 RH-15?:13 |
|
||||
| a84b0b5b6d73 | v1.90.0.0 feat: make browser cookie imports explicit and safe (#2964) | 160 | 95 | 10 | 85 | 35 | 0 | RH-12:3 RH-15:3 RH-15?:22 RH-16:11 |
|
||||
| 730a1017d1a1 | v1.89.1.0 fix: remove continuous checkpoint commits (#2970) | 98 | 74 | 50 | 24 | 69 | 1 | RH-1:1 RH-15?:6 |
|
||||
| 06ed920a9748 | v1.89.0.0 feat: add shared-code extraction audit (#2925) | 267 | 194 | 34 | 160 | 89 | 3 | RH-1:2 RH-15:1 RH-15?:9 |
|
||||
| b9706f3635b6 | v1.88.1.0 fix: harden credential boundaries and owned state (#2942) | 59 | 48 | 48 | 0 | 12 | 0 | RH-1:1 RH-12:1 RH-15?:13 RH-16:8 |
|
||||
| 636175d3496e | v1.87.6.0 fix: make checks reliable and everyday validation faster (#289 | 1276 | 751 | 25 | 726 | 154 | 2 | RH-1:12 RH-14:1 RH-15:6 RH-15?:173 RH-16:4 |
|
||||
| 35dd014c5800 | v1.87.5.0 perf: remove idle waits from tests and CI planning (#2897) | 77 | 50 | 50 | 0 | 12 | 0 | RH-15?:8 |
|
||||
| a6b3a57512ca | v1.87.4.0 fix: preserve health failures and disclose coverage (#2882) | 15 | 8 | 8 | 0 | 6 | 0 | RH-15?:2 |
|
||||
| 85b8c038fc00 | v1.87.3.0 fix: bind review evidence to the reviewed tree (#2875) | 32 | 21 | 21 | 0 | 29 | 0 | RH-15?:2 |
|
||||
|
||||
**Scanned:** 50 of 50 first-parent commits on origin/main; 0 errored.
|
||||
**Listed per commit:** median 40.5, p75 267, max 1276; eligible median 31.5, p75 140, max 863; partial (read cap hit) in 20 commit(s).
|
||||
**Tags:** RH-1: 411 hunk(s) in 18 commit(s); RH-12: 6 hunk(s) in 4 commit(s); RH-13: 8 hunk(s) in 3 commit(s); RH-14: 3 hunk(s) in 2 commit(s); RH-15: 35 hunk(s) in 14 commit(s); RH-16: 80 hunk(s) in 19 commit(s); RH-15?: 848 hunk(s) in 41 commit(s).
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* gate-diff classify — the path floor of the gate-edit detector (W1 of
|
||||
* docs/designs/HONEST_WORK_GATE_INTEGRITY.md).
|
||||
*
|
||||
* A path's class decides whether every hunk in it is LISTED (test, CI/runner/
|
||||
* lint config, snapshot/golden) before any tag is considered. The matcher is
|
||||
* gstack's own, not gstack-diff-scope's: any depth, case-insensitive, with the
|
||||
* Java/Kotlin/Swift/Go/Elixir idioms diff-scope's root-anchored globs miss.
|
||||
* `spec/` counts as a test directory only when the candidate's tree holds
|
||||
* `*_spec.*` or `*.spec.*` files under it (gstack's own `spec/` skill is prose).
|
||||
*
|
||||
* Projects extend the floor through a CLAUDE.md `## Gate Integrity` block
|
||||
* (`Extra test paths:` and `Snapshot paths:`, comma-separated globs), parsed by
|
||||
* `parseGateIntegrityBlock`. Pure: no git, no filesystem.
|
||||
*/
|
||||
|
||||
export type PathClass = 'test' | 'ci' | 'config' | 'snapshot' | 'golden';
|
||||
|
||||
export interface GateProjectConfig {
|
||||
/** Globs from `Extra test paths:`; listed as tests. */
|
||||
extraTestPaths: string[];
|
||||
/** Globs from `Snapshot paths:`; listed as goldens (RH-3?: owner unresolved). */
|
||||
snapshotPaths: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_PROJECT_CONFIG: GateProjectConfig = { extraTestPaths: [], snapshotPaths: [] };
|
||||
|
||||
export interface ClassifyContext {
|
||||
project: GateProjectConfig;
|
||||
/** True when the candidate tree has `*_spec.*`/`*.spec.*` files under a `spec/` directory. */
|
||||
specIsTestPath: boolean;
|
||||
}
|
||||
|
||||
const TEST_DIRS = new Set(['test', 'tests', '__tests__']);
|
||||
|
||||
const TEST_BASENAMES: RegExp[] = [
|
||||
/\.test\.[^.]+$/i,
|
||||
/\.spec\.[^.]+$/i,
|
||||
/_test\.[^.]+$/i,
|
||||
/_spec\.[^.]+$/i,
|
||||
/^test_.*\.py$/i,
|
||||
/Test\.(java|kt|scala)$/i,
|
||||
/Tests\.swift$/i,
|
||||
/_test\.go$/i,
|
||||
/_test\.exs$/i,
|
||||
];
|
||||
|
||||
/** CI, runner and lint configuration: an exact basename, a basename prefix, or a directory. */
|
||||
const CI_DIRS = ['.github/workflows/', '.gitlab/ci/', '.circleci/'];
|
||||
const CI_BASENAMES = new Set(['.gitlab-ci.yml', 'jenkinsfile', '.travis.yml', 'azure-pipelines.yml', 'bitbucket-pipelines.yml']);
|
||||
const CONFIG_BASENAMES = new Set([
|
||||
'pytest.ini', 'setup.cfg', 'tox.ini', 'bunfig.toml', '.rubocop.yml', '.mocharc.yml', '.mocharc.json', '.nycrc', 'codecov.yml', '.codecov.yml',
|
||||
'karma.conf.js', 'playwright.config.ts', 'playwright.config.js', 'cypress.config.ts', 'cypress.config.js', 'phpunit.xml', 'phpunit.xml.dist',
|
||||
'.pre-commit-config.yaml', 'lefthook.yml', '.lefthook.yml', 'mypy.ini', '.flake8', 'ruff.toml', '.ruff.toml', 'biome.json', '.prettierrc',
|
||||
]);
|
||||
const CONFIG_BASENAME_PREFIXES = ['vitest.config', 'jest.config', '.eslintrc', 'eslint.config', 'tsconfig', 'golangci', '.golangci'];
|
||||
/** Files whose hunks are config only inside a tool section (checked per hunk by `hunkTouchesToolSection`). */
|
||||
const SECTIONED_CONFIG = new Set(['package.json', 'pyproject.toml']);
|
||||
|
||||
const SNAPSHOT_DIRS = new Set(['__snapshots__']);
|
||||
const SNAPSHOT_EXT = /\.snap$/i;
|
||||
const GOLDEN_EXT = /\.golden$/i;
|
||||
const GOLDEN_DIRS = new Set(['testdata']);
|
||||
|
||||
function segments(p: string): string[] {
|
||||
return p.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
/** Minimal glob → RegExp: `**` any path, `*` within a segment, `?` one char; a trailing `/` or a bare directory name matches its subtree. */
|
||||
export function globToRegExp(glob: string): RegExp {
|
||||
let g = glob.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
const dirOnly = g.endsWith('/');
|
||||
if (dirOnly) g = g.slice(0, -1);
|
||||
let re = '';
|
||||
for (let i = 0; i < g.length; i++) {
|
||||
const c = g[i];
|
||||
if (c === '*') {
|
||||
if (g[i + 1] === '*') {
|
||||
i++;
|
||||
if (g[i + 1] === '/') {
|
||||
i++;
|
||||
re += '(?:.*/)?';
|
||||
} else re += '.*';
|
||||
} else re += '[^/]*';
|
||||
} else if (c === '?') re += '[^/]';
|
||||
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
const anchored = g.startsWith('/') ? '^' + re.slice(1) : '^(?:.*/)?' + re;
|
||||
return new RegExp(anchored + (dirOnly || !/[.*?]/.test(g) ? '(?:/.*)?$' : '$'), 'i');
|
||||
}
|
||||
|
||||
function matchesAny(p: string, globs: string[]): boolean {
|
||||
return globs.some((g) => globToRegExp(g).test(p));
|
||||
}
|
||||
|
||||
export function isTestPath(p: string, ctx: ClassifyContext): boolean {
|
||||
const segs = segments(p);
|
||||
const base = segs[segs.length - 1] ?? '';
|
||||
const dirs = segs.slice(0, -1).map((s) => s.toLowerCase());
|
||||
if (dirs.some((d) => TEST_DIRS.has(d))) return true;
|
||||
if (ctx.specIsTestPath && dirs.includes('spec')) return true;
|
||||
if (TEST_BASENAMES.some((re) => re.test(base))) return true;
|
||||
return matchesAny(p, ctx.project.extraTestPaths);
|
||||
}
|
||||
|
||||
export function isSnapshotPath(p: string): boolean {
|
||||
const segs = segments(p);
|
||||
const base = segs[segs.length - 1] ?? '';
|
||||
return segs.slice(0, -1).some((d) => SNAPSHOT_DIRS.has(d.toLowerCase())) || SNAPSHOT_EXT.test(base);
|
||||
}
|
||||
|
||||
export function isGoldenPath(p: string, ctx: ClassifyContext): boolean {
|
||||
const segs = segments(p);
|
||||
const base = segs[segs.length - 1] ?? '';
|
||||
if (GOLDEN_EXT.test(base)) return true;
|
||||
if (segs.slice(0, -1).some((d) => GOLDEN_DIRS.has(d.toLowerCase()))) return true;
|
||||
return matchesAny(p, ctx.project.snapshotPaths);
|
||||
}
|
||||
|
||||
export function isCiPath(p: string): boolean {
|
||||
const norm = p.replace(/\\/g, '/').toLowerCase();
|
||||
if (CI_DIRS.some((d) => norm.startsWith(d) || norm.includes('/' + d))) return true;
|
||||
const base = segments(norm).pop() ?? '';
|
||||
return CI_BASENAMES.has(base);
|
||||
}
|
||||
|
||||
export function isConfigPath(p: string): boolean {
|
||||
const base = (segments(p).pop() ?? '').toLowerCase();
|
||||
if (CONFIG_BASENAMES.has(base)) return true;
|
||||
if (CONFIG_BASENAME_PREFIXES.some((pre) => base.startsWith(pre) && base !== pre + '.d.ts')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSectionedConfigPath(p: string): boolean {
|
||||
return SECTIONED_CONFIG.has((segments(p).pop() ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* For package.json and pyproject.toml, a hunk joins the config floor only when
|
||||
* its lines (context included) sit in a tool section: `"scripts"`, a test or
|
||||
* lint runner key, or a `[tool.*]` table.
|
||||
*/
|
||||
export function hunkTouchesToolSection(p: string, lines: string[]): boolean {
|
||||
const base = (segments(p).pop() ?? '').toLowerCase();
|
||||
if (base === 'package.json') {
|
||||
return lines.some((l) =>
|
||||
/^\s*"(scripts|jest|vitest|eslintConfig|husky|lint-staged|mocha|ava|c8|nyc|prettier|browserslist)"\s*:/.test(l)
|
||||
|| /^\s*"(pre)?(test|lint|typecheck|check|ci|push|commit|prepare|coverage)[^"]*"\s*:\s*"/.test(l));
|
||||
}
|
||||
if (base === 'pyproject.toml') return lines.some((l) => /^\s*\[tool\./.test(l));
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The path's floor class, or null when the path is outside the floor. */
|
||||
export function classifyPath(p: string, ctx: ClassifyContext): PathClass | null {
|
||||
if (isSnapshotPath(p)) return 'snapshot';
|
||||
if (isGoldenPath(p, ctx)) return 'golden';
|
||||
if (isTestPath(p, ctx)) return 'test';
|
||||
if (isCiPath(p)) return 'ci';
|
||||
if (isConfigPath(p)) return 'config';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Removed lines matching a test-declaration idiom admit a hunk even outside the floor (content backstop). */
|
||||
export const TEST_DECLARATION = /^\s*(?:(?:export\s+)?(?:async\s+)?(?:test|it|describe|context|suite|specify|scenario|feature)\s*(?:\.\w+)?\s*\(|def\s+test_\w+|async\s+def\s+test_\w+|func\s+Test\w*\s*\(|@Test\b|@pytest\.mark\.parametrize|#\[test\]|#\[tokio::test\]|@testable|func\s+test\w*\s*\(\)\s*(?:async\s+)?(?:throws\s+)?\{|RSpec\.describe|\bit\s+["'])/;
|
||||
|
||||
export function hunkHasRemovedTestDeclaration(removed: string[]): boolean {
|
||||
return removed.some((l) => TEST_DECLARATION.test(l));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLAUDE.md's optional `## Gate Integrity` block. Keys are
|
||||
* `Extra test paths:` and `Snapshot paths:`; values are comma-separated globs.
|
||||
* Unknown keys are ignored; a missing block yields the empty config.
|
||||
*/
|
||||
export function parseGateIntegrityBlock(claudeMd: string): GateProjectConfig {
|
||||
const out: GateProjectConfig = { extraTestPaths: [], snapshotPaths: [] };
|
||||
const lines = claudeMd.split(/\r?\n/);
|
||||
let inBlock = false;
|
||||
for (const raw of lines) {
|
||||
if (/^##\s+/.test(raw)) {
|
||||
inBlock = /^##\s+gate integrity\s*$/i.test(raw.trim());
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) continue;
|
||||
const m = /^\s*[-*]?\s*(extra test paths|snapshot paths)\s*:\s*(.*)$/i.exec(raw);
|
||||
if (!m) continue;
|
||||
const values = m[2].split(',').map((s) => s.trim().replace(/^`|`$/g, '')).filter(Boolean);
|
||||
if (m[1].toLowerCase() === 'extra test paths') out.extraTestPaths.push(...values);
|
||||
else out.snapshotPaths.push(...values);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a tree's path list makes `spec/` a test directory: any `*_spec.*`/`*.spec.*` under a `spec` segment. */
|
||||
export function specDirectoryHoldsSpecs(treePaths: Iterable<string>): boolean {
|
||||
for (const p of treePaths) {
|
||||
const segs = segments(p);
|
||||
if (!segs.slice(0, -1).some((d) => d.toLowerCase() === 'spec')) continue;
|
||||
const base = segs[segs.length - 1] ?? '';
|
||||
if (/(_spec|\.spec)\.[^.]+$/i.test(base)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Code extensions whose test idioms the floor and tags cover, and the ones they do not. */
|
||||
const COVERED_LANGUAGES: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'typescript', mts: 'typescript', cts: 'typescript',
|
||||
js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
py: 'python', go: 'go', rb: 'ruby', java: 'java', kt: 'kotlin', kts: 'kotlin', scala: 'scala',
|
||||
swift: 'swift', ex: 'elixir', exs: 'elixir', rs: 'rust',
|
||||
};
|
||||
const UNCOVERED_LANGUAGES: Record<string, string> = {
|
||||
php: 'php', cs: 'csharp', c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', m: 'objective-c', mm: 'objective-c',
|
||||
dart: 'dart', pl: 'perl', pm: 'perl', lua: 'lua', clj: 'clojure', hs: 'haskell', erl: 'erlang', zig: 'zig', nim: 'nim',
|
||||
fs: 'fsharp', vb: 'visualbasic', groovy: 'groovy', r: 'r', jl: 'julia', ml: 'ocaml',
|
||||
};
|
||||
|
||||
/** Languages present in the changed set whose test/skip idioms the detector does not know (sorted, deduplicated). */
|
||||
export function languagesUnlisted(paths: Iterable<string>): string[] {
|
||||
const found = new Set<string>();
|
||||
for (const p of paths) {
|
||||
const ext = (segments(p).pop() ?? '').split('.').pop()?.toLowerCase() ?? '';
|
||||
if (ext in COVERED_LANGUAGES) continue;
|
||||
if (ext in UNCOVERED_LANGUAGES) found.add(UNCOVERED_LANGUAGES[ext]);
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* gate-diff git — the candidate model and the hardened git calls behind
|
||||
* bin/gstack-gate-diff. ONE comparison: resolved merge-base SHA → the working
|
||||
* tree as it stands (`git diff -M --no-color --no-ext-diff --no-textconv
|
||||
* <merge-base>`), plus non-ignored untracked files as additions; never the
|
||||
* committed + HEAD + untracked concatenation gstack-diff-scope uses. `--commit
|
||||
* <sha>` scans one commit's first-parent delta without the working tree.
|
||||
*
|
||||
* Binary, symlink, submodule and undecodable paths come back as named
|
||||
* `unsupported` files, not zero hunks. Every git call runs with
|
||||
* GIT_OPTIONAL_LOCKS=0, no pager, no prompt, a timeout, and `--` before paths.
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { CommitInfo, FileDiff, FileStatus, Hunk, Unsupported } from './types';
|
||||
|
||||
export const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
||||
const DIFF_FLAGS = ['diff', '-M', '--no-color', '--no-ext-diff', '--no-textconv', '--unified=3'];
|
||||
|
||||
export interface GitResult {
|
||||
status: number;
|
||||
stdout: Buffer;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export function runGit(args: string[], cwd: string, timeoutMs = 60_000): GitResult {
|
||||
const r = spawnSync('git', ['-c', 'core.quotePath=false', '--no-pager', ...args], {
|
||||
cwd, timeout: timeoutMs, maxBuffer: 512 * 1024 * 1024,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
||||
});
|
||||
return { status: r.status ?? -1, stdout: r.stdout ?? Buffer.alloc(0), stderr: (r.stderr ?? Buffer.alloc(0)).toString('utf-8') };
|
||||
}
|
||||
|
||||
function gitText(args: string[], cwd: string): string | null {
|
||||
const r = runGit(args, cwd);
|
||||
return r.status === 0 ? r.stdout.toString('utf-8').trim() : null;
|
||||
}
|
||||
|
||||
export function repoRoot(cwd: string): string | null {
|
||||
return gitText(['rev-parse', '--show-toplevel'], cwd);
|
||||
}
|
||||
|
||||
export function resolvesToCommit(ref: string, cwd: string): string | null {
|
||||
if (!/^[A-Za-z0-9._\/@{}^~-]+$/.test(ref) || ref.startsWith('-')) return null;
|
||||
return gitText(['rev-parse', '--verify', '-q', `${ref}^{commit}`], cwd);
|
||||
}
|
||||
|
||||
/** origin/HEAD's target → `main` → `master` (the gstack-diff-scope chain), as a ref name. */
|
||||
export function defaultBase(cwd: string): string {
|
||||
const head = gitText(['symbolic-ref', '-q', 'refs/remotes/origin/HEAD'], cwd);
|
||||
if (head) return head.replace(/^refs\/remotes\//, '');
|
||||
for (const name of ['main', 'master']) {
|
||||
if (resolvesToCommit(name, cwd) || resolvesToCommit(`origin/${name}`, cwd)) return name;
|
||||
}
|
||||
return 'main';
|
||||
}
|
||||
|
||||
export function isShallow(cwd: string): boolean {
|
||||
return gitText(['rev-parse', '--is-shallow-repository'], cwd) === 'true';
|
||||
}
|
||||
|
||||
/** The concrete recovery for an unresolvable base: fetch it from the detected remote, unshallowing when needed. */
|
||||
export function noBaseFix(ref: string, cwd: string): string {
|
||||
const remotes = (gitText(['remote'], cwd) ?? '').split('\n').filter(Boolean);
|
||||
const m = /^([^/]+)\/(.+)$/.exec(ref);
|
||||
const remote = m && remotes.includes(m[1]) ? m[1] : remotes.includes('origin') ? 'origin' : remotes[0] ?? 'origin';
|
||||
const branch = m && remotes.includes(m[1]) ? m[2] : ref;
|
||||
if (/^[0-9a-f]{7,64}\^?$/.test(ref) || ref.endsWith('^')) return isShallow(cwd) ? `git fetch --deepen=50 ${remote}` : `git fetch ${remote}`;
|
||||
return isShallow(cwd) ? `git fetch --unshallow ${remote} ${branch}` : `git fetch ${remote} ${branch}`;
|
||||
}
|
||||
|
||||
export function mergeBase(a: string, b: string, cwd: string): string | null {
|
||||
return gitText(['merge-base', a, b], cwd);
|
||||
}
|
||||
|
||||
export function headSha(cwd: string): string | null {
|
||||
return gitText(['rev-parse', '--verify', '-q', 'HEAD^{commit}'], cwd);
|
||||
}
|
||||
|
||||
/** Working-tree content fingerprint from the sibling gstack-wtree helper; null when unavailable. */
|
||||
export function wtreeFingerprint(binDir: string, cwd: string): string | null {
|
||||
try {
|
||||
const r = spawnSync(path.join(binDir, 'gstack-wtree'), [], { cwd, encoding: 'utf-8', timeout: 60_000, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
|
||||
const out = (r.stdout || '').trim();
|
||||
return r.status === 0 && /^[0-9a-f]{40,64}$/.test(out) ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** C-style unquote of a git path (`"a\\tb"`), including octal UTF-8 bytes. */
|
||||
export function unquoteGitPath(p: string): string {
|
||||
if (!(p.startsWith('"') && p.endsWith('"') && p.length >= 2)) return p;
|
||||
const bytes: number[] = [];
|
||||
const s = p.slice(1, -1);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (c !== '\\') {
|
||||
bytes.push(...Buffer.from(c, 'utf-8'));
|
||||
continue;
|
||||
}
|
||||
const n = s[++i];
|
||||
if (n === undefined) break;
|
||||
if (/[0-7]/.test(n)) {
|
||||
const oct = s.slice(i, i + 3).match(/^[0-7]{1,3}/)?.[0] ?? n;
|
||||
bytes.push(Number.parseInt(oct, 8));
|
||||
i += oct.length - 1;
|
||||
} else bytes.push(...Buffer.from({ t: '\t', n: '\n', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v' }[n] ?? n, 'utf-8'));
|
||||
}
|
||||
return Buffer.from(bytes).toString('utf-8');
|
||||
}
|
||||
|
||||
function stripPrefix(p: string, prefix: 'a/' | 'b/'): string {
|
||||
const u = unquoteGitPath(p);
|
||||
return u.startsWith(prefix) ? u.slice(2) : u;
|
||||
}
|
||||
|
||||
/** Split `diff --git a/x b/y` into (x, y); equal paths resolve exactly, renames with spaces fall back to the middle ` b/`. */
|
||||
function splitDiffGitLine(rest: string): [string, string] {
|
||||
if (rest.startsWith('"')) {
|
||||
const m = /^("(?:[^"\\]|\\.)*") ("(?:[^"\\]|\\.)*")$/.exec(rest);
|
||||
if (m) return [stripPrefix(m[1], 'a/'), stripPrefix(m[2], 'b/')];
|
||||
}
|
||||
const body = rest.startsWith('a/') ? rest.slice(2) : rest;
|
||||
if (body.length % 2 === 1) {
|
||||
const half = (body.length - 1) / 2;
|
||||
if (body.slice(half, half + 3) === ' b/' && body.slice(0, half) === body.slice(half + 3)) return [body.slice(0, half), body.slice(0, half)];
|
||||
}
|
||||
const idx = body.indexOf(' b/');
|
||||
return idx < 0 ? [body, body] : [body.slice(0, idx), body.slice(idx + 3)];
|
||||
}
|
||||
|
||||
interface Section {
|
||||
headers: string[];
|
||||
body: string[];
|
||||
}
|
||||
|
||||
function splitSections(text: string): Section[] {
|
||||
const sections: Section[] = [];
|
||||
let cur: Section | null = null;
|
||||
let inBody = false;
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
cur = { headers: [line], body: [] };
|
||||
sections.push(cur);
|
||||
inBody = false;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
if (!inBody && line.startsWith('@@')) inBody = true;
|
||||
if (inBody) cur.body.push(line);
|
||||
else cur.headers.push(line);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function parseHunks(body: string[]): Hunk[] {
|
||||
const hunks: Hunk[] = [];
|
||||
let cur: Hunk | null = null;
|
||||
for (const line of body) {
|
||||
if (line.startsWith('@@')) {
|
||||
cur = { header: line, lines: [] };
|
||||
hunks.push(cur);
|
||||
continue;
|
||||
}
|
||||
if (!cur || line.startsWith('\\')) continue;
|
||||
if (line.startsWith('+')) cur.lines.push({ kind: 'add', text: line.slice(1) });
|
||||
else if (line.startsWith('-')) cur.lines.push({ kind: 'del', text: line.slice(1) });
|
||||
else if (line.startsWith(' ') || line === '') cur.lines.push({ kind: 'ctx', text: line.slice(1) });
|
||||
}
|
||||
return hunks;
|
||||
}
|
||||
|
||||
function sectionToFile(sec: Section): FileDiff {
|
||||
let [oldPath, newPath] = splitDiffGitLine(sec.headers[0].slice('diff --git '.length));
|
||||
let status: FileStatus = 'modified';
|
||||
let unsupported: Unsupported | undefined;
|
||||
for (const h of sec.headers.slice(1)) {
|
||||
if (h.startsWith('rename from ')) oldPath = unquoteGitPath(h.slice(12));
|
||||
else if (h.startsWith('rename to ')) { newPath = unquoteGitPath(h.slice(10)); status = 'renamed'; }
|
||||
else if (h.startsWith('copy from ')) oldPath = unquoteGitPath(h.slice(10));
|
||||
else if (h.startsWith('copy to ')) { newPath = unquoteGitPath(h.slice(8)); status = 'copied'; }
|
||||
else if (h.startsWith('new file mode')) status = 'added';
|
||||
else if (h.startsWith('deleted file mode')) status = 'deleted';
|
||||
else if (h.startsWith('--- ') && !h.startsWith('--- /dev/null')) oldPath = stripPrefix(h.slice(4), 'a/');
|
||||
else if (h.startsWith('+++ ') && !h.startsWith('+++ /dev/null')) newPath = stripPrefix(h.slice(4), 'b/');
|
||||
if (/\b120000\b/.test(h) && /mode/.test(h)) unsupported = 'symlink';
|
||||
if (/^index [0-9a-f]+\.\.[0-9a-f]+ 160000$/.test(h) || /\b160000\b/.test(h) && /mode/.test(h)) unsupported = 'submodule';
|
||||
if (h.startsWith('Binary files ')) unsupported = unsupported ?? 'binary';
|
||||
if (h.startsWith('old mode') || h.startsWith('new mode')) status = status === 'modified' ? 'typechange' : status;
|
||||
}
|
||||
if (status === 'added') oldPath = newPath;
|
||||
if (status === 'deleted') newPath = oldPath;
|
||||
const hunks = unsupported ? [] : parseHunks(sec.body);
|
||||
if (!unsupported && hunks.some((h) => h.lines.some((l) => /^Subproject commit [0-9a-f]+/.test(l.text)))) unsupported = 'submodule';
|
||||
if (!unsupported && sec.body.some((l) => l.includes('\uFFFD'))) unsupported = 'undecodable';
|
||||
const file: FileDiff = { oldPath, newPath, status: status === 'typechange' && hunks.length ? 'modified' : status, hunks: unsupported ? [] : hunks };
|
||||
if (unsupported) file.unsupported = unsupported;
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Parse `git diff` text (as produced with the flags above) into structured files. */
|
||||
export function parseUnifiedDiff(text: string): FileDiff[] {
|
||||
return splitSections(text).map(sectionToFile);
|
||||
}
|
||||
|
||||
function decode(buf: Buffer): string {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(buf);
|
||||
}
|
||||
|
||||
/** merge-base → working tree (tracked changes, staged and unstaged, with rename detection). */
|
||||
export function diffWorkingTree(mergeBaseSha: string, cwd: string): FileDiff[] {
|
||||
const r = runGit([...DIFF_FLAGS, mergeBaseSha, '--'], cwd);
|
||||
if (r.status !== 0) throw new Error(`git diff failed: ${r.stderr.trim() || `exit ${r.status}`}`);
|
||||
return parseUnifiedDiff(decode(r.stdout));
|
||||
}
|
||||
|
||||
/** The commit's first parent, EMPTY_TREE for a root commit, or null when the parent is missing (shallow boundary). */
|
||||
export function firstParent(sha: string, cwd: string): string | null {
|
||||
const parents = gitText(['log', '-1', '--format=%P', sha, '--'], cwd);
|
||||
if (parents === null) return null;
|
||||
if (parents === '') return EMPTY_TREE;
|
||||
return resolvesToCommit(parents.split(' ')[0], cwd);
|
||||
}
|
||||
|
||||
/** One commit's first-parent delta (root commits diff against the empty tree). Throws when the parent is missing. */
|
||||
export function diffCommit(sha: string, cwd: string): FileDiff[] {
|
||||
const parent = firstParent(sha, cwd);
|
||||
if (!parent) throw new Error(`first parent of ${sha} is not present (shallow history); fix: ${noBaseFix(`${sha}^`, cwd)}`);
|
||||
const r = runGit([...DIFF_FLAGS, parent, sha, '--'], cwd);
|
||||
if (r.status !== 0) throw new Error(`git diff failed: ${r.stderr.trim() || `exit ${r.status}`}`);
|
||||
return parseUnifiedDiff(decode(r.stdout));
|
||||
}
|
||||
|
||||
export function untrackedPaths(cwd: string): string[] {
|
||||
const r = runGit(['ls-files', '-z', '--others', '--exclude-standard'], cwd);
|
||||
if (r.status !== 0) return [];
|
||||
return r.stdout.toString('utf-8').split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
/** Non-ignored untracked files as whole-file additions; binary/symlink/undecodable named, nested repos as submodules. */
|
||||
export function untrackedAsAdded(cwd: string, root: string): FileDiff[] {
|
||||
const out: FileDiff[] = [];
|
||||
for (const rel of untrackedPaths(cwd)) {
|
||||
const abs = path.join(root, rel);
|
||||
const file: FileDiff = { oldPath: rel, newPath: rel, status: 'added', hunks: [] };
|
||||
try {
|
||||
const st = fs.lstatSync(abs);
|
||||
if (st.isSymbolicLink()) file.unsupported = 'symlink';
|
||||
else if (st.isDirectory()) file.unsupported = 'submodule';
|
||||
else {
|
||||
const buf = fs.readFileSync(abs);
|
||||
if (buf.subarray(0, 8000).includes(0)) file.unsupported = 'binary';
|
||||
else {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||||
} catch {
|
||||
file.unsupported = 'undecodable';
|
||||
out.push(file);
|
||||
continue;
|
||||
}
|
||||
const lines = text.split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
file.hunks = [{ header: `@@ -0,0 +1,${lines.length} @@`, lines: lines.map((t) => ({ kind: 'add' as const, text: t })) }];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
file.unsupported = 'undecodable';
|
||||
}
|
||||
out.push(file);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function commitsInRange(fromExclusive: string, toInclusive: string, cwd: string): CommitInfo[] {
|
||||
const r = runGit(['log', '--format=%H%x00%B%x00', `${fromExclusive}..${toInclusive}`, '--'], cwd);
|
||||
if (r.status !== 0) return [];
|
||||
const parts = r.stdout.toString('utf-8').split('\0');
|
||||
const out: CommitInfo[] = [];
|
||||
for (let i = 0; i + 1 < parts.length; i += 2) {
|
||||
const sha = parts[i].trim();
|
||||
if (/^[0-9a-f]{40,64}$/.test(sha)) out.push({ sha, message: parts[i + 1] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function commitInfo(sha: string, cwd: string): CommitInfo | null {
|
||||
const msg = gitText(['log', '-1', '--format=%B', sha, '--'], cwd);
|
||||
return msg === null ? null : { sha, message: msg };
|
||||
}
|
||||
|
||||
/** Paths in the candidate tree: tracked + untracked for the working tree, `ls-tree` for a commit. */
|
||||
export function treePaths(cwd: string, commit?: string): string[] {
|
||||
const r = commit
|
||||
? runGit(['ls-tree', '-r', '-z', '--name-only', commit, '--'], cwd)
|
||||
: runGit(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd);
|
||||
if (r.status !== 0) return [];
|
||||
return r.stdout.toString('utf-8').split('\0').filter(Boolean);
|
||||
}
|
||||
|
||||
export function readClaudeMd(root: string): string {
|
||||
try {
|
||||
return fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* gate-diff core — `scanCandidate` turns already-fetched structured hunks into
|
||||
* the three-level result (listed / read / the agent's findings) the detector
|
||||
* reports. Pure: no git, no filesystem, so test/gate-diff.test.ts drives it
|
||||
* with planted fixtures. bin/gstack-gate-diff feeds it from lib/gate-diff/git.ts
|
||||
* and prints it through lib/gate-diff/render.ts.
|
||||
*
|
||||
* Admission: a hunk is LISTED when its path is on the floor (classify.ts) or
|
||||
* any tag matches (tags.ts) or a removed line is a test declaration (content
|
||||
* backstop). ELIGIBLE = tagged or deletion-bearing. READ = eligible, tagged
|
||||
* first then deletion-bearing, capped at 50 hunks / 200 KB; anything past the
|
||||
* cap is UNREAD and the result is partial (never "none detected").
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
classifyPath, hunkHasRemovedTestDeclaration, hunkTouchesToolSection, isSectionedConfigPath, languagesUnlisted,
|
||||
type GateProjectConfig, type PathClass,
|
||||
} from './classify';
|
||||
import { COVERAGE_TAGS, findCitation, tagHunk } from './tags';
|
||||
import type { CommitInfo, FileDiff, Hunk, ListedHunk, ScanResult } from './types';
|
||||
|
||||
export type { CommitInfo, DiffLine, FileDiff, FileStatus, Hunk, ListedHunk, ScanResult, ScanSummary, Unsupported } from './types';
|
||||
export { EMPTY_PROJECT_CONFIG, parseGateIntegrityBlock, specDirectoryHoldsSpecs } from './classify';
|
||||
|
||||
export const READ_CAP_HUNKS = 50;
|
||||
export const READ_CAP_BYTES = 200 * 1024;
|
||||
|
||||
export interface ScanInput {
|
||||
files: FileDiff[];
|
||||
/** Commits between merge-base and HEAD (or the one `--commit`), for RH-14 and citations. */
|
||||
commits: CommitInfo[];
|
||||
project: GateProjectConfig;
|
||||
specIsTestPath: boolean;
|
||||
/** `<merge-base-short>+<wtree-short>` or `<sha-short>` in commit mode. */
|
||||
candidate: string;
|
||||
/** Absolute artifact path the CLI will write; echoed in the summary. */
|
||||
artifact: string;
|
||||
readCap?: { hunks: number; bytes: number };
|
||||
}
|
||||
|
||||
const SKIP_CI = /\[(?:skip ci|ci skip)\]/i;
|
||||
|
||||
function normalisedChangedLines(h: Hunk): string {
|
||||
return h.lines
|
||||
.filter((l) => l.kind !== 'ctx')
|
||||
.map((l) => (l.kind === 'del' ? '-' : '+') + l.text.replace(/\r$/, '').replace(/\s+/g, ' ').trim())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function hunkId(oldPath: string, newPath: string, tags: string[], normalised: string): string {
|
||||
return createHash('sha256').update([oldPath, newPath, [...tags].sort().join(','), normalised].join('\0')).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function floorFor(file: FileDiff, h: Hunk | null, project: GateProjectConfig, specIsTestPath: boolean): PathClass | null {
|
||||
const path = file.status === 'deleted' ? file.oldPath : file.newPath;
|
||||
const ctx = { project, specIsTestPath };
|
||||
if (h && isSectionedConfigPath(path)) return hunkTouchesToolSection(path, h.lines.map((l) => l.text)) ? 'config' : null;
|
||||
return classifyPath(path, ctx);
|
||||
}
|
||||
|
||||
function entryFor(file: FileDiff, h: Hunk, floor: PathClass | null, allPaths: ReadonlySet<string>): ListedHunk | null {
|
||||
const path = file.status === 'deleted' ? file.oldPath : file.newPath;
|
||||
const old = h.lines.filter((l) => l.kind === 'del').map((l) => l.text.replace(/\r$/, ''));
|
||||
const added = h.lines.filter((l) => l.kind === 'add').map((l) => l.text.replace(/\r$/, ''));
|
||||
const tagged = tagHunk({ header: h.header, lines: h.lines.map((l) => ({ kind: l.kind, text: l.text.replace(/\r$/, '') })) }, { floor, path, allPaths });
|
||||
const admitted = floor !== null || tagged.tags.length > 0 || hunkHasRemovedTestDeclaration(old);
|
||||
if (!admitted) return null;
|
||||
const bytes = [...old, ...added].reduce((n, l) => n + Buffer.byteLength(l) + 1, 0);
|
||||
return {
|
||||
id: hunkId(file.oldPath, file.newPath, tagged.tags, normalisedChangedLines(h)),
|
||||
path, oldPath: file.oldPath, level: 'listed', tags: tagged.tags, old, new: added,
|
||||
citation: tagged.citation, notes: tagged.notes, header: h.header, floor, bytes,
|
||||
};
|
||||
}
|
||||
|
||||
function commitEntries(commits: CommitInfo[]): ListedHunk[] {
|
||||
const out: ListedHunk[] = [];
|
||||
for (const c of commits) {
|
||||
if (!SKIP_CI.test(c.message)) continue;
|
||||
const subject = c.message.split(/\r?\n/)[0] ?? '';
|
||||
const path = `(commit ${c.sha.slice(0, 12)})`;
|
||||
out.push({
|
||||
id: hunkId(path, path, ['RH-14'], '+' + subject), path, oldPath: path, level: 'listed', tags: ['RH-14'], old: [], new: [subject],
|
||||
citation: null, notes: ['RH-14: commit message carries a CI skip marker'], header: `commit ${c.sha}`, floor: null, bytes: subject.length + 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function suffixDuplicateIds(entries: ListedHunk[]): void {
|
||||
const seen = new Map<string, number>();
|
||||
for (const e of entries) {
|
||||
const n = (seen.get(e.id) ?? 0) + 1;
|
||||
seen.set(e.id, n);
|
||||
if (n > 1) e.id = `${e.id}-${n}`;
|
||||
}
|
||||
}
|
||||
|
||||
function chooseRead(entries: ListedHunk[], cap: { hunks: number; bytes: number }): number {
|
||||
const eligible = entries.filter((e) => e.tags.length > 0 || e.old.length > 0);
|
||||
const ordered = [...eligible.filter((e) => e.tags.length > 0), ...eligible.filter((e) => e.tags.length === 0)];
|
||||
let inspected = 0;
|
||||
let bytes = 0;
|
||||
for (const e of ordered) {
|
||||
if (inspected >= cap.hunks) break;
|
||||
if (inspected > 0 && bytes + e.bytes > cap.bytes) break;
|
||||
e.level = 'read';
|
||||
inspected++;
|
||||
bytes += e.bytes;
|
||||
}
|
||||
return inspected;
|
||||
}
|
||||
|
||||
export function scanCandidate(input: ScanInput): ScanResult {
|
||||
const cap = input.readCap ?? { hunks: READ_CAP_HUNKS, bytes: READ_CAP_BYTES };
|
||||
const allPaths = new Set<string>();
|
||||
for (const f of input.files) {
|
||||
allPaths.add(f.newPath);
|
||||
allPaths.add(f.oldPath);
|
||||
}
|
||||
const entries: ListedHunk[] = [];
|
||||
const unmatchedPaths: string[] = [];
|
||||
const unsupportedPaths: ScanResult['unsupportedPaths'] = [];
|
||||
|
||||
for (const file of input.files) {
|
||||
const path = file.status === 'deleted' ? file.oldPath : file.newPath;
|
||||
if (file.unsupported) {
|
||||
unsupportedPaths.push({ path, reason: file.unsupported });
|
||||
const floor = floorFor(file, null, input.project, input.specIsTestPath);
|
||||
if (floor !== null) {
|
||||
entries.push({
|
||||
id: hunkId(file.oldPath, file.newPath, [], file.unsupported), path, oldPath: file.oldPath, level: 'listed', tags: [], old: [], new: [],
|
||||
citation: null, notes: [], header: `@@ ${file.unsupported} @@`, floor, unsupported: file.unsupported, bytes: 0,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (file.hunks.length === 0 && (file.status === 'renamed' || file.status === 'copied')) {
|
||||
const floor = floorFor(file, null, input.project, input.specIsTestPath);
|
||||
if (floor !== null) {
|
||||
entries.push({
|
||||
id: hunkId(file.oldPath, file.newPath, [], file.status), path, oldPath: file.oldPath, level: 'listed', tags: [], old: [], new: [],
|
||||
citation: null, notes: [`${file.status} from ${file.oldPath}`], header: `@@ ${file.status} @@`, floor, bytes: 0,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let admittedAny = false;
|
||||
for (const h of file.hunks) {
|
||||
const floor = floorFor(file, h, input.project, input.specIsTestPath);
|
||||
const entry = entryFor(file, h, floor, allPaths);
|
||||
if (!entry) continue;
|
||||
admittedAny = true;
|
||||
entries.push(entry);
|
||||
}
|
||||
if (!admittedAny && file.hunks.length > 0) unmatchedPaths.push(path);
|
||||
}
|
||||
entries.push(...commitEntries(input.commits));
|
||||
|
||||
const commitCitation = findCitation(input.commits.flatMap((c) => c.message.split(/\r?\n/)));
|
||||
if (commitCitation) {
|
||||
for (const e of entries) {
|
||||
if (e.citation === null && e.tags.some((t) => t.startsWith('RH-15'))) e.citation = `commit: ${commitCitation}`;
|
||||
}
|
||||
}
|
||||
|
||||
suffixDuplicateIds(entries);
|
||||
const inspected = chooseRead(entries, cap);
|
||||
const eligible = entries.filter((e) => e.tags.length > 0 || e.old.length > 0).length;
|
||||
const tagged: Record<string, number> = {};
|
||||
for (const e of entries) for (const t of e.tags) tagged[t] = (tagged[t] ?? 0) + 1;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
listed: entries.length, eligible, inspected, unread: eligible - inspected, tagged, unmatched: unmatchedPaths.length,
|
||||
coverage: [...COVERAGE_TAGS], languagesUnlisted: languagesUnlisted(allPaths), candidate: input.candidate, artifact: input.artifact,
|
||||
},
|
||||
hunks: entries,
|
||||
unmatchedPaths,
|
||||
unsupportedPaths,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* gate-diff render — the stdout contract of bin/gstack-gate-diff.
|
||||
*
|
||||
* 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=<fingerprint> artifact=<abs path>`. Then either the table (read-level
|
||||
* hunks only: `[<id>] <tags|untagged> <path> @@ <hunk header> @@`, the removed/added
|
||||
* lines indented, optional `note:` lines, then `citation: <text|none>`) or jsonl
|
||||
* (one object per LISTED hunk). The artifact file always holds the full jsonl.
|
||||
*/
|
||||
import type { ListedHunk, ScanResult, ScanSummary } from './types';
|
||||
|
||||
export function renderSummaryLine(s: ScanSummary): string {
|
||||
const tagged = Object.keys(s.tagged).sort(compareTagKeys).map((k) => `${k}:${s.tagged[k]}`).join(',');
|
||||
return [
|
||||
'GATE_SUMMARY:',
|
||||
`listed=${s.listed}`, `eligible=${s.eligible}`, `inspected=${s.inspected}`, `unread=${s.unread}`,
|
||||
`tagged={${tagged}}`, `unmatched=${s.unmatched}`, `coverage=${s.coverage.join(',')}`,
|
||||
`languages_unlisted=${s.languagesUnlisted.length ? s.languagesUnlisted.join(',') : 'none'}`,
|
||||
`candidate=${s.candidate}`, `artifact=${s.artifact}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function compareTagKeys(a: string, b: string): number {
|
||||
const num = (t: string) => Number.parseInt(t.replace(/^RH-/, ''), 10);
|
||||
return num(a) - num(b) || a.localeCompare(b);
|
||||
}
|
||||
|
||||
export function renderHunkBlock(h: ListedHunk): string {
|
||||
const out: string[] = [`[${h.id}] ${h.tags.length ? h.tags.join(',') : 'untagged'} ${h.path} ${h.header}`];
|
||||
if (h.unsupported) out.push(` (unsupported: ${h.unsupported})`);
|
||||
for (const l of h.old) out.push(` -${l}`);
|
||||
for (const l of h.new) out.push(` +${l}`);
|
||||
for (const n of h.notes) out.push(` note: ${n}`);
|
||||
out.push(` citation: ${h.citation ?? 'none'}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function renderTable(r: ScanResult): string {
|
||||
const lines = [renderSummaryLine(r.summary)];
|
||||
for (const h of r.hunks) if (h.level === 'read') lines.push(renderHunkBlock(h));
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
export function toJsonRecord(h: ListedHunk): Record<string, unknown> {
|
||||
const rec: Record<string, unknown> = {
|
||||
id: h.id, path: h.path, level: h.level, tags: h.tags, old: h.old, new: h.new, citation: h.citation,
|
||||
};
|
||||
if (h.oldPath !== h.path) rec.old_path = h.oldPath;
|
||||
if (h.notes.length) rec.notes = h.notes;
|
||||
if (h.unsupported) rec.unsupported = h.unsupported;
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** One JSON object per listed hunk, newline-terminated; the artifact body. */
|
||||
export function renderJsonlBody(r: ScanResult): string {
|
||||
return r.hunks.map((h) => JSON.stringify(toJsonRecord(h)) + '\n').join('');
|
||||
}
|
||||
|
||||
export function renderJsonl(r: ScanResult): string {
|
||||
return renderSummaryLine(r.summary) + '\n' + renderJsonlBody(r);
|
||||
}
|
||||
|
||||
/** stderr diagnostics: unsupported paths and a bounded unmatched list. */
|
||||
export function renderDiagnostics(r: ScanResult, cap = 50): string[] {
|
||||
const out: string[] = [];
|
||||
for (const u of r.unsupportedPaths) out.push(`gstack-gate-diff: unsupported (${u.reason}): ${u.path}`);
|
||||
for (const p of r.unmatchedPaths.slice(0, cap)) out.push(`gstack-gate-diff: unmatched: ${p}`);
|
||||
if (r.unmatchedPaths.length > cap) out.push(`gstack-gate-diff: unmatched: … ${r.unmatchedPaths.length - cap} more`);
|
||||
if (r.summary.unread > 0) out.push(`gstack-gate-diff: partial — ${r.summary.inspected} of ${r.summary.eligible} eligible hunks read (cap ${r.summary.inspected} reached)`);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* gate-diff tags — best-effort RH labels on top of the floor (W1 table of
|
||||
* docs/designs/HONEST_WORK_GATE_INTEGRITY.md, after the Eng amendments).
|
||||
*
|
||||
* Tags are FACTUAL: a test deleted beside its deleted source is still RH-1;
|
||||
* the agent's judgment, never this module, decides whether a tag is a finding.
|
||||
* Nothing here suppresses: a citation found beside a hunk is reported, not
|
||||
* used to drop the tag. Pure functions over one hunk's lines.
|
||||
*
|
||||
* RH-1 test removed or disabled RH-13 suppression pragma added
|
||||
* RH-3 snapshot changed without owner RH-14 gate bypass
|
||||
* RH-4 placeholder committed RH-15 gate value relaxed (RH-15? unpaired)
|
||||
* RH-12 env sniffing in product code RH-16 exception swallowed
|
||||
*/
|
||||
import { TEST_DECLARATION, type PathClass } from './classify';
|
||||
import type { Hunk } from './types';
|
||||
|
||||
export const COVERAGE_TAGS = ['RH-1', 'RH-3', 'RH-4', 'RH-12', 'RH-13', 'RH-14', 'RH-15', 'RH-16'] as const;
|
||||
|
||||
export interface TagContext {
|
||||
floor: PathClass | null;
|
||||
path: string;
|
||||
/** Every changed path in the candidate (new paths), for RH-3 owner lookup. */
|
||||
allPaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Prose files (docs, prompt templates) carry no code idioms: only the path-based RH-3 applies. */
|
||||
export const PROSE_FILE = /\.(?:md|mdx|markdown|txt|rst|adoc|asciidoc)(?:\.tmpl)?$/i;
|
||||
/** Generated, vendored or minified files: unreadable as hunks, so floor inventory only. */
|
||||
export const GENERATED_FILE = /(?:^|\/)(?:dist|build|vendor|node_modules|\.yarn)\/|\.min\.(?:js|css)$|(?:^|\/)[^/]*lock(?:\.json|\.yaml|b)?$/i;
|
||||
|
||||
export interface TagResult {
|
||||
tags: string[];
|
||||
notes: string[];
|
||||
citation: string | null;
|
||||
}
|
||||
|
||||
const SKIP_IDIOMS: RegExp[] = [
|
||||
/\b(?:test|it|describe|suite|context|specify|bench)\s*\.\s*(?:skip|only)\b/,
|
||||
/\bx(?:it|describe|test|context|specify)\s*\(/,
|
||||
/\bf(?:it|describe|test)\s*\(/,
|
||||
/@pytest\.mark\.skip/,
|
||||
/#\[ignore\b/,
|
||||
/\bt\.Skip\w*\(/,
|
||||
/@Disabled\b/,
|
||||
/@Ignore\b/,
|
||||
/\bXCTSkip/,
|
||||
/@tag\s+:skip\b/,
|
||||
];
|
||||
/** Bare rspec `skip`/`pending` statements: only meaningful inside a test file. */
|
||||
const SKIP_IDIOMS_TEST_ONLY: RegExp[] = [/^\s*(?:skip|pending)\s*(?:\(|["']|$)/];
|
||||
|
||||
const PLACEHOLDER_IDIOMS: RegExp[] = [
|
||||
/\btodo!\s*\(/, /\bunimplemented!\s*\(/, /\braise\s+NotImplementedError\b/,
|
||||
/throw\s+new\s+Error\s*\(\s*["'`][^"'`]*not\s+implemented/i, /\bpass\s+#\s*TODO\b/i,
|
||||
];
|
||||
|
||||
const ENV_SNIFF_IDIOMS: RegExp[] = [
|
||||
/process\.env\.CI\b/, /\bNODE_ENV\s*[!=]==?\s*["']test["']/, /["']test["']\s*[!=]==?\s*process\.env\.NODE_ENV/,
|
||||
/\bRAILS_ENV\s*==\s*["']test["']/, /\bRails\.env\.test\?/, /\bPYTEST_CURRENT_TEST\b/, /\bGITHUB_ACTIONS\b/,
|
||||
];
|
||||
|
||||
const SUPPRESSION_IDIOMS: RegExp[] = [
|
||||
/eslint-disable/, /@ts-ignore\b/, /@ts-expect-error\b/, /#\s*type:\s*ignore\b/, /#\s*noqa\b/,
|
||||
/#\[allow\(/, /\/\/\s*nolint\b/, /rubocop:disable\b/, /@SuppressWarnings\b/,
|
||||
];
|
||||
|
||||
const BYPASS_IDIOMS: RegExp[] = [/\bcontinue-on-error:\s*true\b/, /\ballow_failure:\s*true\b/, /--no-verify\b/];
|
||||
|
||||
const SWALLOW_SINGLE_LINE: RegExp[] = [
|
||||
/\bcatch\s*(?:\([^)]*\))?\s*\{\s*\}/, /\bexcept(?:\s+[\w.,\s()]+)?\s*:\s*pass\b/, /\brescue\s+nil\b/, /(?:^|[^\w.])_\s*=\s*err\b/,
|
||||
];
|
||||
|
||||
const GATE_KEYS_UP = ['timeout', 'retries', 'retry', 'tolerance', 'epsilon', 'delta', 'maxdiff', 'jitter'];
|
||||
const GATE_KEYS_DOWN = ['threshold', 'min_score', 'minscore', 'minimum', 'coverage', 'required'];
|
||||
/** API calls that carry a gate keyword without being a gate value. */
|
||||
const NOT_GATE_KEYS = new Set(['settimeout', 'cleartimeout', 'setinterval', 'clearinterval']);
|
||||
const NUMBER = /-?\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?/g;
|
||||
/** `key: 5`, `key = 5`, `key(5)`, `key => 5`, `--key=5`, `--key 5`; the number may be quoted and may carry a unit. */
|
||||
const KEY_VALUE = /(?:--([A-Za-z][A-Za-z0-9_-]*)[= ]|([A-Za-z_][A-Za-z0-9_-]*)\s*(?::|=>?|\()\s*)['"`]?(-?\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?)/g;
|
||||
const EXACT_COUNT = /^(?<subject>.*?)\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\(\s*-?\d+(?:\.\d+)?\s*\)/;
|
||||
const BOUND = /^(?<subject>.*?)\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual|toBeTruthy|toBeDefined)\(/;
|
||||
|
||||
const CITATION_IDIOMS: RegExp[] = [/ship-measure[\w./-]*/i, /\bdec-[a-z0-9]+(?:-[a-z0-9]+)*\b/, /\bmeasured\b/i];
|
||||
|
||||
function added(h: Hunk): string[] {
|
||||
return h.lines.filter((l) => l.kind === 'add').map((l) => l.text);
|
||||
}
|
||||
function removed(h: Hunk): string[] {
|
||||
return h.lines.filter((l) => l.kind === 'del').map((l) => l.text);
|
||||
}
|
||||
function anyMatch(lines: string[], idioms: RegExp[]): boolean {
|
||||
return lines.some((l) => idioms.some((re) => re.test(l)));
|
||||
}
|
||||
|
||||
/** Multi-line empty catch / `except:` + `pass` across consecutive added lines. */
|
||||
function swallowsAcrossLines(adds: string[]): boolean {
|
||||
for (let i = 0; i + 1 < adds.length; i++) {
|
||||
const a = adds[i].trim();
|
||||
const b = adds[i + 1].trim();
|
||||
if (/\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(a) && b === '}') return true;
|
||||
if (/\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(a) && /^\/[/*]/.test(b) && adds[i + 2]?.trim() === '}') return true;
|
||||
if (/^except\b[^:]*:\s*$/.test(a) && /^pass\b/.test(b)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseNumber(text: string): number {
|
||||
return Number(text.replace(/_/g, ''));
|
||||
}
|
||||
|
||||
/** The gate value assignment on a line: its key, the direction that relaxes it, and the assigned number. */
|
||||
function relaxDirection(key: string): 'up' | 'down' | null {
|
||||
const low = key.toLowerCase();
|
||||
if (NOT_GATE_KEYS.has(low)) return null;
|
||||
if (GATE_KEYS_UP.some((k) => low.includes(k))) return 'up';
|
||||
if (GATE_KEYS_DOWN.some((k) => low.includes(k))) return 'down';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A gate keyword opening a nested structure on one line: `coverage: { thresholds: { lines: 90 } }`. */
|
||||
const KEY_OPENS_STRUCTURE = /([A-Za-z_][A-Za-z0-9_-]*)\s*(?::|=>?|\()\s*[[{(]/g;
|
||||
|
||||
export function gateKey(line: string): { key: string; relaxes: 'up' | 'down'; value: number } | null {
|
||||
const pairs = [...line.matchAll(KEY_VALUE)].map((m) => ({ key: m[1] ?? m[2], value: m[3], index: m.index ?? 0 }));
|
||||
for (const p of pairs) {
|
||||
const relaxes = relaxDirection(p.key);
|
||||
if (relaxes) return { key: p.key, relaxes, value: parseNumber(p.value) };
|
||||
}
|
||||
for (const m of line.matchAll(KEY_OPENS_STRUCTURE)) {
|
||||
const relaxes = relaxDirection(m[1]);
|
||||
if (!relaxes) continue;
|
||||
const inner = pairs.find((p) => p.index > (m.index ?? 0));
|
||||
if (inner) return { key: m[1], relaxes, value: parseNumber(inner.value) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function numericShape(line: string): string {
|
||||
return line.replace(NUMBER, '#').replace(/\s+/g, ' ').replace(/[,;]\s*$/, '').trim();
|
||||
}
|
||||
|
||||
interface ValueLine {
|
||||
text: string;
|
||||
key: string;
|
||||
relaxes: 'up' | 'down';
|
||||
shape: string;
|
||||
value: number;
|
||||
paired: boolean;
|
||||
}
|
||||
|
||||
function valueLines(lines: string[]): ValueLine[] {
|
||||
const out: ValueLine[] = [];
|
||||
for (const text of lines) {
|
||||
const k = gateKey(text);
|
||||
if (!k) continue;
|
||||
out.push({ text, key: k.key, relaxes: k.relaxes, shape: numericShape(text), value: k.value, paired: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* RH-15 inside one hunk: removed/added pairs with the same gate key and the
|
||||
* same numeric shape whose numbers differ (direction shown in notes), an
|
||||
* exact-count assertion relaxed to a bound, and RH-15? for unpaired values.
|
||||
*/
|
||||
export function detectRelaxation(h: Hunk): { tags: string[]; notes: string[] } {
|
||||
const dels = valueLines(removed(h));
|
||||
const adds = valueLines(added(h));
|
||||
const tags = new Set<string>();
|
||||
const notes: string[] = [];
|
||||
for (const d of dels) {
|
||||
const a = adds.find((x) => !x.paired && x.key === d.key && x.shape === d.shape);
|
||||
if (!a) continue;
|
||||
d.paired = true;
|
||||
a.paired = true;
|
||||
if (d.value === a.value) continue;
|
||||
const relaxed = d.relaxes === 'up' ? a.value > d.value : a.value < d.value;
|
||||
tags.add('RH-15');
|
||||
notes.push(`RH-15: ${d.key} ${d.value} -> ${a.value} (${relaxed ? 'relaxed' : 'tightened'}: ${d.relaxes === 'up' ? 'higher' : 'lower'} relaxes)`);
|
||||
}
|
||||
for (const d of removed(h)) {
|
||||
const m = EXACT_COUNT.exec(d.trim());
|
||||
if (!m?.groups) continue;
|
||||
const subject = m.groups.subject;
|
||||
const a = added(h).find((x) => BOUND.exec(x.trim())?.groups?.subject === subject);
|
||||
if (!a) continue;
|
||||
tags.add('RH-15');
|
||||
notes.push(`RH-15: exact-count assertion relaxed to a bound (${subject.trim()})`);
|
||||
}
|
||||
for (const v of [...dels, ...adds]) {
|
||||
if (v.paired) continue;
|
||||
tags.add('RH-15?');
|
||||
notes.push(`RH-15?: unpaired gate value ${v.key} = ${v.value}`);
|
||||
}
|
||||
return { tags: [...tags], notes };
|
||||
}
|
||||
|
||||
/** Conventional snapshot owner: `__snapshots__/<name>.snap` → `<name>` beside it; `<name>.snap` → `<name>` or `<stem>.*` beside it. */
|
||||
export function snapshotOwnerChanged(path: string, allPaths: ReadonlySet<string>): boolean {
|
||||
const norm = path.replace(/\\/g, '/');
|
||||
const segs = norm.split('/');
|
||||
const base = segs.pop() ?? '';
|
||||
const name = base.replace(/\.snap$/i, '');
|
||||
const dir = segs.length && segs[segs.length - 1].toLowerCase() === '__snapshots__' ? segs.slice(0, -1) : segs;
|
||||
const stem = name.split('.')[0];
|
||||
for (const p of allPaths) {
|
||||
const ps = p.replace(/\\/g, '/').split('/');
|
||||
const pb = ps.pop() ?? '';
|
||||
if (ps.join('/') !== dir.join('/')) continue;
|
||||
if (pb === name) return true;
|
||||
if (!name.includes('.') && pb.startsWith(stem + '.')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findCitation(lines: string[]): string | null {
|
||||
for (const l of lines) {
|
||||
if (CITATION_IDIOMS.some((re) => re.test(l))) return l.trim().slice(0, 200);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tagHunk(h: Hunk, ctx: TagContext): TagResult {
|
||||
const adds = added(h);
|
||||
const dels = removed(h);
|
||||
const tags = new Set<string>();
|
||||
const notes: string[] = [];
|
||||
const nonTest = ctx.floor !== 'test';
|
||||
|
||||
if (ctx.floor === 'snapshot') {
|
||||
if (!snapshotOwnerChanged(ctx.path, ctx.allPaths)) tags.add('RH-3');
|
||||
} else if (ctx.floor === 'golden') tags.add('RH-3?');
|
||||
if (PROSE_FILE.test(ctx.path) || GENERATED_FILE.test(ctx.path)) return { tags: [...tags], notes, citation: null };
|
||||
|
||||
const removedDecls = dels.filter((l) => TEST_DECLARATION.test(l)).length;
|
||||
const addedDecls = adds.filter((l) => TEST_DECLARATION.test(l)).length;
|
||||
if (removedDecls > addedDecls) {
|
||||
tags.add('RH-1');
|
||||
notes.push(`RH-1: ${removedDecls - addedDecls} test declaration(s) removed`);
|
||||
}
|
||||
if (anyMatch(adds, SKIP_IDIOMS) || (ctx.floor === 'test' && anyMatch(adds, SKIP_IDIOMS_TEST_ONLY))) tags.add('RH-1');
|
||||
|
||||
if (nonTest && anyMatch(adds, PLACEHOLDER_IDIOMS)) tags.add('RH-4');
|
||||
if (nonTest && anyMatch(adds, ENV_SNIFF_IDIOMS)) tags.add('RH-12');
|
||||
if (anyMatch(adds, SUPPRESSION_IDIOMS)) tags.add('RH-13');
|
||||
if (anyMatch(adds, BYPASS_IDIOMS)) tags.add('RH-14');
|
||||
if (ctx.floor === 'test' || ctx.floor === 'ci' || ctx.floor === 'config') {
|
||||
const r = detectRelaxation(h);
|
||||
for (const t of r.tags) tags.add(t);
|
||||
notes.push(...r.notes);
|
||||
}
|
||||
if (nonTest && (anyMatch(adds, SWALLOW_SINGLE_LINE) || swallowsAcrossLines(adds))) tags.add('RH-16');
|
||||
|
||||
const sorted = [...tags].sort(compareTags);
|
||||
return { tags: sorted, notes, citation: sorted.length ? findCitation(adds) : null };
|
||||
}
|
||||
|
||||
/** RH ids in numeric order; a `?` variant follows its base. */
|
||||
export function compareTags(a: string, b: string): number {
|
||||
const num = (t: string) => Number.parseInt(t.replace(/^RH-/, ''), 10);
|
||||
return num(a) - num(b) || a.localeCompare(b);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Shared shapes of the gate-edit detector (lib/gate-diff/*). */
|
||||
|
||||
export type DiffLineKind = 'ctx' | 'del' | 'add';
|
||||
|
||||
export interface DiffLine {
|
||||
kind: DiffLineKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Hunk {
|
||||
/** The `@@ -a,b +c,d @@ …` header as git printed it. */
|
||||
header: string;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
|
||||
|
||||
/** Why a path has no readable hunks. Named coverage, never zero hunks. */
|
||||
export type Unsupported = 'binary' | 'symlink' | 'submodule' | 'undecodable';
|
||||
|
||||
export interface FileDiff {
|
||||
/** Path before the change; equals newPath unless renamed. `/dev/null` is never used: added files carry newPath in both. */
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
status: FileStatus;
|
||||
hunks: Hunk[];
|
||||
unsupported?: Unsupported;
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
sha: string;
|
||||
/** Full message body. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type Level = 'listed' | 'read';
|
||||
|
||||
export interface ListedHunk {
|
||||
id: string;
|
||||
path: string;
|
||||
oldPath: string;
|
||||
level: Level;
|
||||
tags: string[];
|
||||
old: string[];
|
||||
new: string[];
|
||||
citation: string | null;
|
||||
/** Human-readable facts beside a tag (RH-15 key, values and direction). */
|
||||
notes: string[];
|
||||
header: string;
|
||||
/** Floor class that admitted the hunk, when any. */
|
||||
floor: string | null;
|
||||
unsupported?: Unsupported;
|
||||
/** Approximate byte size of old+new lines, for the 200 KB read budget. */
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
listed: number;
|
||||
eligible: number;
|
||||
inspected: number;
|
||||
unread: number;
|
||||
tagged: Record<string, number>;
|
||||
unmatched: number;
|
||||
coverage: string[];
|
||||
languagesUnlisted: string[];
|
||||
candidate: string;
|
||||
artifact: string;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
summary: ScanSummary;
|
||||
hunks: ListedHunk[];
|
||||
/** Changed paths that matched no floor class and carried no tag. */
|
||||
unmatchedPaths: string[];
|
||||
/** Paths with no readable hunks and why. */
|
||||
unsupportedPaths: { path: string; reason: Unsupported }[];
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* gate-diff calibration — free, unshipped-to-skills tool inventory for the
|
||||
* gate-edit detector (W1 of docs/designs/HONEST_WORK_GATE_INTEGRITY.md).
|
||||
*
|
||||
* bun scripts/gate-diff-calibrate.ts [--ref origin/main] [--count 50] [--repo <path>] [--markdown]
|
||||
*
|
||||
* Runs the pure core (lib/gate-diff) over the first-parent delta of the last
|
||||
* N commits on <ref>'s first-parent line (one per landed PR: a merge commit
|
||||
* or a squash merge), exactly what `gstack-gate-diff --commit <sha>` scans. Prints one row per
|
||||
* commit: listed, eligible, read, unread, tags by id, unmatched, unsupported.
|
||||
*
|
||||
* What this measures: the TOOL's inventory (how much it lists, how often each
|
||||
* tag fires, how often the read cap bites). It does NOT measure agent findings,
|
||||
* kept/restored/open dispositions or reviewer value; those are collected after
|
||||
* ship from the persisted review records. The hand read of tagged hunks is a
|
||||
* human step this script only supports (--markdown gives the table to annotate).
|
||||
* No artifact is written: calibration never touches the state root.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { EMPTY_PROJECT_CONFIG, parseGateIntegrityBlock, scanCandidate, specDirectoryHoldsSpecs, type ScanResult } from '../lib/gate-diff/index';
|
||||
import { commitInfo, diffCommit, firstParent, readClaudeMd, repoRoot, runGit, treePaths } from '../lib/gate-diff/git';
|
||||
import { COVERAGE_TAGS } from '../lib/gate-diff/tags';
|
||||
|
||||
interface Args {
|
||||
ref: string;
|
||||
count: number;
|
||||
repo: string;
|
||||
markdown: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const a: Args = { ref: 'origin/main', count: 50, repo: process.cwd(), markdown: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const x = argv[i];
|
||||
if (x === '--ref') a.ref = argv[++i] ?? a.ref;
|
||||
else if (x === '--count') a.count = Number(argv[++i] ?? a.count);
|
||||
else if (x === '--repo') a.repo = argv[++i] ?? a.repo;
|
||||
else if (x === '--markdown') a.markdown = true;
|
||||
else if (x === '--help' || x === '-h') {
|
||||
console.log('usage: bun scripts/gate-diff-calibrate.ts [--ref origin/main] [--count 50] [--repo <path>] [--markdown]');
|
||||
process.exit(0);
|
||||
} else throw new Error(`unknown argument ${x}`);
|
||||
}
|
||||
if (!Number.isFinite(a.count) || a.count <= 0) throw new Error('--count must be a positive integer');
|
||||
return a;
|
||||
}
|
||||
|
||||
/** The last N commits on <ref>'s first-parent line: merge commits and squash merges alike, one per landed PR. */
|
||||
function firstParentCommits(ref: string, count: number, cwd: string): string[] {
|
||||
const r = runGit(['log', '--first-parent', '--format=%H', '-n', String(count), ref, '--'], cwd);
|
||||
if (r.status !== 0) throw new Error(`git log ${ref} failed: ${r.stderr.trim()}`);
|
||||
return r.stdout.toString('utf-8').split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
interface Row {
|
||||
sha: string;
|
||||
subject: string;
|
||||
result?: ScanResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function scanOne(sha: string, root: string, project: ReturnType<typeof parseGateIntegrityBlock>): Row {
|
||||
const info = commitInfo(sha, root);
|
||||
const subject = (info?.message.split('\n')[0] ?? '').slice(0, 72);
|
||||
if (!firstParent(sha, root)) return { sha, subject, error: 'parent missing (shallow history: git fetch --deepen)' };
|
||||
try {
|
||||
const files = diffCommit(sha, root);
|
||||
const result = scanCandidate({
|
||||
files, commits: info ? [info] : [], project, specIsTestPath: specDirectoryHoldsSpecs(treePaths(root, sha)),
|
||||
candidate: sha.slice(0, 12), artifact: '(calibration: not written)',
|
||||
});
|
||||
return { sha, subject, result };
|
||||
} catch (e: any) {
|
||||
return { sha, subject, error: String(e?.message ?? e) };
|
||||
}
|
||||
}
|
||||
|
||||
function tagCell(tagged: Record<string, number>): string {
|
||||
const keys = Object.keys(tagged).sort((a, b) => Number.parseInt(a.slice(3), 10) - Number.parseInt(b.slice(3), 10) || a.localeCompare(b));
|
||||
return keys.length ? keys.map((k) => `${k}:${tagged[k]}`).join(' ') : '-';
|
||||
}
|
||||
|
||||
function median(xs: number[]): number {
|
||||
if (xs.length === 0) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
const mid = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
||||
}
|
||||
|
||||
function percentile(xs: number[], p: number): number {
|
||||
if (xs.length === 0) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
}
|
||||
|
||||
function main(): number {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const root = repoRoot(path.resolve(args.repo));
|
||||
if (!root) throw new Error(`${args.repo} is not inside a git repository`);
|
||||
const shas = firstParentCommits(args.ref, args.count, root);
|
||||
const project = parseGateIntegrityBlock(readClaudeMd(root));
|
||||
const rows = shas.map((sha) => scanOne(sha, root, project));
|
||||
|
||||
const ok = rows.filter((r) => r.result).map((r) => r.result as ScanResult);
|
||||
const totals: Record<string, number> = {};
|
||||
const commitsWithTag: Record<string, number> = {};
|
||||
for (const r of ok) {
|
||||
for (const [k, v] of Object.entries(r.summary.tagged)) {
|
||||
totals[k] = (totals[k] ?? 0) + v;
|
||||
commitsWithTag[k] = (commitsWithTag[k] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const listed = ok.map((r) => r.summary.listed);
|
||||
const eligible = ok.map((r) => r.summary.eligible);
|
||||
const partial = ok.filter((r) => r.summary.unread > 0).length;
|
||||
|
||||
const sep = args.markdown ? ' | ' : '\t';
|
||||
const line = (cells: string[]) => (args.markdown ? `| ${cells.join(' | ')} |` : cells.join(sep));
|
||||
const out: string[] = [];
|
||||
out.push(line(['commit', 'subject', 'listed', 'eligible', 'read', 'unread', 'unmatched', 'unsupported', 'tags']));
|
||||
if (args.markdown) out.push('|---|---|---:|---:|---:|---:|---:|---:|---|');
|
||||
for (const r of rows) {
|
||||
if (!r.result) {
|
||||
out.push(line([r.sha.slice(0, 12), r.subject.replace(/\|/g, '\\|'), '-', '-', '-', '-', '-', '-', `ERROR: ${r.error}`]));
|
||||
continue;
|
||||
}
|
||||
const s = r.result.summary;
|
||||
out.push(line([
|
||||
r.sha.slice(0, 12), r.subject.replace(/\|/g, '\\|'), String(s.listed), String(s.eligible), String(s.inspected), String(s.unread),
|
||||
String(s.unmatched), String(r.result.unsupportedPaths.length), tagCell(s.tagged),
|
||||
]));
|
||||
}
|
||||
out.push('');
|
||||
out.push(`${args.markdown ? '**Scanned:**' : 'Scanned:'} ${ok.length} of ${rows.length} first-parent commits on ${args.ref}; ${rows.length - ok.length} errored.`);
|
||||
out.push(`${args.markdown ? '**Listed per commit:**' : 'Listed per commit:'} median ${median(listed)}, p75 ${percentile(listed, 75)}, max ${Math.max(0, ...listed)}; eligible median ${median(eligible)}, p75 ${percentile(eligible, 75)}, max ${Math.max(0, ...eligible)}; partial (read cap hit) in ${partial} commit(s).`);
|
||||
const tagLines = [...COVERAGE_TAGS, 'RH-3?', 'RH-15?'].filter((t) => totals[t]).map((t) => `${t}: ${totals[t]} hunk(s) in ${commitsWithTag[t]} commit(s)`);
|
||||
out.push(`${args.markdown ? '**Tags:**' : 'Tags:'} ${tagLines.length ? tagLines.join('; ') : 'none fired'}.`);
|
||||
console.log(out.join('\n'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = main();
|
||||
} catch (e: any) {
|
||||
console.error(`gate-diff-calibrate: ${e?.message ?? e}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -405,3 +405,97 @@ describe('gstack-evidence run — bun dotenv autoload must not reach the child',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-evidence tests_ran — zero-run green is a third state, never merged with pass or unknown', () => {
|
||||
// The receipt carries the runner's own executed-test count parsed from a
|
||||
// bounded tail of the streamed output. `run` never changes its exit on it
|
||||
// (TRANSPARENCY INVARIANT); `check` grades 0 as ZERO-RUN (STALE-equivalent,
|
||||
// its own label) and unknown as non-blocking.
|
||||
const bunOutput = (pass: number, skip: number, fail: number) =>
|
||||
`printf 'bun test v1\\n\\n ${pass} pass\\n ${skip} skip\\n ${fail} fail\\nRan ${pass + skip + fail} tests across 1 file. [10.00ms]\\n' >&2`;
|
||||
|
||||
test('bun: executed = pass + fail; the count lands in the receipt and check stays FRESH', () => {
|
||||
const r = run(['run', '--label', 'tests', '--', bunOutput(7, 2, 1) + ' && exit 0']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe(8);
|
||||
expect(r.stderr).toContain('recorded label=tests exit=0 tests_ran=8');
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=tests exit=0 tests_ran=8');
|
||||
});
|
||||
|
||||
test('bun all-skip is ZERO-RUN: run still exits 0, check fails under its own label', () => {
|
||||
const r = run(['run', '--label', 'tests', '--', bunOutput(0, 5, 0)]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe(0);
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(1);
|
||||
expect(chk.stdout).toContain('EVIDENCE: ZERO-RUN label=tests exit=0 tests_ran=0');
|
||||
expect(chk.stdout).toContain('reason=runner reported 0 executed tests');
|
||||
expect(chk.stdout).not.toContain('STALE');
|
||||
});
|
||||
|
||||
test('an unrecognised runner is unknown: non-blocking, labelled in the detail', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe('unknown');
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('EVIDENCE: FRESH label=tests exit=0 tests_ran=unknown');
|
||||
});
|
||||
|
||||
test('jest, vitest, pytest, rspec and go zero-only summaries', () => {
|
||||
const cases: [string, number | 'unknown'][] = [
|
||||
["printf 'Tests: 1 failed, 2 passed, 1 skipped, 4 total\\nTime: 1s\\n'", 3],
|
||||
["printf 'No tests found, exiting with code 0\\n'", 0],
|
||||
["printf ' Tests 3 passed | 1 skipped (4)\\n Start at 10:00\\n'", 3],
|
||||
["printf 'No test files found, exiting with code 1\\n'", 0],
|
||||
["printf '===== 3 passed, 1 skipped, 2 xfailed in 0.12s =====\\n'", 5],
|
||||
["printf '============ no tests ran in 0.01s ============\\n'", 0],
|
||||
["printf 'Finished in 0.2 seconds\\n12 examples, 0 failures, 2 pending\\n'", 10],
|
||||
["printf '? \\texample.com/pkg\\t[no test files]\\n? \\texample.com/other\\t[no test files]\\n'", 0],
|
||||
["printf 'ok \\texample.com/pkg\\t0.1s\\n? \\texample.com/other\\t[no test files]\\n'", 'unknown'],
|
||||
];
|
||||
for (const [cmd, want] of cases) {
|
||||
expect(run(['run', '--label', 'lane', '--', cmd]).status).toBe(0);
|
||||
expect([cmd, records().pop().tests_ran]).toEqual([cmd, want]);
|
||||
}
|
||||
});
|
||||
|
||||
test('the last summary wins when a command runs a suite twice', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', `${bunOutput(9, 0, 0)}; ${bunOutput(0, 3, 0)}`]).status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe(0);
|
||||
expect(run(['run', '--label', 'tests', '--', `${bunOutput(0, 3, 0)}; ${bunOutput(9, 0, 0)}`]).status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe(9);
|
||||
});
|
||||
|
||||
test('the summary survives a 2MB+ stream that truncates the saved log', () => {
|
||||
const r = run(['run', '--label', 'big', '--', `head -c 2500000 /dev/zero | tr "\\0" a; echo; ${bunOutput(4, 0, 0)}`]);
|
||||
expect(r.status).toBe(0);
|
||||
expect(records().pop().tests_ran).toBe(4);
|
||||
});
|
||||
|
||||
test('TRANSPARENCY: a parser exception never alters the exit code; the receipt says unknown', () => {
|
||||
const green = spawnSync(EVIDENCE, ['run', '--label', 'tests', '--', bunOutput(3, 0, 0)], {
|
||||
cwd: repoDir, env: { ...process.env, GSTACK_HOME: gstackHome, GSTACK_EVIDENCE_FAULT: 'tests_ran' }, encoding: 'utf-8', timeout: 60000,
|
||||
});
|
||||
expect(green.status).toBe(0);
|
||||
expect(green.stderr).toContain('tests_ran parse failed');
|
||||
expect(records().pop().tests_ran).toBe('unknown');
|
||||
const red = spawnSync(EVIDENCE, ['run', '--label', 'tests', '--', `${bunOutput(3, 0, 0)}; exit 3`], {
|
||||
cwd: repoDir, env: { ...process.env, GSTACK_HOME: gstackHome, GSTACK_EVIDENCE_FAULT: 'tests_ran' }, encoding: 'utf-8', timeout: 60000,
|
||||
});
|
||||
expect(red.status).toBe(3);
|
||||
expect(records().pop()).toMatchObject({ exit: 3, tests_ran: 'unknown' });
|
||||
});
|
||||
|
||||
test('a record without tests_ran (older ledger) grades as unknown, not ZERO-RUN', () => {
|
||||
expect(run(['run', '--label', 'tests', '--', 'echo green']).status).toBe(0);
|
||||
const file = ledgerFile();
|
||||
const rec = JSON.parse(fs.readFileSync(file, 'utf-8').trim());
|
||||
delete rec.tests_ran;
|
||||
fs.writeFileSync(file, JSON.stringify(rec) + '\n');
|
||||
const chk = run(['check', '--label', 'tests']);
|
||||
expect(chk.status).toBe(0);
|
||||
expect(chk.stdout).toContain('tests_ran=unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
diff --git a/test/flaky.test.ts b/test/flaky.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..c68ab36
|
||||
--- /dev/null
|
||||
+++ b/test/flaky.test.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+
|
||||
+test('syncs', async () => {
|
||||
+ expect(await sync()).toBe(true);
|
||||
+}, { timeout: 5000 });
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/test/flaky.test.ts b/test/flaky.test.ts
|
||||
index c68ab36..50ad1c0 100644
|
||||
--- a/test/flaky.test.ts
|
||||
+++ b/test/flaky.test.ts
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
|
||||
+// measured: ship-measure report 2026-10-09 shows p99 48s
|
||||
test('syncs', async () => {
|
||||
expect(await sync()).toBe(true);
|
||||
-}, { timeout: 5000 });
|
||||
+}, { timeout: 60000 });
|
||||
@@ -0,0 +1,34 @@
|
||||
diff --git a/src/Card.ts b/src/Card.ts
|
||||
new file mode 100644
|
||||
index 0000000..6e2b409
|
||||
--- /dev/null
|
||||
+++ b/src/Card.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export const Card = () => `<div/>`;
|
||||
diff --git a/src/unrelated.ts b/src/unrelated.ts
|
||||
new file mode 100644
|
||||
index 0000000..ad1d380
|
||||
--- /dev/null
|
||||
+++ b/src/unrelated.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export const x = 1;
|
||||
diff --git a/test/Card.test.ts b/test/Card.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..473e003
|
||||
--- /dev/null
|
||||
+++ b/test/Card.test.ts
|
||||
@@ -0,0 +1 @@
|
||||
+test('Card renders', () => {});
|
||||
diff --git a/test/__snapshots__/Card.test.ts.snap b/test/__snapshots__/Card.test.ts.snap
|
||||
new file mode 100644
|
||||
index 0000000..eef8a11
|
||||
--- /dev/null
|
||||
+++ b/test/__snapshots__/Card.test.ts.snap
|
||||
@@ -0,0 +1,7 @@
|
||||
+// Bun Snapshot v1
|
||||
+
|
||||
+exports[`Button renders 1`] = `
|
||||
+<button>
|
||||
+ Click
|
||||
+</button>
|
||||
+`;
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/src/unrelated.ts b/src/unrelated.ts
|
||||
index ad1d380..25dfdc4 100644
|
||||
--- a/src/unrelated.ts
|
||||
+++ b/src/unrelated.ts
|
||||
@@ -1 +1 @@
|
||||
-export const x = 1;
|
||||
+export const x = 2;
|
||||
diff --git a/test/__snapshots__/Card.test.ts.snap b/test/__snapshots__/Card.test.ts.snap
|
||||
index eef8a11..2b1d741 100644
|
||||
--- a/test/__snapshots__/Card.test.ts.snap
|
||||
+++ b/test/__snapshots__/Card.test.ts.snap
|
||||
@@ -1,7 +1,7 @@
|
||||
// Bun Snapshot v1
|
||||
|
||||
exports[`Button renders 1`] = `
|
||||
-<button>
|
||||
+<button class="primary">
|
||||
Click
|
||||
</button>
|
||||
`;
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/src/test/kotlin/FooTest.kt b/src/test/kotlin/FooTest.kt
|
||||
new file mode 100644
|
||||
index 0000000..265a18b
|
||||
--- /dev/null
|
||||
+++ b/src/test/kotlin/FooTest.kt
|
||||
@@ -0,0 +1,8 @@
|
||||
+import org.junit.Test
|
||||
+
|
||||
+class FooTest {
|
||||
+ @Test
|
||||
+ fun adds() {
|
||||
+ assert(1 + 1 == 2)
|
||||
+ }
|
||||
+}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
diff --git a/src/test/kotlin/FooTest.kt b/src/test/kotlin/FooTest.kt
|
||||
index 265a18b..3ec1c4e 100644
|
||||
--- a/src/test/kotlin/FooTest.kt
|
||||
+++ b/src/test/kotlin/FooTest.kt
|
||||
@@ -1,6 +1,8 @@
|
||||
import org.junit.Test
|
||||
+import org.junit.Ignore
|
||||
|
||||
class FooTest {
|
||||
+ @Ignore("flaky")
|
||||
@Test
|
||||
fun adds() {
|
||||
assert(1 + 1 == 2)
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
new file mode 100644
|
||||
index 0000000..8d9b8a2
|
||||
--- /dev/null
|
||||
+++ b/src/add.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function add(a: number, b: number): number {
|
||||
+ return a + b;
|
||||
+}
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ac2562b
|
||||
--- /dev/null
|
||||
+++ b/test/add.test.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { add } from '../src/add';
|
||||
+
|
||||
+test('adds two numbers', () => {
|
||||
+ expect(add(1, 2)).toBe(3);
|
||||
+});
|
||||
+
|
||||
+test('adds negatives', () => {
|
||||
+ expect(add(-1, -2)).toBe(-3);
|
||||
+});
|
||||
+
|
||||
+test('is commutative', () => {
|
||||
+ expect(add(2, 3)).toBe(add(3, 2));
|
||||
+});
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
new file mode 100644
|
||||
index 0000000..8d9b8a2
|
||||
--- /dev/null
|
||||
+++ b/src/add.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function add(a: number, b: number): number {
|
||||
+ return a + b;
|
||||
+}
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ac2562b
|
||||
--- /dev/null
|
||||
+++ b/test/add.test.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { add } from '../src/add';
|
||||
+
|
||||
+test('adds two numbers', () => {
|
||||
+ expect(add(1, 2)).toBe(3);
|
||||
+});
|
||||
+
|
||||
+test('adds negatives', () => {
|
||||
+ expect(add(-1, -2)).toBe(-3);
|
||||
+});
|
||||
+
|
||||
+test('is commutative', () => {
|
||||
+ expect(add(2, 3)).toBe(add(3, 2));
|
||||
+});
|
||||
@@ -0,0 +1,4 @@
|
||||
diff --git a/test/add.test.ts b/test/math/add.test.ts
|
||||
similarity index 100%
|
||||
rename from test/add.test.ts
|
||||
rename to test/math/add.test.ts
|
||||
@@ -0,0 +1,32 @@
|
||||
diff --git a/src/Button.ts b/src/Button.ts
|
||||
new file mode 100644
|
||||
index 0000000..8adac76
|
||||
--- /dev/null
|
||||
+++ b/src/Button.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export const Button = (p: { label: string }) => `<button>${p.label}</button>`;
|
||||
diff --git a/test/Button.test.ts b/test/Button.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..6546d09
|
||||
--- /dev/null
|
||||
+++ b/test/Button.test.ts
|
||||
@@ -0,0 +1,6 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { Button } from '../src/Button';
|
||||
+
|
||||
+test('Button renders', () => {
|
||||
+ expect(Button({ label: 'Click' })).toMatchSnapshot();
|
||||
+});
|
||||
diff --git a/test/__snapshots__/Button.test.ts.snap b/test/__snapshots__/Button.test.ts.snap
|
||||
new file mode 100644
|
||||
index 0000000..eef8a11
|
||||
--- /dev/null
|
||||
+++ b/test/__snapshots__/Button.test.ts.snap
|
||||
@@ -0,0 +1,7 @@
|
||||
+// Bun Snapshot v1
|
||||
+
|
||||
+exports[`Button renders 1`] = `
|
||||
+<button>
|
||||
+ Click
|
||||
+</button>
|
||||
+`;
|
||||
@@ -0,0 +1,24 @@
|
||||
diff --git a/test/Button.test.ts b/test/Button.test.ts
|
||||
index 6546d09..e33c488 100644
|
||||
--- a/test/Button.test.ts
|
||||
+++ b/test/Button.test.ts
|
||||
@@ -2,5 +2,5 @@ import { test, expect } from 'bun:test';
|
||||
import { Button } from '../src/Button';
|
||||
|
||||
test('Button renders', () => {
|
||||
- expect(Button({ label: 'Click' })).toMatchSnapshot();
|
||||
+ expect(Button({ label: 'Click', primary: true })).toMatchSnapshot();
|
||||
});
|
||||
diff --git a/test/__snapshots__/Button.test.ts.snap b/test/__snapshots__/Button.test.ts.snap
|
||||
index eef8a11..2b1d741 100644
|
||||
--- a/test/__snapshots__/Button.test.ts.snap
|
||||
+++ b/test/__snapshots__/Button.test.ts.snap
|
||||
@@ -1,7 +1,7 @@
|
||||
// Bun Snapshot v1
|
||||
|
||||
exports[`Button renders 1`] = `
|
||||
-<button>
|
||||
+<button class="primary">
|
||||
Click
|
||||
</button>
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
diff --git a/spec/SKILL.md b/spec/SKILL.md
|
||||
new file mode 100644
|
||||
index 0000000..61712aa
|
||||
--- /dev/null
|
||||
+++ b/spec/SKILL.md
|
||||
@@ -0,0 +1,3 @@
|
||||
+# spec
|
||||
+
|
||||
+Turn intent into a spec.
|
||||
diff --git a/spec/sections/intro.md b/spec/sections/intro.md
|
||||
new file mode 100644
|
||||
index 0000000..a6ad51b
|
||||
--- /dev/null
|
||||
+++ b/spec/sections/intro.md
|
||||
@@ -0,0 +1 @@
|
||||
+Intro.
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/spec/SKILL.md b/spec/SKILL.md
|
||||
index 61712aa..0aaf0d4 100644
|
||||
--- a/spec/SKILL.md
|
||||
+++ b/spec/SKILL.md
|
||||
@@ -1,3 +1,3 @@
|
||||
# spec
|
||||
|
||||
-Turn intent into a spec.
|
||||
+Turn vague intent into a precise spec.
|
||||
@@ -0,0 +1,36 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
new file mode 100644
|
||||
index 0000000..8d9b8a2
|
||||
--- /dev/null
|
||||
+++ b/src/add.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function add(a: number, b: number): number {
|
||||
+ return a + b;
|
||||
+}
|
||||
diff --git a/src/keep.ts b/src/keep.ts
|
||||
new file mode 100644
|
||||
index 0000000..f6bc2b3
|
||||
--- /dev/null
|
||||
+++ b/src/keep.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export const keep = 1;
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ac2562b
|
||||
--- /dev/null
|
||||
+++ b/test/add.test.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { add } from '../src/add';
|
||||
+
|
||||
+test('adds two numbers', () => {
|
||||
+ expect(add(1, 2)).toBe(3);
|
||||
+});
|
||||
+
|
||||
+test('adds negatives', () => {
|
||||
+ expect(add(-1, -2)).toBe(-3);
|
||||
+});
|
||||
+
|
||||
+test('is commutative', () => {
|
||||
+ expect(add(2, 3)).toBe(add(3, 2));
|
||||
+});
|
||||
@@ -0,0 +1,29 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
deleted file mode 100644
|
||||
index 8d9b8a2..0000000
|
||||
--- a/src/add.ts
|
||||
+++ /dev/null
|
||||
@@ -1,3 +0,0 @@
|
||||
-export function add(a: number, b: number): number {
|
||||
- return a + b;
|
||||
-}
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
deleted file mode 100644
|
||||
index ac2562b..0000000
|
||||
--- a/test/add.test.ts
|
||||
+++ /dev/null
|
||||
@@ -1,14 +0,0 @@
|
||||
-import { test, expect } from 'bun:test';
|
||||
-import { add } from '../src/add';
|
||||
-
|
||||
-test('adds two numbers', () => {
|
||||
- expect(add(1, 2)).toBe(3);
|
||||
-});
|
||||
-
|
||||
-test('adds negatives', () => {
|
||||
- expect(add(-1, -2)).toBe(-3);
|
||||
-});
|
||||
-
|
||||
-test('is commutative', () => {
|
||||
- expect(add(2, 3)).toBe(add(3, 2));
|
||||
-});
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/package.json b/package.json
|
||||
new file mode 100644
|
||||
index 0000000..c0877f7
|
||||
--- /dev/null
|
||||
+++ b/package.json
|
||||
@@ -0,0 +1,9 @@
|
||||
+{
|
||||
+ "name": "x",
|
||||
+ "scripts": {
|
||||
+ "test": "bun test test/"
|
||||
+ },
|
||||
+ "dependencies": {
|
||||
+ "left-pad": "1.0.0"
|
||||
+ }
|
||||
+}
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/package.json b/package.json
|
||||
index c0877f7..9272333 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "x",
|
||||
"scripts": {
|
||||
- "test": "bun test test/"
|
||||
+ "test": "bun test test/ --bail || true"
|
||||
},
|
||||
"dependencies": {
|
||||
"left-pad": "1.0.0"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
new file mode 100644
|
||||
index 0000000..8d9b8a2
|
||||
--- /dev/null
|
||||
+++ b/src/add.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function add(a: number, b: number): number {
|
||||
+ return a + b;
|
||||
+}
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ac2562b
|
||||
--- /dev/null
|
||||
+++ b/test/add.test.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { add } from '../src/add';
|
||||
+
|
||||
+test('adds two numbers', () => {
|
||||
+ expect(add(1, 2)).toBe(3);
|
||||
+});
|
||||
+
|
||||
+test('adds negatives', () => {
|
||||
+ expect(add(-1, -2)).toBe(-3);
|
||||
+});
|
||||
+
|
||||
+test('is commutative', () => {
|
||||
+ expect(add(2, 3)).toBe(add(3, 2));
|
||||
+});
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
index ac2562b..ce1b02c 100644
|
||||
--- a/test/add.test.ts
|
||||
+++ b/test/add.test.ts
|
||||
@@ -5,10 +5,6 @@ test('adds two numbers', () => {
|
||||
expect(add(1, 2)).toBe(3);
|
||||
});
|
||||
|
||||
-test('adds negatives', () => {
|
||||
- expect(add(-1, -2)).toBe(-3);
|
||||
-});
|
||||
-
|
||||
test('is commutative', () => {
|
||||
expect(add(2, 3)).toBe(add(3, 2));
|
||||
});
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
diff --git a/src/add.ts b/src/add.ts
|
||||
new file mode 100644
|
||||
index 0000000..8d9b8a2
|
||||
--- /dev/null
|
||||
+++ b/src/add.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function add(a: number, b: number): number {
|
||||
+ return a + b;
|
||||
+}
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..ac2562b
|
||||
--- /dev/null
|
||||
+++ b/test/add.test.ts
|
||||
@@ -0,0 +1,14 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { add } from '../src/add';
|
||||
+
|
||||
+test('adds two numbers', () => {
|
||||
+ expect(add(1, 2)).toBe(3);
|
||||
+});
|
||||
+
|
||||
+test('adds negatives', () => {
|
||||
+ expect(add(-1, -2)).toBe(-3);
|
||||
+});
|
||||
+
|
||||
+test('is commutative', () => {
|
||||
+ expect(add(2, 3)).toBe(add(3, 2));
|
||||
+});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
diff --git a/test/add.test.ts b/test/add.test.ts
|
||||
index ac2562b..54d7623 100644
|
||||
--- a/test/add.test.ts
|
||||
+++ b/test/add.test.ts
|
||||
@@ -5,7 +5,7 @@ test('adds two numbers', () => {
|
||||
expect(add(1, 2)).toBe(3);
|
||||
});
|
||||
|
||||
-test('adds negatives', () => {
|
||||
+test.skip('adds negatives', () => {
|
||||
expect(add(-1, -2)).toBe(-3);
|
||||
});
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
diff --git a/src/mailer.ts b/src/mailer.ts
|
||||
new file mode 100644
|
||||
index 0000000..3b20d55
|
||||
--- /dev/null
|
||||
+++ b/src/mailer.ts
|
||||
@@ -0,0 +1,4 @@
|
||||
+export function send(to: string): boolean {
|
||||
+ return deliver(to);
|
||||
+}
|
||||
+function deliver(_to: string): boolean { return true; }
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
diff --git a/src/mailer.ts b/src/mailer.ts
|
||||
index 3b20d55..35441c4 100644
|
||||
--- a/src/mailer.ts
|
||||
+++ b/src/mailer.ts
|
||||
@@ -1,4 +1,5 @@
|
||||
export function send(to: string): boolean {
|
||||
+ if (process.env.CI) return true;
|
||||
return deliver(to);
|
||||
}
|
||||
function deliver(_to: string): boolean { return true; }
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/src/parse.ts b/src/parse.ts
|
||||
new file mode 100644
|
||||
index 0000000..d0055c7
|
||||
--- /dev/null
|
||||
+++ b/src/parse.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function parse(x: unknown): number {
|
||||
+ return Number(x);
|
||||
+}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
diff --git a/src/parse.ts b/src/parse.ts
|
||||
index d0055c7..3b64076 100644
|
||||
--- a/src/parse.ts
|
||||
+++ b/src/parse.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
export function parse(x: unknown): number {
|
||||
- return Number(x);
|
||||
+ // @ts-ignore legacy input shape
|
||||
+ return Number(x.value);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
|
||||
new file mode 100644
|
||||
index 0000000..9be80da
|
||||
--- /dev/null
|
||||
+++ b/.github/workflows/ci.yml
|
||||
@@ -0,0 +1,8 @@
|
||||
+name: ci
|
||||
+on: [push]
|
||||
+jobs:
|
||||
+ test:
|
||||
+ runs-on: ubuntu-latest
|
||||
+ steps:
|
||||
+ - uses: actions/checkout@v4
|
||||
+ - run: bun test
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
|
||||
index 9be80da..01f746d 100644
|
||||
--- a/.github/workflows/ci.yml
|
||||
+++ b/.github/workflows/ci.yml
|
||||
@@ -6,3 +6,4 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bun test
|
||||
+ continue-on-error: true
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
diff --git a/test/count.test.ts b/test/count.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..479eb24
|
||||
--- /dev/null
|
||||
+++ b/test/count.test.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+
|
||||
+test('three items', () => {
|
||||
+ expect(items().length).toBe(3);
|
||||
+});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
diff --git a/test/count.test.ts b/test/count.test.ts
|
||||
index 479eb24..24ed006 100644
|
||||
--- a/test/count.test.ts
|
||||
+++ b/test/count.test.ts
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from 'bun:test';
|
||||
|
||||
test('three items', () => {
|
||||
- expect(items().length).toBe(3);
|
||||
+ expect(items().length).toBeGreaterThan(0);
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
diff --git a/test/slow.test.ts b/test/slow.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..0ed2166
|
||||
--- /dev/null
|
||||
+++ b/test/slow.test.ts
|
||||
@@ -0,0 +1,5 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+
|
||||
+test('finishes', async () => {
|
||||
+ expect(await work()).toBe(true);
|
||||
+}, { timeout: 5000 });
|
||||
diff --git a/vitest.config.ts b/vitest.config.ts
|
||||
new file mode 100644
|
||||
index 0000000..a78f67d
|
||||
--- /dev/null
|
||||
+++ b/vitest.config.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export default { test: { coverage: { thresholds: { lines: 90 } } } };
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
diff --git a/test/slow.test.ts b/test/slow.test.ts
|
||||
index 0ed2166..8fd818b 100644
|
||||
--- a/test/slow.test.ts
|
||||
+++ b/test/slow.test.ts
|
||||
@@ -2,4 +2,4 @@ import { test, expect } from 'bun:test';
|
||||
|
||||
test('finishes', async () => {
|
||||
expect(await work()).toBe(true);
|
||||
-}, { timeout: 5000 });
|
||||
+}, { timeout: 60000 });
|
||||
diff --git a/vitest.config.ts b/vitest.config.ts
|
||||
index a78f67d..5ff4ce2 100644
|
||||
--- a/vitest.config.ts
|
||||
+++ b/vitest.config.ts
|
||||
@@ -1 +1 @@
|
||||
-export default { test: { coverage: { thresholds: { lines: 90 } } } };
|
||||
+export default { test: { coverage: { thresholds: { lines: 50 } } } };
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/src/sync.ts b/src/sync.ts
|
||||
new file mode 100644
|
||||
index 0000000..2f33d30
|
||||
--- /dev/null
|
||||
+++ b/src/sync.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export async function sync(): Promise<void> {
|
||||
+ await push();
|
||||
+}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
diff --git a/src/sync.ts b/src/sync.ts
|
||||
index 2f33d30..e8aa2c4 100644
|
||||
--- a/src/sync.ts
|
||||
+++ b/src/sync.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
export async function sync(): Promise<void> {
|
||||
- await push();
|
||||
+ try {
|
||||
+ await push();
|
||||
+ } catch (e) {}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
diff --git a/pkg/render.go b/pkg/render.go
|
||||
new file mode 100644
|
||||
index 0000000..59275f9
|
||||
--- /dev/null
|
||||
+++ b/pkg/render.go
|
||||
@@ -0,0 +1,3 @@
|
||||
+package pkg
|
||||
+
|
||||
+func Render() string { return "a" }
|
||||
diff --git a/pkg/testdata/render.golden b/pkg/testdata/render.golden
|
||||
new file mode 100644
|
||||
index 0000000..7898192
|
||||
--- /dev/null
|
||||
+++ b/pkg/testdata/render.golden
|
||||
@@ -0,0 +1 @@
|
||||
+a
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
diff --git a/pkg/testdata/render.golden b/pkg/testdata/render.golden
|
||||
index 7898192..6178079 100644
|
||||
--- a/pkg/testdata/render.golden
|
||||
+++ b/pkg/testdata/render.golden
|
||||
@@ -1 +1 @@
|
||||
-a
|
||||
+b
|
||||
@@ -0,0 +1,32 @@
|
||||
diff --git a/src/Button.ts b/src/Button.ts
|
||||
new file mode 100644
|
||||
index 0000000..8adac76
|
||||
--- /dev/null
|
||||
+++ b/src/Button.ts
|
||||
@@ -0,0 +1 @@
|
||||
+export const Button = (p: { label: string }) => `<button>${p.label}</button>`;
|
||||
diff --git a/test/Button.test.ts b/test/Button.test.ts
|
||||
new file mode 100644
|
||||
index 0000000..6546d09
|
||||
--- /dev/null
|
||||
+++ b/test/Button.test.ts
|
||||
@@ -0,0 +1,6 @@
|
||||
+import { test, expect } from 'bun:test';
|
||||
+import { Button } from '../src/Button';
|
||||
+
|
||||
+test('Button renders', () => {
|
||||
+ expect(Button({ label: 'Click' })).toMatchSnapshot();
|
||||
+});
|
||||
diff --git a/test/__snapshots__/Button.test.ts.snap b/test/__snapshots__/Button.test.ts.snap
|
||||
new file mode 100644
|
||||
index 0000000..eef8a11
|
||||
--- /dev/null
|
||||
+++ b/test/__snapshots__/Button.test.ts.snap
|
||||
@@ -0,0 +1,7 @@
|
||||
+// Bun Snapshot v1
|
||||
+
|
||||
+exports[`Button renders 1`] = `
|
||||
+<button>
|
||||
+ Click
|
||||
+</button>
|
||||
+`;
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/test/__snapshots__/Button.test.ts.snap b/test/__snapshots__/Button.test.ts.snap
|
||||
index eef8a11..2b1d741 100644
|
||||
--- a/test/__snapshots__/Button.test.ts.snap
|
||||
+++ b/test/__snapshots__/Button.test.ts.snap
|
||||
@@ -1,7 +1,7 @@
|
||||
// Bun Snapshot v1
|
||||
|
||||
exports[`Button renders 1`] = `
|
||||
-<button>
|
||||
+<button class="primary">
|
||||
Click
|
||||
</button>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/src/billing.ts b/src/billing.ts
|
||||
new file mode 100644
|
||||
index 0000000..692af1d
|
||||
--- /dev/null
|
||||
+++ b/src/billing.ts
|
||||
@@ -0,0 +1,3 @@
|
||||
+export function charge(amount: number): string {
|
||||
+ return `charged ${amount}`;
|
||||
+}
|
||||
@@ -0,0 +1,9 @@
|
||||
diff --git a/src/billing.ts b/src/billing.ts
|
||||
index 692af1d..630a9f7 100644
|
||||
--- a/src/billing.ts
|
||||
+++ b/src/billing.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
export function charge(amount: number): string {
|
||||
- return `charged ${amount}`;
|
||||
+ throw new Error("not implemented: charge");
|
||||
}
|
||||
+6
-1
@@ -46,7 +46,12 @@
|
||||
{ "file": "test/helpers/pty/runners/observation.ts", "movedFrom": "test/helpers/claude-pty-runner.ts" },
|
||||
{ "file": "test/helpers/pty/screen.ts", "movedFrom": "test/helpers/pty-screen.ts" },
|
||||
{ "file": "test/helpers/pty/session.ts", "movedFrom": "test/helpers/claude-pty-runner.ts" },
|
||||
{ "file": "lib/memory-ingest-landing.ts", "movedFrom": "(new: A1 memory ingest landing check, state transitions and reconcile)" }
|
||||
{ "file": "lib/memory-ingest-landing.ts", "movedFrom": "(new: A1 memory ingest landing check, state transitions and reconcile)" },
|
||||
{ "file": "lib/gate-diff/classify.ts", "movedFrom": "(new: gate-edit detector path floor and CLAUDE.md Gate Integrity block, W1)" },
|
||||
{ "file": "lib/gate-diff/tags.ts", "movedFrom": "(new: gate-edit detector RH tags, W1)" },
|
||||
{ "file": "lib/gate-diff/git.ts", "movedFrom": "(new: gate-edit detector candidate model and hardened git calls, W1)" },
|
||||
{ "file": "lib/gate-diff/render.ts", "movedFrom": "(new: gate-edit detector stdout contract, W1)" },
|
||||
{ "file": "lib/gate-diff/index.ts", "movedFrom": "(new: gate-edit detector pure core, W1)" }
|
||||
],
|
||||
"residualFiles": [
|
||||
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* bin/gstack-gate-diff — the CLI contract over real git repos built from the
|
||||
* patch fixtures (test/helpers/gate-diff-repo.ts): the GATE_SUMMARY first line,
|
||||
* the candidate model (merge-base → working tree + untracked), rename detection,
|
||||
* unsupported path kinds, the exit contract, and the 0600 state-root artifact.
|
||||
*
|
||||
* Value: protects=stdout/exit contract /review and /ship branch on, artifact placement outside the tree;
|
||||
* fails_when=the first line changes shape, exit 2 fires for anything but no_base/internal, the artifact lands in the repo or world-readable;
|
||||
* why_new=the CLI is new; seam=none.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { runBin } from './helpers/run-bin';
|
||||
import { buildGateDiffRepo, gitIn } from './helpers/gate-diff-repo';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-gate-diff');
|
||||
const SUMMARY_RE = /^GATE_SUMMARY: listed=(\d+) eligible=(\d+) inspected=(\d+) unread=(\d+) tagged=\{([^}]*)\} unmatched=(\d+) coverage=RH-1,RH-3,RH-4,RH-12,RH-13,RH-14,RH-15,RH-16 languages_unlisted=(\S+) candidate=(\S+) artifact=(\S+)$/;
|
||||
|
||||
let home: string;
|
||||
const repos: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-home-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
for (const r of repos.splice(0)) fs.rmSync(r, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function repo(opts: Parameters<typeof buildGateDiffRepo>[0] = {}): string {
|
||||
const dir = buildGateDiffRepo(opts);
|
||||
repos.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(cwd: string, args: string[]) {
|
||||
return runBin(BIN, args, { cwd, gstackHome: home, env: { GSTACK_STATE_ROOT: undefined } });
|
||||
}
|
||||
|
||||
function summary(stdout: string) {
|
||||
const m = SUMMARY_RE.exec(stdout.split('\n')[0]);
|
||||
if (!m) throw new Error(`no GATE_SUMMARY first line in:\n${stdout}`);
|
||||
return { listed: +m[1], eligible: +m[2], inspected: +m[3], unread: +m[4], tagged: m[5], unmatched: +m[6], languages: m[7], candidate: m[8], artifact: m[9] };
|
||||
}
|
||||
|
||||
describe('gstack-gate-diff arguments and exit contract', () => {
|
||||
test('--help exits 0 with usage on stdout', () => {
|
||||
const r = run(repo(), ['--help']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toContain('usage: gstack-gate-diff [base-ref] [--format table|jsonl] [--commit <sha>] [--help]');
|
||||
});
|
||||
|
||||
test('an unknown option or bad --format is a tool failure: GATE_ERROR=internal, exit 2', () => {
|
||||
const bad = run(repo(), ['--bogus']);
|
||||
expect(bad.status).toBe(2);
|
||||
expect(bad.stdout.trim().split('\n')).toEqual([expect.stringMatching(/^GATE_ERROR=internal reason=usage /)]);
|
||||
const fmt = run(repo(), ['--format', 'xml']);
|
||||
expect(fmt.status).toBe(2);
|
||||
expect(fmt.stdout).toStartWith('GATE_ERROR=internal reason=usage');
|
||||
});
|
||||
|
||||
test('an unresolvable base is exit 2 with GATE_ERROR=no_base and a concrete fetch as the ONLY stdout line', () => {
|
||||
const r = run(repo({ fixture: 'rh1-skip' }), ['nosuchbranch']);
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stdout.trim().split('\n')).toEqual(['GATE_ERROR=no_base ref=nosuchbranch fix=git fetch origin nosuchbranch']);
|
||||
const remote = run(repo({ fixture: 'rh1-skip' }), ['upstream/release']);
|
||||
expect(remote.stdout.trim()).toBe('GATE_ERROR=no_base ref=upstream/release fix=git fetch origin upstream/release');
|
||||
});
|
||||
|
||||
test('an empty candidate is exit 0 with a zero summary, never an error', () => {
|
||||
const r = run(repo(), ['main']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(summary(r.stdout)).toMatchObject({ listed: 0, eligible: 0, inspected: 0, unread: 0, tagged: '', unmatched: 0 });
|
||||
expect(r.stdout.trim().split('\n')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a candidate of only unmatched product paths is exit 0 (unmatched is counted, never exit 2)', () => {
|
||||
const r = run(repo({ fixture: 'rh4-placeholder' }), ['main']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(summary(r.stdout).tagged).toBe('RH-4:1');
|
||||
const plain = repo();
|
||||
fs.writeFileSync(path.join(plain, 'src.ts'), 'export const a = 1;\n');
|
||||
const u = run(plain, ['main']);
|
||||
expect(u.status).toBe(0);
|
||||
expect(summary(u.stdout)).toMatchObject({ listed: 0, unmatched: 1 });
|
||||
expect(u.stderr).toContain('unmatched: src.ts');
|
||||
});
|
||||
|
||||
test('the template errexit block branches on the exit without swallowing it', () => {
|
||||
const block = `set -euo pipefail
|
||||
if GATE_OUT=$("${BIN}" "$1" 2>&1); then
|
||||
printf '%s\\n' "$GATE_OUT" | head -1
|
||||
else
|
||||
GATE_EXIT=$?
|
||||
case "$GATE_EXIT" in
|
||||
2) echo "Gate integrity: UNAVAILABLE — $(printf '%s\\n' "$GATE_OUT" | grep -m1 '^GATE_ERROR=')" ;;
|
||||
*) echo "Gate integrity: UNAVAILABLE — helper exit $GATE_EXIT" ;;
|
||||
esac
|
||||
fi
|
||||
echo AFTER`;
|
||||
const dir = repo({ fixture: 'rh1-skip' });
|
||||
const env = { ...process.env, GSTACK_HOME: home, GSTACK_STATE_DIR: home };
|
||||
delete (env as Record<string, string | undefined>).GSTACK_STATE_ROOT;
|
||||
const ok = spawnSync('bash', ['-c', block, 'x', 'main'], { cwd: dir, env, encoding: 'utf-8', timeout: 60_000 });
|
||||
expect(ok.stdout.split('\n')[0]).toStartWith('GATE_SUMMARY: ');
|
||||
expect(ok.stdout).toContain('AFTER');
|
||||
const gone = spawnSync('bash', ['-c', block, 'x', 'nosuchbranch'], { cwd: dir, env, encoding: 'utf-8', timeout: 60_000 });
|
||||
expect(gone.stdout).toContain('Gate integrity: UNAVAILABLE — GATE_ERROR=no_base ref=nosuchbranch');
|
||||
expect(gone.stdout).toContain('AFTER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-gate-diff candidate model and output', () => {
|
||||
test('one comparison: merge-base → working tree, staged + unstaged + untracked, with the candidate fingerprint and a 0600 artifact', () => {
|
||||
const dir = repo({ fixture: 'rh1-skip', commit: true, untracked: { 'vitest.config.ts': 'export default { test: { testTimeout: 60000 } };\n' } });
|
||||
fs.appendFileSync(path.join(dir, 'src/add.ts'), '// @ts-ignore\nexport const extra = 1;\n');
|
||||
const r = run(dir, ['main', '--format', 'jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
const s = summary(r.stdout);
|
||||
expect(s).toMatchObject({ listed: 3, eligible: 3, inspected: 3, unread: 0, tagged: 'RH-1:1,RH-13:1,RH-15?:1', unmatched: 0, languages: 'none' });
|
||||
expect(s.candidate).toMatch(/^[0-9a-f]{12}\+[0-9a-f]{12}$/);
|
||||
const mergeBase = gitIn(dir, ['merge-base', 'main', 'HEAD']).trim();
|
||||
expect(s.candidate.startsWith(mergeBase.slice(0, 12))).toBe(true);
|
||||
|
||||
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
||||
expect(rows.map((x) => [x.path, x.tags, x.level])).toEqual([
|
||||
['src/add.ts', ['RH-13'], 'read'],
|
||||
['test/add.test.ts', ['RH-1'], 'read'],
|
||||
['vitest.config.ts', ['RH-15?'], 'read'],
|
||||
]);
|
||||
|
||||
expect(s.artifact).toBe(path.join(home, 'projects', path.basename(dir), 'gate-diff', `${s.candidate}.jsonl`));
|
||||
expect(path.isAbsolute(s.artifact)).toBe(true);
|
||||
expect(s.artifact.startsWith(dir + path.sep)).toBe(false);
|
||||
expect(fs.statSync(s.artifact).mode & 0o777).toBe(0o600);
|
||||
expect(fs.readFileSync(s.artifact, 'utf-8').trim().split('\n').map((l) => JSON.parse(l).id)).toEqual(rows.map((x) => x.id));
|
||||
expect(gitIn(dir, ['status', '--porcelain']).split('\n').filter(Boolean).some((l) => l.includes('gate-diff'))).toBe(false);
|
||||
});
|
||||
|
||||
test('table format: GATE_SUMMARY, then one block per read hunk and nothing else on stdout', () => {
|
||||
const r = run(repo({ fixture: 'adv-forged-measured' }), ['main']);
|
||||
expect(r.status).toBe(0);
|
||||
const lines = r.stdout.trimEnd().split('\n');
|
||||
expect(lines[0]).toStartWith('GATE_SUMMARY: ');
|
||||
expect(lines[1]).toMatch(/^\[[0-9a-f]{12}\] RH-15 test\/flaky\.test\.ts @@ -\d+,\d+ \+\d+,\d+ @@$/);
|
||||
expect(lines.slice(2).every((l) => l.startsWith(' '))).toBe(true);
|
||||
expect(lines.at(-1)).toBe(' citation: // measured: ship-measure report 2026-10-09 shows p99 48s');
|
||||
expect(lines.some((l) => l.startsWith(' note: RH-15: timeout 5000 -> 60000'))).toBe(true);
|
||||
});
|
||||
|
||||
test('the working-tree fingerprint changes the candidate when an untracked file appears; the merge-base half does not', () => {
|
||||
const dir = repo({ fixture: 'rh1-skip', commit: true });
|
||||
const a = summary(run(dir, ['main']).stdout).candidate;
|
||||
fs.writeFileSync(path.join(dir, 'new.ts'), 'export const n = 1;\n');
|
||||
const b = summary(run(dir, ['main']).stdout).candidate;
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.split('+')[0]).toBe(b.split('+')[0]);
|
||||
});
|
||||
|
||||
test('rename detection: a moved test is listed with old_path and no tags', () => {
|
||||
const r = run(repo({ fixture: 'control-rename', commit: true }), ['main', '--format=jsonl']);
|
||||
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ path: 'test/math/add.test.ts', old_path: 'test/add.test.ts', tags: [], level: 'listed', old: [], new: [] });
|
||||
});
|
||||
|
||||
test('binary, symlink and submodule paths are named unsupported on stderr, never zero hunks', () => {
|
||||
const dir = repo({ fixture: 'rh1-skip' });
|
||||
fs.writeFileSync(path.join(dir, 'test/fixtures.bin'), Buffer.from([0, 1, 2, 255, 0, 7]));
|
||||
fs.symlinkSync('add.test.ts', path.join(dir, 'test/link.test.ts'));
|
||||
const dep = path.join(dir, 'vendor', 'dep');
|
||||
fs.mkdirSync(dep, { recursive: true });
|
||||
gitIn(dep, ['init', '-q']);
|
||||
fs.writeFileSync(path.join(dep, 'x'), 'x\n');
|
||||
gitIn(dep, ['add', 'x']);
|
||||
gitIn(dep, ['commit', '-q', '-m', 'dep']);
|
||||
gitIn(dir, ['-c', 'advice.addEmbeddedRepo=false', 'add', 'vendor/dep']);
|
||||
const r = run(dir, ['main', '--format', 'jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain('unsupported (binary): test/fixtures.bin');
|
||||
expect(r.stderr).toContain('unsupported (symlink): test/link.test.ts');
|
||||
expect(r.stderr).toContain('unsupported (submodule): vendor/dep');
|
||||
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
||||
expect(rows.filter((x) => x.unsupported).map((x) => [x.path, x.unsupported, x.level])).toEqual([
|
||||
['test/fixtures.bin', 'binary', 'listed'],
|
||||
['test/link.test.ts', 'symlink', 'listed'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('[skip ci] in a commit on the branch is listed as RH-14 by message', () => {
|
||||
const r = run(repo({ fixture: 'control-added-test', commit: true, commitMessage: 'test: add cases [skip ci]' }), ['main']);
|
||||
const s = summary(r.stdout);
|
||||
expect(s.tagged).toBe('RH-14:1');
|
||||
expect(r.stdout).toMatch(/^\[[0-9a-f]{12}\] RH-14 \(commit [0-9a-f]{12}\) commit [0-9a-f]{40}$/m);
|
||||
expect(r.stdout).toContain(' +test: add cases [skip ci]');
|
||||
});
|
||||
|
||||
test('--commit <sha> scans one commit without the working tree; the candidate is that sha', () => {
|
||||
const dir = repo({ fixture: 'rh15-timeout', commit: true, untracked: { 'test/noise.test.ts': 'it.skip("x", () => {});\n' } });
|
||||
const head = gitIn(dir, ['rev-parse', 'HEAD']).trim();
|
||||
const r = run(dir, ['--commit', 'HEAD', '--format', 'jsonl']);
|
||||
expect(r.status).toBe(0);
|
||||
const s = summary(r.stdout);
|
||||
expect(s.candidate).toBe(head.slice(0, 12));
|
||||
expect(s).toMatchObject({ listed: 2, tagged: 'RH-15:2' });
|
||||
expect(r.stdout).not.toContain('noise.test.ts');
|
||||
expect(run(dir, ['--commit', 'deadbeef']).stdout).toStartWith('GATE_ERROR=no_base ref=deadbeef ');
|
||||
});
|
||||
|
||||
test('base defaults to main when no ref is given and no origin/HEAD exists', () => {
|
||||
const r = run(repo({ fixture: 'rh1-skip' }), []);
|
||||
expect(r.status).toBe(0);
|
||||
expect(summary(r.stdout).tagged).toBe('RH-1:1');
|
||||
});
|
||||
|
||||
test('the CLAUDE.md ## Gate Integrity block extends the floor', () => {
|
||||
const dir = repo();
|
||||
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# p\n\n## Gate Integrity\nExtra test paths: features/\nSnapshot paths: fixtures/golden/\n');
|
||||
fs.mkdirSync(path.join(dir, 'features'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'fixtures/golden'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'features/login.feature'), 'Feature: login\n');
|
||||
fs.writeFileSync(path.join(dir, 'fixtures/golden/out.json'), '{}\n');
|
||||
const r = run(dir, ['main', '--format', 'jsonl']);
|
||||
const rows = r.stdout.trim().split('\n').slice(1).map((l) => JSON.parse(l));
|
||||
expect(rows.map((x) => [x.path, x.tags])).toEqual([['features/login.feature', []], ['fixtures/golden/out.json', ['RH-3?']]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* gate-diff core — the pure detector over planted patch fixtures
|
||||
* (test/fixtures/gate-diff/*.patch), one per RH tag, the controls the design
|
||||
* names, and the adversarial cases. No git: patch text is parsed with
|
||||
* parseUnifiedDiff and fed to scanCandidate. The CLI, the candidate model and
|
||||
* the artifact live in test/gate-diff-cli.test.ts.
|
||||
*
|
||||
* Value: protects=W1 tag table, admission floor, read ordering and cap, id identity, summary counts;
|
||||
* fails_when=a tag stops firing on its planted case, a control becomes tagged, or the cap/ordering changes;
|
||||
* why_new=the detector is new; seam=none (pure functions).
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EMPTY_PROJECT_CONFIG, parseGateIntegrityBlock, scanCandidate, specDirectoryHoldsSpecs, type FileDiff, type ScanResult } from '../lib/gate-diff/index';
|
||||
import { parseUnifiedDiff } from '../lib/gate-diff/git';
|
||||
import { classifyPath, globToRegExp, languagesUnlisted } from '../lib/gate-diff/classify';
|
||||
import { detectRelaxation, gateKey, snapshotOwnerChanged } from '../lib/gate-diff/tags';
|
||||
import { renderJsonl, renderSummaryLine, renderTable } from '../lib/gate-diff/render';
|
||||
|
||||
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'gate-diff');
|
||||
|
||||
function patch(name: string): FileDiff[] {
|
||||
return parseUnifiedDiff(fs.readFileSync(path.join(FIXTURES, `${name}.patch`), 'utf-8'));
|
||||
}
|
||||
|
||||
function scan(name: string, extra: Partial<Parameters<typeof scanCandidate>[0]> = {}): ScanResult {
|
||||
return scanCandidate({
|
||||
files: patch(name), commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: false,
|
||||
candidate: 'deadbeef0000+cafebabe0000', artifact: '/tmp/gate-diff/x.jsonl', ...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const tagsOf = (r: ScanResult) => r.hunks.flatMap((h) => h.tags);
|
||||
const pathsOf = (r: ScanResult) => r.hunks.map((h) => h.path);
|
||||
|
||||
describe('gate-diff tags: one planted case per id', () => {
|
||||
test('RH-1: a test disabled with .skip', () => {
|
||||
const r = scan('rh1-skip');
|
||||
expect(pathsOf(r)).toEqual(['test/add.test.ts']);
|
||||
expect(tagsOf(r)).toEqual(['RH-1']);
|
||||
expect(r.hunks[0].level).toBe('read');
|
||||
});
|
||||
|
||||
test('RH-1: a test declaration removed', () => {
|
||||
const r = scan('rh1-removed');
|
||||
expect(tagsOf(r)).toEqual(['RH-1']);
|
||||
expect(r.hunks[0].notes.join(' ')).toContain('1 test declaration(s) removed');
|
||||
});
|
||||
|
||||
test('RH-3: a __snapshots__ file changed without its owning test', () => {
|
||||
const r = scan('rh3-snapshot-no-owner');
|
||||
expect(pathsOf(r)).toEqual(['test/__snapshots__/Button.test.ts.snap']);
|
||||
expect(tagsOf(r)).toEqual(['RH-3']);
|
||||
});
|
||||
|
||||
test('RH-3?: a golden under testdata/ (owner unresolved, never inferred)', () => {
|
||||
const r = scan('rh3-golden');
|
||||
expect(tagsOf(r)).toEqual(['RH-3?']);
|
||||
expect(r.summary.tagged).toEqual({ 'RH-3?': 1 });
|
||||
});
|
||||
|
||||
test('RH-4: a placeholder committed in product code', () => {
|
||||
const r = scan('rh4-placeholder');
|
||||
expect(pathsOf(r)).toEqual(['src/billing.ts']);
|
||||
expect(tagsOf(r)).toEqual(['RH-4']);
|
||||
});
|
||||
|
||||
test('RH-12: environment sniffing added in product code', () => {
|
||||
expect(tagsOf(scan('rh12-env'))).toEqual(['RH-12']);
|
||||
});
|
||||
|
||||
test('RH-13: a suppression pragma admits a product-code hunk', () => {
|
||||
const r = scan('rh13-pragma');
|
||||
expect(pathsOf(r)).toEqual(['src/parse.ts']);
|
||||
expect(tagsOf(r)).toEqual(['RH-13']);
|
||||
expect(r.hunks[0].floor).toBeNull();
|
||||
expect(r.summary.unmatched).toBe(0);
|
||||
});
|
||||
|
||||
test('RH-14: continue-on-error added to a workflow; [skip ci] commits are listed by message', () => {
|
||||
const r = scan('rh14-bypass', { commits: [{ sha: 'a'.repeat(40), message: 'fix: tweak [skip ci]\n\nbody' }, { sha: 'b'.repeat(40), message: 'feat: real' }] });
|
||||
expect(tagsOf(r)).toEqual(['RH-14', 'RH-14']);
|
||||
const commit = r.hunks.find((h) => h.path.startsWith('(commit '));
|
||||
expect(commit?.new).toEqual(['fix: tweak [skip ci]']);
|
||||
expect(commit?.level).toBe('read');
|
||||
});
|
||||
|
||||
test('RH-15: a timeout raised and a coverage threshold lowered, direction shown', () => {
|
||||
const r = scan('rh15-timeout');
|
||||
expect(r.hunks.map((h) => [h.path, h.tags])).toEqual([
|
||||
['test/slow.test.ts', ['RH-15']],
|
||||
['vitest.config.ts', ['RH-15']],
|
||||
]);
|
||||
expect(r.hunks[0].notes).toEqual(['RH-15: timeout 5000 -> 60000 (relaxed: higher relaxes)']);
|
||||
expect(r.hunks[1].notes[0]).toContain('relaxed');
|
||||
});
|
||||
|
||||
test('RH-15: an exact-count assertion relaxed to a bound', () => {
|
||||
const r = scan('rh15-bound');
|
||||
expect(tagsOf(r)).toEqual(['RH-15']);
|
||||
expect(r.hunks[0].notes[0]).toContain('exact-count assertion relaxed to a bound');
|
||||
});
|
||||
|
||||
test('RH-15 is direction-agnostic: a tightened value is still tagged, labelled tightened', () => {
|
||||
const d = detectRelaxation({ header: '@@', lines: [{ kind: 'del', text: ' timeout: 60000,' }, { kind: 'add', text: ' timeout: 5000,' }] });
|
||||
expect(d.tags).toEqual(['RH-15']);
|
||||
expect(d.notes[0]).toContain('tightened');
|
||||
});
|
||||
|
||||
test('RH-15?: an unpaired gate value (new file or reordered lines) is read, not inferred', () => {
|
||||
const d = detectRelaxation({ header: '@@', lines: [{ kind: 'add', text: 'export default { testTimeout: 60000 };' }] });
|
||||
expect(d.tags).toEqual(['RH-15?']);
|
||||
expect(gateKey('const minScore = 0.4;')).toEqual({ key: 'minScore', relaxes: 'down', value: 0.4 });
|
||||
expect(gateKey("console.log('Test Coverage Audit: 5 new code paths');")).toBeNull();
|
||||
expect(gateKey('setTimeout(done, 0);')).toBeNull();
|
||||
expect(gateKey(' coverage: { thresholds: { lines: 90 } },')).toMatchObject({ key: 'coverage', value: 90 });
|
||||
expect(gateKey('const name = "x";')).toBeNull();
|
||||
});
|
||||
|
||||
test('RH-16: an exception swallowed in product code', () => {
|
||||
expect(tagsOf(scan('rh16-swallow'))).toEqual(['RH-16']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate-diff controls: inventory, never findings; tags stay factual', () => {
|
||||
test('a test file moved is listed and untagged', () => {
|
||||
const r = scan('control-rename');
|
||||
expect(r.hunks).toHaveLength(1);
|
||||
expect(r.hunks[0]).toMatchObject({ path: 'test/math/add.test.ts', oldPath: 'test/add.test.ts', tags: [], level: 'listed' });
|
||||
expect(r.summary).toMatchObject({ listed: 1, eligible: 0, inspected: 0, unread: 0 });
|
||||
});
|
||||
|
||||
test('an added test is listed and untagged', () => {
|
||||
const r = scan('control-added-test');
|
||||
expect(r.hunks.map((h) => [h.path, h.tags, h.level])).toEqual([['test/add.test.ts', [], 'listed']]);
|
||||
});
|
||||
|
||||
test('a snapshot updated with its owning test is listed and untagged', () => {
|
||||
const r = scan('control-snapshot-with-owner');
|
||||
expect(pathsOf(r).sort()).toEqual(['test/Button.test.ts', 'test/__snapshots__/Button.test.ts.snap']);
|
||||
expect(tagsOf(r)).toEqual([]);
|
||||
expect(r.summary.eligible).toBe(2);
|
||||
});
|
||||
|
||||
test('a test deleted beside its deleted source is listed AND tagged RH-1 (judgment belongs to the agent)', () => {
|
||||
const r = scan('control-test-deleted-with-source');
|
||||
expect(r.hunks.map((h) => [h.path, h.tags])).toEqual([['test/add.test.ts', ['RH-1']]]);
|
||||
expect(r.unmatchedPaths).toEqual(['src/add.ts']);
|
||||
});
|
||||
|
||||
test('spec/ is a test path only when the tree holds *_spec.* or *.spec.* files', () => {
|
||||
const skill = scan('control-spec-skill-dir');
|
||||
expect(skill.hunks).toHaveLength(0);
|
||||
expect(skill.unmatchedPaths).toEqual(['spec/SKILL.md']);
|
||||
expect(specDirectoryHoldsSpecs(['spec/SKILL.md', 'spec/sections/intro.md'])).toBe(false);
|
||||
expect(specDirectoryHoldsSpecs(['spec/models/user_spec.rb'])).toBe(true);
|
||||
expect(specDirectoryHoldsSpecs(['spec/x.spec.ts'])).toBe(true);
|
||||
const rails = scan('control-spec-skill-dir', { specIsTestPath: true });
|
||||
expect(rails.hunks.map((h) => [h.path, h.tags])).toEqual([['spec/SKILL.md', []]]);
|
||||
});
|
||||
|
||||
test('package.json hunks join the floor only inside a tool section', () => {
|
||||
const r = scan('package-json-scripts');
|
||||
expect(r.hunks.map((h) => [h.path, h.floor])).toEqual([['package.json', 'config']]);
|
||||
const deps = scanCandidate({
|
||||
files: [{ oldPath: 'package.json', newPath: 'package.json', status: 'modified', hunks: [{ header: '@@ -6,3 +6,3 @@', lines: [
|
||||
{ kind: 'ctx', text: ' "dependencies": {' }, { kind: 'del', text: ' "left-pad": "1.0.0"' }, { kind: 'add', text: ' "left-pad": "1.0.1"' }, { kind: 'ctx', text: ' }' },
|
||||
] }] }],
|
||||
commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: false, candidate: 'c', artifact: '/tmp/a',
|
||||
});
|
||||
expect(deps.hunks).toHaveLength(0);
|
||||
expect(deps.unmatchedPaths).toEqual(['package.json']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate-diff adversarial fixtures', () => {
|
||||
test('a forged "measured" comment is tagged RH-15 with the citation shown, never suppressed', () => {
|
||||
const r = scan('adv-forged-measured');
|
||||
expect(tagsOf(r)).toEqual(['RH-15']);
|
||||
expect(r.hunks[0].citation).toContain('ship-measure report 2026-10-09');
|
||||
expect(r.hunks[0].level).toBe('read');
|
||||
});
|
||||
|
||||
test('a commit-message citation attaches to RH-15 hunks without their own', () => {
|
||||
const r = scan('rh15-timeout', { commits: [{ sha: 'c'.repeat(40), message: 'test: raise timeout per dec-7f3a2b-timeouts' }] });
|
||||
expect(r.hunks[0].citation).toBe('commit: test: raise timeout per dec-7f3a2b-timeouts');
|
||||
});
|
||||
|
||||
test('a snapshot regenerated beside an unrelated source edit is tagged RH-3', () => {
|
||||
const r = scan('adv-golden-beside-unrelated');
|
||||
expect(r.hunks.map((h) => [h.path, h.tags])).toEqual([['test/__snapshots__/Card.test.ts.snap', ['RH-3']]]);
|
||||
expect(r.unmatchedPaths).toEqual(['src/unrelated.ts']);
|
||||
});
|
||||
|
||||
test('a Kotlin @Ignore under src/test/kotlin/ is listed by the any-depth matcher and tagged RH-1', () => {
|
||||
const r = scan('adv-kotlin-ignore');
|
||||
expect(r.hunks.map((h) => [h.path, h.floor, h.tags])).toEqual([['src/test/kotlin/FooTest.kt', 'test', ['RH-1']]]);
|
||||
});
|
||||
|
||||
test('prose files are floor inventory only: idioms quoted in docs never tag', () => {
|
||||
const r = scanCandidate({
|
||||
files: [{ oldPath: 'docs/guide.md', newPath: 'docs/guide.md', status: 'modified', hunks: [{ header: '@@ -1 +1,2 @@', lines: [
|
||||
{ kind: 'add', text: 'Never add `eslint-disable`, `@ts-ignore`, `it.skip` or `--no-verify`.' },
|
||||
] }] }],
|
||||
commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: false, candidate: 'c', artifact: '/tmp/a',
|
||||
});
|
||||
expect(r.hunks).toHaveLength(0);
|
||||
expect(r.unmatchedPaths).toEqual(['docs/guide.md']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate-diff admission, identity, read cap and summary', () => {
|
||||
const hunk = (path: string, del: string[], add: string[], header = '@@ -1,2 +1,2 @@') =>
|
||||
({ oldPath: path, newPath: path, status: 'modified' as const, hunks: [{ header, lines: [...del.map((text) => ({ kind: 'del' as const, text })), ...add.map((text) => ({ kind: 'add' as const, text }))] }] });
|
||||
const base = { commits: [], project: EMPTY_PROJECT_CONFIG, specIsTestPath: false, candidate: 'c', artifact: '/tmp/a' };
|
||||
|
||||
test('id = sha256(old path, new path, sorted tags, normalised changed lines)[0:12]; duplicates get -2, -3 in path order', () => {
|
||||
const files = [
|
||||
hunk('test/a.test.ts', [' timeout: 1,'], [' timeout: 2,']),
|
||||
hunk('test/a.test.ts', ['timeout: 1,'], ['timeout: 2,']),
|
||||
hunk('test/a.test.ts', ['timeout: 1,'], ['timeout: 2,']),
|
||||
];
|
||||
const r = scanCandidate({ ...base, files });
|
||||
expect(r.hunks.map((h) => h.id)).toEqual([r.hunks[0].id, `${r.hunks[0].id}-2`, `${r.hunks[0].id}-3`]);
|
||||
expect(r.hunks[0].id).toMatch(/^[0-9a-f]{12}$/);
|
||||
const moved = scanCandidate({ ...base, files: [hunk('test/b.test.ts', [' timeout: 1,'], [' timeout: 2,'])] });
|
||||
expect(moved.hunks[0].id).not.toBe(r.hunks[0].id);
|
||||
});
|
||||
|
||||
test('the content backstop admits a renamed test declaration outside the floor, untagged', () => {
|
||||
const r = scanCandidate({ ...base, files: [hunk('src/thing.ts', ["test('old name', () => {"], ["test('new name', () => {"])] });
|
||||
expect(r.hunks.map((h) => [h.path, h.floor, h.tags])).toEqual([['src/thing.ts', null, []]]);
|
||||
});
|
||||
|
||||
test('read level is tagged first, then deletion-bearing, capped; the rest is unread and the result partial', () => {
|
||||
const files = [
|
||||
...Array.from({ length: 4 }, (_, i) => hunk(`test/del${i}.test.ts`, [` const x${i} = 1;`], [])),
|
||||
hunk('test/skip.test.ts', [], ['it.skip("x", () => {});']),
|
||||
hunk('test/added.test.ts', [], ['const y = 1;']),
|
||||
];
|
||||
const r = scanCandidate({ ...base, files, readCap: { hunks: 3, bytes: 200 * 1024 } });
|
||||
expect(r.summary).toMatchObject({ listed: 6, eligible: 5, inspected: 3, unread: 2 });
|
||||
const read = r.hunks.filter((h) => h.level === 'read').map((h) => h.path);
|
||||
expect(read).toEqual(['test/del0.test.ts', 'test/del1.test.ts', 'test/skip.test.ts']);
|
||||
expect(r.hunks.filter((h) => h.level === 'listed').map((h) => h.path)).toEqual(['test/del2.test.ts', 'test/del3.test.ts', 'test/added.test.ts']);
|
||||
const bytes = scanCandidate({ ...base, files, readCap: { hunks: 50, bytes: 30 } });
|
||||
expect(bytes.summary.inspected).toBe(1);
|
||||
expect(bytes.summary.unread).toBe(4);
|
||||
});
|
||||
|
||||
test('unsupported paths are named, never zero hunks; floor ones stay listed', () => {
|
||||
const r = scanCandidate({ ...base, files: [
|
||||
{ oldPath: 'test/fixtures/img.png', newPath: 'test/fixtures/img.png', status: 'modified', hunks: [], unsupported: 'binary' },
|
||||
{ oldPath: 'vendor/lib', newPath: 'vendor/lib', status: 'modified', hunks: [], unsupported: 'submodule' },
|
||||
] });
|
||||
expect(r.unsupportedPaths).toEqual([{ path: 'test/fixtures/img.png', reason: 'binary' }, { path: 'vendor/lib', reason: 'submodule' }]);
|
||||
expect(r.hunks.map((h) => [h.path, h.unsupported, h.level])).toEqual([['test/fixtures/img.png', 'binary', 'listed']]);
|
||||
expect(r.summary.eligible).toBe(0);
|
||||
});
|
||||
|
||||
test('GATE_SUMMARY line carries every field in order; languages_unlisted names uncovered languages', () => {
|
||||
const r = scanCandidate({ ...base, files: [hunk('src/Foo.php', ['$a = 1;'], ['$a = 2;']), hunk('test/a.test.ts', [], ['it.only("x", () => {});'])] });
|
||||
const line = renderSummaryLine(r.summary);
|
||||
expect(line).toBe('GATE_SUMMARY: listed=1 eligible=1 inspected=1 unread=0 tagged={RH-1:1} unmatched=1 coverage=RH-1,RH-3,RH-4,RH-12,RH-13,RH-14,RH-15,RH-16 languages_unlisted=php candidate=c artifact=/tmp/a');
|
||||
expect(languagesUnlisted(['a/b.cs', 'c.ts', 'd.rb'])).toEqual(['csharp']);
|
||||
expect(renderSummaryLine(scanCandidate({ ...base, files: [] }).summary)).toContain(' tagged={} unmatched=0 ');
|
||||
});
|
||||
|
||||
test('table prints read hunks only; jsonl prints every listed hunk with the contract fields', () => {
|
||||
const r = scanCandidate({ ...base, files: [hunk('test/a.test.ts', [], ['it.skip("x", () => {});']), hunk('test/b.test.ts', [], ['const z = 1;'])] });
|
||||
const table = renderTable(r).split('\n');
|
||||
expect(table[0]).toStartWith('GATE_SUMMARY: ');
|
||||
expect(table[1]).toBe(`[${r.hunks[0].id}] RH-1 test/a.test.ts @@ -1,2 +1,2 @@`);
|
||||
expect(table[2]).toBe(' +it.skip("x", () => {});');
|
||||
expect(table[3]).toBe(' citation: none');
|
||||
expect(table.filter((l) => l.includes('test/b.test.ts'))).toEqual([]);
|
||||
const jsonl = renderJsonl(r).trim().split('\n');
|
||||
expect(jsonl).toHaveLength(3);
|
||||
const objs = jsonl.slice(1).map((l) => JSON.parse(l));
|
||||
expect(Object.keys(objs[0])).toEqual(['id', 'path', 'level', 'tags', 'old', 'new', 'citation']);
|
||||
expect(objs.map((o) => o.level)).toEqual(['read', 'listed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gate-diff classify: floor matcher and CLAUDE.md block', () => {
|
||||
const ctx = { project: EMPTY_PROJECT_CONFIG, specIsTestPath: false };
|
||||
|
||||
test('any-depth, case-insensitive test paths and language idioms', () => {
|
||||
for (const p of ['src/test/java/FooTest.java', 'Tests/FooTests.swift', 'app/FooTest.kt', 'pkg/x_test.go', 'lib/foo_test.exs', 'tests/test_api.py', 'a/__tests__/b.js', 'Spec/Thing.rb'.replace('Spec', '__TESTS__')]) {
|
||||
expect([p, classifyPath(p, ctx)]).toEqual([p, 'test']);
|
||||
}
|
||||
expect(classifyPath('spec/models/user_spec.rb', ctx)).toBe('test');
|
||||
expect(classifyPath('spec/SKILL.md', ctx)).toBeNull();
|
||||
expect(classifyPath('spec/SKILL.md', { ...ctx, specIsTestPath: true })).toBe('test');
|
||||
expect(classifyPath('src/contest/winner.ts', ctx)).toBeNull();
|
||||
});
|
||||
|
||||
test('CI, config, snapshot and golden classes', () => {
|
||||
expect(classifyPath('.github/workflows/ci.yml', ctx)).toBe('ci');
|
||||
expect(classifyPath('.gitlab-ci.yml', ctx)).toBe('ci');
|
||||
expect(classifyPath('Jenkinsfile', ctx)).toBe('ci');
|
||||
for (const p of ['vitest.config.mts', 'jest.config.js', 'pytest.ini', 'setup.cfg', 'tox.ini', 'bunfig.toml', '.eslintrc.json', 'eslint.config.js', 'tsconfig.build.json', '.rubocop.yml', '.golangci.yml']) {
|
||||
expect([p, classifyPath(p, ctx)]).toEqual([p, 'config']);
|
||||
}
|
||||
expect(classifyPath('x/__snapshots__/a.snap', ctx)).toBe('snapshot');
|
||||
expect(classifyPath('pkg/testdata/a.golden', ctx)).toBe('golden');
|
||||
expect(classifyPath('docs/x.yml', ctx)).toBeNull();
|
||||
expect(classifyPath('bun.lock', ctx)).toBeNull();
|
||||
});
|
||||
|
||||
test('## Gate Integrity block adds test and snapshot paths; globs match any depth', () => {
|
||||
const cfg = parseGateIntegrityBlock('# Project\n\n## Test Coverage\nMinimum: 60%\n\n## Gate Integrity\nExtra test paths: features/**, `qa/*.feature`\nSnapshot paths: fixtures/golden/\n\n## Other\nExtra test paths: ignored/\n');
|
||||
expect(cfg).toEqual({ extraTestPaths: ['features/**', 'qa/*.feature'], snapshotPaths: ['fixtures/golden/'] });
|
||||
const c2 = { project: cfg, specIsTestPath: false };
|
||||
expect(classifyPath('features/login.feature', c2)).toBe('test');
|
||||
expect(classifyPath('qa/smoke.feature', c2)).toBe('test');
|
||||
expect(classifyPath('app/fixtures/golden/out.json', c2)).toBe('golden');
|
||||
expect(parseGateIntegrityBlock('no block')).toEqual(EMPTY_PROJECT_CONFIG);
|
||||
expect(globToRegExp('/root-only/*.ts').test('a/root-only/b.ts')).toBe(false);
|
||||
expect(globToRegExp('/root-only/*.ts').test('root-only/b.ts')).toBe(true);
|
||||
});
|
||||
|
||||
test('snapshot owner: conventional __snapshots__/<name>.snap and sibling <name>.snap only', () => {
|
||||
expect(snapshotOwnerChanged('t/__snapshots__/a.test.ts.snap', new Set(['t/a.test.ts']))).toBe(true);
|
||||
expect(snapshotOwnerChanged('t/__snapshots__/a.test.ts.snap', new Set(['other/a.test.ts']))).toBe(false);
|
||||
expect(snapshotOwnerChanged('t/a.snap', new Set(['t/a.test.ts']))).toBe(true);
|
||||
expect(snapshotOwnerChanged('t/a.snap', new Set(['t/b.test.ts']))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* gate-diff-repo — materialise a test/fixtures/gate-diff patch pair into a
|
||||
* throwaway git repo. Fixtures are patch text only (never a nested .git):
|
||||
* `<name>.base.patch` seeds the base commit on `main`, `<name>.patch` is the
|
||||
* candidate change applied on a `feature` branch, committed or left in the
|
||||
* working tree. Hermetic git identity and no gpg via scratch-repo.
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { gitArgvIn } from './scratch-repo';
|
||||
|
||||
export const GATE_DIFF_FIXTURES = path.resolve(import.meta.dir, '..', 'fixtures', 'gate-diff');
|
||||
|
||||
export interface GateDiffRepoOptions {
|
||||
/** Fixture stem; omit for an empty seed commit. */
|
||||
fixture?: string;
|
||||
/** Commit the candidate change (default: leave it staged in the working tree). */
|
||||
commit?: boolean;
|
||||
commitMessage?: string;
|
||||
/** Extra non-ignored untracked files written after the change. */
|
||||
untracked?: Record<string, string>;
|
||||
}
|
||||
|
||||
function git(dir: string, args: string[]): string {
|
||||
const r = gitArgvIn(dir, args, 10_000);
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr ?? '').toString()}`);
|
||||
return (r.stdout ?? '').toString();
|
||||
}
|
||||
|
||||
export function buildGateDiffRepo(opts: GateDiffRepoOptions = {}): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-gate-diff-repo-'));
|
||||
git(dir, ['init', '-q', '-b', 'main']);
|
||||
if (opts.fixture) git(dir, ['apply', path.join(GATE_DIFF_FIXTURES, `${opts.fixture}.base.patch`)]);
|
||||
else fs.writeFileSync(path.join(dir, 'README.md'), '# seed\n');
|
||||
git(dir, ['add', '-A']);
|
||||
git(dir, ['commit', '-q', '-m', 'seed']);
|
||||
git(dir, ['checkout', '-q', '-b', 'feature']);
|
||||
if (opts.fixture) {
|
||||
git(dir, ['apply', '--index', path.join(GATE_DIFF_FIXTURES, `${opts.fixture}.patch`)]);
|
||||
if (opts.commit) git(dir, ['commit', '-q', '-m', opts.commitMessage ?? `apply ${opts.fixture}`]);
|
||||
}
|
||||
for (const [rel, content] of Object.entries(opts.untracked ?? {})) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function gitIn(dir: string, args: string[]): string {
|
||||
return git(dir, args);
|
||||
}
|
||||
Reference in new issue
Block a user