import { createHash } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; /** * The key a gate verdict is valid under: the command, plus the path, size and **contents** of every file it ran * against. A hash match means re-running would produce the same answer * ([/decisions/ad-045.md](/decisions/ad-045.md)). * * why: contents rather than mtime. A `touch`, a branch switch that restores identical bytes, or a clock that * moved all change mtime without changing what the gate would read. */ const MAX_FILES = 400; const MAX_BYTES = 12_000_000; export type InputsHash = { hash: string; /** * False when something could not be read or a cap was hit. * * invariant: an incomplete hash is never a cache hit. Unknown means run the gate — the conservative direction * is the one where a real failure is not hidden by a hash nobody could compute. */ complete: boolean; }; function fileEntry(root: string, relative: string): { entry: string; bytes: number } | null { // hazard: an absolute path in the changed-file list would resolve against the machine rather than the // repository, so the same tree would hash differently depending on where it was checked out. if (isAbsolute(relative)) { return null; } const absolute = resolve(root, relative); try { const stat = statSync(absolute); if (!stat.isFile()) { return null; } const contents = readFileSync(absolute); return { entry: `${relative}${stat.size}${createHash("sha256").update(contents).digest("hex")}`, bytes: stat.size, }; } catch { return null; } } export function computeInputsHash( root: string, files: readonly string[], command: readonly string[], ): InputsHash { // why: sorted, so the order the caller happened to collect paths in cannot change the key. const sorted = [...new Set(files)].sort(); if (sorted.length > MAX_FILES) { return { hash: "", complete: false }; } const entries: string[] = []; let bytes = 0; let complete = true; for (const relative of sorted) { const entry = fileEntry(root, relative); if (entry === null) { complete = false; continue; } bytes += entry.bytes; if (bytes > MAX_BYTES) { return { hash: "", complete: false }; } entries.push(entry.entry); } const raw = JSON.stringify({ command: [...command], entries }); return { hash: createHash("sha256").update(raw).digest("hex").slice(0, 32), complete }; } /** * invariant: both sides must be present, equal and complete. An artifact written before `inputsHash` existed has * none, so the first run after an upgrade always executes. */ export function isCacheHit(current: InputsHash, recorded: string | undefined): boolean { return current.complete && current.hash.length > 0 && recorded === current.hash; } /** * The verdict to reuse, or `null` to run the gate. * * hazard: the gate name is compared as well as the hash. Today the command is part of the key, so two gates * cannot collide — but that makes the name check unfalsifiable end to end, which is why it is a function with its * own test rather than an inline condition nothing can exercise * ([/decisions/ad-045.md](/decisions/ad-045.md)). */ export function cachedVerdict( last: T | null, gate: string, current: InputsHash, ): T | null { if (last === null || last.gate !== gate) { return null; } return isCacheHit(current, last.inputsHash) ? last : null; }