oxmutate 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,3 @@
1
- /**
2
- * One mutation run, end to end:
3
- *
4
- * instrument (native, parallel) → dry run ×2 (coverage, timings, nondeterministic tests)
5
- * → incremental reuse → plan → warm units on a work-stealing server pool, under a watchdog
6
- * → isolated re-runs (static mutants, canary-flagged files, unfinished work, flaky verdicts)
7
- * → verdicts
8
- *
9
- * Every decision that can make a verdict wrong is guarded and counted in `stats`, so a run says
10
- * what it had to correct, not just what it concluded.
11
- */
12
1
  import { createHash } from 'node:crypto';
13
2
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
3
  import { tmpdir } from 'node:os';
@@ -17,6 +6,7 @@ import { ambientRuntime, run as spawn } from 'bellpull';
17
6
  import { checkSurvivors } from './checker.js';
18
7
  import { runCommandMutants } from './command-runner.js';
19
8
  import { disables } from './config.js';
9
+ import { rangeDisables } from './directives.js';
20
10
  import { globToRegExp, matchFiles } from './files.js';
21
11
  import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
22
12
  import { native } from './native.js';
@@ -36,20 +26,12 @@ function readRecords(file) {
36
26
  .map((l) => JSON.parse(l));
37
27
  }
38
28
  const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
39
- /**
40
- * Work units of at most this many (mutant, test) executions: small enough to steal, large enough
41
- * that each unit's fresh worker, which imports the test file's whole graph, is paid for few units.
42
- * At 120, module loading was most of the CPU on burgee/controlroom (React/Ink in every worker); 400
43
- * cut units 172 -> 93 and wall 44 -> 39 s (paratext 39 -> 35 s), and 1000 gained nothing more
44
- * (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
45
- */
46
29
  const UNIT_PAIRS = 400;
30
+ const RERUN_SLACK = 3;
47
31
  const MS_PER_MINUTE = 60_000;
48
32
  const PERCENT = 100;
49
- /** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
50
33
  const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
51
34
  const percent = (n, of) => (of ? (PERCENT * n) / of : PERCENT);
52
- /** Stryker's score: detected over everything valid and not ignored; `covered` leaves out no-coverage. */
53
35
  function scoreOf(mutants) {
54
36
  const scored = mutants.filter((m) => !['Ignored', 'CompileError', 'RuntimeError'].includes(m.status));
55
37
  const detected = scored.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
@@ -61,8 +43,8 @@ export async function run(options) {
61
43
  const { root } = options;
62
44
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
63
45
  const stats = {
64
- files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
65
- watchdogKills: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
46
+ files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
47
+ watchdogKills: 0, idleStalls: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
66
48
  };
67
49
  let unitSeq = 0;
68
50
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -70,8 +52,6 @@ export async function run(options) {
70
52
  const progress = startProgress(options.reporters, stderr);
71
53
  try {
72
54
  progress.phase('instrumenting');
73
- // ── 1. Instrument ─────────────────────────────────────────────────────────────────────────
74
- // Stryker's `file:line-line` entries select files by their glob and mutants by their range.
75
55
  const specs = options.mutate.map(parseMutateSpec);
76
56
  const files = matchFiles(root, specs.map((x) => x.glob));
77
57
  const rangesFor = (file) => {
@@ -93,8 +73,9 @@ export async function run(options) {
93
73
  const directiveErrors = [];
94
74
  for (const f of instrumented) {
95
75
  const { disables: offs, errors } = disables(sources[f.path] ?? '', f.path);
76
+ const ranged = rangeDisables(sources[f.path] ?? '', f.path);
96
77
  const ranges = rangesFor(f.path);
97
- directiveErrors.push(...errors);
78
+ directiveErrors.push(...errors, ...ranged.errors);
98
79
  let any = false;
99
80
  for (const m of f.mutants) {
100
81
  if (changed && !touches(changed, f.path, m.start.line, m.end.line))
@@ -104,13 +85,14 @@ export async function run(options) {
104
85
  if (options.shard && shardOf(m.id, options.shard.count) !== options.shard.index)
105
86
  continue;
106
87
  const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
88
+ const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
107
89
  const excluded = options.excludedMutations.includes(m.mutatorName);
108
90
  mutants.push({
109
91
  ...m,
110
92
  file: f.path,
111
93
  coveredBy: [],
112
- status: off || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
113
- statusReason: off?.reason ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
94
+ status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
95
+ statusReason: off?.reason ?? inRange ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
114
96
  });
115
97
  any = true;
116
98
  }
@@ -122,16 +104,13 @@ export async function run(options) {
122
104
  stats.files = files.length;
123
105
  stats.mutants = mutants.length;
124
106
  const mapFile = path.join(work, 'instrumented.json');
125
- // The frozen oracle's base-ref test files ride along in the same map (frozen.ts).
126
107
  writeFileSync(mapFile, JSON.stringify({ ...codeMap, ...options.testOverrides }));
127
- // ── 1b. The build, once, before anything runs (Stryker's `buildCommand`) ─────────────────
128
108
  if (options.buildCommand) {
129
109
  const [command = '', ...args] = options.buildCommand.trim().split(/\s+/);
130
110
  const built = await spawn(command, args, { cwd: root, runtime: ambientRuntime() });
131
111
  if (!built.ok)
132
112
  throw new InstrumentFault(`buildCommand \`${options.buildCommand}\` exited ${String(built.code)}: ${(built.stderr || built.stdout).trim().slice(-1500)}. fix: make the build pass on its own first`);
133
113
  }
134
- // ── 1c. The command runner: any test command, one process per mutant (command-runner.ts) ──
135
114
  if (options.commandRunner) {
136
115
  const m0 = performance.now();
137
116
  try {
@@ -146,8 +125,6 @@ export async function run(options) {
146
125
  stats.wallMs = performance.now() - wall0;
147
126
  return { root, options, sources, mutants, tests: [], stats, score: scoreOf(mutants) };
148
127
  }
149
- // ── 2. Dry run, twice (the second only to find nondeterministic tests) ────────────────────
150
- // Spec oracles' traces, generated once; servers inherit where they are (spec.ts reads it).
151
128
  const traceDirs = {};
152
129
  for (const oracle of options.specOracles)
153
130
  traceDirs[oracle.name] = await tracesFor(oracle, root, work);
@@ -193,8 +170,6 @@ export async function run(options) {
193
170
  throw new InstrumentFault(`${String(failing.length)} test(s) fail with no mutant active, so no verdict can be trusted: ${failing.slice(0, 5).map((t) => t.name).join('; ')}. fix: make the suite pass first`);
194
171
  if (dry.tests.length === 0 && !options.allowEmpty)
195
172
  throw new InstrumentFault('the dry run ran no tests. fix: check the Vitest config the run used, or set allowEmpty');
196
- // The second dry run only finds nondeterministic tests, which matter at step 7: it runs beside
197
- // the warm phase instead of before it (6 s of a 107 s burgee/controlroom run, 2026-10-27).
198
173
  const findFlakyTests = async () => {
199
174
  const flaky = new Set();
200
175
  if (options.flakyReruns > 0) {
@@ -211,11 +186,10 @@ export async function run(options) {
211
186
  return flaky;
212
187
  };
213
188
  const flakyCheck = findFlakyTests();
214
- flakyCheck.catch(() => undefined); // awaited at step 7; a run that fails first must not also reject unhandled
189
+ flakyCheck.catch(() => undefined);
215
190
  const testInfo = new Map(dry.tests.map((t) => [t.id, t]));
216
191
  const coveringTests = new Map();
217
192
  const hits = new Map();
218
- /** Static mutant → the test files whose collection (imports, beforeAll) reached it. */
219
193
  const staticFiles = new Map();
220
194
  for (const [testId, perMutant] of Object.entries(dry.perTest)) {
221
195
  if (testId.startsWith(FILE_KEY)) {
@@ -234,8 +208,6 @@ export async function run(options) {
234
208
  }
235
209
  }
236
210
  const staticIds = new Set([...Object.keys(dry.static), ...staticFiles.keys()]);
237
- // A spec oracle's replay tests, by test id → oracle name. They are not tests of the score:
238
- // their kills are reported as the spec's, on a track of their own.
239
211
  const specOf = new Map();
240
212
  for (const oracle of options.specOracles) {
241
213
  const replay = new Set(matchFiles(root, oracle.replay).map((f) => path.join(root, f)));
@@ -272,7 +244,6 @@ export async function run(options) {
272
244
  const tests = dry.tests.map(({ id, file, name, ms }) => ({ id, file, name, ms }));
273
245
  return { root, options, sources, mutants, tests, stats, score: { detected: 0, undetected: 0, total: 0, mutationScore: 0, covered: 0 }, dryRunOnly: true };
274
246
  }
275
- // ── 3. Incremental reuse ──────────────────────────────────────────────────────────────────
276
247
  const fileHashes = new Map();
277
248
  const fileHash = (abs) => {
278
249
  let h = fileHashes.get(abs);
@@ -296,21 +267,12 @@ export async function run(options) {
296
267
  stats.reused++;
297
268
  }
298
269
  }
299
- // ── 4. Warm units on the server pool ──────────────────────────────────────────────────────
300
- // Static mutants, hybrids included, never run warm: a module evaluated without the mutant
301
- // cannot stand in for one evaluated with it. Tried 2026-10-27: warm kills of hybrids gave 3
302
- // false kills on burgee/paratext (an import-time registration the tests then read).
303
270
  const toRun = mutants.filter((m) => !m.reused && m.status !== 'Ignored' && !m.static && allCovering(m).length > 0);
304
271
  const byId = new Map(mutants.map((m) => [m.id, m]));
305
- /** The switch-taken count past which a run is a runaway: 100x what the dry run saw, at least 1000. */
306
272
  const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
307
273
  const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
308
274
  const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
309
- // The watchdog is for runaways, which loop in fast code; a slow test under a loaded machine is
310
- // not one. 3x the covering test's dry-run time, not Stryker's 1.5x: at 1.5x, subprocess-heavy
311
- // burgee/paratext tests were stopped as false timeouts (2026-10-27).
312
275
  const warmBudget = (id) => options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250;
313
- // Files a mutant's covering tests live in, cheapest first (by the covering tests' time there).
314
276
  const fileOrder = new Map();
315
277
  for (const m of toRun) {
316
278
  const cost = new Map();
@@ -330,7 +292,6 @@ export async function run(options) {
330
292
  stats.units++;
331
293
  return unit;
332
294
  };
333
- /** Split a file's plan into units of about UNIT_PAIRS executions, by mutant. */
334
295
  const unitsFor = (file, plan) => {
335
296
  const ids = [...new Set(Object.values(plan).flat())];
336
297
  const pairs = Object.values(plan).reduce((sum, l) => sum + l.length, 0);
@@ -340,7 +301,6 @@ export async function run(options) {
340
301
  return makeUnit(file, Object.fromEntries(Object.entries(plan).map(([t, l]) => [t, l.filter((id) => mine.has(id))]).filter(([, l]) => l.length > 0)));
341
302
  });
342
303
  };
343
- /** Units for these mutants, each planned only in the files `pick` selects for it. */
344
304
  const unitsForRound = (mutantsHere, pick) => {
345
305
  const perFile = new Map();
346
306
  for (const m of mutantsHere) {
@@ -358,22 +318,16 @@ export async function run(options) {
358
318
  }
359
319
  }
360
320
  const units = [...perFile].flatMap(([file, tests]) => unitsFor(file, Object.fromEntries(tests)));
361
- // Sizing and ordering units by dry-run time instead of pair count (longest first) was tried:
362
- // 30% more units and ~10% more CPU on burgee/controlroom, and no wall-clock gain that this
363
- // machine's load (average 21 to 76 across the A/B) could show (2026-10-27).
364
321
  const weight = (u) => Object.values(planUnits.get(u.results)?.plan ?? {}).reduce((sum, l) => sum + l.length, 0);
365
322
  return units.sort((a, b) => weight(b) - weight(a));
366
323
  };
367
324
  const records = [];
368
325
  const runaways = new Set();
369
326
  const crashedIds = new Set();
370
- // Dependencies inlined here too, so the static phase (5b) reuses these servers instead of starting its own.
371
327
  const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
372
328
  pools.push(pool);
373
- /** Mutants decided on one verdict track: the tests', or a spec oracle's replay tests'. */
374
329
  const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
375
330
  const afterUnit = (unit, outcome) => {
376
- // Verdicts a failed canary cannot vouch for are dropped; their pairs are re-queued below.
377
331
  const raw = readRecords(unit.results);
378
332
  const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
379
333
  const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
@@ -388,7 +342,6 @@ export async function run(options) {
388
342
  runaways.add(outcome.killedFor);
389
343
  else if (bad)
390
344
  crashedIds.add(bad);
391
- // The rest of the unit: pairs not yet run, mutants not yet decided, into a fresh context.
392
345
  const ran = new Set(recs.filter((r) => r.type === 'mutant').map((r) => `${r.id ?? ''}|${r.test ?? ''}`));
393
346
  const decided = { tests: killedSoFar(false), spec: killedSoFar(true) };
394
347
  const { file, plan, depth } = planUnits.get(unit.results);
@@ -401,8 +354,6 @@ export async function run(options) {
401
354
  }
402
355
  if (Object.keys(rest).length === 0)
403
356
  return [];
404
- // A unit that made no progress, or that keeps being polluted, is left to the isolated
405
- // re-run rather than retried for ever.
406
357
  if (ran.size === 0 || depth >= 40) {
407
358
  for (const id of new Set(Object.values(rest).flat()))
408
359
  unfinished.add(id);
@@ -418,13 +369,6 @@ export async function run(options) {
418
369
  const killedCount = () => `${String(killedSoFar(false).size)} of ${String(toRun.length)} mutants killed so far`;
419
370
  progress.phase('warm runs', killedCount);
420
371
  const m0 = performance.now();
421
- // One round, every covering file at once. A two-round plan (cheapest file first, survivors
422
- // to the rest) cut executions 23% on burgee/paratext but took 2.9x longer: the second round
423
- // waits for the first to drain, and the expensive files then run as a tail (2026-10-27).
424
- // Kills are filtered out at dispatch, not when a unit is built: most units wait in the queue while
425
- // others run, and a mutant another unit has killed (vouched for by its canary) needs no more of its
426
- // covering tests. 1,022 of 6,550 warm executions on burgee/paratext ran after the mutant's kill
427
- // (2026-10-30). Verdicts are unchanged: a Killed mutant needs no other pair (dispatch.qnt).
428
372
  const beforeUnit = (unit) => {
429
373
  const entry = planUnits.get(unit.results);
430
374
  if (!entry)
@@ -445,7 +389,6 @@ export async function run(options) {
445
389
  };
446
390
  await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
447
391
  stats.mutationMs = performance.now() - m0;
448
- // ── 5. Verdicts from the warm run ─────────────────────────────────────────────────────────
449
392
  const byMutant = new Map();
450
393
  for (const r of records)
451
394
  if (r.type === 'mutant' && r.id)
@@ -460,10 +403,10 @@ export async function run(options) {
460
403
  if (m.specCoveredBy)
461
404
  m.spec = specVerdict(m, all.filter((r) => r.spec));
462
405
  if (m.coveredBy.length === 0 && !runaways.has(m.id) && !crashedIds.has(m.id)) {
463
- continue; // reached only by a spec oracle: no test covers it, and its status says so
406
+ continue;
464
407
  }
465
408
  else if (runaways.has(m.id)) {
466
- isolatedQueue.push(m); // a watchdog stop is confirmed before it is reported (6, below)
409
+ isolatedQueue.push(m);
467
410
  }
468
411
  else if (crashedIds.has(m.id)) {
469
412
  m.status = 'RuntimeError';
@@ -479,22 +422,15 @@ export async function run(options) {
479
422
  m.testsCompleted = recs.length;
480
423
  }
481
424
  else {
482
- isolatedQueue.push(m); // unfinished: a crash left work undone
425
+ isolatedQueue.push(m);
483
426
  }
484
427
  }
485
428
  const staticMutants = mutants.filter((m) => m.static && m.status !== 'Ignored' && !m.reused);
486
- // ── 6. Isolated re-runs: the switch armed before import, Vitest run normally ──────────────
487
429
  const allTestFiles = [...new Set(dry.tests.map((t) => t.file))];
488
- // A static mutant needs the files that load it (plus any whose tests reach it); only a hit
489
- // with no file to attribute it to (a setup file outside collection) falls back to all files.
490
430
  const fileCost = new Map();
491
431
  for (const t of dry.tests)
492
432
  fileCost.set(t.file, (fileCost.get(t.file) ?? 0) + t.ms);
493
433
  const byCost = (files) => [...files].sort((a, b) => (fileCost.get(a) ?? 0) - (fileCost.get(b) ?? 0));
494
- // Test file -> source file -> how many of that source's mutants its tests reach. A static mutant
495
- // rarely has covering tests of its own, so its loaders were tried cheapest first, and the cheapest
496
- // seldom tests the module: on burgee/controlroom each tabs.ts static mutant ran 75 tests in two
497
- // files where Stryker needed 37 (2026-10-30). The file that exercises the module goes first.
498
434
  const affinity = new Map();
499
435
  for (const mm of mutants) {
500
436
  for (const t of mm.coveredBy) {
@@ -513,35 +449,20 @@ export async function run(options) {
513
449
  const loaders = staticFiles.get(m.id);
514
450
  if (!loaders || m.id in dry.static)
515
451
  return byCost(allTestFiles);
516
- // Files whose tests reach the mutant first: they kill it. Ordering all loaders by cost alone
517
- // ran a file that only imports it first, and 288 of 349 isolated kills on burgee/paratext
518
- // came from a later file (2026-10-27).
519
452
  const covering = byCost([...new Set(tests)]);
520
453
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
521
454
  };
522
455
  const isolatedBudget = (id) => {
523
456
  const m = byId.get(id);
524
457
  const fs = m ? filesFor(m) : allTestFiles;
525
- // A non-static mutant's isolated run executes only its covering tests (`tests` below), so they
526
- // are its budget; every test of its files made a hang wait 30-40 s on oxmutate's own suite,
527
- // where its covering tests take a fraction of that (2026-10-30). A static mutant runs them all.
528
458
  const covering = new Set(m && !m.static ? m.coveredBy : []);
529
459
  const ms = dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).reduce((sum, t) => sum + t.ms, 0);
530
460
  return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
531
461
  };
532
- // A non-static mutant runs only its covering tests, armed across all of them in file order:
533
- // Stryker's semantics, so cross-test effects still show (a no-op reset() lets state pile up
534
- // for the next covering test). A static mutant runs every test of its files, because an
535
- // import-time change reaches tests that never execute the mutated line.
536
- /**
537
- * Static mutants decided in live workers, one unit per (test file, batch of its static mutants),
538
- * on a pool that inlines dependencies. Returns the mutants this path could not decide.
539
- */
540
462
  const decideStaticLive = async (items) => {
541
463
  if (items.length === 0)
542
464
  return [];
543
465
  const STATIC_BATCH = 25;
544
- /** Units of up to STATIC_BATCH mutants per test file, for the files each mutant gets this round. */
545
466
  const unitsFor = (filesOf) => {
546
467
  const byFile = new Map();
547
468
  for (const m of items)
@@ -553,9 +474,8 @@ export async function run(options) {
553
474
  })));
554
475
  };
555
476
  const killed = new Map();
556
- /** Decided by the hit-count limit: a runaway, Timeout like a warm one. */
557
477
  const timedOut = new Set();
558
- const passed = new Map(); // mutant -> files that ran it clean
478
+ const passed = new Map();
559
479
  const back = new Set();
560
480
  const ran = new Map();
561
481
  const staticPool = pool;
@@ -563,6 +483,8 @@ export async function run(options) {
563
483
  const file = unit.files[0] ?? '';
564
484
  const decided = new Set();
565
485
  for (const r of readRecords(unit.results)) {
486
+ if (r.type === 'recollection')
487
+ stats.staticRecollections++;
566
488
  if (r.type === 'static-fallback')
567
489
  for (const id of r.ids ?? [])
568
490
  back.add(id);
@@ -577,7 +499,6 @@ export async function run(options) {
577
499
  else
578
500
  (passed.get(r.id) ?? passed.set(r.id, new Set()).get(r.id))?.add(file);
579
501
  }
580
- // A stop or a crash leaves the rest of the batch undecided: the fresh-worker path decides it.
581
502
  if (outcome.killedFor || outcome.crashed)
582
503
  for (const id of unit.staticIds ?? [])
583
504
  if (!decided.has(id))
@@ -588,13 +509,8 @@ export async function run(options) {
588
509
  unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
589
510
  return unit.staticIds.length > 0;
590
511
  };
591
- // Two rounds: each mutant first in the file most likely to kill it (filesFor puts the files whose
592
- // tests reach it first, cheapest first), then its other files only if it survived. In one round
593
- // every loading file ran a mutant at once, and a kill in one did not spare the others: 41 tests a
594
- // static kill on burgee/controlroom against Stryker's 33.6 (2026-10-30).
595
512
  const open = (m) => !killed.has(m.id) && !back.has(m.id);
596
513
  const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
597
- // the mutant's run and the unarmed canary after it
598
514
  for (const round of rounds)
599
515
  await staticPool.drain(unitsFor(round), (_, id) => 2 * isolatedBudget(id), afterStatic, keepUndecided);
600
516
  const undecided = [];
@@ -619,9 +535,6 @@ export async function run(options) {
619
535
  if (items.length === 0)
620
536
  return verdicts;
621
537
  const mk = (batch) => ({ mode: 'isolated', files: [], results: fileFor('iso', 'jsonl'), progress: fileFor('iso', 'progress'), batch: batch.map((m) => ({ id: m.id, files: filesFor(m), tests: m.static ? undefined : m.coveredBy, hitLimit: hitLimitOf(m.id) })) });
622
- // Packed by estimated cost, heaviest first into the lightest bin, and the heaviest bins run
623
- // first. Equal mutant counts left one unit at 8.4 s while the others finished in under 3 s on
624
- // burgee/controlroom (2026-10-30): a mutant's cost is its files' dry-run time, not one.
625
538
  const cost = (m) => isolatedBudget(m.id);
626
539
  const bins = Array.from({ length: Math.min(items.length, options.concurrency * 2) }, () => ({ load: 0, batch: [] }));
627
540
  for (const m of [...items].sort((x, y) => cost(y) - cost(x))) {
@@ -632,7 +545,6 @@ export async function run(options) {
632
545
  const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
633
546
  stats.isolatedRuns += items.length;
634
547
  await pool.drain(units, (_, id) => slack * isolatedBudget(id), (unit, outcome) => {
635
- // Vitest reports per test file: a mutant is killed if any of its files failed.
636
548
  for (const rec of readRecords(unit.results)) {
637
549
  if (rec.type !== 'isolated' || !rec.id)
638
550
  continue;
@@ -646,7 +558,7 @@ export async function run(options) {
646
558
  verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
647
559
  }
648
560
  if (outcome.killedFor)
649
- verdicts.set(outcome.killedFor, { status: 'Timeout' });
561
+ verdicts.set(outcome.killedFor, { status: 'Timeout', idle: outcome.stall === 'idle' });
650
562
  if (outcome.crashed && outcome.crashedFor)
651
563
  verdicts.set(outcome.crashedFor, { status: 'RuntimeError' });
652
564
  if (!outcome.killedFor && !outcome.crashed)
@@ -660,21 +572,15 @@ export async function run(options) {
660
572
  verdicts.set(m.id, { status: 'RuntimeError' });
661
573
  return verdicts;
662
574
  };
663
- // ── 5b. Static mutants in live workers (.sdlc/intents/oxmutate/staticwarm.qnt) ─────────────
664
- // A fresh worker per (static mutant, file) cost ~80 ms of start-up around tests that take a few
665
- // ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
666
- // per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
667
- // OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
575
+ const s0 = performance.now();
668
576
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
577
+ stats.staticMs = performance.now() - s0;
669
578
  isolatedQueue.push(...staticBack);
670
579
  progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
671
580
  const i0 = performance.now();
672
581
  const first = await runIsolated(isolatedQueue);
673
- // A watchdog stop on a loaded machine is a slow test, not a runaway: 31 of them on one
674
- // burgee/paratext run while the load average sat at 40, all Survived for Stryker (2026-10-27).
675
- // Each stop gets one more run with 3x the budget; only one that is stopped again is a Timeout.
676
- const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout');
677
- const again = await runIsolated(stopped, 3);
582
+ const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
583
+ const again = await runIsolated(stopped, RERUN_SLACK);
678
584
  for (const m of stopped) {
679
585
  const v = again.get(m.id);
680
586
  if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
@@ -689,25 +595,13 @@ export async function run(options) {
689
595
  m.killedBy = v.killedBy;
690
596
  m.testsCompleted = v.testsCompleted;
691
597
  if (v.status === 'Timeout')
692
- m.statusReason = 'stopped by the watchdog twice, the second time with 3x the budget';
598
+ m.statusReason = v.idle ? 'its test waited past the budget with the worker idle: a hang, not a slow test' : 'stopped by the watchdog twice, the second time with 3x the budget';
693
599
  }
694
600
  }
695
- // ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
696
- // The warm runner arms a mutant for one test at a time, so a mutant whose effect only shows
697
- // across tests (a `reset()` in beforeEach made a no-op, so state piles up for the next test)
698
- // survives warm and is killed when armed for the whole file, the way Stryker runs it (burgee/
699
- // paratext plugin.ts, 2026-10-27). Survivors are what a person acts on, so each one is
700
- // re-run once with the mutant armed across its covering files before it is reported.
701
- // A survivor with one covering test is not re-run: confirmation runs only covering tests, so there
702
- // is no later test for its leftover state to reach, and the warm run already armed that test
703
- // through its hooks from clean state (.sdlc/intents/oxmutate/confirm.qnt).
704
601
  const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
705
602
  const confirmed = await runIsolated(survivors);
706
- // A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
707
- // closeout survivors were reported killed this way while a docs build shared the machine
708
- // (2026-10-27). The same rule as section 6: one more run at 3x the budget before believing it.
709
- const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout');
710
- const recheck = await runIsolated(stoppedHere, 3);
603
+ const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
604
+ const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
711
605
  for (const m of stoppedHere) {
712
606
  const v = recheck.get(m.id);
713
607
  if (v)
@@ -722,10 +616,8 @@ export async function run(options) {
722
616
  stats.survivorsOverturned++;
723
617
  }
724
618
  }
725
- // ── 6c. Survivors that do not type-check are CompileErrors (checker.ts) ────────────────────
726
619
  if (options.typeCheck) {
727
620
  progress.phase('type-checking survivors');
728
- // Every undetected mutant is one somebody would act on: survivors and the uncovered.
729
621
  const survived = mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
730
622
  const refused = await checkSurvivors({ root, tsconfig: options.tsconfigFile, command: options.typeChecker, concurrency: options.concurrency }, survived).catch((error) => {
731
623
  throw new InstrumentFault(error instanceof Error ? error.message : String(error));
@@ -736,7 +628,6 @@ export async function run(options) {
736
628
  stats.compileErrors++;
737
629
  }
738
630
  }
739
- // ── 7. Flaky verdicts: re-run mutants whose covering tests are nondeterministic ───────────
740
631
  const flakyTests = await flakyCheck;
741
632
  stats.flakyTests = flakyTests.size;
742
633
  const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
@@ -759,8 +650,8 @@ export async function run(options) {
759
650
  }
760
651
  stats.isolatedMs = performance.now() - i0;
761
652
  stats.watchdogKills = pool.stats.killed;
653
+ stats.idleStalls = pool.stats.idle;
762
654
  stats.crashes = pool.stats.crashed;
763
- // ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
764
655
  if (options.incremental) {
765
656
  mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
766
657
  const next = { ...cache };
@@ -800,4 +691,3 @@ export async function run(options) {
800
691
  rmSync(work, { recursive: true, force: true });
801
692
  }
802
693
  }
803
- //# sourceMappingURL=coordinator.js.map
@@ -0,0 +1,38 @@
1
+ const HEAD = /\/\/\s*(?:Stryker|oxmutate)\s+(disable|restore)\s+(\S.*)/i;
2
+ const LIST = /^(?:all|\w+(?:\s*,\s*\w+)*)$/i;
3
+ const covers = (r, mutator) => r.mutators === 'all' || r.mutators.includes(mutator);
4
+ export function rangeDisables(source, file) {
5
+ const rules = [];
6
+ const errors = [];
7
+ source.split('\n').forEach((text, i) => {
8
+ const m = HEAD.exec(text);
9
+ if (!m)
10
+ return;
11
+ const body = m[2];
12
+ const colon = body.indexOf(':');
13
+ const list = (colon === -1 ? body : body.slice(0, colon)).trim();
14
+ if (!LIST.test(list))
15
+ return;
16
+ const kind = m[1].toLowerCase();
17
+ const reason = colon === -1 ? '' : body.slice(colon + 1).trim();
18
+ if (kind === 'disable' && !reason) {
19
+ errors.push(`${file}:${String(i + 1)}: disable comment without a reason. fix: add ": <why these mutants cannot be killed>"`);
20
+ return;
21
+ }
22
+ const mutators = list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim().toLowerCase());
23
+ rules.push({ line: i + 1, kind, mutators, reason });
24
+ });
25
+ return {
26
+ errors,
27
+ reasonFor(line, mutator) {
28
+ const name = mutator.toLowerCase();
29
+ for (let k = rules.length - 1; k >= 0; k--) {
30
+ const r = rules[k];
31
+ if (r.line >= line || !covers(r, name))
32
+ continue;
33
+ return r.kind === 'disable' ? r.reason : undefined;
34
+ }
35
+ return undefined;
36
+ },
37
+ };
38
+ }
package/dist/files.js CHANGED
@@ -1,21 +1,13 @@
1
- /**
2
- * The files to mutate, from Stryker-style globs (`src/**\/*.ts`, `!src/**\/*.test.ts`, `{a,b}`).
3
- * No dependency: a glob is translated to one regular expression, and the walk skips the
4
- * directories a project never mutates.
5
- */
6
1
  import { readdirSync } from 'node:fs';
7
2
  import path from 'node:path';
8
3
  const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo', '.oxmutate', '.stryker-tmp', 'reports']);
9
4
  const escapeRegExp = (s) => s.replace(/[.+^$()|[\]\\]/g, '\\$&');
10
- /** Translate one glob (without a leading `!`) into an anchored regular expression. */
11
5
  export function globToRegExp(glob) {
12
6
  let re = '';
13
- // Stryker disable next-line EqualityOperator: one step past the end reads charAt(length), an empty string, which appends nothing
14
7
  for (let i = 0; i < glob.length; i++) {
15
8
  const c = glob.charAt(i);
16
9
  if (c === '*') {
17
10
  if (glob[i + 1] === '*') {
18
- // `**/` matches zero or more directories; a bare `**` matches anything.
19
11
  if (glob[i + 2] === '/') {
20
12
  re += '(?:.*/)?';
21
13
  i += 2;
@@ -40,7 +32,6 @@ export function globToRegExp(glob) {
40
32
  }
41
33
  return new RegExp(`^${re}$`);
42
34
  }
43
- /** Repository-relative paths (forward slashes) of files matching the include and exclude globs. */
44
35
  export function matchFiles(root, patterns) {
45
36
  const include = patterns.filter((p) => !p.startsWith('!')).map(globToRegExp);
46
37
  const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => globToRegExp(p.slice(1)));
@@ -59,4 +50,3 @@ export function matchFiles(root, patterns) {
59
50
  walk('');
60
51
  return out.sort();
61
52
  }
62
- //# sourceMappingURL=files.js.map
package/dist/frozen.js CHANGED
@@ -1,22 +1,8 @@
1
- /**
2
- * The frozen oracle (spec R14): a change that edits tests is also scored against the base ref's
3
- * tests. The code under test is the same in both runs, so the mutants and their ids are the same;
4
- * only the test files differ. A mutant the base tests detect and the changed tests do not is a test
5
- * the change weakened, which is how a patch that "makes the check pass" by editing the check shows
6
- * up (the reward-hacking concern in the intent, written as a CI result).
7
- *
8
- * The base tests are served to Vitest from memory, the way instrumented sources are: nothing in
9
- * the working tree is touched.
10
- */
11
1
  import path from 'node:path';
12
2
  import { ambientRuntime, run as spawn } from 'bellpull';
13
3
  import { run } from './coordinator.js';
14
4
  const detected = (m) => m?.status === 'Killed' || m?.status === 'Timeout';
15
5
  const undetected = (m) => m?.status === 'Survived' || m?.status === 'NoCoverage';
16
- /**
17
- * A test file the change added has no base version. It is served as one skipped test, so the base
18
- * run has the file (Vitest finds it on disk) and none of its tests.
19
- */
20
6
  const ABSENT_AT_BASE = "import { it } from 'vitest';\nit.skip('absent at the base ref (oxmutate frozen oracle)', () => {});\n";
21
7
  async function git(root, args) {
22
8
  const r = await spawn('git', args, { cwd: root, runtime: ambientRuntime() });
@@ -44,4 +30,3 @@ export async function frozenOracle(head, base) {
44
30
  .map((m) => ({ id: m.id, file: m.file, line: m.start.line, mutatorName: m.mutatorName, replacement: m.replacement }));
45
31
  return result;
46
32
  }
47
- //# sourceMappingURL=frozen.js.map
@@ -0,0 +1,62 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export const HEARTBEAT_MS = 250;
4
+ export const STALE_MS = 1000;
5
+ export const CPU_SAMPLE_MS = 250;
6
+ export const CPU_WINDOW_MS = 1000;
7
+ export const MIN_CPU_SPAN_MS = 500;
8
+ export const IDLE_CPU = 0.25;
9
+ export const BEAT_ENV = 'OXMUTATE_BEAT';
10
+ export const beatFile = (workDir, pid) => path.join(workDir, `beat-${String(pid)}`);
11
+ const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
12
+ export const realNow = Date.now.bind(Date);
13
+ const BEAT_KEY = Symbol.for('oxmutate.heartbeat');
14
+ let inTest = false;
15
+ export function markTest(running) {
16
+ inTest = running;
17
+ }
18
+ export function startHeartbeat(file) {
19
+ if (!file)
20
+ return;
21
+ const holder = globalThis;
22
+ realClearInterval(holder[BEAT_KEY]);
23
+ const beat = () => {
24
+ try {
25
+ writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ return true;
31
+ };
32
+ beat();
33
+ holder[BEAT_KEY] = realSetInterval(() => {
34
+ if (!beat())
35
+ realClearInterval(holder[BEAT_KEY]);
36
+ }, HEARTBEAT_MS);
37
+ holder[BEAT_KEY].unref();
38
+ }
39
+ export function readBeat(file, now = Date.now()) {
40
+ let text;
41
+ try {
42
+ text = String(readFileSync(file));
43
+ }
44
+ catch {
45
+ return { age: Number.POSITIVE_INFINITY, inTest: false };
46
+ }
47
+ const [stamp, where] = text.split(' ');
48
+ const at = Number(stamp);
49
+ return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
50
+ }
51
+ const US_PER_MS = 1000;
52
+ export function cpuShare(samples, now) {
53
+ const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
54
+ const first = recent[0];
55
+ const last = recent.at(-1);
56
+ if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
57
+ return undefined;
58
+ return (last.us - first.us) / US_PER_MS / (last.at - first.at);
59
+ }
60
+ export function classifyStall(o) {
61
+ return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
62
+ }