Files
msd-core/tests/mutation-matrix-ratchet.test.cjs
Tom Boucher 98866a0c69 feat(#1187): per-module Stryker mutation-score ratchet (ADR-456 80% floor) (#1200)
* feat(#1187): per-module mutation-score ratchet + graduate core-utils

ADR-456's 80% mutation floor was unenforceable as a single global break=50:
4 of 6 covered modules sit at 63-79% and forcing them to 80 would require
brittle exact-string assertions on equivalent string-literal mutants (a
Goodhart's-Law trap). Instead, each covered module declares a minScore floor
(locked at its measured score, TARGET 80) enforced per CI shard via
stryker --break, ratcheting up over time without brittle tests.
- mutation-matrix.cjs: minScore per module + TARGET_MUTATION_SCORE=80, emitted
  in the matrix; require.main guard + exports for testability.
- mutation.yml: per-shard --break <minScore>.
- stryker.config.mjs: global break 50->60 as a local backstop (CI uses minScore).
- Graduated core-utils (measured 77.5%, floor 75).
- context-utilization 79.5->92.3% via behavioral killers (state classification
  outputs + error-value contract, not exact-string matches) -> minScore 80 (TARGET).
- ratchet-integrity guard test (28 cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1187): pass mutation break via MUTATION_BREAK env (no stryker --break flag)

Adversarial review caught that Stryker 9.x has no --break CLI flag, so the
per-shard 'stryker run --break <minScore>' errored out every mutation shard.
Read the per-module floor from process.env.MUTATION_BREAK in stryker.config.mjs
and set it per shard via env in mutation.yml. Red-green verified: MUTATION_BREAK=99
exits 1, =80 exits 0. Also make the ratchet guard monotonic (RATCHET_BASELINE
floors; lowering a floor now fails the guard unless the baseline is edited).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1187): fail closed on bad MUTATION_BREAK + monotonic ratchet baseline

Code review: Number(env)||60 failed OPEN — an empty/invalid MUTATION_BREAK
(e.g. a future module missing minScore -> matrix expands to '') silently
degraded the shard to break 60, letting a high-floor module regress undetected.
resolveMutationBreak() now returns 60 only when the env is truly unset (local
backstop) and THROWS on present-but-empty/non-numeric/out-of-range (fail closed);
stryker.config.mjs imports it via createRequire. Also make RATCHET_BASELINE an
equality mirror (=== not >=) so any floor change is explicit in review and no
floor can be silently lowered. Tests: 46 (incl resolveMutationBreak cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1187): recalibrate config-schema/prompt-budget floors to CI scores

First CI mutation run failed two shards: the floors were set from local Stryker
runs whose TIMEOUTS were counted as kills (env-variable), inflating scores. CI
runs with timeout~0, so the real deterministic scores are lower:
- config-schema: local 69.7% -> CI 54.55% (5 local timeouts vanished) -> floor 52
- prompt-budget: local 99.6% -> CI 68.33% (239 local timeouts vanished) -> floor 66
Calibrate floors from CI (the documented source of truth) and record the lesson
in the comment so future floors aren't set from timeout-inflated local runs.
Baseline updated to match. The other 5 shards passed (deterministic CI scores
above their floors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 08:25:41 -04:00

296 lines
11 KiB
JavaScript

'use strict';
/**
* tests/mutation-matrix-ratchet.test.cjs
*
* Guards the per-module mutation-score ratchet contract defined in
* scripts/mutation-matrix.cjs (ADR-456 / issue #1187).
*
* Assertions:
* (a) TARGET_MUTATION_SCORE is exported as a numeric constant equal to 80.
* (b) Every COVERED module declares a numeric minScore (50 ≤ minScore ≤ 100).
* (c) The matrix entry emitted by buildResult() / the script's JSON output
* includes minScore for each module.
* (d) A COVERED module missing minScore causes the above assertions to fail
* (negative proof — ensured by the ≥50 / ≤100 range check).
*
* Design note: this test imports the script's internals via require() — the
* script exports COVERED and TARGET_MUTATION_SCORE so they can be tested
* without subprocess overhead. buildResult() is not exported so we verify the
* matrix output by running the script as a child process (stdin pipe mode).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const path = require('node:path');
const MATRIX_SCRIPT = path.resolve(__dirname, '../scripts/mutation-matrix.cjs');
const matrix = require(MATRIX_SCRIPT);
// ── (a) TARGET_MUTATION_SCORE ─────────────────────────────────────────────────
describe('mutation-matrix ratchet: TARGET_MUTATION_SCORE export', () => {
test('exports TARGET_MUTATION_SCORE', () => {
assert.ok(
Object.prototype.hasOwnProperty.call(matrix, 'TARGET_MUTATION_SCORE'),
'mutation-matrix.cjs must export TARGET_MUTATION_SCORE'
);
});
test('TARGET_MUTATION_SCORE is numeric', () => {
assert.strictEqual(
typeof matrix.TARGET_MUTATION_SCORE,
'number',
'TARGET_MUTATION_SCORE must be a number'
);
});
test('TARGET_MUTATION_SCORE equals 80', () => {
assert.strictEqual(
matrix.TARGET_MUTATION_SCORE,
80,
'TARGET_MUTATION_SCORE must equal 80 (ADR-456 floor)'
);
});
});
// ── (b) every COVERED module has a valid minScore ─────────────────────────────
describe('mutation-matrix ratchet: per-module minScore in COVERED', () => {
test('exports COVERED object', () => {
assert.ok(
Object.prototype.hasOwnProperty.call(matrix, 'COVERED'),
'mutation-matrix.cjs must export COVERED'
);
assert.strictEqual(typeof matrix.COVERED, 'object');
assert.ok(matrix.COVERED !== null);
});
const covered = matrix.COVERED || {};
const moduleNames = Object.keys(covered);
test('COVERED has at least one module', () => {
assert.ok(moduleNames.length > 0, 'COVERED must contain at least one module');
});
for (const name of moduleNames) {
describe(`module: ${name}`, () => {
test(`${name}: declares minScore`, () => {
const entry = covered[name];
assert.ok(
Object.prototype.hasOwnProperty.call(entry, 'minScore'),
`COVERED['${name}'] must have a minScore property`
);
});
test(`${name}: minScore is a number`, () => {
const entry = covered[name];
assert.strictEqual(
typeof entry.minScore,
'number',
`COVERED['${name}'].minScore must be a number`
);
});
test(`${name}: minScore is between 50 and 100 (inclusive)`, () => {
const entry = covered[name];
assert.ok(
entry.minScore >= 50,
`COVERED['${name}'].minScore (${entry.minScore}) must be ≥ 50`
);
assert.ok(
entry.minScore <= 100,
`COVERED['${name}'].minScore (${entry.minScore}) must be ≤ 100`
);
});
});
}
});
// ── (c) matrix JSON emitted by the script includes minScore per module ────────
describe('mutation-matrix ratchet: matrix JSON output includes minScore', () => {
test('script emits valid JSON with minScore in each matrix include entry', () => {
// Use stdin pipe mode: pass every COVERED module's src/*.cts path as
// "changed" files so all modules appear in the matrix output.
// computeMatrix() matches `src/<module>.cts` — derive from the module name.
const covered = matrix.COVERED || {};
const moduleNames = Object.keys(covered);
const stdinLines = moduleNames.map(name => `src/${name}.cts`).join('\n');
const raw = execFileSync(
process.execPath,
[MATRIX_SCRIPT],
{
input: stdinLines + '\n',
encoding: 'utf8',
cwd: path.resolve(__dirname, '..'),
}
);
let result;
try {
result = JSON.parse(raw);
} catch (e) {
assert.fail(`mutation-matrix.cjs did not emit valid JSON: ${e.message}\nOutput: ${raw}`);
}
assert.strictEqual(result.has_work, 'true', 'has_work must be "true" when covered modules change');
assert.ok(Array.isArray(result.matrix.include), 'matrix.include must be an array');
assert.ok(result.matrix.include.length > 0, 'matrix.include must not be empty');
for (const entry of result.matrix.include) {
assert.ok(
Object.prototype.hasOwnProperty.call(entry, 'minScore'),
`matrix entry for '${entry.name}' must include minScore in JSON output`
);
assert.strictEqual(
typeof entry.minScore,
'number',
`matrix entry for '${entry.name}' minScore must be a number`
);
assert.ok(
entry.minScore >= 50 && entry.minScore <= 100,
`matrix entry for '${entry.name}' minScore (${entry.minScore}) must be between 50 and 100`
);
}
});
});
// ── (d) negative proof: missing minScore is detectable ───────────────────────
describe('mutation-matrix ratchet: guard detects missing minScore', () => {
test('a module entry without minScore would fail the range check (50-100)', () => {
// Simulate the invariant: if minScore is missing, typeof === 'undefined',
// which is not 'number' → the per-module assertion above would catch it.
const fakeEntry = { cjs: 'foo.cjs', tests: ['tests/foo.test.cjs'] };
assert.notStrictEqual(
typeof fakeEntry.minScore,
'number',
'An entry without minScore must NOT pass the typeof-number check'
);
// Also verify that undefined < 50 and undefined > 100 are both false,
// meaning the ≥50 check below would also catch it if typeof were lenient.
assert.ok(
!(fakeEntry.minScore >= 50),
'undefined minScore must fail the ≥50 guard'
);
});
});
// ── (e) monotonic ratchet: minScore must exactly match baseline ───────────────
// RATCHET_BASELINE is a deliberate review-visible mirror of the floors in COVERED.
//
// CONTRACT: every COVERED module's minScore must EQUAL its entry here.
// ANY change to a floor (up or down) requires updating RATCHET_BASELINE in the
// same diff, making the change explicit in code review. This prevents a floor
// from being raised in COVERED and later silently lowered back to baseline.
//
// To ADD a new module to COVERED: add a baseline entry here in the same diff.
// The assertion "every COVERED module has a baseline" enforces this.
// The assertion "every baseline module still exists in COVERED" enforces the
// reverse: removing a module from COVERED also requires updating the baseline.
const RATCHET_BASELINE = {
'context-utilization': 80,
'prompt-budget': 66, // CI 68.33% 2026-06-14; was 90 (timeout-inflated local)
'frontmatter': 62,
'adr-parser': 68,
'config-schema': 52, // CI 54.55% 2026-06-14; was 68 (timeout-inflated local)
'active-workstream-store': 80,
'core-utils': 75,
};
describe('mutation-matrix ratchet: floor equality enforcement', () => {
const covered = matrix.COVERED || {};
const coveredNames = Object.keys(covered);
test('every COVERED module has a RATCHET_BASELINE entry (new modules must add one)', () => {
for (const name of coveredNames) {
assert.ok(
Object.prototype.hasOwnProperty.call(RATCHET_BASELINE, name),
`COVERED module '${name}' has no RATCHET_BASELINE entry — add one before merging`
);
}
});
test('every RATCHET_BASELINE module still exists in COVERED (removed modules must drop their baseline entry)', () => {
for (const name of Object.keys(RATCHET_BASELINE)) {
assert.ok(
Object.prototype.hasOwnProperty.call(covered, name),
`RATCHET_BASELINE has entry for '${name}' but it no longer exists in COVERED — remove the baseline entry`
);
}
});
for (const name of coveredNames) {
test(`${name}: minScore === baseline (${RATCHET_BASELINE[name] ?? 'NO BASELINE'}) — any floor change must update RATCHET_BASELINE`, () => {
const baseline = RATCHET_BASELINE[name];
if (baseline === undefined) {
// Already caught by the presence check above; skip the numeric compare
// to avoid a confusing NaN comparison error.
assert.fail(`no RATCHET_BASELINE for '${name}' — add it`);
return;
}
const actual = covered[name].minScore;
assert.strictEqual(
actual,
baseline,
`COVERED['${name}'].minScore (${actual}) !== RATCHET_BASELINE (${baseline}) — update RATCHET_BASELINE to match the new floor`
);
});
}
});
// ── (f) resolveMutationBreak behaviour ───────────────────────────────────────
describe('resolveMutationBreak: fail-closed env-var resolver', () => {
const { resolveMutationBreak } = matrix;
test('exports resolveMutationBreak as a function', () => {
assert.strictEqual(typeof resolveMutationBreak, 'function');
});
test('undefined → 60 (local run backstop)', () => {
assert.strictEqual(resolveMutationBreak(undefined), 60);
});
test("'' (empty string) → throws (CI shard wiring error)", () => {
assert.throws(
() => resolveMutationBreak(''),
/MUTATION_BREAK is set but empty/
);
});
test("' ' (whitespace-only) → throws (CI shard wiring error)", () => {
assert.throws(
() => resolveMutationBreak(' '),
/MUTATION_BREAK is set but empty/
);
});
test("'abc' → throws (non-numeric)", () => {
assert.throws(
() => resolveMutationBreak('abc'),
/MUTATION_BREAK invalid/
);
});
test("'0' → throws (out of range: below 1)", () => {
assert.throws(
() => resolveMutationBreak('0'),
/MUTATION_BREAK invalid/
);
});
test("'150' → throws (out of range: above 100)", () => {
assert.throws(
() => resolveMutationBreak('150'),
/MUTATION_BREAK invalid/
);
});
test("'80' → 80", () => {
assert.strictEqual(resolveMutationBreak('80'), 80);
});
test("'62' → 62", () => {
assert.strictEqual(resolveMutationBreak('62'), 62);
});
});