diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 878c38557..4c08ab73b 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -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//-evidence.jsonl. + * dirty, wtree, log_path, tests_ran} to ~/.gstack/projects//-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 { + 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 { } // 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 { } catch {} } }; - const pump = async (stream: ReadableStream | undefined, out: NodeJS.WriteStream) => { + const pump = async (stream: ReadableStream | 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 { }); } 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 { } } - 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)) { diff --git a/bin/gstack-gate-diff b/bin/gstack-gate-diff new file mode 100755 index 000000000..3544416c7 --- /dev/null +++ b/bin/gstack-gate-diff @@ -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 ] [--help] + * + * The tool produces the inventory and the tags; it never judges and never + * suppresses. stdout, first line always: + * + * GATE_SUMMARY: listed=N eligible=N inspected=N unread=N tagged={RH-1:2,RH-15:1} + * unmatched=N coverage=RH-1,… languages_unlisted= + * candidate=+ artifact= + * + * then the read-level listing (table) or one JSON object per listed hunk + * (jsonl). Diagnostics go to stderr. The full jsonl is always written to + * $GSTACK_STATE_ROOT/projects//gate-diff/.jsonl (mode 0600, + * never inside the working tree: an untracked file under the repo would flip + * /review's review_binding and make /ship loop). + * + * Exit: 0 with or without hunks. 2 only when the base is unresolvable + * (`GATE_ERROR=no_base ref= fix=` as the only stdout line) + * or the tool itself failed (`GATE_ERROR=internal …`). Unmatched paths are + * counted, never an exit 2. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { 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 ] [--help] + +Lists every hunk in a test, CI, runner/lint-config, snapshot or golden path of the +candidate (merge-base of and HEAD -> working tree + untracked files), +tags the gate edits it recognises (RH-1 RH-3 RH-4 RH-12 RH-13 RH-14 RH-15 RH-16), +and prints the read-level listing after one GATE_SUMMARY: line. --commit +scans that commit's first-parent delta instead of the working tree. +Exit 0 with or without hunks; 2 only for an unresolvable base (GATE_ERROR=no_base) +or a tool failure (GATE_ERROR=internal). Full jsonl artifact path is in the summary.`; + +interface Args { + base?: string; + format: 'table' | 'jsonl'; + commit?: string; + help: boolean; +} + +function parseArgs(argv: string[]): Args | string { + const args: Args = { format: 'table', help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--help' || a === '-h') args.help = true; + else if (a === '--format') { + const v = argv[++i]; + if (v !== 'table' && v !== 'jsonl') return `--format must be table or jsonl, got ${JSON.stringify(v ?? '')}`; + args.format = v; + } else if (a.startsWith('--format=')) { + const v = a.slice(9); + if (v !== 'table' && v !== 'jsonl') return `--format must be table or jsonl, got ${JSON.stringify(v)}`; + args.format = v; + } else if (a === '--commit') { + const v = argv[++i]; + if (!v || v.startsWith('-')) return '--commit requires a '; + args.commit = v; + } else if (a.startsWith('-')) return `unknown option ${a}`; + else if (args.base !== undefined) return `unexpected extra argument ${a}`; + else args.base = a; + } + return args; +} + +function projectSlug(cwd: string): string { + try { + const r = 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; +} diff --git a/docs/designs/gate-diff-calibration.md b/docs/designs/gate-diff-calibration.md new file mode 100644 index 000000000..9b7624cd9 --- /dev/null +++ b/docs/designs/gate-diff-calibration.md @@ -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 ` 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). diff --git a/lib/gate-diff/classify.ts b/lib/gate-diff/classify.ts new file mode 100644 index 000000000..e507cfb74 --- /dev/null +++ b/lib/gate-diff/classify.ts @@ -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): 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 = { + 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 = { + 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[] { + const found = new Set(); + 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(); +} diff --git a/lib/gate-diff/git.ts b/lib/gate-diff/git.ts new file mode 100644 index 000000000..ff01c100c --- /dev/null +++ b/lib/gate-diff/git.ts @@ -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 + * `), plus non-ignored untracked files as additions; never the + * committed + HEAD + untracked concatenation gstack-diff-scope uses. `--commit + * ` 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 ''; + } +} diff --git a/lib/gate-diff/index.ts b/lib/gate-diff/index.ts new file mode 100644 index 000000000..b03686eae --- /dev/null +++ b/lib/gate-diff/index.ts @@ -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; + /** `+` or `` 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): 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(); + 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(); + 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 = {}; + 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, + }; +} diff --git a/lib/gate-diff/render.ts b/lib/gate-diff/render.ts new file mode 100644 index 000000000..797add1a2 --- /dev/null +++ b/lib/gate-diff/render.ts @@ -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= + * candidate= artifact=`. Then either the table (read-level + * hunks only: `[] @@ @@`, the removed/added + * lines indented, optional `note:` lines, then `citation: `) 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 { + const rec: Record = { + 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; +} diff --git a/lib/gate-diff/tags.ts b/lib/gate-diff/tags.ts new file mode 100644 index 000000000..00b7548f4 --- /dev/null +++ b/lib/gate-diff/tags.ts @@ -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; +} + +/** 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 = /^(?.*?)\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\(\s*-?\d+(?:\.\d+)?\s*\)/; +const BOUND = /^(?.*?)\.(?: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(); + 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__/.snap` → `` beside it; `.snap` → `` or `.*` beside it. */ +export function snapshotOwnerChanged(path: string, allPaths: ReadonlySet): 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(); + 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); +} diff --git a/lib/gate-diff/types.ts b/lib/gate-diff/types.ts new file mode 100644 index 000000000..6d8933b5e --- /dev/null +++ b/lib/gate-diff/types.ts @@ -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; + 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 }[]; +} diff --git a/scripts/gate-diff-calibrate.ts b/scripts/gate-diff-calibrate.ts new file mode 100644 index 000000000..166452597 --- /dev/null +++ b/scripts/gate-diff-calibrate.ts @@ -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 ] [--markdown] + * + * Runs the pure core (lib/gate-diff) over the first-parent delta of the last + * N commits on 's first-parent line (one per landed PR: a merge commit + * or a squash merge), exactly what `gstack-gate-diff --commit ` 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 ] [--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 '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): 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 { + 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 = {}; + const commitsWithTag: Record = {}; + 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; +} diff --git a/test/evidence.test.ts b/test/evidence.test.ts index c7637c4d6..d5db0343b 100644 --- a/test/evidence.test.ts +++ b/test/evidence.test.ts @@ -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'); + }); +}); diff --git a/test/fixtures/gate-diff/adv-forged-measured.base.patch b/test/fixtures/gate-diff/adv-forged-measured.base.patch new file mode 100644 index 000000000..7e76de793 --- /dev/null +++ b/test/fixtures/gate-diff/adv-forged-measured.base.patch @@ -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 }); diff --git a/test/fixtures/gate-diff/adv-forged-measured.patch b/test/fixtures/gate-diff/adv-forged-measured.patch new file mode 100644 index 000000000..da041458e --- /dev/null +++ b/test/fixtures/gate-diff/adv-forged-measured.patch @@ -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 }); diff --git a/test/fixtures/gate-diff/adv-golden-beside-unrelated.base.patch b/test/fixtures/gate-diff/adv-golden-beside-unrelated.base.patch new file mode 100644 index 000000000..6975fa5eb --- /dev/null +++ b/test/fixtures/gate-diff/adv-golden-beside-unrelated.base.patch @@ -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 = () => `
`; +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`] = ` ++ ++`; diff --git a/test/fixtures/gate-diff/adv-golden-beside-unrelated.patch b/test/fixtures/gate-diff/adv-golden-beside-unrelated.patch new file mode 100644 index 000000000..273dc3141 --- /dev/null +++ b/test/fixtures/gate-diff/adv-golden-beside-unrelated.patch @@ -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`] = ` +- + `; diff --git a/test/fixtures/gate-diff/adv-kotlin-ignore.base.patch b/test/fixtures/gate-diff/adv-kotlin-ignore.base.patch new file mode 100644 index 000000000..452c39c73 --- /dev/null +++ b/test/fixtures/gate-diff/adv-kotlin-ignore.base.patch @@ -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) ++ } ++} diff --git a/test/fixtures/gate-diff/adv-kotlin-ignore.patch b/test/fixtures/gate-diff/adv-kotlin-ignore.patch new file mode 100644 index 000000000..1275cb866 --- /dev/null +++ b/test/fixtures/gate-diff/adv-kotlin-ignore.patch @@ -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) diff --git a/test/fixtures/gate-diff/control-added-test.base.patch b/test/fixtures/gate-diff/control-added-test.base.patch new file mode 100644 index 000000000..e0d6b93ed --- /dev/null +++ b/test/fixtures/gate-diff/control-added-test.base.patch @@ -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; ++} diff --git a/test/fixtures/gate-diff/control-added-test.patch b/test/fixtures/gate-diff/control-added-test.patch new file mode 100644 index 000000000..d4b12c9b4 --- /dev/null +++ b/test/fixtures/gate-diff/control-added-test.patch @@ -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)); ++}); diff --git a/test/fixtures/gate-diff/control-rename.base.patch b/test/fixtures/gate-diff/control-rename.base.patch new file mode 100644 index 000000000..364c1e414 --- /dev/null +++ b/test/fixtures/gate-diff/control-rename.base.patch @@ -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)); ++}); diff --git a/test/fixtures/gate-diff/control-rename.patch b/test/fixtures/gate-diff/control-rename.patch new file mode 100644 index 000000000..b6b8095cc --- /dev/null +++ b/test/fixtures/gate-diff/control-rename.patch @@ -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 diff --git a/test/fixtures/gate-diff/control-snapshot-with-owner.base.patch b/test/fixtures/gate-diff/control-snapshot-with-owner.base.patch new file mode 100644 index 000000000..7023fe1cd --- /dev/null +++ b/test/fixtures/gate-diff/control-snapshot-with-owner.base.patch @@ -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 }) => ``; +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`] = ` ++ ++`; diff --git a/test/fixtures/gate-diff/control-snapshot-with-owner.patch b/test/fixtures/gate-diff/control-snapshot-with-owner.patch new file mode 100644 index 000000000..418074c6f --- /dev/null +++ b/test/fixtures/gate-diff/control-snapshot-with-owner.patch @@ -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`] = ` +- + `; diff --git a/test/fixtures/gate-diff/control-spec-skill-dir.base.patch b/test/fixtures/gate-diff/control-spec-skill-dir.base.patch new file mode 100644 index 000000000..628054f35 --- /dev/null +++ b/test/fixtures/gate-diff/control-spec-skill-dir.base.patch @@ -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. diff --git a/test/fixtures/gate-diff/control-spec-skill-dir.patch b/test/fixtures/gate-diff/control-spec-skill-dir.patch new file mode 100644 index 000000000..ec9f3c552 --- /dev/null +++ b/test/fixtures/gate-diff/control-spec-skill-dir.patch @@ -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. diff --git a/test/fixtures/gate-diff/control-test-deleted-with-source.base.patch b/test/fixtures/gate-diff/control-test-deleted-with-source.base.patch new file mode 100644 index 000000000..92b0c9450 --- /dev/null +++ b/test/fixtures/gate-diff/control-test-deleted-with-source.base.patch @@ -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)); ++}); diff --git a/test/fixtures/gate-diff/control-test-deleted-with-source.patch b/test/fixtures/gate-diff/control-test-deleted-with-source.patch new file mode 100644 index 000000000..467efa8f9 --- /dev/null +++ b/test/fixtures/gate-diff/control-test-deleted-with-source.patch @@ -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)); +-}); diff --git a/test/fixtures/gate-diff/package-json-scripts.base.patch b/test/fixtures/gate-diff/package-json-scripts.base.patch new file mode 100644 index 000000000..fd5a00f4b --- /dev/null +++ b/test/fixtures/gate-diff/package-json-scripts.base.patch @@ -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" ++ } ++} diff --git a/test/fixtures/gate-diff/package-json-scripts.patch b/test/fixtures/gate-diff/package-json-scripts.patch new file mode 100644 index 000000000..a4c576409 --- /dev/null +++ b/test/fixtures/gate-diff/package-json-scripts.patch @@ -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" diff --git a/test/fixtures/gate-diff/rh1-removed.base.patch b/test/fixtures/gate-diff/rh1-removed.base.patch new file mode 100644 index 000000000..364c1e414 --- /dev/null +++ b/test/fixtures/gate-diff/rh1-removed.base.patch @@ -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)); ++}); diff --git a/test/fixtures/gate-diff/rh1-removed.patch b/test/fixtures/gate-diff/rh1-removed.patch new file mode 100644 index 000000000..75a8813b6 --- /dev/null +++ b/test/fixtures/gate-diff/rh1-removed.patch @@ -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)); + }); diff --git a/test/fixtures/gate-diff/rh1-skip.base.patch b/test/fixtures/gate-diff/rh1-skip.base.patch new file mode 100644 index 000000000..364c1e414 --- /dev/null +++ b/test/fixtures/gate-diff/rh1-skip.base.patch @@ -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)); ++}); diff --git a/test/fixtures/gate-diff/rh1-skip.patch b/test/fixtures/gate-diff/rh1-skip.patch new file mode 100644 index 000000000..3173db823 --- /dev/null +++ b/test/fixtures/gate-diff/rh1-skip.patch @@ -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); + }); + diff --git a/test/fixtures/gate-diff/rh12-env.base.patch b/test/fixtures/gate-diff/rh12-env.base.patch new file mode 100644 index 000000000..9191d5a8e --- /dev/null +++ b/test/fixtures/gate-diff/rh12-env.base.patch @@ -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; } diff --git a/test/fixtures/gate-diff/rh12-env.patch b/test/fixtures/gate-diff/rh12-env.patch new file mode 100644 index 000000000..c9dd8fd75 --- /dev/null +++ b/test/fixtures/gate-diff/rh12-env.patch @@ -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; } diff --git a/test/fixtures/gate-diff/rh13-pragma.base.patch b/test/fixtures/gate-diff/rh13-pragma.base.patch new file mode 100644 index 000000000..60dc6b81a --- /dev/null +++ b/test/fixtures/gate-diff/rh13-pragma.base.patch @@ -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); ++} diff --git a/test/fixtures/gate-diff/rh13-pragma.patch b/test/fixtures/gate-diff/rh13-pragma.patch new file mode 100644 index 000000000..23776eed6 --- /dev/null +++ b/test/fixtures/gate-diff/rh13-pragma.patch @@ -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); + } diff --git a/test/fixtures/gate-diff/rh14-bypass.base.patch b/test/fixtures/gate-diff/rh14-bypass.base.patch new file mode 100644 index 000000000..9c5e9c136 --- /dev/null +++ b/test/fixtures/gate-diff/rh14-bypass.base.patch @@ -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 diff --git a/test/fixtures/gate-diff/rh14-bypass.patch b/test/fixtures/gate-diff/rh14-bypass.patch new file mode 100644 index 000000000..62de4a82a --- /dev/null +++ b/test/fixtures/gate-diff/rh14-bypass.patch @@ -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 diff --git a/test/fixtures/gate-diff/rh15-bound.base.patch b/test/fixtures/gate-diff/rh15-bound.base.patch new file mode 100644 index 000000000..86e2d932c --- /dev/null +++ b/test/fixtures/gate-diff/rh15-bound.base.patch @@ -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); ++}); diff --git a/test/fixtures/gate-diff/rh15-bound.patch b/test/fixtures/gate-diff/rh15-bound.patch new file mode 100644 index 000000000..001541b00 --- /dev/null +++ b/test/fixtures/gate-diff/rh15-bound.patch @@ -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); + }); diff --git a/test/fixtures/gate-diff/rh15-timeout.base.patch b/test/fixtures/gate-diff/rh15-timeout.base.patch new file mode 100644 index 000000000..4a0827ef3 --- /dev/null +++ b/test/fixtures/gate-diff/rh15-timeout.base.patch @@ -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 } } } }; diff --git a/test/fixtures/gate-diff/rh15-timeout.patch b/test/fixtures/gate-diff/rh15-timeout.patch new file mode 100644 index 000000000..6cf35cd2b --- /dev/null +++ b/test/fixtures/gate-diff/rh15-timeout.patch @@ -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 } } } }; diff --git a/test/fixtures/gate-diff/rh16-swallow.base.patch b/test/fixtures/gate-diff/rh16-swallow.base.patch new file mode 100644 index 000000000..2a3dae790 --- /dev/null +++ b/test/fixtures/gate-diff/rh16-swallow.base.patch @@ -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 { ++ await push(); ++} diff --git a/test/fixtures/gate-diff/rh16-swallow.patch b/test/fixtures/gate-diff/rh16-swallow.patch new file mode 100644 index 000000000..4036e2c10 --- /dev/null +++ b/test/fixtures/gate-diff/rh16-swallow.patch @@ -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 { +- await push(); ++ try { ++ await push(); ++ } catch (e) {} + } diff --git a/test/fixtures/gate-diff/rh3-golden.base.patch b/test/fixtures/gate-diff/rh3-golden.base.patch new file mode 100644 index 000000000..be092a354 --- /dev/null +++ b/test/fixtures/gate-diff/rh3-golden.base.patch @@ -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 diff --git a/test/fixtures/gate-diff/rh3-golden.patch b/test/fixtures/gate-diff/rh3-golden.patch new file mode 100644 index 000000000..6bc94f64d --- /dev/null +++ b/test/fixtures/gate-diff/rh3-golden.patch @@ -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 diff --git a/test/fixtures/gate-diff/rh3-snapshot-no-owner.base.patch b/test/fixtures/gate-diff/rh3-snapshot-no-owner.base.patch new file mode 100644 index 000000000..7023fe1cd --- /dev/null +++ b/test/fixtures/gate-diff/rh3-snapshot-no-owner.base.patch @@ -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 }) => ``; +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`] = ` ++ ++`; diff --git a/test/fixtures/gate-diff/rh3-snapshot-no-owner.patch b/test/fixtures/gate-diff/rh3-snapshot-no-owner.patch new file mode 100644 index 000000000..3b760998b --- /dev/null +++ b/test/fixtures/gate-diff/rh3-snapshot-no-owner.patch @@ -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`] = ` +- + `; diff --git a/test/fixtures/gate-diff/rh4-placeholder.base.patch b/test/fixtures/gate-diff/rh4-placeholder.base.patch new file mode 100644 index 000000000..8819b9d06 --- /dev/null +++ b/test/fixtures/gate-diff/rh4-placeholder.base.patch @@ -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}`; ++} diff --git a/test/fixtures/gate-diff/rh4-placeholder.patch b/test/fixtures/gate-diff/rh4-placeholder.patch new file mode 100644 index 000000000..2c1ee303b --- /dev/null +++ b/test/fixtures/gate-diff/rh4-placeholder.patch @@ -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"); + } diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 4cbf69b23..3d5e31a33 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -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" }, diff --git a/test/gate-diff-cli.test.ts b/test/gate-diff-cli.test.ts new file mode 100644 index 000000000..0be4c891c --- /dev/null +++ b/test/gate-diff-cli.test.ts @@ -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[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 ] [--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).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 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?']]]); + }); +}); diff --git a/test/gate-diff.test.ts b/test/gate-diff.test.ts new file mode 100644 index 000000000..49e40b382 --- /dev/null +++ b/test/gate-diff.test.ts @@ -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[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__/.snap and sibling .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); + }); +}); diff --git a/test/helpers/gate-diff-repo.ts b/test/helpers/gate-diff-repo.ts new file mode 100644 index 000000000..51995527e --- /dev/null +++ b/test/helpers/gate-diff-repo.ts @@ -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): + * `.base.patch` seeds the base commit on `main`, `.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; +} + +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); +}