From 4d43c9799e240b6940562c74c39ad87b732c29ec Mon Sep 17 00:00:00 2001 From: Cotal merge validation Date: Sun, 6 Sep 2026 08:44:17 +0200 Subject: [PATCH] fix(ci): select mutation fixtures when suites change --- bin/smoke/mutation-reproof.smoke.ts | 45 +++++++++++++++++++++-- bin/smoke/mutations/mutation-reproof.json | 10 ++++- scripts/mutation-reproof.mjs | 7 ++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/bin/smoke/mutation-reproof.smoke.ts b/bin/smoke/mutation-reproof.smoke.ts index 3bea965b9..3db779ce9 100644 --- a/bin/smoke/mutation-reproof.smoke.ts +++ b/bin/smoke/mutation-reproof.smoke.ts @@ -90,6 +90,7 @@ function makeRepo(mutate: (root: string) => void): { root: string; base: string; "", ].join("\n")); writeFileSync(join(root, "smoke", "mutations", `${name}.mutations.json`), JSON.stringify({ + suite: `${name}.suite.mjs`, command: `node ${name}.suite.mjs`, mutations: [{ name: `the ${name} cap is removed`, @@ -228,7 +229,45 @@ try { ); } - // 5. A selected fixture whose suite is ALREADY RED before any mutation. a.mjs changes (so a's + // 5. A suite-only change must select the fixture that names that suite, even though neither its + // config nor guarded source changed. The other suite is the negative arm: changing a file whose + // name merely looks like a suite must not select either fixture. + { + const { root, base, head } = build((r) => { + writeFileSync(join(r, "a.suite.mjs"), [ + "// suite-only change; a.mjs and the fixture config are untouched", + "import { capped_a } from './a.mjs';", + "if (capped_a(100) !== 32) { console.error('✗ FAIL: the a cap holds'); process.exit(1); }", + "console.log('✓ the a cap holds');", + "", + ].join("\n")); + git(r, ["add", "a.suite.mjs"]); + git(r, ["commit", "--quiet", "-m", "change only a's suite"]); + }); + const { status, out } = scan(root, base, head); + check( + "a suite-only change selects exactly the fixture that names that suite", + status === 0 + && eq(selectedPaths(out), ["smoke/mutations/a.mutations.json"]) + && JSON.stringify(okCounts(out)) === JSON.stringify({ discriminated: 1, preRed: 0, inconclusive: 0 }), + `status=${status} selected=${JSON.stringify(selectedPaths(out))} counts=${JSON.stringify(okCounts(out))}\n${out}`, + ); + } + { + const { root, base, head } = build((r) => { + writeFileSync(join(r, "unrelated.suite.mjs"), "// not named by any fixture\n"); + git(r, ["add", "unrelated.suite.mjs"]); + git(r, ["commit", "--quiet", "-m", "change an unrelated suite"]); + }); + const { status, out } = scan(root, base, head); + check( + "an unrelated suite selects no fixture", + status === 0 && selectedPaths(out).length === 0, + `status=${status} selected=${JSON.stringify(selectedPaths(out))}\n${out}`, + ); + } + + // 6. A selected fixture whose suite is ALREADY RED before any mutation. a.mjs changes (so a's // fixture is selected) but a's suite fails unconditionally, so mutation-proof refuses at its // baseline (exit 4). That is the suite's own defect, not this diff's — the #1279/sandbox-guard // shape the reviewer reproduced. The gate must report it as PRE-RED, name exactly that fixture, @@ -263,7 +302,7 @@ try { ); } - // 6. INCONCLUSIVE (not a timeout — deterministic): a mutation that leaves the suite exiting 0 but + // 7. INCONCLUSIVE (not a timeout — deterministic): a mutation that leaves the suite exiting 0 but // never printing its named assertion. mutation-proof grades that INCONCLUSIVE, "a green status // is not a pass". The gate must report it as INCONCLUSIVE, not fail, and NOT collapse it into // SURVIVED (false blocker) or KILLED (false clearance). This is the outcome the reviewer flagged @@ -303,7 +342,7 @@ try { ); } - // 7. A real all-clear that reaches the OK summary (a fixture whose mutant is genuinely killed). + // 8. A real all-clear that reaches the OK summary (a fixture whose mutant is genuinely killed). // Its counts must read `discriminated > 0, pre-red 0, inconclusive 0` — the positive contrast to // cases 5 and 6, so "the gate worked and everything was clean" is not the same output as "every // selected fixture was pre-red or inconclusive". diff --git a/bin/smoke/mutations/mutation-reproof.json b/bin/smoke/mutations/mutation-reproof.json index e65f1909c..0fac61021 100644 --- a/bin/smoke/mutations/mutation-reproof.json +++ b/bin/smoke/mutations/mutation-reproof.json @@ -1,6 +1,6 @@ { "suite": "bin/smoke/mutation-reproof.smoke.ts", - "guard": "changed guarded source re-proves its fixture; a deleted or renamed-away source is a loud dangling failure naming the exact fixture; both sides of a rename enter the changed set", + "guard": "changed fixture config, suite, or guarded source re-proves its fixture; a deleted or renamed-away source is a loud dangling failure naming the exact fixture; both sides of a rename enter the changed set", "command": "pnpm smoke:mutation-reproof", "grades": "tool", "proveWith": "pnpm mutation-proof --config bin/smoke/mutations/mutation-reproof.json", @@ -21,6 +21,14 @@ "expectRed": "the gate FAILS on a renamed-away guarded source and names exactly the dangling fixture", "cell": "the gate FAILS on a renamed-away guarded source and names exactly the dangling fixture" }, + { + "name": "a changed configured suite is ignored by changed-set selection", + "file": "scripts/mutation-reproof.mjs", + "find": " || (typeof suite === \"string\" && changed.has(suite))\n", + "replace": "", + "expectRed": "a suite-only change selects exactly the fixture that names that suite", + "cell": "a suite-only change selects exactly the fixture that names that suite" + }, { "name": "a dangling fixture is no longer refused, so a deleted source stops being a loud failure", "file": "scripts/mutation-reproof.mjs", diff --git a/scripts/mutation-reproof.mjs b/scripts/mutation-reproof.mjs index 4ba0a56c1..89f2f1e97 100644 --- a/scripts/mutation-reproof.mjs +++ b/scripts/mutation-reproof.mjs @@ -81,7 +81,7 @@ function loadCorpus(root, paths) { errors.push(`${path}: no top-level "mutations" array`); continue; } - fixtures.push({ path, mutations: config.mutations }); + fixtures.push({ path, suite: config.suite, mutations: config.mutations }); } return { fixtures, errors }; } @@ -158,7 +158,8 @@ if (errors.length) { const { changed, diffSize } = a.all ? { changed: new Set(), diffSize: 0 } : changedSet(root, a.base, head); -let selected = fixtures.filter(({ path, mutations }) => a.all || changed.has(path) +let selected = fixtures.filter(({ path, suite, mutations }) => a.all || changed.has(path) + || (typeof suite === "string" && changed.has(suite)) || mutations.some((mutation) => typeof mutation?.file === "string" && changed.has(mutation.file))); if (shard) selected = selected.filter(({ path }) => shardOf(path, Number(shard[2])) === Number(shard[1])); @@ -186,7 +187,7 @@ console.log(`mutation reproof: ${selected.length} fixture(s) selected from ${fix ? " for a full sweep" : ` (diff ${diffSize} record(s), ${changed.size} changed path(s), corpus ${fixtures.length})`)); if (selected.length === 0) { - console.log("No mutation fixtures to re-prove: no fixture config or guarded source intersects the diff."); + console.log("No mutation fixtures to re-prove: no fixture config, suite, or guarded source intersects the diff."); process.exit(0); } console.log(`selected fixture paths:\n${selected.map(({ path }) => ` ${path}`).join("\n")}`);