diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index d33bdfd20..dcaee19dc 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -20,6 +20,7 @@ on: - 'tests/**/*.unit.test.cjs' - 'tests/adr-parser.test.cjs' - 'tests/active-workstream-store.test.cjs' + - 'tests/core-utils.test.cjs' - 'stryker.config.mjs' - 'scripts/mutation-matrix.cjs' workflow_dispatch: @@ -106,14 +107,19 @@ jobs: # MUTATION_TEST_CMD scopes the command runner to only this module's tests. # --mutate scopes mutation to only the changed module's built artifact. # --incremental reuses cached results for unchanged mutants. - # The break threshold (50) is read from stryker.config.mjs. + # MUTATION_BREAK passes the per-module minScore ratchet floor to + # stryker.config.mjs (which reads process.env.MUTATION_BREAK for the + # break threshold). See ADR-456 / issue #1187. + # Note: Stryker 9.x has no --break CLI flag; the threshold is config-only. env: NODE_OPTIONS: '--max-old-space-size=4096' MUTATION_TEST_CMD: node --test ${{ matrix.tests }} + MUTATION_BREAK: ${{ matrix.minScore }} run: | - echo "Module: ${{ matrix.name }}" - echo "Mutate: ${{ matrix.mutate }}" - echo "Tests: ${{ matrix.tests }}" + echo "Module: ${{ matrix.name }}" + echo "Mutate: ${{ matrix.mutate }}" + echo "Tests: ${{ matrix.tests }}" + echo "MinScore: ${{ matrix.minScore }}" npx stryker run --incremental --mutate "${{ matrix.mutate }}" - name: Upload mutation report — ${{ matrix.name }} diff --git a/scripts/mutation-matrix.cjs b/scripts/mutation-matrix.cjs index 562c8995c..18dad2b96 100644 --- a/scripts/mutation-matrix.cjs +++ b/scripts/mutation-matrix.cjs @@ -34,17 +34,59 @@ const { readFileSync } = require('fs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +// ── Per-module mutation score ratchet ───────────────────────────────────────── +// ADR-456 / issue #1187: every covered module declares a minScore floor. +// +// HOW THE RATCHET WORKS: +// • minScore locks in the current measured mutation score (minus a 1–2 pt +// margin for run-to-run timeout variance). +// • CI fails a shard if the module's live score drops below its minScore. +// • Raise minScore (never lower) as a module's tests improve. +// • The goal is every module reaching TARGET_MUTATION_SCORE (80). +// +// GOODHART SAFETY: scores are improved by writing genuine behavioural +// assertions that kill real mutants — never by adding brittle exact-string +// matches on incidental output. A justified `// Stryker disable` on a +// confirmed equivalent mutant is acceptable. +// +// HOW TO UPDATE: +// 1. Run the per-module Stryker shard locally. +// 2. Note the reported score. +// 3. Set minScore = floor(score) - 1 (never lower than current value). +// 4. Open a PR — the CI gate will enforce the new floor on every future run. + +/** Long-run target for all modules (ADR-456). */ +const TARGET_MUTATION_SCORE = 80; + // ── Single source of truth: covered modules ─────────────────────────────────── -// Each entry: { cjs: '', tests: ['tests/...', ...] } -// A module is "covered" iff its tests are wired into the Stryker command runner -// (stryker.config.mjs commandRunner.command). Mutating an uncovered module can -// only ever produce survived mutants — so we scope strictly to these 6. +// Each entry: { cjs: '', tests: ['tests/...', ...], minScore: N } +// +// minScore is the CI break threshold for this module's shard. +// Floors are measured scores minus 1–2 pts for run-to-run variance. +// Measured CI scores 2026-06-14 (issue #1187, timeout-free — source of truth): +// context-utilization 92.31% → floor 80 (target already met) +// prompt-budget 68.33% → floor 66 (local was 99.6% — TIMEOUT INFLATION; CI is the truth) +// frontmatter 63.35% → floor 62 +// adr-parser 69.30% → floor 68 +// config-schema 54.55% → floor 52 (local was 69.7% — TIMEOUT INFLATION; CI is the truth) +// active-workstream-store 81.91% → floor 80 +// core-utils 77.52% → floor 75 +// +// LESSON: floors MUST be calibrated from CI mutation runs (CI runs with +// timeout≈0, deterministic). Local runs count timeouts as kills and +// inflate scores significantly (prompt-budget: 99.6% local vs 68.3% CI; +// config-schema: 69.7% local vs 54.55% CI). Never set a floor from a +// local run without CI cross-check. const COVERED = { 'context-utilization': { cjs: 'gsd-core/bin/lib/context-utilization.cjs', tests: [ 'tests/context-utilization.property.test.cjs', ], + // After mutation-killer assertions added in #1187: measured 92.31% (2026-06-14). + // 3 survivors are __esModule boilerplate (genuinely equivalent CJS interop mutants). + // minScore raised to TARGET (80) — module now meets ADR-456 goal. + minScore: 80, }, 'prompt-budget': { cjs: 'gsd-core/bin/lib/prompt-budget.cjs', @@ -52,6 +94,9 @@ const COVERED = { 'tests/prompt-budget.property.test.cjs', 'tests/prompt-budget.unit.test.cjs', ], + // CI 68.33% timeout-free (164 killed / 1 timeout / 240 total) 2026-06-14; + // local was 99.6% — timeout inflation. Floor = 68 - 2 margin. + minScore: 66, }, frontmatter: { cjs: 'gsd-core/bin/lib/frontmatter.cjs', @@ -59,6 +104,7 @@ const COVERED = { 'tests/frontmatter.property.test.cjs', 'tests/frontmatter.unit.test.cjs', ], + minScore: 62, }, 'adr-parser': { cjs: 'gsd-core/bin/lib/adr-parser.cjs', @@ -67,12 +113,16 @@ const COVERED = { 'tests/adr-parser.test.cjs', 'tests/adr-parser.unit.test.cjs', ], + minScore: 68, }, 'config-schema': { cjs: 'gsd-core/bin/lib/config-schema.cjs', tests: [ 'tests/config-schema.property.test.cjs', ], + // CI 54.55% timeout-free (18 killed / 0 timeout / 33 total) 2026-06-14; + // local was 69.7% — timeout inflation. Floor = 54 - 2 margin. + minScore: 52, }, 'active-workstream-store': { cjs: 'gsd-core/bin/lib/active-workstream-store.cjs', @@ -80,6 +130,14 @@ const COVERED = { 'tests/active-workstream-store.test.cjs', 'tests/active-workstream-store.unit.test.cjs', ], + minScore: 80, + }, + 'core-utils': { + cjs: 'gsd-core/bin/lib/core-utils.cjs', + tests: [ + 'tests/core-utils.test.cjs', + ], + minScore: 75, // measured 77.52% (2026-06-14, issue #1187); floor = 77 - 2 }, }; @@ -178,6 +236,7 @@ function buildResult(moduleNames) { name, mutate: COVERED[name].cjs, tests: COVERED[name].tests.join(' '), + minScore: COVERED[name].minScore, })); return { @@ -194,8 +253,9 @@ function printHuman(result, changedFiles) { console.log(`Shards (${result.matrix.include.length}):`); for (const shard of result.matrix.include) { console.log(` [${shard.name}]`); - console.log(` mutate: ${shard.mutate}`); - console.log(` tests: ${shard.tests}`); + console.log(` mutate: ${shard.mutate}`); + console.log(` tests: ${shard.tests}`); + console.log(` minScore: ${shard.minScore}`); } } @@ -219,4 +279,45 @@ function main() { } } -runMain(main); +// ── MUTATION_BREAK resolver ─────────────────────────────────────────────────── +/** + * Resolves the per-shard mutation break threshold from the MUTATION_BREAK env var. + * + * Fail-closed contract: + * - undefined → 60 (local run: no env set, documented backstop) + * - set but empty (e.g. CI matrix.minScore missing) → throws (wiring error) + * - non-numeric or out-of-range [1, 100] → throws (invalid config) + * - valid integer string → returns that number + * + * This function is the single call site for reading MUTATION_BREAK. + * stryker.config.mjs imports and calls it so CI shards with a bad + * MUTATION_BREAK fail immediately rather than silently falling back to 60 + * and bypassing a per-module floor above 60 (e.g. prompt-budget: 90). + * + * @param {string|undefined} raw - value of process.env.MUTATION_BREAK + * @returns {number} + */ +function resolveMutationBreak(raw) { + if (raw === undefined) { + // Local run with no MUTATION_BREAK set — use documented backstop. + return 60; + } + if (typeof raw !== 'string' || raw.trim() === '') { + throw new Error( + 'MUTATION_BREAK is set but empty — CI shard wiring is broken (matrix.minScore missing?)' + ); + } + const n = Number(raw); + if (!Number.isFinite(n) || n < 1 || n > 100) { + throw new Error( + `MUTATION_BREAK invalid: "${raw}" (expected a per-module minScore 1-100)` + ); + } + return n; +} + +// Export internals for programmatic use (tests/mutation-matrix-ratchet.test.cjs). +// The require.main guard prevents main() from running when this file is require()d. +module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak }; + +if (require.main === module) runMain(main); diff --git a/stryker.config.mjs b/stryker.config.mjs index f21ee66b3..08c78fcc6 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -21,6 +21,12 @@ * to stay bounded. Full runs are for local exploration only. */ +import { createRequire } from 'node:module'; +const _require = createRequire(import.meta.url); +// resolveMutationBreak: fail-closed resolver for MUTATION_BREAK env var. +// undefined → 60 (local backstop); set-but-empty or non-numeric → throws. +const { resolveMutationBreak } = _require('./scripts/mutation-matrix.cjs'); + // ADR-457: bin/lib/*.cjs are gitignored build artifacts (compiled from // src/*.cts by `npm run build:lib`, which the mutation CI job runs via `npm ci` // → prepare before Stryker). Stryker mutates the *built* .cjs directly — the @@ -55,7 +61,8 @@ const UNMUTATED = [ // Full test command used by local runs and as the fallback when CI does not // inject a per-shard command via MUTATION_TEST_CMD. -const DEFAULT_TEST_CMD = 'node --test tests/context-utilization.property.test.cjs tests/prompt-budget.property.test.cjs tests/frontmatter.property.test.cjs tests/adr-parser.property.test.cjs tests/config-schema.property.test.cjs tests/adr-parser.test.cjs tests/active-workstream-store.test.cjs tests/active-workstream-store.unit.test.cjs tests/prompt-budget.unit.test.cjs tests/adr-parser.unit.test.cjs tests/frontmatter.unit.test.cjs'; +// Keep this list in sync with the tests arrays in scripts/mutation-matrix.cjs COVERED. +const DEFAULT_TEST_CMD = 'node --test tests/context-utilization.property.test.cjs tests/prompt-budget.property.test.cjs tests/frontmatter.property.test.cjs tests/adr-parser.property.test.cjs tests/config-schema.property.test.cjs tests/adr-parser.test.cjs tests/active-workstream-store.test.cjs tests/active-workstream-store.unit.test.cjs tests/prompt-budget.unit.test.cjs tests/adr-parser.unit.test.cjs tests/frontmatter.unit.test.cjs tests/core-utils.test.cjs'; /** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ export default { @@ -83,10 +90,16 @@ export default { coverageAnalysis: 'off', // ── Thresholds ─────────────────────────────────────────────────────────────── + // ADR-456 / issue #1187: CI passes the per-module minScore (from + // scripts/mutation-matrix.cjs) via the MUTATION_BREAK environment variable. + // Each CI shard sets MUTATION_BREAK to its module's floor so Stryker enforces + // the ratchet. Local runs without MUTATION_BREAK fall back to 60 (backstop). + // Do NOT raise the fallback here; raise individual minScore values in + // mutation-matrix.cjs instead. thresholds: { high: 80, low: 60, - break: 50, + break: resolveMutationBreak(process.env.MUTATION_BREAK), }, // ── Incremental mode ───────────────────────────────────────────────────────── diff --git a/tests/context-utilization.property.test.cjs b/tests/context-utilization.property.test.cjs index 630c6a543..e64bb67f0 100644 --- a/tests/context-utilization.property.test.cjs +++ b/tests/context-utilization.property.test.cjs @@ -249,4 +249,87 @@ describe('context-utilization property tests', () => { ) ); }); + + // ─── STATES string-literal mutation killers ─────────────────────────────── + // Stryker survivors: HEALTHY/WARNING/CRITICAL → "" (StringLiteral mutants). + // The property tests above use STATES.HEALTHY etc. which would still pass + // even if all strings were emptied (self-comparison). These tests assert the + // LITERAL string values — the documented public API contract of STATES. + + test('STATES.HEALTHY literal value is "healthy"', () => { + assert.strictEqual( + STATES.HEALTHY, + 'healthy', + 'STATES.HEALTHY must be the string "healthy" (catches StringLiteral mutant → "")' + ); + }); + + test('STATES.WARNING literal value is "warning"', () => { + assert.strictEqual( + STATES.WARNING, + 'warning', + 'STATES.WARNING must be the string "warning" (catches StringLiteral mutant → "")' + ); + }); + + test('STATES.CRITICAL literal value is "critical"', () => { + assert.strictEqual( + STATES.CRITICAL, + 'critical', + 'STATES.CRITICAL must be the string "critical" (catches StringLiteral mutant → "")' + ); + }); + + test('classifyContextUtilization state values are the documented string literals', () => { + // Asserts the actual returned state strings — not just STATES membership. + // Kills mutants that swap the STATES object values to empty strings. + const healthy = classifyContextUtilization(0, 10000); + assert.strictEqual(healthy.state, 'healthy', 'zero tokens must produce state "healthy"'); + + const warning = classifyContextUtilization(6000, 10000); + assert.strictEqual(warning.state, 'warning', '60% tokens must produce state "warning"'); + + const critical = classifyContextUtilization(7000, 10000); + assert.strictEqual(critical.state, 'critical', '70% tokens must produce state "critical"'); + }); + + // ─── Error message content mutation killers ─────────────────────────────── + // Stryker survivors: error message template → "" (StringLiteral mutants). + // Asserting only TypeError type doesn't kill these — need message content. + + test('TypeError for bad tokensUsed includes descriptive message mentioning the value', () => { + const badValue = -5; + try { + classifyContextUtilization(badValue, 10000); + assert.fail('Expected TypeError was not thrown'); + } catch (err) { + assert.ok(err instanceof TypeError); + assert.ok( + err.message.includes(String(badValue)), + `Error message must include the bad value (${badValue}), got: "${err.message}"` + ); + assert.ok( + err.message.length > 0, + 'Error message must not be empty (catches StringLiteral → "" mutant)' + ); + } + }); + + test('TypeError for bad contextWindow includes descriptive message mentioning the value', () => { + const badValue = 0; + try { + classifyContextUtilization(100, badValue); + assert.fail('Expected TypeError was not thrown'); + } catch (err) { + assert.ok(err instanceof TypeError); + assert.ok( + err.message.includes(String(badValue)), + `Error message must include the bad value (${badValue}), got: "${err.message}"` + ); + assert.ok( + err.message.length > 0, + 'Error message must not be empty (catches StringLiteral → "" mutant)' + ); + } + }); }); diff --git a/tests/mutation-matrix-ratchet.test.cjs b/tests/mutation-matrix-ratchet.test.cjs new file mode 100644 index 000000000..614ff4e7e --- /dev/null +++ b/tests/mutation-matrix-ratchet.test.cjs @@ -0,0 +1,295 @@ +'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/.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); + }); +});