diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md index 886a4fe62..957915a46 100644 --- a/aidd_docs/memory/coding-assertions.md +++ b/aidd_docs/memory/coding-assertions.md @@ -43,6 +43,8 @@ Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no | ----- | ------- | ------ | | 1 | `pnpm exec lefthook run pre-push` | `cli knip`, then the full `cli` suite, when `cli/` changed | +Each runs through `scripts/gate-witness.js`, which skips a gate this exact tree already passed: same index, same unstaged edits, same untracked files. A tree that changed while the gate ran is never stamped. + `--no-verify` buys nothing: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request. ## Behavior diff --git a/lefthook.yml b/lefthook.yml index 483fb4f90..14eefeb00 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -175,10 +175,10 @@ pre-push: commands: cli-knip: glob: "cli/**" - run: cd cli && pnpm knip + run: node scripts/gate-witness.js cli-knip -- pnpm --dir cli knip cli-test: glob: "cli/**" - run: cd cli && pnpm test + run: node scripts/gate-witness.js cli-test -- pnpm --dir cli test commit-msg: commands: diff --git a/scripts/__tests__/gate-witness.test.js b/scripts/__tests__/gate-witness.test.js new file mode 100644 index 000000000..75cac1fcb --- /dev/null +++ b/scripts/__tests__/gate-witness.test.js @@ -0,0 +1,102 @@ +const assert = require("node:assert/strict"); +const { execFileSync, spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const WITNESS = path.resolve(__dirname, "../gate-witness.js"); +const CLEAN_ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))); + +function repo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gate-witness-")); + const git = (...args) => execFileSync("git", args, { cwd: dir, env: CLEAN_ENV, stdio: "pipe" }); + git("init", "-q"); + git("config", "user.email", "test@example.com"); + git("config", "user.name", "Test"); + fs.writeFileSync(path.join(dir, "a.txt"), "a\n"); + fs.writeFileSync(path.join(dir, "b.txt"), "b\n"); + git("add", "-A"); + git("commit", "-q", "-m", "init"); + const runs = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gate-witness-runs-")), "count"); + fs.writeFileSync(runs, ""); + return { dir, git, runs }; +} + +function witness(r, commandLine, name = "suite") { + return spawnSync(process.execPath, [WITNESS, name, "--", commandLine], { cwd: r.dir, env: CLEAN_ENV, encoding: "utf8" }); +} + +function commandLine(r, script) { + const file = path.join(path.dirname(r.runs), `${createHash("sha256").update(script).digest("hex")}.js`); + fs.writeFileSync(file, script); + return [process.execPath, file, r.runs].map((part) => `"${part}"`).join(" "); +} + +const gate = (r, script, name) => witness(r, commandLine(r, script), name); + +const COUNT = "require('fs').appendFileSync(process.argv[2], 'x')"; +const runsOf = (r) => fs.readFileSync(r.runs, "utf8").length; + +test("skips a gate on a tree that already passed it, and says so", () => { + const r = repo(); + assert.equal(gate(r, COUNT).status, 0); + const second = gate(r, COUNT); + assert.equal(second.status, 0); + assert.equal(runsOf(r), 1); + assert.match(second.stdout + second.stderr, /already passed/); +}); + +test("runs again after a tracked file changes, staged or not", () => { + const r = repo(); + gate(r, COUNT); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a changed\n"); + gate(r, COUNT); + r.git("add", "a.txt"); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a changed\n"); + gate(r, COUNT); + assert.equal(runsOf(r), 3); +}); + +test("runs again after a file is added, left untracked, or removed", () => { + const r = repo(); + gate(r, COUNT); + fs.writeFileSync(path.join(r.dir, "new.txt"), "new\n"); + gate(r, COUNT); + r.git("add", "new.txt"); + gate(r, COUNT); + r.git("rm", "-q", "b.txt"); + gate(r, COUNT); + assert.equal(runsOf(r), 4); +}); + +test("stamps nothing when the tree changed while the gate ran, even once it changes back", () => { + const r = repo(); + const editsOnItsFirstRun = `${COUNT}; if (require('fs').readFileSync(process.argv[2], 'utf8') === 'x') require('fs').writeFileSync('a.txt', 'edited during the run')`; + assert.equal(gate(r, editsOnItsFirstRun).status, 0); + fs.writeFileSync(path.join(r.dir, "a.txt"), "a\n"); + gate(r, editsOnItsFirstRun); + assert.equal(runsOf(r), 2); +}); + +test("passes a failure through and stamps nothing", () => { + const r = repo(); + assert.equal(gate(r, `${COUNT}; process.exit(3)`).status, 3); + gate(r, COUNT); + assert.equal(runsOf(r), 2); +}); + +test("keeps one stamp per gate, so another gate still runs", () => { + const r = repo(); + gate(r, COUNT, "knip"); + gate(r, COUNT, "suite"); + assert.equal(runsOf(r), 2); +}); + +test("runs what follows -- as one shell command line, on every platform", () => { + const r = repo(); + const count = commandLine(r, COUNT); + assert.equal(witness(r, `${count} && ${count}`).status, 0); + assert.equal(runsOf(r), 2); +}); diff --git a/scripts/gate-witness.js b/scripts/gate-witness.js new file mode 100644 index 000000000..8ea099269 --- /dev/null +++ b/scripts/gate-witness.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Runs a gate unless this exact tree already passed it. The witness is keyed on everything a + * gate reads from the checkout: the index, so `git add` and `git rm` count; unstaged edits to + * tracked files; and untracked files git does not ignore. It is taken before the run and saved + * only when the run is green and the tree is still the one it was taken on, so an edit made + * while the gate ran is never signed off. The command's own exit code passes through. + * + * What follows `--` is one shell command line, run by the platform's shell everywhere: on + * Windows only a shell can start `pnpm`, and a shell given separate arguments would re-split + * them unquoted, so the caller quotes once and every platform reads the same line. + * + * Usage: + * node scripts/gate-witness.js -- + */ +const { execFileSync, spawnSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const USAGE_EXIT = 2; + +function git(root, args, input) { + return execFileSync("git", args, { + cwd: root, + input, + maxBuffer: 1 << 30, + stdio: ["pipe", "pipe", "ignore"], + }); +} + +function treeKey(root, command) { + const hash = createHash("sha256"); + hash.update(JSON.stringify(command)); + hash.update(git(root, ["ls-files", "-s", "-z"])); + hash.update(git(root, ["diff", "-z", "--no-ext-diff", "--binary"])); + const untracked = git(root, ["ls-files", "-o", "--exclude-standard", "-z"]) + .toString("utf8") + .split("\0") + .filter(Boolean); + if (untracked.length > 0) { + hash.update(untracked.join("\0")); + hash.update(git(root, ["hash-object", "--stdin-paths"], untracked.join("\n"))); + } + return hash.digest("hex"); +} + +function readStamp(file) { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +function main() { + const [name, separator, ...command] = process.argv.slice(2); + if (!/^[\w-]+$/.test(name ?? "") || separator !== "--" || command.length === 0) { + console.error("Usage: node scripts/gate-witness.js -- "); + return USAGE_EXIT; + } + const root = git(process.cwd(), ["rev-parse", "--show-toplevel"]).toString("utf8").trim(); + const gitPath = git(root, ["rev-parse", "--git-path", `aidd-gate-witness/${name}`]); + const stamp = path.resolve(root, gitPath.toString("utf8").trim()); + const before = treeKey(root, command); + if (readStamp(stamp) === before) { + console.log(`✓ ${name}: skipped, this exact tree already passed it.`); + return 0; + } + const result = spawnSync(command.join(" "), { stdio: "inherit", shell: true }); + if (result.status !== 0) return result.status ?? 1; + if (treeKey(root, command) !== before) { + console.log(`${name}: passed, but the tree changed while it ran, so it is not stamped.`); + return 0; + } + fs.mkdirSync(path.dirname(stamp), { recursive: true }); + fs.writeFileSync(stamp, before); + return 0; +} + +process.exitCode = main();