* feat(#2790): add read-only planning.inspect schema-v1 snapshot query Adds a read-only query emitting a schema-versioned JSON projection of .planning/ so downstream harness UIs can consume planning state without parsing GSD's Markdown a second time. Composed strictly from the ADR-3180 section 7 owners plus parsePlanDocument, parseRequirements and parseUatItems; markdown structure is read through the Markdown Sectionizer and Markdown Table Model seams. It declares its own flat external schema rather than serializing PlanningSnapshot, which is the diagnostic-rule subject and still growing. Extracts plan-document parsing out of cmdPhasePlanIndex into a shared leaf module so phase.plan-index and planning.inspect cannot drift, including the plan-id derivation both surfaces report. Also fixes parseRequirements dropping the separator delimiter used by the shipped requirements template, surfaced while wiring the requirement rows. * fix(#2790): close spec gaps and a raw-text test assertion found in review Review findings from the standards, spec and security passes: - phases[] rows carry goal and dependencies, the two per-phase elements the issue Summary names that had no corresponding field. Goal is bounded to the section's leading prose so the Depends-on line, the Plans checklist and the wave annotations are not duplicated into it. - requirement rows carry their own diagnostic codes, so a consumer no longer has to string-parse the global diagnostics subject to correlate. - roadmap_acceptance.checkbox is looked up through the phase-id key owners. It was compared raw against the on-disk directory name, so it read null for every real-world slugged phase directory and the evidence channel was inert. - the hostile-input test asserts the structured payload instead of matching the raw stdout string. The absence proof over raw stdout is kept deliberately. * fix(#2790): register planning in the runtime usage list and repair fixtures Remote runner reported 9 failures on 9b3f9aa. Two root causes, both fixed: - gsd-tools.cjs registered the planning family in HOST_COMMAND_ROUTERS but never added it to TOP_LEVEL_USAGE's Commands list. Those are two surfaces a parity test guards, and the top-of-file block comment is not the runtime help string. A real wiring gap that every local gate and three review passes missed. - the new suite's fixtures could not produce a resolvable phase set. STATE.md frontmatter omitted the milestone field, which ADR-3180 7.2 rule 1 makes the primary milestone selector, so the phase set scoped unscoped and every percentage was correctly withheld. Separately declarePhase returned a path without creating the directory, so a phase declared but never written to left phases empty. Both reproduced against the built module before fixing. No assertion was weakened. The withholding path is still exercised and still returns null when the roadmap is absent. * chore(#2790): backfill changeset pr number * test(#2790): cover every enumerated matrix row and contain a symlink escape Reverses a silent deferral. An earlier revision left 23 of the 78 enumerated matrix rows unimplemented and 7 more as one-off manual checks, with a paragraph in the artifact and the PR body describing the gap. CLAUDE.md is explicit that such a note is not a fix and is not surfacing. The rows are implemented instead and the manual-evidence bucket is gone: 49 test cases become 88, covering all 78. Writing the symlink row proved a real leak: a *-PLAN.md symlinked outside .planning/ had its content emitted into the payload, confirmed via a direct call and the spawned CLI. readDocument now resolves target and planning root with realpathSync and rejects an escape, returning the ordinary unreadable-document shape. Tested both ways, because a containment check that over-rejects is its own defect: an escaping symlink leaks nothing and degrades that plan alone, while a legitimately relocated .planning/ symlink stays fully readable. The three new modules are registered in the mutation COVERED registry, which had been reporting has_work false and skipping the Stryker gate entirely. Provisional non-binding floors so the shards run and report; raised to the measured value before merge, since the registry forbids calibrating from a local run. * fix(#2790): satisfy the mutation ratchet contract and scope the 1MB test Remote runner reported 16 failures on 8c451ed. Two causes. The COVERED registry has a paired contract the earlier commit violated: every module needs a matching RATCHET_BASELINE entry, and minScore must be between 50 and 100 with minScore === baseline. The provisional floor of 1 was illegal on both counts. All three modules now sit at 50 — the registry's own enforced minimum — with matching baselines. The score cannot be measured locally: the shard runs node --test, which this repo hard-blocks, so CI is the only source. Floors are raised to the measured value once this PR's shards report; a shard below 50 means the tests need strengthening, since the floor cannot go lower. The 1MB test was measuring the test harness rather than the product. The command handles the oversized payload correctly by spilling to a tmpfile and resolving it back, but the resolved stdout then exceeds runGsdTools' maxBuffer and the helper reports ENOBUFS. It now uses --pick so stdout stays one byte while the full 1MB document is still read and parsed end to end. * fix(#2790): wire containment across every document read this command drives An isolated security review of the containment control found the boundary logic sound but not comprehensively wired: two content reads reached the filesystem without it. An escaped phase DIRECTORY could enumerate external filenames into the file fields and diagnostic subjects. Both enumeration sites now containment-check the directory before reading. Worth recording that the leak was already prevented one layer earlier than the review claimed: Dirent#isDirectory() reports false for a directory symlink, so such a directory never becomes a phase row at all. The guard is defense-in-depth for a direct caller and for platforms where a reparse point reports as a directory. A *-VERIFICATION.md symlinked outside the root leaked one frontmatter value verbatim, because readVerificationStatus does its own read and copies an unrecognized status into the payload's next_action. Closed from the consumer side through that function's existing fs injection seam, so src/verification.cts keeps its signature and its other callers are untouched. The reviewer additionally rated a forged status: passed as an integrity bypass. It is not: anyone able to plant the symlink can plant a real VERIFICATION.md saying the same thing. The incremental risk is confidentiality, which is what these fixes close. src/plan-scan.cts is deliberately unchanged: isPlanSuperseded reads symlink-followed content but yields only a derived boolean, no document text. * test(#2790): give the mutation shards an in-process surface Two Stryker shards were CANCELLED at the 15-minute cap, not failed on score. CI log: 640 mutants instrumented, and the dry run reported 'Ran 1 tests in 20 seconds' because the shards pointed at the integration suite, where nearly every case spawns a gsd-tools subprocess and Stryker's command runner treats the whole test-runner invocation as a single test. 640 x 20s cannot finish in 15 minutes; at the kill it was 27/640 with an ETA over an hour. Every other COVERED module points at a property or unit file, and the workflow's own paths filter lists exactly those two patterns. In-process is the intended mutation surface; the shards were pointed at the wrong shape of test. Adds tests/planning-inspect.unit.test.cjs — 39 cases in 10 describes that spawn nothing and call the built modules directly. plan-document and the router need no filesystem at all, one being a pure content-to-object parser and the other taking an injected mock. The three shards now point here. The 91-case integration suite is untouched and still runs in the normal test job. * chore(#2790): ratchet mutation floors to the measured CI scores CI run 32392791843 measured all three shards, which is the only source the registry accepts — local runs count timeouts as kills and inflate badly. planning-command-router 95.65 -> floor 94 plan-document 76.58 -> floor 75 planning-inspect 57.03 -> floor 56 Applied the registry's own rule, floor(score) - 1, and updated RATCHET_BASELINE to match, since the ratchet test enforces equality. planning-inspect sits well below the file's target of 80 and is the obvious ratchet candidate as its tests improve. planning-command-router already exceeds the target. The placeholder comment about floors pending measurement is removed rather than left standing as a false statement. --------- Co-authored-by: sim <sim@local>
444 lines
18 KiB
JavaScript
444 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* scripts/mutation-matrix.cjs
|
||
*
|
||
* Single source of truth for the ADR-457 Stryker mutation gate dynamic matrix.
|
||
*
|
||
* Computes which covered modules changed vs a base ref and emits a GitHub
|
||
* Actions matrix JSON so CI can run one Stryker shard per changed module in
|
||
* parallel rather than a single serial run over all modules.
|
||
*
|
||
* Usage:
|
||
* node scripts/mutation-matrix.cjs --base origin/next
|
||
* printf 'src/config-schema.cts\n' | node scripts/mutation-matrix.cjs
|
||
* node scripts/mutation-matrix.cjs --base origin/next --print
|
||
*
|
||
* Output (stdout, default): JSON object
|
||
* {
|
||
* "has_work": "true"|"false",
|
||
* "matrix": {
|
||
* "include": [
|
||
* { "name": "<module>", "mutate": "gsd-core/bin/lib/<module>.cjs", "tests": "<space-joined test files>" },
|
||
* ...
|
||
* ]
|
||
* }
|
||
* }
|
||
*
|
||
* Exit codes: 0 always (empty matrix is not an error, has_work "false").
|
||
*/
|
||
|
||
const { execFileSync } = require('child_process');
|
||
const fs = require('fs');
|
||
|
||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||
|
||
// ── Resilient stdin reader ────────────────────────────────────────────────────
|
||
// On macOS, libuv sets the stdin pipe fd to non-blocking mode. A synchronous
|
||
// readFileSync(process.stdin.fd) can therefore throw EAGAIN ("resource
|
||
// temporarily unavailable") when the writer hasn't yet filled the pipe — this
|
||
// is intermittent under heavy CI shard load and causes a spurious status 2
|
||
// exit. We work around it by calling fs.readSync in a loop and retrying on
|
||
// EAGAIN with a 1 ms synchronous pause (Atomics.wait on a fresh SharedArrayBuffer
|
||
// — no hot spin, no real-clock dependency, works under --experimental-vm-modules).
|
||
/**
|
||
* Read all of stdin synchronously, retrying on EAGAIN.
|
||
*
|
||
* @returns {string} UTF-8 decoded full stdin content.
|
||
*/
|
||
function readStdinSync() {
|
||
const BUF_SIZE = 64 * 1024; // 64 KB chunks
|
||
const buf = Buffer.allocUnsafe(BUF_SIZE);
|
||
const chunks = [];
|
||
|
||
for (;;) {
|
||
let bytesRead;
|
||
try {
|
||
bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUF_SIZE, null);
|
||
} catch (err) {
|
||
if (err.code === 'EAGAIN') {
|
||
// Non-blocking pipe not yet ready — yield for ~1 ms then retry.
|
||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
|
||
continue;
|
||
}
|
||
if (err.code === 'EOF') {
|
||
break;
|
||
}
|
||
throw err;
|
||
}
|
||
if (bytesRead === 0) {
|
||
break; // Clean EOF
|
||
}
|
||
chunks.push(Buffer.from(buf.slice(0, bytesRead)));
|
||
}
|
||
|
||
return Buffer.concat(chunks).toString('utf8');
|
||
}
|
||
|
||
// ── 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: '<built artifact>', 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,
|
||
},
|
||
// context-composer: extracted from prompt-budget by #2929. Needs its own entry because
|
||
// mutation coverage does not migrate with relocated code — scoring only prompt-budget.cjs
|
||
// would leave the extracted ladder unmeasured.
|
||
'context-composer': {
|
||
cjs: 'gsd-core/bin/lib/context-composer.cjs',
|
||
tests: [
|
||
'tests/prompt-budget-parity.test.cjs',
|
||
'tests/prompt-budget.unit.test.cjs',
|
||
'tests/context-composer.test.cjs',
|
||
'tests/context-composer.property.test.cjs',
|
||
],
|
||
minScore: 66,
|
||
},
|
||
'prompt-budget': {
|
||
cjs: 'gsd-core/bin/lib/prompt-budget.cjs',
|
||
tests: [
|
||
'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',
|
||
tests: [
|
||
'tests/frontmatter.property.test.cjs',
|
||
'tests/frontmatter.unit.test.cjs',
|
||
// #1882 added the unterminated-fence detection to frontmatter.cjs, and the tests that
|
||
// constrain it live here. Without this entry the mutants in that branch are covered by
|
||
// no test in the shard, so the module's score drops even though the behaviour is tested.
|
||
'tests/unusable-input.test.cjs',
|
||
],
|
||
minScore: 62,
|
||
},
|
||
'adr-parser': {
|
||
cjs: 'gsd-core/bin/lib/adr-parser.cjs',
|
||
tests: [
|
||
'tests/adr-parser.property.test.cjs',
|
||
'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',
|
||
tests: [
|
||
'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
|
||
},
|
||
// planning-inspect / plan-document / planning-command-router: net-new modules
|
||
// added by #2790. Registered here so the Stryker gate stops SKIPPING them
|
||
// (previously has_work: "false" — ~1000 LOC entirely outside mutation scoring).
|
||
//
|
||
// WHY THESE SHARDS POINT AT tests/planning-inspect.unit.test.cjs, NOT
|
||
// tests/planning-inspect.test.cjs. CI evidence: two shards pointed at the
|
||
// integration file were CANCELLED at the workflow's 15-minute cap —
|
||
// "Mutation testing 4% (elapsed: ~3m, remaining: ~1h 19m) 27/640 tested".
|
||
// tests/planning-inspect.test.cjs is INTEGRATION-shaped (91 cases, most
|
||
// spawning a `gsd-tools` child process via `runGsdTools`); Stryker's command
|
||
// runner treats the whole `node --test <file>` invocation as ONE test costing
|
||
// whatever the slowest case costs (measured ~20s), and re-runs that entire
|
||
// file once per mutant — 640 mutants x 20s cannot finish in 15 minutes.
|
||
// tests/planning-inspect.unit.test.cjs is the dedicated, spawn-free,
|
||
// in-process mutation surface for exactly these three modules (measured
|
||
// locally: the whole file runs in well under a second) — the same shape
|
||
// every other entry in this registry already uses (*.property.test.cjs /
|
||
// *.unit.test.cjs). The integration suite is UNAFFECTED by this change: it
|
||
// keeps running in full in the normal (non-mutation) test job, and remains
|
||
// the source of truth for spawn-boundary/CLI-dispatch/read-only-proof
|
||
// behaviour that an in-process unit file cannot exercise.
|
||
//
|
||
// Measured CI scores (GitHub Actions run 32392791843, all three shards
|
||
// PASSED — not a local run; mutation shards run `node --test`, hard-blocked
|
||
// in this repo's local environment):
|
||
// planning-command-router 95.65% → floor 94 (already exceeds TARGET_MUTATION_SCORE (80))
|
||
// plan-document 76.58% → floor 75
|
||
// planning-inspect 57.03% → floor 56 (well below TARGET (80) — ratchet
|
||
// candidate; comfortably clears its own floor but has real room to grow.
|
||
// Raise as its tests improve, never lower it.)
|
||
//
|
||
// All three shards point at tests/planning-inspect.unit.test.cjs (in-process,
|
||
// spawn-free, ~0.3s dry run), not tests/planning-inspect.test.cjs — that is
|
||
// what made measurement possible at all. The integration file spawns a
|
||
// subprocess per case via runGsdTools; Stryker's command runner treats the
|
||
// whole `node --test <file>` invocation as one test costing whatever the
|
||
// slowest case costs (measured ~20s), and re-runs that entire file once per
|
||
// mutant, so 640 mutants x 20s could not finish inside the 15-minute shard
|
||
// cap. The integration suite is unaffected by this change: it keeps running
|
||
// in full in the normal (non-mutation) test job.
|
||
'planning-inspect': {
|
||
cjs: 'gsd-core/bin/lib/planning-inspect.cjs',
|
||
tests: [
|
||
'tests/planning-inspect.unit.test.cjs',
|
||
],
|
||
minScore: 56,
|
||
},
|
||
'plan-document': {
|
||
cjs: 'gsd-core/bin/lib/plan-document.cjs',
|
||
tests: [
|
||
'tests/planning-inspect.unit.test.cjs',
|
||
],
|
||
minScore: 75,
|
||
},
|
||
'planning-command-router': {
|
||
cjs: 'gsd-core/bin/lib/planning-command-router.cjs',
|
||
tests: [
|
||
'tests/planning-inspect.unit.test.cjs',
|
||
],
|
||
minScore: 94,
|
||
},
|
||
};
|
||
|
||
// ── Files that, when changed, invalidate ALL modules ─────────────────────────
|
||
// Changes to the Stryker config, this script itself, or any covered test file
|
||
// affect all mutation scores and must force a full re-run.
|
||
const GLOBAL_TRIGGERS = new Set([
|
||
'stryker.config.mjs',
|
||
'scripts/mutation-matrix.cjs',
|
||
]);
|
||
|
||
// Also flag all test files that belong to any covered module as global triggers.
|
||
for (const mod of Object.values(COVERED)) {
|
||
for (const t of mod.tests) {
|
||
GLOBAL_TRIGGERS.add(t);
|
||
}
|
||
}
|
||
|
||
// ── Argument parsing ──────────────────────────────────────────────────────────
|
||
function parseArgs(argv) {
|
||
const out = { base: null, print: false };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const arg = argv[i];
|
||
if (arg === '--base') {
|
||
out.base = argv[++i];
|
||
if (!out.base || out.base.startsWith('--')) {
|
||
throw new Error('--base requires a value');
|
||
}
|
||
} else if (arg.startsWith('--base=')) {
|
||
out.base = arg.slice('--base='.length);
|
||
if (!out.base) throw new Error('--base requires a value');
|
||
} else if (arg === '--print') {
|
||
out.print = true;
|
||
} else if (arg === '--help' || arg === '-h') {
|
||
console.log([
|
||
'Usage:',
|
||
' node scripts/mutation-matrix.cjs --base <ref> [--print]',
|
||
' printf "src/foo.cts\\n" | node scripts/mutation-matrix.cjs [--print]',
|
||
'',
|
||
'Options:',
|
||
' --base <ref> Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})',
|
||
' --print Human-readable output instead of JSON',
|
||
].join('\n'));
|
||
throw new ExitError(0);
|
||
} else {
|
||
throw new Error(`unknown argument: ${arg}`);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Changed-file resolution ───────────────────────────────────────────────────
|
||
function resolveChangedFiles(args) {
|
||
// When --base is provided, always use git diff (regardless of stdin).
|
||
// When --base is absent AND stdin is not a TTY (isTTY is falsy / undefined),
|
||
// read a newline-delimited file list from stdin.
|
||
if (!args.base && process.stdin.isTTY !== true) {
|
||
const raw = readStdinSync();
|
||
return raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||
}
|
||
|
||
// Otherwise (--base given, or stdin is a real TTY), diff against the base ref.
|
||
const defaultBase = `origin/${process.env.GITHUB_BASE_REF || 'next'}`;
|
||
const base = args.base || defaultBase;
|
||
const stdout = execFileSync('git', ['diff', '--name-only', `${base}...HEAD`], {
|
||
encoding: 'utf8',
|
||
});
|
||
return stdout.split('\n').map(l => l.trim()).filter(Boolean);
|
||
}
|
||
|
||
// ── Module classification ─────────────────────────────────────────────────────
|
||
function computeMatrix(changedFiles) {
|
||
// Check for global triggers first — if any hit, include every covered module.
|
||
const allModuleNames = Object.keys(COVERED);
|
||
for (const f of changedFiles) {
|
||
if (GLOBAL_TRIGGERS.has(f)) {
|
||
return allModuleNames;
|
||
}
|
||
}
|
||
|
||
// Otherwise find which modules have their src/*.cts changed.
|
||
const changed = new Set();
|
||
for (const f of changedFiles) {
|
||
// Match src/<module>.cts (top-level src/, not nested)
|
||
const m = f.match(/^src\/([^/]+)\.cts$/);
|
||
if (m && COVERED[m[1]]) {
|
||
changed.add(m[1]);
|
||
}
|
||
}
|
||
return [...changed];
|
||
}
|
||
|
||
// ── Output formatting ─────────────────────────────────────────────────────────
|
||
function buildResult(moduleNames) {
|
||
const include = moduleNames.map(name => ({
|
||
name,
|
||
mutate: COVERED[name].cjs,
|
||
tests: COVERED[name].tests.join(' '),
|
||
minScore: COVERED[name].minScore,
|
||
}));
|
||
|
||
return {
|
||
has_work: include.length > 0 ? 'true' : 'false',
|
||
matrix: { include },
|
||
};
|
||
}
|
||
|
||
function printHuman(result, changedFiles) {
|
||
console.log(`Changed files (${changedFiles.length}):`);
|
||
for (const f of changedFiles) console.log(` ${f}`);
|
||
console.log('');
|
||
console.log(`has_work: ${result.has_work}`);
|
||
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(` minScore: ${shard.minScore}`);
|
||
}
|
||
}
|
||
|
||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||
function main() {
|
||
try {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const changedFiles = resolveChangedFiles(args);
|
||
const moduleNames = computeMatrix(changedFiles);
|
||
const result = buildResult(moduleNames);
|
||
|
||
if (args.print) {
|
||
printHuman(result, changedFiles);
|
||
} else {
|
||
console.log(JSON.stringify(result, null, 2));
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof ExitError) throw err;
|
||
console.error(`mutation-matrix: ${err.message}`);
|
||
throw new ExitError(2);
|
||
}
|
||
}
|
||
|
||
// ── 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, readStdinSync };
|
||
|
||
if (require.main === module) runMain(main);
|