oxmutate 1.1.0 → 1.2.0

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.
package/README.md CHANGED
@@ -189,7 +189,7 @@ setting a value inside the worker that already has the tests loaded.
189
189
  | Re-parse | Every instrumented file is parsed again before it runs; one that does not parse stops the run (exit 3) instead of scoring a broken file |
190
190
  | Canary | The unmutated test must still pass after mutants run in a context, with its `beforeEach`/`afterEach` hooks re-run around every run, file-level ones included; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again. A kill that follows unchecked runs is checked at once and decided again from clean state, so no kill rests on another mutant's leftovers |
191
191
  | Survivor confirmation | A survivor is re-run with the mutant armed across its covering tests in one file, as Stryker arms it, so a mutant that only fails across tests is killed |
192
- | Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout |
192
+ | Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout. A stop whose worker was idle inside a test body (its event loop still turning, by a heartbeat, and its server under 25% of a core) is a hang, not a slow test: it is a Timeout as it stands, the way Stryker scores it, with no 3x re-run |
193
193
  | Static mutants | Code that runs at import is decided in a live worker: the mutant is armed, the module registry reset and the test file collected again, against the files that load it. A file whose collection changes globals, `process.env` or a built-in prototype, or a mutant that leaves a trace, goes back to a fresh worker per mutant (`OXMUTATE_ISOLATE_STATIC=1` sends all of them there) |
194
194
  | Flaky tests | Tests that behave differently across two dry runs have their mutants re-run; a verdict that changes is reported `flaky` |
195
195
  | No orphans | Every Vitest server exits when its run does, killed or not |
@@ -261,7 +261,8 @@ head-to-head runs (`bench/burgee-pair.mts`, concurrency 4, one tool after the ot
261
261
 
262
262
  The ceiling depends on the suite as much as the tool: controlroom's own `vitest run` takes ~8 s and
263
263
  every fresh worker imports React and Ink, so even zero overhead would make it ~6x. The figures to
264
- publish will come from the `oxmutate-bench.yml` lane on a runner nothing else shares.
264
+ publish come from `bench/burgee-pair.mts`, run locally on a quiet machine: no mutation run happens
265
+ in GitHub Actions (`D-20261101-no-mutation-in-gha`).
265
266
 
266
267
  ## Benchmarks
267
268
 
package/dist/child.js CHANGED
@@ -14,11 +14,14 @@ import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { ExitCode } from 'burgee';
17
+ import { BEAT_ENV, beatFile, CPU_SAMPLE_MS } from './heartbeat.js';
17
18
  import { resolveEsm } from './resolve.js';
18
- import { ACTIVE_MUTANT_ENV, argv, env, exit } from './runtime.js';
19
+ import { ACTIVE_MUTANT_ENV, argv, env, exit, pid } from './runtime.js';
19
20
  import { instrumentedSources } from './vite-plugin.js';
20
21
  const config = JSON.parse(readFileSync(argv[2] ?? '', 'utf8'));
21
22
  env['NODE_ENV'] ??= 'test';
23
+ // Before Vitest starts any worker, so each one inherits it (heartbeat.ts).
24
+ env[BEAT_ENV] = beatFile(path.dirname(config.instrumentedMap), pid);
22
25
  const vitestNode = (await import(resolveEsm('vitest/node', config.root)));
23
26
  const runner = fileURLToPath(new URL('./runner.js', import.meta.url));
24
27
  const silent = { onInit() { }, onFinished() { } };
@@ -142,5 +145,13 @@ process.on('message', (msg) => {
142
145
  // and a worker thread may still be looping in a mutant. Two such servers were found spinning at
143
146
  // 100% CPU 11 hours after their run ended, and slowed the next run 50x (2026-10-27).
144
147
  process.on('disconnect', () => exit(ExitCode.OK));
148
+ // The server's CPU time, every thread (the test worker included), for the watchdog to tell an idle
149
+ // stall from a loop (heartbeat.ts). This thread stays free while a worker loops, so the samples come.
150
+ const sendCpu = () => {
151
+ const { user, system } = process.cpuUsage();
152
+ process.send?.({ type: 'cpu', at: Date.now(), us: user + system });
153
+ };
154
+ sendCpu();
155
+ setInterval(sendCpu, CPU_SAMPLE_MS).unref();
145
156
  process.send?.({ type: 'ready' });
146
157
  //# sourceMappingURL=child.js.map
@@ -36,10 +36,16 @@ export interface RunStats {
36
36
  instrumentMs: number;
37
37
  dryRunMs: number;
38
38
  mutationMs: number;
39
+ /** Static mutants decided in live workers (section 5b), between the warm and the isolated phases. */
40
+ staticMs: number;
41
+ /** Unarmed re-collections in that phase, the guard each live static verdict waits on (runner.ts). */
42
+ staticRecollections: number;
39
43
  isolatedMs: number;
40
44
  wallMs: number;
41
45
  testExecutions: number;
42
46
  watchdogKills: number;
47
+ /** Of `watchdogKills`, the stops whose worker was idle, waiting (heartbeat.ts). In an isolated run each is a Timeout as it stands. */
48
+ idleStalls: number;
43
49
  crashes: number;
44
50
  canaryFiles: number;
45
51
  /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
@@ -17,6 +17,7 @@ import { ambientRuntime, run as spawn } from 'bellpull';
17
17
  import { checkSurvivors } from './checker.js';
18
18
  import { runCommandMutants } from './command-runner.js';
19
19
  import { disables } from './config.js';
20
+ import { rangeDisables } from './directives.js';
20
21
  import { globToRegExp, matchFiles } from './files.js';
21
22
  import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
22
23
  import { native } from './native.js';
@@ -44,6 +45,8 @@ const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
44
45
  * (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
45
46
  */
46
47
  const UNIT_PAIRS = 400;
48
+ /** How many times its isolated budget a busy stop gets on its re-run. */
49
+ const RERUN_SLACK = 3;
47
50
  const MS_PER_MINUTE = 60_000;
48
51
  const PERCENT = 100;
49
52
  /** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
@@ -61,8 +64,8 @@ export async function run(options) {
61
64
  const { root } = options;
62
65
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
63
66
  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,
67
+ files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
68
+ 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
69
  };
67
70
  let unitSeq = 0;
68
71
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -93,8 +96,9 @@ export async function run(options) {
93
96
  const directiveErrors = [];
94
97
  for (const f of instrumented) {
95
98
  const { disables: offs, errors } = disables(sources[f.path] ?? '', f.path);
99
+ const ranged = rangeDisables(sources[f.path] ?? '', f.path);
96
100
  const ranges = rangesFor(f.path);
97
- directiveErrors.push(...errors);
101
+ directiveErrors.push(...errors, ...ranged.errors);
98
102
  let any = false;
99
103
  for (const m of f.mutants) {
100
104
  if (changed && !touches(changed, f.path, m.start.line, m.end.line))
@@ -104,13 +108,14 @@ export async function run(options) {
104
108
  if (options.shard && shardOf(m.id, options.shard.count) !== options.shard.index)
105
109
  continue;
106
110
  const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
111
+ const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
107
112
  const excluded = options.excludedMutations.includes(m.mutatorName);
108
113
  mutants.push({
109
114
  ...m,
110
115
  file: f.path,
111
116
  coveredBy: [],
112
- status: off || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
113
- statusReason: off?.reason ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
117
+ status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
118
+ statusReason: off?.reason ?? inRange ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
114
119
  });
115
120
  any = true;
116
121
  }
@@ -563,6 +568,8 @@ export async function run(options) {
563
568
  const file = unit.files[0] ?? '';
564
569
  const decided = new Set();
565
570
  for (const r of readRecords(unit.results)) {
571
+ if (r.type === 'recollection')
572
+ stats.staticRecollections++;
566
573
  if (r.type === 'static-fallback')
567
574
  for (const id of r.ids ?? [])
568
575
  back.add(id);
@@ -646,7 +653,7 @@ export async function run(options) {
646
653
  verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
647
654
  }
648
655
  if (outcome.killedFor)
649
- verdicts.set(outcome.killedFor, { status: 'Timeout' });
656
+ verdicts.set(outcome.killedFor, { status: 'Timeout', idle: outcome.stall === 'idle' });
650
657
  if (outcome.crashed && outcome.crashedFor)
651
658
  verdicts.set(outcome.crashedFor, { status: 'RuntimeError' });
652
659
  if (!outcome.killedFor && !outcome.crashed)
@@ -665,7 +672,9 @@ export async function run(options) {
665
672
  // ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
666
673
  // per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
667
674
  // OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
675
+ const s0 = performance.now();
668
676
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
677
+ stats.staticMs = performance.now() - s0;
669
678
  isolatedQueue.push(...staticBack);
670
679
  progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
671
680
  const i0 = performance.now();
@@ -673,8 +682,12 @@ export async function run(options) {
673
682
  // A watchdog stop on a loaded machine is a slow test, not a runaway: 31 of them on one
674
683
  // burgee/paratext run while the load average sat at 40, all Survived for Stryker (2026-10-27).
675
684
  // 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);
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
+ const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
690
+ const again = await runIsolated(stopped, RERUN_SLACK);
678
691
  for (const m of stopped) {
679
692
  const v = again.get(m.id);
680
693
  if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
@@ -689,7 +702,7 @@ export async function run(options) {
689
702
  m.killedBy = v.killedBy;
690
703
  m.testsCompleted = v.testsCompleted;
691
704
  if (v.status === 'Timeout')
692
- m.statusReason = 'stopped by the watchdog twice, the second time with 3x the budget';
705
+ 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
706
  }
694
707
  }
695
708
  // ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
@@ -706,8 +719,8 @@ export async function run(options) {
706
719
  // A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
707
720
  // closeout survivors were reported killed this way while a docs build shared the machine
708
721
  // (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);
722
+ const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
723
+ const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
711
724
  for (const m of stoppedHere) {
712
725
  const v = recheck.get(m.id);
713
726
  if (v)
@@ -759,6 +772,7 @@ export async function run(options) {
759
772
  }
760
773
  stats.isolatedMs = performance.now() - i0;
761
774
  stats.watchdogKills = pool.stats.killed;
775
+ stats.idleStalls = pool.stats.idle;
762
776
  stats.crashes = pool.stats.crashed;
763
777
  // ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
764
778
  if (options.incremental) {
@@ -0,0 +1,55 @@
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
+ const HEAD = /\/\/\s*(?:Stryker|oxmutate)\s+(disable|restore)\s+(\S.*)/i;
14
+ /** `all`, or a comma list of mutator names. */
15
+ const LIST = /^(?:all|\w+(?:\s*,\s*\w+)*)$/i;
16
+ 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
+ export function rangeDisables(source, file) {
19
+ const rules = [];
20
+ const errors = [];
21
+ source.split('\n').forEach((text, i) => {
22
+ const m = HEAD.exec(text);
23
+ if (!m)
24
+ return;
25
+ // `next-line` is not a list (the hyphen), so that form is left to config.ts here too.
26
+ const body = m[2];
27
+ const colon = body.indexOf(':');
28
+ const list = (colon === -1 ? body : body.slice(0, colon)).trim();
29
+ if (!LIST.test(list))
30
+ return;
31
+ const kind = m[1].toLowerCase();
32
+ const reason = colon === -1 ? '' : body.slice(colon + 1).trim();
33
+ if (kind === 'disable' && !reason) {
34
+ errors.push(`${file}:${String(i + 1)}: disable comment without a reason. fix: add ": <why these mutants cannot be killed>"`);
35
+ return;
36
+ }
37
+ const mutators = list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim().toLowerCase());
38
+ rules.push({ line: i + 1, kind, mutators, reason });
39
+ });
40
+ return {
41
+ errors,
42
+ reasonFor(line, mutator) {
43
+ const name = mutator.toLowerCase();
44
+ // The latest directive before the line that names this mutator decides it.
45
+ for (let k = rules.length - 1; k >= 0; k--) {
46
+ const r = rules[k];
47
+ if (r.line >= line || !covers(r, name))
48
+ continue;
49
+ return r.kind === 'disable' ? r.reason : undefined;
50
+ }
51
+ return undefined;
52
+ },
53
+ };
54
+ }
55
+ //# sourceMappingURL=directives.js.map
@@ -0,0 +1,111 @@
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
+ import { readFileSync, writeFileSync } from 'node:fs';
26
+ import path from 'node:path';
27
+ /** How often the worker writes its beat. */
28
+ 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
+ export const STALE_MS = 1000;
31
+ /** How often a server reports its CPU time. */
32
+ export const CPU_SAMPLE_MS = 250;
33
+ /** The window a stop's CPU share is measured over. */
34
+ 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
+ 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
+ export const IDLE_CPU = 0.25;
39
+ /** The environment variable that hands a server's beat file to its test workers. */
40
+ export const BEAT_ENV = 'OXMUTATE_BEAT';
41
+ /** A server's beat file, in the run's work directory. */
42
+ 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
+ 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
49
+ 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
+ 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
+ export function markTest(running) {
54
+ inTest = running;
55
+ }
56
+ /** Start this worker's beat into `file`. Does nothing when no beat file is set. */
57
+ export function startHeartbeat(file) {
58
+ if (!file)
59
+ return;
60
+ const holder = globalThis;
61
+ 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
+ const beat = () => {
64
+ try {
65
+ writeFileSync(file, inTest ? `${String(realNow())} test` : String(realNow()));
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ return true;
71
+ };
72
+ beat();
73
+ holder[BEAT_KEY] = realSetInterval(() => {
74
+ if (!beat())
75
+ realClearInterval(holder[BEAT_KEY]);
76
+ }, 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
+ holder[BEAT_KEY].unref();
79
+ }
80
+ /** The last beat of this server's workers. */
81
+ export function readBeat(file, now = Date.now()) {
82
+ let text;
83
+ try {
84
+ text = String(readFileSync(file));
85
+ }
86
+ catch {
87
+ return { age: Number.POSITIVE_INFINITY, inTest: false };
88
+ }
89
+ const [stamp, where] = text.split(' ');
90
+ const at = Number(stamp);
91
+ return { age: Number.isFinite(at) && at > 0 ? now - at : Number.POSITIVE_INFINITY, inTest: where === 'test' };
92
+ }
93
+ 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
+ export function cpuShare(samples, now) {
99
+ const recent = samples.filter((s) => s.at >= now - CPU_WINDOW_MS && s.at <= now);
100
+ const first = recent[0];
101
+ 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
+ if (!first || !last || last.at - first.at < MIN_CPU_SPAN_MS)
104
+ return undefined;
105
+ return (last.us - first.us) / US_PER_MS / (last.at - first.at);
106
+ }
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
+ export function classifyStall(o) {
109
+ return o.beat.inTest && o.beat.age < STALE_MS && (o.cpuShare ?? IDLE_CPU) < IDLE_CPU ? 'idle' : 'busy';
110
+ }
111
+ //# sourceMappingURL=heartbeat.js.map
package/dist/reports.js CHANGED
@@ -142,7 +142,7 @@ export function textSummary(r, opts = {}) {
142
142
  `oxmutate ${VERSION}: ${String(r.mutants.length)} mutants in ${String(s.files)} files, ${(s.wallMs / MS_PER_S).toFixed(1)} s`,
143
143
  ` mutation score ${r.score.mutationScore.toFixed(2)}% (covered ${r.score.covered.toFixed(2)}%)`,
144
144
  ` killed ${String(count('Killed'))} · timeout ${String(count('Timeout'))} · survived ${String(count('Survived'))} · no coverage ${String(count('NoCoverage'))} · ignored ${String(count('Ignored'))} · errors ${String(count('RuntimeError') + count('CompileError'))}`,
145
- ` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
145
+ ` instrument ${s.instrumentMs.toFixed(0)} ms · dry run ${(s.dryRunMs / MS_PER_S).toFixed(1)} s · mutants ${(s.mutationMs / MS_PER_S).toFixed(1)} s · static ${(s.staticMs / MS_PER_S).toFixed(1)} s · isolated ${(s.isolatedMs / MS_PER_S).toFixed(1)} s`,
146
146
  ];
147
147
  const corrected = corrections(s);
148
148
  if (corrected.length)
package/dist/runner.js CHANGED
@@ -25,6 +25,7 @@
25
25
  import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { createRequire } from 'node:module';
27
27
  import path from 'node:path';
28
+ import { BEAT_ENV, markTest, startHeartbeat } from './heartbeat.js';
28
29
  import { resolveEsm } from './resolve.js';
29
30
  import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
30
31
  /**
@@ -65,6 +66,7 @@ const { Base, statics } = await loadBase();
65
66
  const mode = env['OXMUTATE_MODE'] ?? 'dry';
66
67
  const resultsPath = env['OXMUTATE_RESULTS'];
67
68
  const progressPath = env['OXMUTATE_PROGRESS'];
69
+ startHeartbeat(env[BEAT_ENV]); // the watchdog tells a wait from a loop by it (heartbeat.ts)
68
70
  const ns = (globalThis[NAMESPACE] ??= {});
69
71
  ns.mutantCoverage ??= { static: {}, perTest: {} };
70
72
  const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
@@ -123,6 +125,8 @@ const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
123
125
  let pristine;
124
126
  /** Set when a mutant left state behind; the rest of this context runs no mutants. */
125
127
  let polluted = false;
128
+ /** `static` mode: the most mutants decided between two unarmed re-collections (staticwarm.qnt `grouped`). */
129
+ const STATIC_CANARY_MAX = 4;
126
130
  /** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
127
131
  let canaryEvery = 1;
128
132
  const MAX_CANARY_EVERY = 8;
@@ -249,7 +253,14 @@ export default class OxmutateRunner extends Base {
249
253
  if (mode !== 'mutant') {
250
254
  if (only && !only.has(test.id))
251
255
  return;
252
- await fn();
256
+ // Only a wait inside a test body can be an idle stall; collection at low CPU is a slow machine.
257
+ markTest(true);
258
+ try {
259
+ await fn();
260
+ }
261
+ finally {
262
+ markTest(false);
263
+ }
253
264
  return;
254
265
  }
255
266
  const context = test.context;
@@ -428,22 +439,40 @@ export default class OxmutateRunner extends Base {
428
439
  ns.hitLimit = undefined;
429
440
  }
430
441
  };
442
+ // Verdicts since the last clean unarmed re-collection are held, not emitted: the re-collection runs
443
+ // after the first mutant, then after every 2, then every STATIC_CANARY_MAX while it stays clean, and
444
+ // one that fails sends every held mutant back with the rest. Each mutant's own snapshot diff still
445
+ // runs at once. The re-collection runs the file's tests unarmed, and after every mutant it was half
446
+ // of the live static phase, 6,713 s of compat-oracle's 11,193 s weekly run (sc8-profile.md,
447
+ // staticwarm.qnt `grouped`).
448
+ let held = [];
449
+ let every = 1;
450
+ const back = (reason, keys = []) => {
451
+ rest.unshift(...held.map((h) => h.id));
452
+ held = [];
453
+ fallback(reason, keys);
454
+ };
431
455
  while (rest.length) {
432
- const id = rest[0];
456
+ const id = rest.shift();
433
457
  if (progressPath)
434
458
  writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
435
- const verdict = await evaluate(id);
459
+ held.push({ id, verdict: await evaluate(id) });
436
460
  const left = snapshotDiff(before, snapshot());
437
461
  if (left.length)
438
- return fallback('the mutant left a trace', left);
462
+ return back('the mutant left a trace', left);
463
+ if (held.length < every && rest.length)
464
+ continue;
439
465
  const unarmed = await evaluate(undefined);
466
+ emit({ type: 'recollection' });
440
467
  if (unarmed.failed.length > 0)
441
- return fallback('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
468
+ return back('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
442
469
  const after = snapshotDiff(before, snapshot());
443
470
  if (after.length)
444
- return fallback('the unarmed re-collection left a trace', after);
445
- rest.shift();
446
- emit({ type: 'isolated', id, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
471
+ return back('the unarmed re-collection left a trace', after);
472
+ for (const { id: done, verdict } of held)
473
+ emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
474
+ held = [];
475
+ every = Math.min(STATIC_CANARY_MAX, every * 2);
447
476
  }
448
477
  if (progressPath)
449
478
  writeFileSync(progressPath, '');
package/dist/runtime.js CHANGED
@@ -18,6 +18,7 @@ export const ACTIVE_MUTANT_ENV = '__OXMUTATE_ACTIVE_MUTANT__';
18
18
  // place the package touches it. `env` is the live object: a write here is a write to the process.
19
19
  export const env = process.env;
20
20
  export const argv = process.argv;
21
+ export const pid = process.pid;
21
22
  export const cwd = () => process.cwd();
22
23
  export const stderr = process.stderr;
23
24
  export const exit = (code) => process.exit(code);
package/dist/scheduler.js CHANGED
@@ -12,6 +12,8 @@ import { fork } from 'node:child_process';
12
12
  import { readFileSync, writeFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
16
+ import { env, stderr } from './runtime.js';
15
17
  const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
16
18
  /** The tail of a server's stderr kept for an error message. */
17
19
  const STDERR_TAIL = 4000;
@@ -28,15 +30,23 @@ function inFlight(progress) {
28
30
  return undefined;
29
31
  }
30
32
  }
33
+ const PERCENT = 100;
34
+ /** How many CPU samples a server keeps: a little over the window. */
35
+ const CPU_SAMPLES_KEPT = 8;
31
36
  class Server {
32
37
  child;
33
38
  ready;
34
39
  stderr = '';
40
+ /** Where this server's test workers beat (heartbeat.ts). */
41
+ beat;
42
+ /** The server's latest CPU readings, oldest first (child.ts sends them). */
43
+ cpu = [];
35
44
  onMessage;
36
45
  onExit;
37
46
  exited = false;
38
- constructor(configFile, cwd, execArgv) {
47
+ constructor(configFile, cwd, execArgv, workDir) {
39
48
  this.child = fork(childJs, [configFile], { cwd, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv });
49
+ this.beat = beatFile(workDir, this.child.pid);
40
50
  this.child.stderr?.on('data', (d) => (this.stderr = (this.stderr + d.toString()).slice(-STDERR_TAIL)));
41
51
  this.ready = new Promise((resolve) => {
42
52
  const first = (m) => {
@@ -48,7 +58,15 @@ class Server {
48
58
  this.child.on('message', first);
49
59
  this.child.once('exit', () => resolve(false));
50
60
  });
51
- this.child.on('message', (m) => this.onMessage?.(m));
61
+ this.child.on('message', (m) => {
62
+ if (m.type === 'cpu') {
63
+ this.cpu.push({ at: m.at ?? 0, us: m.us ?? 0 });
64
+ if (this.cpu.length > CPU_SAMPLES_KEPT)
65
+ this.cpu.shift();
66
+ return;
67
+ }
68
+ this.onMessage?.(m);
69
+ });
52
70
  this.child.once('exit', (code) => {
53
71
  this.exited = true;
54
72
  this.onExit?.(code);
@@ -78,9 +96,18 @@ class Server {
78
96
  if (!line)
79
97
  return;
80
98
  const [id = '', since = '0'] = line.split(' ');
81
- if (Date.now() - Number(since) > budget(id)) {
99
+ const now = Date.now();
100
+ if (now - Number(since) > budget(id)) {
101
+ // Read before the kill: an idle stop is a Timeout as it stands, a busy one is re-run (heartbeat.ts).
102
+ const beat = readBeat(this.beat, now);
103
+ const share = cpuShare(this.cpu, now);
104
+ const stall = classifyStall({ beat, cpuShare: share });
105
+ if (env['OXMUTATE_DEBUG_WATCHDOG']) {
106
+ const cpu = share === undefined ? '?' : `${String(Math.round(share * PERCENT))}%`;
107
+ stderr.write(`oxmutate watchdog: stop ${id} after ${String(now - Number(since))} ms; ${stall}; beat ${Number.isFinite(beat.age) ? `${String(beat.age)} ms ago` : 'never'}${beat.inTest ? ' in a test' : ''}; cpu ${cpu} over ${String(CPU_WINDOW_MS)} ms\n`);
108
+ }
82
109
  this.child.kill('SIGKILL');
83
- finish({ killedFor: id, stderr: this.stderr });
110
+ finish({ killedFor: id, stall, stderr: this.stderr });
84
111
  }
85
112
  }, WATCHDOG_POLL_MS);
86
113
  this.onMessage = (m) => {
@@ -104,13 +131,21 @@ class Server {
104
131
  export function createPool(config, size, workDir, tag) {
105
132
  const configFile = path.join(workDir, `server-${tag}.json`);
106
133
  writeFileSync(configFile, JSON.stringify(config));
107
- const stats = { spawned: 0, killed: 0, crashed: 0 };
134
+ const stats = { spawned: 0, killed: 0, idle: 0, crashed: 0 };
108
135
  const spawn = () => {
109
136
  stats.spawned++;
110
- return new Server(configFile, config.root, config.nodeArgs ?? []);
137
+ return new Server(configFile, config.root, config.nodeArgs ?? [], workDir);
111
138
  };
112
139
  const servers = Array.from({ length: size }, spawn);
113
140
  let spare = spawn();
141
+ const tally = (outcome) => {
142
+ if (outcome.killedFor)
143
+ stats.killed++;
144
+ if (outcome.stall === 'idle')
145
+ stats.idle++;
146
+ if (outcome.crashed)
147
+ stats.crashed++;
148
+ };
114
149
  const replacement = () => {
115
150
  const next = spare ?? spawn();
116
151
  spare = spawn();
@@ -152,10 +187,7 @@ export function createPool(config, size, workDir, tag) {
152
187
  await server.ready;
153
188
  }
154
189
  const outcome = await server.run(unit, (id) => budget(unit, id));
155
- if (outcome.killedFor)
156
- stats.killed++;
157
- if (outcome.crashed)
158
- stats.crashed++;
190
+ tally(outcome);
159
191
  if (outcome.killedFor || outcome.crashed) {
160
192
  servers[slot] = replacement();
161
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
5
5
  "license": "MIT",
6
6
  "repository": {