oxmutate 1.2.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';
@@ -37,22 +26,12 @@ function readRecords(file) {
37
26
  .map((l) => JSON.parse(l));
38
27
  }
39
28
  const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
40
- /**
41
- * Work units of at most this many (mutant, test) executions: small enough to steal, large enough
42
- * that each unit's fresh worker, which imports the test file's whole graph, is paid for few units.
43
- * At 120, module loading was most of the CPU on burgee/controlroom (React/Ink in every worker); 400
44
- * cut units 172 -> 93 and wall 44 -> 39 s (paratext 39 -> 35 s), and 1000 gained nothing more
45
- * (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
46
- */
47
29
  const UNIT_PAIRS = 400;
48
- /** How many times its isolated budget a busy stop gets on its re-run. */
49
30
  const RERUN_SLACK = 3;
50
31
  const MS_PER_MINUTE = 60_000;
51
32
  const PERCENT = 100;
52
- /** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
53
33
  const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
54
34
  const percent = (n, of) => (of ? (PERCENT * n) / of : PERCENT);
55
- /** Stryker's score: detected over everything valid and not ignored; `covered` leaves out no-coverage. */
56
35
  function scoreOf(mutants) {
57
36
  const scored = mutants.filter((m) => !['Ignored', 'CompileError', 'RuntimeError'].includes(m.status));
58
37
  const detected = scored.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
@@ -73,8 +52,6 @@ export async function run(options) {
73
52
  const progress = startProgress(options.reporters, stderr);
74
53
  try {
75
54
  progress.phase('instrumenting');
76
- // ── 1. Instrument ─────────────────────────────────────────────────────────────────────────
77
- // Stryker's `file:line-line` entries select files by their glob and mutants by their range.
78
55
  const specs = options.mutate.map(parseMutateSpec);
79
56
  const files = matchFiles(root, specs.map((x) => x.glob));
80
57
  const rangesFor = (file) => {
@@ -127,16 +104,13 @@ export async function run(options) {
127
104
  stats.files = files.length;
128
105
  stats.mutants = mutants.length;
129
106
  const mapFile = path.join(work, 'instrumented.json');
130
- // The frozen oracle's base-ref test files ride along in the same map (frozen.ts).
131
107
  writeFileSync(mapFile, JSON.stringify({ ...codeMap, ...options.testOverrides }));
132
- // ── 1b. The build, once, before anything runs (Stryker's `buildCommand`) ─────────────────
133
108
  if (options.buildCommand) {
134
109
  const [command = '', ...args] = options.buildCommand.trim().split(/\s+/);
135
110
  const built = await spawn(command, args, { cwd: root, runtime: ambientRuntime() });
136
111
  if (!built.ok)
137
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`);
138
113
  }
139
- // ── 1c. The command runner: any test command, one process per mutant (command-runner.ts) ──
140
114
  if (options.commandRunner) {
141
115
  const m0 = performance.now();
142
116
  try {
@@ -151,8 +125,6 @@ export async function run(options) {
151
125
  stats.wallMs = performance.now() - wall0;
152
126
  return { root, options, sources, mutants, tests: [], stats, score: scoreOf(mutants) };
153
127
  }
154
- // ── 2. Dry run, twice (the second only to find nondeterministic tests) ────────────────────
155
- // Spec oracles' traces, generated once; servers inherit where they are (spec.ts reads it).
156
128
  const traceDirs = {};
157
129
  for (const oracle of options.specOracles)
158
130
  traceDirs[oracle.name] = await tracesFor(oracle, root, work);
@@ -198,8 +170,6 @@ export async function run(options) {
198
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`);
199
171
  if (dry.tests.length === 0 && !options.allowEmpty)
200
172
  throw new InstrumentFault('the dry run ran no tests. fix: check the Vitest config the run used, or set allowEmpty');
201
- // The second dry run only finds nondeterministic tests, which matter at step 7: it runs beside
202
- // the warm phase instead of before it (6 s of a 107 s burgee/controlroom run, 2026-10-27).
203
173
  const findFlakyTests = async () => {
204
174
  const flaky = new Set();
205
175
  if (options.flakyReruns > 0) {
@@ -216,11 +186,10 @@ export async function run(options) {
216
186
  return flaky;
217
187
  };
218
188
  const flakyCheck = findFlakyTests();
219
- flakyCheck.catch(() => undefined); // awaited at step 7; a run that fails first must not also reject unhandled
189
+ flakyCheck.catch(() => undefined);
220
190
  const testInfo = new Map(dry.tests.map((t) => [t.id, t]));
221
191
  const coveringTests = new Map();
222
192
  const hits = new Map();
223
- /** Static mutant → the test files whose collection (imports, beforeAll) reached it. */
224
193
  const staticFiles = new Map();
225
194
  for (const [testId, perMutant] of Object.entries(dry.perTest)) {
226
195
  if (testId.startsWith(FILE_KEY)) {
@@ -239,8 +208,6 @@ export async function run(options) {
239
208
  }
240
209
  }
241
210
  const staticIds = new Set([...Object.keys(dry.static), ...staticFiles.keys()]);
242
- // A spec oracle's replay tests, by test id → oracle name. They are not tests of the score:
243
- // their kills are reported as the spec's, on a track of their own.
244
211
  const specOf = new Map();
245
212
  for (const oracle of options.specOracles) {
246
213
  const replay = new Set(matchFiles(root, oracle.replay).map((f) => path.join(root, f)));
@@ -277,7 +244,6 @@ export async function run(options) {
277
244
  const tests = dry.tests.map(({ id, file, name, ms }) => ({ id, file, name, ms }));
278
245
  return { root, options, sources, mutants, tests, stats, score: { detected: 0, undetected: 0, total: 0, mutationScore: 0, covered: 0 }, dryRunOnly: true };
279
246
  }
280
- // ── 3. Incremental reuse ──────────────────────────────────────────────────────────────────
281
247
  const fileHashes = new Map();
282
248
  const fileHash = (abs) => {
283
249
  let h = fileHashes.get(abs);
@@ -301,21 +267,12 @@ export async function run(options) {
301
267
  stats.reused++;
302
268
  }
303
269
  }
304
- // ── 4. Warm units on the server pool ──────────────────────────────────────────────────────
305
- // Static mutants, hybrids included, never run warm: a module evaluated without the mutant
306
- // cannot stand in for one evaluated with it. Tried 2026-10-27: warm kills of hybrids gave 3
307
- // false kills on burgee/paratext (an import-time registration the tests then read).
308
270
  const toRun = mutants.filter((m) => !m.reused && m.status !== 'Ignored' && !m.static && allCovering(m).length > 0);
309
271
  const byId = new Map(mutants.map((m) => [m.id, m]));
310
- /** The switch-taken count past which a run is a runaway: 100x what the dry run saw, at least 1000. */
311
272
  const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
312
273
  const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
313
274
  const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
314
- // The watchdog is for runaways, which loop in fast code; a slow test under a loaded machine is
315
- // not one. 3x the covering test's dry-run time, not Stryker's 1.5x: at 1.5x, subprocess-heavy
316
- // burgee/paratext tests were stopped as false timeouts (2026-10-27).
317
275
  const warmBudget = (id) => options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250;
318
- // Files a mutant's covering tests live in, cheapest first (by the covering tests' time there).
319
276
  const fileOrder = new Map();
320
277
  for (const m of toRun) {
321
278
  const cost = new Map();
@@ -335,7 +292,6 @@ export async function run(options) {
335
292
  stats.units++;
336
293
  return unit;
337
294
  };
338
- /** Split a file's plan into units of about UNIT_PAIRS executions, by mutant. */
339
295
  const unitsFor = (file, plan) => {
340
296
  const ids = [...new Set(Object.values(plan).flat())];
341
297
  const pairs = Object.values(plan).reduce((sum, l) => sum + l.length, 0);
@@ -345,7 +301,6 @@ export async function run(options) {
345
301
  return makeUnit(file, Object.fromEntries(Object.entries(plan).map(([t, l]) => [t, l.filter((id) => mine.has(id))]).filter(([, l]) => l.length > 0)));
346
302
  });
347
303
  };
348
- /** Units for these mutants, each planned only in the files `pick` selects for it. */
349
304
  const unitsForRound = (mutantsHere, pick) => {
350
305
  const perFile = new Map();
351
306
  for (const m of mutantsHere) {
@@ -363,22 +318,16 @@ export async function run(options) {
363
318
  }
364
319
  }
365
320
  const units = [...perFile].flatMap(([file, tests]) => unitsFor(file, Object.fromEntries(tests)));
366
- // Sizing and ordering units by dry-run time instead of pair count (longest first) was tried:
367
- // 30% more units and ~10% more CPU on burgee/controlroom, and no wall-clock gain that this
368
- // machine's load (average 21 to 76 across the A/B) could show (2026-10-27).
369
321
  const weight = (u) => Object.values(planUnits.get(u.results)?.plan ?? {}).reduce((sum, l) => sum + l.length, 0);
370
322
  return units.sort((a, b) => weight(b) - weight(a));
371
323
  };
372
324
  const records = [];
373
325
  const runaways = new Set();
374
326
  const crashedIds = new Set();
375
- // Dependencies inlined here too, so the static phase (5b) reuses these servers instead of starting its own.
376
327
  const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
377
328
  pools.push(pool);
378
- /** Mutants decided on one verdict track: the tests', or a spec oracle's replay tests'. */
379
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 ?? '')]);
380
330
  const afterUnit = (unit, outcome) => {
381
- // Verdicts a failed canary cannot vouch for are dropped; their pairs are re-queued below.
382
331
  const raw = readRecords(unit.results);
383
332
  const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
384
333
  const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
@@ -393,7 +342,6 @@ export async function run(options) {
393
342
  runaways.add(outcome.killedFor);
394
343
  else if (bad)
395
344
  crashedIds.add(bad);
396
- // The rest of the unit: pairs not yet run, mutants not yet decided, into a fresh context.
397
345
  const ran = new Set(recs.filter((r) => r.type === 'mutant').map((r) => `${r.id ?? ''}|${r.test ?? ''}`));
398
346
  const decided = { tests: killedSoFar(false), spec: killedSoFar(true) };
399
347
  const { file, plan, depth } = planUnits.get(unit.results);
@@ -406,8 +354,6 @@ export async function run(options) {
406
354
  }
407
355
  if (Object.keys(rest).length === 0)
408
356
  return [];
409
- // A unit that made no progress, or that keeps being polluted, is left to the isolated
410
- // re-run rather than retried for ever.
411
357
  if (ran.size === 0 || depth >= 40) {
412
358
  for (const id of new Set(Object.values(rest).flat()))
413
359
  unfinished.add(id);
@@ -423,13 +369,6 @@ export async function run(options) {
423
369
  const killedCount = () => `${String(killedSoFar(false).size)} of ${String(toRun.length)} mutants killed so far`;
424
370
  progress.phase('warm runs', killedCount);
425
371
  const m0 = performance.now();
426
- // One round, every covering file at once. A two-round plan (cheapest file first, survivors
427
- // to the rest) cut executions 23% on burgee/paratext but took 2.9x longer: the second round
428
- // waits for the first to drain, and the expensive files then run as a tail (2026-10-27).
429
- // Kills are filtered out at dispatch, not when a unit is built: most units wait in the queue while
430
- // others run, and a mutant another unit has killed (vouched for by its canary) needs no more of its
431
- // covering tests. 1,022 of 6,550 warm executions on burgee/paratext ran after the mutant's kill
432
- // (2026-10-30). Verdicts are unchanged: a Killed mutant needs no other pair (dispatch.qnt).
433
372
  const beforeUnit = (unit) => {
434
373
  const entry = planUnits.get(unit.results);
435
374
  if (!entry)
@@ -450,7 +389,6 @@ export async function run(options) {
450
389
  };
451
390
  await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
452
391
  stats.mutationMs = performance.now() - m0;
453
- // ── 5. Verdicts from the warm run ─────────────────────────────────────────────────────────
454
392
  const byMutant = new Map();
455
393
  for (const r of records)
456
394
  if (r.type === 'mutant' && r.id)
@@ -465,10 +403,10 @@ export async function run(options) {
465
403
  if (m.specCoveredBy)
466
404
  m.spec = specVerdict(m, all.filter((r) => r.spec));
467
405
  if (m.coveredBy.length === 0 && !runaways.has(m.id) && !crashedIds.has(m.id)) {
468
- continue; // reached only by a spec oracle: no test covers it, and its status says so
406
+ continue;
469
407
  }
470
408
  else if (runaways.has(m.id)) {
471
- isolatedQueue.push(m); // a watchdog stop is confirmed before it is reported (6, below)
409
+ isolatedQueue.push(m);
472
410
  }
473
411
  else if (crashedIds.has(m.id)) {
474
412
  m.status = 'RuntimeError';
@@ -484,22 +422,15 @@ export async function run(options) {
484
422
  m.testsCompleted = recs.length;
485
423
  }
486
424
  else {
487
- isolatedQueue.push(m); // unfinished: a crash left work undone
425
+ isolatedQueue.push(m);
488
426
  }
489
427
  }
490
428
  const staticMutants = mutants.filter((m) => m.static && m.status !== 'Ignored' && !m.reused);
491
- // ── 6. Isolated re-runs: the switch armed before import, Vitest run normally ──────────────
492
429
  const allTestFiles = [...new Set(dry.tests.map((t) => t.file))];
493
- // A static mutant needs the files that load it (plus any whose tests reach it); only a hit
494
- // with no file to attribute it to (a setup file outside collection) falls back to all files.
495
430
  const fileCost = new Map();
496
431
  for (const t of dry.tests)
497
432
  fileCost.set(t.file, (fileCost.get(t.file) ?? 0) + t.ms);
498
433
  const byCost = (files) => [...files].sort((a, b) => (fileCost.get(a) ?? 0) - (fileCost.get(b) ?? 0));
499
- // Test file -> source file -> how many of that source's mutants its tests reach. A static mutant
500
- // rarely has covering tests of its own, so its loaders were tried cheapest first, and the cheapest
501
- // seldom tests the module: on burgee/controlroom each tabs.ts static mutant ran 75 tests in two
502
- // files where Stryker needed 37 (2026-10-30). The file that exercises the module goes first.
503
434
  const affinity = new Map();
504
435
  for (const mm of mutants) {
505
436
  for (const t of mm.coveredBy) {
@@ -518,35 +449,20 @@ export async function run(options) {
518
449
  const loaders = staticFiles.get(m.id);
519
450
  if (!loaders || m.id in dry.static)
520
451
  return byCost(allTestFiles);
521
- // Files whose tests reach the mutant first: they kill it. Ordering all loaders by cost alone
522
- // ran a file that only imports it first, and 288 of 349 isolated kills on burgee/paratext
523
- // came from a later file (2026-10-27).
524
452
  const covering = byCost([...new Set(tests)]);
525
453
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
526
454
  };
527
455
  const isolatedBudget = (id) => {
528
456
  const m = byId.get(id);
529
457
  const fs = m ? filesFor(m) : allTestFiles;
530
- // A non-static mutant's isolated run executes only its covering tests (`tests` below), so they
531
- // are its budget; every test of its files made a hang wait 30-40 s on oxmutate's own suite,
532
- // where its covering tests take a fraction of that (2026-10-30). A static mutant runs them all.
533
458
  const covering = new Set(m && !m.static ? m.coveredBy : []);
534
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);
535
460
  return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
536
461
  };
537
- // A non-static mutant runs only its covering tests, armed across all of them in file order:
538
- // Stryker's semantics, so cross-test effects still show (a no-op reset() lets state pile up
539
- // for the next covering test). A static mutant runs every test of its files, because an
540
- // import-time change reaches tests that never execute the mutated line.
541
- /**
542
- * Static mutants decided in live workers, one unit per (test file, batch of its static mutants),
543
- * on a pool that inlines dependencies. Returns the mutants this path could not decide.
544
- */
545
462
  const decideStaticLive = async (items) => {
546
463
  if (items.length === 0)
547
464
  return [];
548
465
  const STATIC_BATCH = 25;
549
- /** Units of up to STATIC_BATCH mutants per test file, for the files each mutant gets this round. */
550
466
  const unitsFor = (filesOf) => {
551
467
  const byFile = new Map();
552
468
  for (const m of items)
@@ -558,9 +474,8 @@ export async function run(options) {
558
474
  })));
559
475
  };
560
476
  const killed = new Map();
561
- /** Decided by the hit-count limit: a runaway, Timeout like a warm one. */
562
477
  const timedOut = new Set();
563
- const passed = new Map(); // mutant -> files that ran it clean
478
+ const passed = new Map();
564
479
  const back = new Set();
565
480
  const ran = new Map();
566
481
  const staticPool = pool;
@@ -584,7 +499,6 @@ export async function run(options) {
584
499
  else
585
500
  (passed.get(r.id) ?? passed.set(r.id, new Set()).get(r.id))?.add(file);
586
501
  }
587
- // A stop or a crash leaves the rest of the batch undecided: the fresh-worker path decides it.
588
502
  if (outcome.killedFor || outcome.crashed)
589
503
  for (const id of unit.staticIds ?? [])
590
504
  if (!decided.has(id))
@@ -595,13 +509,8 @@ export async function run(options) {
595
509
  unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
596
510
  return unit.staticIds.length > 0;
597
511
  };
598
- // Two rounds: each mutant first in the file most likely to kill it (filesFor puts the files whose
599
- // tests reach it first, cheapest first), then its other files only if it survived. In one round
600
- // every loading file ran a mutant at once, and a kill in one did not spare the others: 41 tests a
601
- // static kill on burgee/controlroom against Stryker's 33.6 (2026-10-30).
602
512
  const open = (m) => !killed.has(m.id) && !back.has(m.id);
603
513
  const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
604
- // the mutant's run and the unarmed canary after it
605
514
  for (const round of rounds)
606
515
  await staticPool.drain(unitsFor(round), (_, id) => 2 * isolatedBudget(id), afterStatic, keepUndecided);
607
516
  const undecided = [];
@@ -626,9 +535,6 @@ export async function run(options) {
626
535
  if (items.length === 0)
627
536
  return verdicts;
628
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) })) });
629
- // Packed by estimated cost, heaviest first into the lightest bin, and the heaviest bins run
630
- // first. Equal mutant counts left one unit at 8.4 s while the others finished in under 3 s on
631
- // burgee/controlroom (2026-10-30): a mutant's cost is its files' dry-run time, not one.
632
538
  const cost = (m) => isolatedBudget(m.id);
633
539
  const bins = Array.from({ length: Math.min(items.length, options.concurrency * 2) }, () => ({ load: 0, batch: [] }));
634
540
  for (const m of [...items].sort((x, y) => cost(y) - cost(x))) {
@@ -639,7 +545,6 @@ export async function run(options) {
639
545
  const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
640
546
  stats.isolatedRuns += items.length;
641
547
  await pool.drain(units, (_, id) => slack * isolatedBudget(id), (unit, outcome) => {
642
- // Vitest reports per test file: a mutant is killed if any of its files failed.
643
548
  for (const rec of readRecords(unit.results)) {
644
549
  if (rec.type !== 'isolated' || !rec.id)
645
550
  continue;
@@ -667,11 +572,6 @@ export async function run(options) {
667
572
  verdicts.set(m.id, { status: 'RuntimeError' });
668
573
  return verdicts;
669
574
  };
670
- // ── 5b. Static mutants in live workers (.sdlc/intents/oxmutate/staticwarm.qnt) ─────────────
671
- // A fresh worker per (static mutant, file) cost ~80 ms of start-up around tests that take a few
672
- // ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
673
- // per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
674
- // OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
675
575
  const s0 = performance.now();
676
576
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
677
577
  stats.staticMs = performance.now() - s0;
@@ -679,13 +579,6 @@ export async function run(options) {
679
579
  progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
680
580
  const i0 = performance.now();
681
581
  const first = await runIsolated(isolatedQueue);
682
- // A watchdog stop on a loaded machine is a slow test, not a runaway: 31 of them on one
683
- // burgee/paratext run while the load average sat at 40, all Survived for Stryker (2026-10-27).
684
- // Each stop gets one more run with 3x the budget; only one that is stopped again is a Timeout.
685
- // An idle stop is not re-run: its worker was waiting with its event loop turning and no CPU spent,
686
- // on something the mutant never lets happen, which no budget cures; it is a Timeout as Stryker
687
- // scores it. Re-run at 3x, 375 of them on caique's 10-31 run "finished" only because Vitest's 30 s
688
- // test timeout failed the test (D-20261101-idle-stall-is-timeout, stall.qnt).
689
582
  const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
690
583
  const again = await runIsolated(stopped, RERUN_SLACK);
691
584
  for (const m of stopped) {
@@ -705,20 +598,8 @@ export async function run(options) {
705
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';
706
599
  }
707
600
  }
708
- // ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
709
- // The warm runner arms a mutant for one test at a time, so a mutant whose effect only shows
710
- // across tests (a `reset()` in beforeEach made a no-op, so state piles up for the next test)
711
- // survives warm and is killed when armed for the whole file, the way Stryker runs it (burgee/
712
- // paratext plugin.ts, 2026-10-27). Survivors are what a person acts on, so each one is
713
- // re-run once with the mutant armed across its covering files before it is reported.
714
- // A survivor with one covering test is not re-run: confirmation runs only covering tests, so there
715
- // is no later test for its leftover state to reach, and the warm run already armed that test
716
- // through its hooks from clean state (.sdlc/intents/oxmutate/confirm.qnt).
717
601
  const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
718
602
  const confirmed = await runIsolated(survivors);
719
- // A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
720
- // closeout survivors were reported killed this way while a docs build shared the machine
721
- // (2026-10-27). The same rule as section 6: one more run at 3x the budget before believing it.
722
603
  const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
723
604
  const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
724
605
  for (const m of stoppedHere) {
@@ -735,10 +616,8 @@ export async function run(options) {
735
616
  stats.survivorsOverturned++;
736
617
  }
737
618
  }
738
- // ── 6c. Survivors that do not type-check are CompileErrors (checker.ts) ────────────────────
739
619
  if (options.typeCheck) {
740
620
  progress.phase('type-checking survivors');
741
- // Every undetected mutant is one somebody would act on: survivors and the uncovered.
742
621
  const survived = mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
743
622
  const refused = await checkSurvivors({ root, tsconfig: options.tsconfigFile, command: options.typeChecker, concurrency: options.concurrency }, survived).catch((error) => {
744
623
  throw new InstrumentFault(error instanceof Error ? error.message : String(error));
@@ -749,7 +628,6 @@ export async function run(options) {
749
628
  stats.compileErrors++;
750
629
  }
751
630
  }
752
- // ── 7. Flaky verdicts: re-run mutants whose covering tests are nondeterministic ───────────
753
631
  const flakyTests = await flakyCheck;
754
632
  stats.flakyTests = flakyTests.size;
755
633
  const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
@@ -774,7 +652,6 @@ export async function run(options) {
774
652
  stats.watchdogKills = pool.stats.killed;
775
653
  stats.idleStalls = pool.stats.idle;
776
654
  stats.crashes = pool.stats.crashed;
777
- // ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
778
655
  if (options.incremental) {
779
656
  mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
780
657
  const next = { ...cache };
@@ -814,4 +691,3 @@ export async function run(options) {
814
691
  rmSync(work, { recursive: true, force: true });
815
692
  }
816
693
  }
817
- //# sourceMappingURL=coordinator.js.map
@@ -1,20 +1,6 @@
1
- /**
2
- * Stryker's range directives: a `disable` comment naming mutators (or `all`) and a reason turns them
3
- * off from the next line on, until a `restore` comment naming them (either spelled with `Stryker` or
4
- * `oxmutate`). The `next-line` form is config.ts's `disables`. Before this, oxmutate read only the
5
- * `next-line` form and graded every mutant inside a range as if no directive were there: 116 such
6
- * mutants in compat-oracle's `hosts.ts` alone (.sdlc/intents/oxmutate/sc8-profile.md).
7
- *
8
- * Read by line, as written. Stryker reads a directive only as the leading comment of the next AST
9
- * node, so a `restore` that closes an object (`// Stryker restore all` above its `}`) is invisible to
10
- * it, and its `disable` then runs to the end of the file. oxmutate does not reproduce that: the
11
- * directive means what it says.
12
- */
13
1
  const HEAD = /\/\/\s*(?:Stryker|oxmutate)\s+(disable|restore)\s+(\S.*)/i;
14
- /** `all`, or a comma list of mutator names. */
15
2
  const LIST = /^(?:all|\w+(?:\s*,\s*\w+)*)$/i;
16
3
  const covers = (r, mutator) => r.mutators === 'all' || r.mutators.includes(mutator);
17
- /** The range directives of a file. A `disable` without a reason is an error, as `next-line`'s is. */
18
4
  export function rangeDisables(source, file) {
19
5
  const rules = [];
20
6
  const errors = [];
@@ -22,7 +8,6 @@ export function rangeDisables(source, file) {
22
8
  const m = HEAD.exec(text);
23
9
  if (!m)
24
10
  return;
25
- // `next-line` is not a list (the hyphen), so that form is left to config.ts here too.
26
11
  const body = m[2];
27
12
  const colon = body.indexOf(':');
28
13
  const list = (colon === -1 ? body : body.slice(0, colon)).trim();
@@ -41,7 +26,6 @@ export function rangeDisables(source, file) {
41
26
  errors,
42
27
  reasonFor(line, mutator) {
43
28
  const name = mutator.toLowerCase();
44
- // The latest directive before the line that names this mutator decides it.
45
29
  for (let k = rules.length - 1; k >= 0; k--) {
46
30
  const r = rules[k];
47
31
  if (r.line >= line || !covers(r, name))
@@ -52,4 +36,3 @@ export function rangeDisables(source, file) {
52
36
  },
53
37
  };
54
38
  }
55
- //# sourceMappingURL=directives.js.map
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
package/dist/heartbeat.js CHANGED
@@ -1,65 +1,25 @@
1
- /**
2
- * The watchdog's evidence for the difference between a mutant that loops and one that hangs
3
- * (.sdlc/intents/oxmutate/stall.qnt, D-20261101-idle-stall-is-timeout).
4
- *
5
- * Two readings, both taken when a run passes its budget:
6
- *
7
- * - **The beat.** The test worker writes the time to its server's beat file every HEARTBEAT_MS
8
- * from a real interval timer. The beat keeps coming only while the worker's event loop turns. A
9
- * synchronous loop stops it, and so does a microtask loop.
10
- * - **The CPU.** The server reports its process's CPU time (every thread, the worker included)
11
- * every CPU_SAMPLE_MS. Over the last CPU_WINDOW_MS, a loop burns about a core.
12
- *
13
- * A run whose worker is inside a test body, with a fresh beat and under IDLE_CPU of a core, is
14
- * **idle**: the test waits on something the mutant never lets happen (a prompt awaiting a
15
- * keypress). Anything else is **busy**, including a worker still collecting its file: on a loaded
16
- * machine a short budget can run out while modules load, at low CPU, and that is a slow machine. On a caique
17
- * subset 18 of 19 stops read 0–2% CPU with a live beat, and the one loop read 72%
18
- * (sc8-profile.md). An idle stop is a Timeout as it stands, the way Stryker scores it. A busy stop
19
- * may be a slow test on a loaded machine, so it keeps its re-run at 3x.
20
- *
21
- * The beat file is the server's, named by its pid and set in its environment when it starts, so
22
- * every worker it ever runs inherits it: a path set per unit could reach a worker that started
23
- * under an earlier unit, and on caique two stops read a beat that never came.
24
- */
25
1
  import { readFileSync, writeFileSync } from 'node:fs';
26
2
  import path from 'node:path';
27
- /** How often the worker writes its beat. */
28
3
  export const HEARTBEAT_MS = 250;
29
- /** A beat older than this means the worker's event loop is not turning: a loop, not a wait. */
30
4
  export const STALE_MS = 1000;
31
- /** How often a server reports its CPU time. */
32
5
  export const CPU_SAMPLE_MS = 250;
33
- /** The window a stop's CPU share is measured over. */
34
6
  export const CPU_WINDOW_MS = 1000;
35
- /** The shortest span of samples a CPU share is read from; less, and the stop counts as busy. */
36
7
  export const MIN_CPU_SPAN_MS = 500;
37
- /** Under this share of one core, with a fresh beat, a stopped run is idle. Measured: idle 0–2%, a loop 72–108%. */
38
8
  export const IDLE_CPU = 0.25;
39
- /** The environment variable that hands a server's beat file to its test workers. */
40
9
  export const BEAT_ENV = 'OXMUTATE_BEAT';
41
- /** A server's beat file, in the run's work directory. */
42
10
  export const beatFile = (workDir, pid) => path.join(workDir, `beat-${String(pid)}`);
43
- // Captured when this module is evaluated, before any test file can install fake timers: a faked
44
- // clock would write a frozen time, and the watchdog would read a live worker as a stale one.
45
11
  const { setInterval: realSetInterval, clearInterval: realClearInterval } = globalThis;
46
- const realNow = Date.now.bind(Date);
47
- /** The beat this thread runs: one at a time, whatever re-evaluates this module. */
48
- // Stryker disable next-line StringLiteral: any key names one registry symbol on every evaluation of this module, which is all the key is for
12
+ export const realNow = Date.now.bind(Date);
49
13
  const BEAT_KEY = Symbol.for('oxmutate.heartbeat');
50
- /** Whether a test body is running in this worker (the runner marks it); the beat carries it. */
51
14
  let inTest = false;
52
- /** Mark a test body as running, or done. Only a wait inside a test body can be an idle stall. */
53
15
  export function markTest(running) {
54
16
  inTest = running;
55
17
  }
56
- /** Start this worker's beat into `file`. Does nothing when no beat file is set. */
57
18
  export function startHeartbeat(file) {
58
19
  if (!file)
59
20
  return;
60
21
  const holder = globalThis;
61
22
  realClearInterval(holder[BEAT_KEY]);
62
- /** One beat; false once the run's directory is gone: the run is over, and nothing reads the beat. */
63
23
  const beat = () => {
64
24
  try {
65
25
  writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
@@ -74,10 +34,8 @@ export function startHeartbeat(file) {
74
34
  if (!beat())
75
35
  realClearInterval(holder[BEAT_KEY]);
76
36
  }, HEARTBEAT_MS);
77
- // Stryker disable next-line CallExpression: unref only lets a process exit while its beat runs; Vitest ends its workers regardless, so no verdict depends on it
78
37
  holder[BEAT_KEY].unref();
79
38
  }
80
- /** The last beat of this server's workers. */
81
39
  export function readBeat(file, now = Date.now()) {
82
40
  let text;
83
41
  try {
@@ -91,21 +49,14 @@ export function readBeat(file, now = Date.now()) {
91
49
  return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
92
50
  }
93
51
  const US_PER_MS = 1000;
94
- /**
95
- * The share of one core a server used over the last CPU_WINDOW_MS before `now`, from its samples
96
- * (oldest first). Undefined when the samples span less than MIN_CPU_SPAN_MS of that window.
97
- */
98
52
  export function cpuShare(samples, now) {
99
53
  const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
100
54
  const first = recent[0];
101
55
  const last = recent.at(-1);
102
- // Stryker disable next-line LogicalOperator: the first and the last sample are both there or both missing, so `&&` reads the same
103
56
  if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
104
57
  return undefined;
105
58
  return (last.us - first.us) / US_PER_MS / (last.at - first.at);
106
59
  }
107
- /** A stopped run is idle when its worker waits inside a test body, its event loop turning, and its server burns almost no CPU. */
108
60
  export function classifyStall(o) {
109
61
  return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
110
62
  }
111
- //# sourceMappingURL=heartbeat.js.map
package/dist/index.js CHANGED
@@ -1,9 +1,5 @@
1
- /**
2
- * oxmutate's programmatic API. The CLI (`oxmutate run`) is this, declared on burgee.
3
- */
4
1
  export { loadOptions } from './config.js';
5
2
  export { InstrumentFault, run } from './coordinator.js';
6
3
  export { changedLines, parseShard, shardOf } from './lanes.js';
7
4
  export { native } from './native.js';
8
5
  export { sarifReport, strykerReport, textSummary, VERSION } from './reports.js';
9
- //# sourceMappingURL=index.js.map