From d901915f86d4596e396f3c95f2f083d475be5bd9 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sun, 11 Oct 2026 02:20:48 -0400 Subject: [PATCH] fix(gates): test-aware mutation cache fingerprint (Refs #169) --- packages/core/src/gates/engine.ts | 9 +++++++-- packages/core/src/gates/mutation.ts | 25 ++++++++++++++++++++++++- packages/core/test/mutation.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/core/src/gates/engine.ts b/packages/core/src/gates/engine.ts index 0a8022a..155a61f 100644 --- a/packages/core/src/gates/engine.ts +++ b/packages/core/src/gates/engine.ts @@ -35,8 +35,8 @@ import { generateMutants, type MutationCacheState, type MutationOutcome, - mutantCacheKey, mutationFindings, + mutationFingerprint, } from "./mutation.ts" import { parseOutput } from "./parsers.ts" import { scanFileForSecrets } from "./secrets.ts" @@ -385,7 +385,12 @@ async function runMutationCheck( const absolute = path.join(check.dir, file) const source = await readFile(absolute, "utf8").catch(() => undefined) if (source === undefined) continue - const hash = mutantCacheKey(file, source) + const hash = await mutationFingerprint( + (candidate) => readFile(path.join(check.dir, candidate), "utf8").catch(() => undefined), + file, + source, + check.files.filter((candidate) => isTestFile(candidate)), + ) const entry = next[file] if (entry?.hash === hash) { const killed = new Set(entry.killed) diff --git a/packages/core/src/gates/mutation.ts b/packages/core/src/gates/mutation.ts index 5a996bd..923dcc9 100644 --- a/packages/core/src/gates/mutation.ts +++ b/packages/core/src/gates/mutation.ts @@ -142,6 +142,27 @@ export function mutantCacheKey(file: string, content: string): string { return sha256(`${file}\n${content}`) } +/** + * Cache fingerprint for one source file: its own content plus the content of + * the in-scope test files. Weakening (or strengthening) the tests changes + * the fingerprint, so a stale verdict can never green-light edited tests — + * the suite reruns. + */ +export async function mutationFingerprint( + readFile: (file: string) => Promise, + file: string, + source: string, + testFiles: readonly string[], +): Promise { + const tests: string[] = [] + for (const candidate of testFiles) { + if (candidate === file) continue + const content = await readFile(candidate) + if (content !== undefined) tests.push(`${candidate}\n${content}`) + } + return mutantCacheKey(file, `${source}\n${tests.join("\n")}`) +} + export function evaluateMutations( outcomes: readonly MutationOutcome[], threshold = MUTATION_KILL_THRESHOLD, @@ -186,6 +207,8 @@ export interface MutationGateOptions { readonly runTests: (mutant: Mutant) => Promise<{ killed: boolean }> /** Reused across runs: unchanged files skip the suite. */ readonly cache?: MutationCacheState + /** Test files whose content joins the cache fingerprint (edited tests rerun the suite). */ + readonly testFiles?: readonly string[] readonly threshold?: number readonly maxPerFile?: number } @@ -203,7 +226,7 @@ export async function runMutationGate(options: MutationGateOptions): Promise { expect(mutantCacheKey("src/a.ts", "a + b")).not.toBe(mutantCacheKey("src/a.ts", "a - b")) expect(mutantCacheKey("src/a.ts", "same")).toBe(mutantCacheKey("src/a.ts", "same")) }) + + test("changing a test file invalidates the cache: the suite reruns", async () => { + let suite = "strong" + let runs = 0 + const options = { + files: ["src/sum.ts"], + readFile: async (file: string) => + file === "src/sum.ts" ? "export function sum(a: number, b: number): number {\n return a + b\n}\n" : suite, + runTests: async () => { + runs++ + return { killed: true } + }, + testFiles: ["test/sum.test.ts"], + } + const first = await runMutationGate(options) + expect(runs).toBeGreaterThan(0) + // Same tests: cached, no rerun. + await runMutationGate({ ...options, cache: first.cache }) + expect(runs).toBe(first.cache["src/sum.ts"]!.total) + // Weakened tests: the fingerprint changes, so the suite reruns. + suite = "weakened" + const third = await runMutationGate({ ...options, cache: first.cache }) + expect(third.cached).not.toContain("src/sum.ts") + expect(runs).toBeGreaterThan(first.cache["src/sum.ts"]!.total) + }) })