oxmutate 1.0.3 → 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.
@@ -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/lanes.js CHANGED
@@ -49,6 +49,7 @@ export function shardOf(id, count) {
49
49
  // Ids are hex (`native/src/stable_id.rs`), so a code point is a code unit and the shards are
50
50
  // the ones `charCodeAt` gave.
51
51
  for (let i = 0; i < id.length; i++) {
52
+ // Stryker disable next-line NumericLiteral: `i < id.length`, so codePointAt never returns undefined here
52
53
  h ^= id.codePointAt(i) ?? 0;
53
54
  h = Math.imul(h, FNV_PRIME) >>> 0;
54
55
  }
package/dist/progress.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * shows the run moving without a carriage-return mess. Off unless a progress reporter is named.
6
6
  */
7
7
  const REPORTERS = new Set(['progress', 'progress-append-only', 'ci', 'dots']);
8
+ // Stryker disable next-line NumericLiteral: a repaint interval; no test can see 1,000 ms against 1,001
8
9
  const TTY_EVERY_MS = 1000;
9
10
  const APPEND_EVERY_MS = 10_000;
10
11
  const MS_PER_S = 1000;
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.d.ts CHANGED
@@ -13,6 +13,8 @@ type AnyTask = {
13
13
  duration?: number;
14
14
  };
15
15
  tasks?: AnyTask[] | undefined;
16
+ mode?: string;
17
+ context?: unknown;
16
18
  };
17
19
  /** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
18
20
  export declare class PeerMissingError extends Error {
@@ -21,6 +23,9 @@ export declare class PeerMissingError extends Error {
21
23
  constructor(cause: unknown);
22
24
  }
23
25
  declare const OxmutateRunner_base: new (config: unknown) => {
26
+ onBeforeCollect?(paths: string[]): unknown;
27
+ importFile?(filepath: string, source: string): unknown;
28
+ onBeforeRunSuite?(suite: AnyTask): unknown;
24
29
  onCollectStart?(file: unknown): unknown;
25
30
  onBeforeTryTask?(test: AnyTask, options: {
26
31
  retry: number;
@@ -42,6 +47,22 @@ export default class OxmutateRunner extends OxmutateRunner_base {
42
47
  runTask(test: AnyTask & {
43
48
  context?: unknown;
44
49
  }): Promise<void>;
50
+ /**
51
+ * The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
52
+ * files: those run before every collection, re-collections included, and write the same values
53
+ * each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
54
+ */
55
+ importFile(filepath: string, source: string): unknown;
56
+ onBeforeRunSuite(suite: AnyTask): Promise<unknown>;
57
+ /**
58
+ * `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
59
+ * this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
60
+ * 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
61
+ * collection must leave globalThis, process.env and the built-in prototypes as they were; so must
62
+ * each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
63
+ * rest of the batch back to the fresh-worker path.
64
+ */
65
+ private decideStatic;
45
66
  /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
46
67
  private retryReset;
47
68
  onAfterRunFiles(files: AnyTask[]): unknown;
package/dist/runner.js CHANGED
@@ -13,6 +13,11 @@
13
13
  * failure there means an earlier mutant polluted shared state (the canary).
14
14
  * - `isolated`: one mutant was armed before import (static mutants and canary re-runs). Run
15
15
  * normally; any failed test kills it.
16
+ * - `static`: a batch of static mutants decided in this live worker (staticwarm.qnt). Before the file
17
+ * runs, each mutant is armed, the module registry is reset and the file is collected again, so
18
+ * its tests close over modules evaluated under the mutant; they run with their hooks, then an
19
+ * unarmed re-collection must pass. A file whose own collection touches globals, or a mutant that
20
+ * leaves a trace, sends the rest of the batch back to the fresh-worker path (`static-fallback`).
16
21
  *
17
22
  * Before each mutant the runner writes a progress line synchronously, so the coordinator can
18
23
  * attribute and kill a worker stuck in a synchronous loop that the hit counter cannot break.
@@ -20,6 +25,7 @@
20
25
  import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
21
26
  import { createRequire } from 'node:module';
22
27
  import path from 'node:path';
28
+ import { BEAT_ENV, markTest, startHeartbeat } from './heartbeat.js';
23
29
  import { resolveEsm } from './resolve.js';
24
30
  import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
25
31
  /**
@@ -60,6 +66,7 @@ const { Base, statics } = await loadBase();
60
66
  const mode = env['OXMUTATE_MODE'] ?? 'dry';
61
67
  const resultsPath = env['OXMUTATE_RESULTS'];
62
68
  const progressPath = env['OXMUTATE_PROGRESS'];
69
+ startHeartbeat(env[BEAT_ENV]); // the watchdog tells a wait from a loop by it (heartbeat.ts)
63
70
  const ns = (globalThis[NAMESPACE] ??= {});
64
71
  ns.mutantCoverage ??= { static: {}, perTest: {} };
65
72
  const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
@@ -69,8 +76,57 @@ const specTests = new Set(plan.specTests ?? []);
69
76
  const decidedBySpec = new Set();
70
77
  /** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
71
78
  const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
79
+ /** `static`: the static mutants this file run decides in the live worker. */
80
+ const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
81
+ /** `static`: each mutant's hit-count limit. */
82
+ const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
83
+ // `isolated`: the mutant is armed from process start, so its hit counter is too. A loop it causes
84
+ // stops at the limit, as in warm runs, instead of holding the worker until the watchdog's budget:
85
+ // on oxmutate's own suite 17 runaways left the isolated phase idle 140 of 186 s (2026-10-30).
86
+ if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
87
+ ns.hitCount = 0;
88
+ ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
89
+ }
90
+ /** Did the armed mutant's switch pass its limit, i.e. was this run stopped as a runaway? */
91
+ const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
92
+ // ── `static` mode: what a registry reset does not reset ─────────────────────────────────────────
93
+ /** Built-ins a module may patch in place; their own methods are compared by identity. */
94
+ const PROTOTYPES = {
95
+ Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
96
+ Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
97
+ Set: Set.prototype, RegExp: RegExp.prototype, Date: Date.prototype, Error: Error.prototype,
98
+ };
99
+ /** Keys Vitest and oxmutate own: they change by design while a file runs. */
100
+ const OWN_GLOBAL = /^(__vitest|__oxmutate__$|__VITEST|__vite)/;
101
+ /** globalThis, process.env and the built-in prototypes, as a map a later look can be compared with. */
102
+ function snapshot() {
103
+ const out = new Map();
104
+ const g = globalThis;
105
+ for (const k of Object.getOwnPropertyNames(g)) {
106
+ if (OWN_GLOBAL.test(k))
107
+ continue;
108
+ const d = Object.getOwnPropertyDescriptor(g, k);
109
+ out.set(`g:${k}`, d && 'value' in d ? d.value : d?.get);
110
+ }
111
+ for (const [k, v] of Object.entries(env))
112
+ out.set(`env:${k}`, v);
113
+ for (const [name, proto] of Object.entries(PROTOTYPES)) {
114
+ for (const k of Object.getOwnPropertyNames(proto))
115
+ out.set(`proto:${name}.${k}`, Object.getOwnPropertyDescriptor(proto, k)?.value);
116
+ }
117
+ return out;
118
+ }
119
+ /** How many differing keys a `static-fallback` record names. */
120
+ const DIFF_KEYS = 8;
121
+ /** The keys whose presence or value differs between two snapshots (the first few, for a report). */
122
+ const snapshotDiff = (a, b) => [...new Set([...a.keys(), ...b.keys()])].filter((k) => !(a.has(k) && b.has(k) && Object.is(a.get(k), b.get(k)))).slice(0, DIFF_KEYS);
123
+ const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
124
+ /** The state before this worker evaluated the test file: the baseline every later look compares with. */
125
+ let pristine;
72
126
  /** Set when a mutant left state behind; the rest of this context runs no mutants. */
73
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;
74
130
  /** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
75
131
  let canaryEvery = 1;
76
132
  const MAX_CANARY_EVERY = 8;
@@ -81,10 +137,16 @@ function emit(record) {
81
137
  if (resultsPath)
82
138
  appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
83
139
  }
140
+ // The suites whose hooks wrap `test`, outermost first. Vitest 5 leaves a top-level `describe`'s
141
+ // `suite` unset and keeps file-level hooks on `test.file`, so the walk adds the file itself: without
142
+ // it a file-level `beforeEach` reset never re-ran between mutants and every canary read as pollution.
84
143
  function chain(test) {
85
144
  const suites = [];
86
145
  for (let s = test.suite; s; s = s.suite)
87
146
  suites.unshift(s);
147
+ const file = test.file;
148
+ if (file && suites[0] !== file)
149
+ suites.unshift(file);
88
150
  return suites;
89
151
  }
90
152
  async function beforeEach(test, context) {
@@ -107,6 +169,61 @@ async function afterEach(test, context, cleanups) {
107
169
  await cleanup();
108
170
  }
109
171
  const isHitLimit = (e) => e instanceof Error && /Hit count limit/.test(e.message);
172
+ /**
173
+ * Runs a file collected outside Vitest's own run (`static` mode): each suite's beforeAll/afterAll,
174
+ * each test with its beforeEach/afterEach chain, stopping at the first failure, which is all a
175
+ * mutant's verdict needs.
176
+ */
177
+ async function runCollected(file, runnable) {
178
+ const failed = [];
179
+ let error;
180
+ const note = (t, e) => {
181
+ error ??= `${fullName(t)}: ${e instanceof Error ? e.message : String(e)}`.slice(0, POLLUTION_ERROR_CHARS);
182
+ };
183
+ let ran = 0;
184
+ const runSuite = async (suite) => {
185
+ const hooks = statics.getSuiteHooks(suite);
186
+ for (const hook of hooks.beforeAll)
187
+ await hook(suite);
188
+ try {
189
+ for (const t of suite.tasks ?? []) {
190
+ if (failed.length)
191
+ return;
192
+ if (t.type === 'suite') {
193
+ await runSuite(t);
194
+ continue;
195
+ }
196
+ const fn = runnable.has(t.id) ? statics.getTestFn(t) : undefined;
197
+ if (!fn)
198
+ continue;
199
+ const cleanups = await beforeEach(t, t.context);
200
+ ran++;
201
+ try {
202
+ await fn();
203
+ }
204
+ catch (e) {
205
+ failed.push(t.id);
206
+ note(t, e);
207
+ }
208
+ finally {
209
+ await afterEach(t, t.context, cleanups);
210
+ }
211
+ }
212
+ }
213
+ finally {
214
+ for (const hook of hooks.afterAll)
215
+ await hook(suite);
216
+ }
217
+ };
218
+ try {
219
+ await runSuite(file);
220
+ }
221
+ catch (e) {
222
+ failed.push(file.id); // a hook threw: the file failed under the mutant, as Stryker scores it
223
+ note(file, e);
224
+ }
225
+ return { failed, ran, ...(error !== undefined && { error }) };
226
+ }
110
227
  function fullName(t) {
111
228
  const parts = [t.name];
112
229
  for (let s = t.suite; s && s.type === 'suite' && s.name; s = s.suite)
@@ -136,7 +253,14 @@ export default class OxmutateRunner extends Base {
136
253
  if (mode !== 'mutant') {
137
254
  if (only && !only.has(test.id))
138
255
  return;
139
- 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
+ }
140
264
  return;
141
265
  }
142
266
  const context = test.context;
@@ -147,11 +271,18 @@ export default class OxmutateRunner extends Base {
147
271
  const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
148
272
  let cleanups = [];
149
273
  let attempt = 0;
150
- const fresh = async () => {
274
+ const teardown = async () => {
151
275
  await afterEach(test, context, cleanups);
276
+ cleanups = [];
277
+ };
278
+ const setup = async () => {
152
279
  cleanups = await beforeEach(test, context);
153
280
  await this.retryReset(test, ++attempt);
154
281
  };
282
+ const fresh = async () => {
283
+ await teardown();
284
+ await setup();
285
+ };
155
286
  // Mutants run since the last clean canary. The first of them started from a clean state, so its
156
287
  // verdict stands whatever the next canary says; the rest are suspects if it fails.
157
288
  let unchecked = [];
@@ -172,19 +303,20 @@ export default class OxmutateRunner extends Base {
172
303
  return false;
173
304
  }
174
305
  };
175
- for (const id of ids) {
176
- if (done.has(id))
177
- continue;
178
- if (attempt > 0)
179
- await fresh();
180
- if (progressPath)
181
- writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
306
+ // The mutant is armed through the test's own `beforeEach`, as Stryker arms it: the dry run credits
307
+ // code a hook runs to the test, so a mutant in a fixture builder or a seed is planned here, and with
308
+ // the hooks run unarmed it always survived, to be killed later by an isolated run that costs a
309
+ // fresh process (58 of 400 on a Mongo-backed suite, 2026-10-29). The previous run's `afterEach`
310
+ // runs first, unarmed: its cleanup belongs to that run.
311
+ const runArmed = async (id) => {
312
+ await teardown();
182
313
  const t0 = performance.now();
183
314
  ns.activeMutant = id;
184
315
  ns.hitCount = 0;
185
316
  ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
186
317
  let status = 'Survived';
187
318
  try {
319
+ await setup();
188
320
  await fn();
189
321
  }
190
322
  catch (e) {
@@ -195,9 +327,27 @@ export default class OxmutateRunner extends Base {
195
327
  ns.hitCount = undefined;
196
328
  ns.hitLimit = undefined;
197
329
  }
330
+ return { status, ms: performance.now() - t0 };
331
+ };
332
+ for (const id of ids) {
333
+ if (done.has(id))
334
+ continue;
335
+ if (progressPath)
336
+ writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
337
+ let { status, ms } = await runArmed(id);
338
+ // A kill that follows unchecked runs may be one of their leftovers, which this run's own cleanup
339
+ // (a `finally`) then wipes, so the batch canary would pass and vouch for a wrong kill. Survivors
340
+ // are confirmed in isolation later; kills are checked here: a failing canary is pollution as
341
+ // usual (this verdict goes back with the unit), a passing one leaves the state clean to decide
342
+ // the mutant again from (.sdlc/intents/oxmutate/canary.qnt).
343
+ if (status !== 'Survived' && unchecked.length > 0) {
344
+ if (!(await canary()))
345
+ break;
346
+ ({ status, ms } = await runArmed(id));
347
+ }
198
348
  if (status !== 'Survived')
199
349
  done.add(id);
200
- emit({ type: 'mutant', id, test: test.id, status, ms: performance.now() - t0, ...(spec ? { spec: true } : {}) });
350
+ emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
201
351
  unchecked.push(id);
202
352
  // The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
203
353
  // behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
@@ -220,6 +370,114 @@ export default class OxmutateRunner extends Base {
220
370
  await cleanup();
221
371
  }
222
372
  }
373
+ /**
374
+ * The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
375
+ * files: those run before every collection, re-collections included, and write the same values
376
+ * each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
377
+ */
378
+ importFile(filepath, source) {
379
+ if (mode === 'static' && source === 'collect' && !pristine)
380
+ pristine = snapshot();
381
+ return super.importFile?.(filepath, source);
382
+ }
383
+ async onBeforeRunSuite(suite) {
384
+ if (mode === 'static' && suite.filepath && staticIds.length)
385
+ await this.decideStatic(suite);
386
+ return super.onBeforeRunSuite?.(suite);
387
+ }
388
+ /**
389
+ * `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
390
+ * this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
391
+ * 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
392
+ * collection must leave globalThis, process.env and the built-in prototypes as they were; so must
393
+ * each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
394
+ * rest of the batch back to the fresh-worker path.
395
+ */
396
+ async decideStatic(suite) {
397
+ const filepath = suite.filepath;
398
+ // The tests this run will execute, after Vitest's own filters (a name pattern, `.only`, `.skip`):
399
+ // a re-collected file has the same test ids, and runs exactly these.
400
+ const runnable = new Set();
401
+ const visit = (t) => {
402
+ if (t.type === 'test' && t.mode === 'run')
403
+ runnable.add(t.id);
404
+ for (const c of t.tasks ?? [])
405
+ visit(c);
406
+ };
407
+ visit(suite);
408
+ const rest = [...staticIds];
409
+ const fallback = (reason, keys = []) => {
410
+ emit({ type: 'static-fallback', ids: rest.splice(0), reason, keys });
411
+ };
412
+ const before = pristine;
413
+ if (!before)
414
+ return fallback('no baseline');
415
+ const atCollect = snapshotDiff(before, snapshot());
416
+ if (atCollect.length)
417
+ return fallback('the file changed globals while it was collected', atCollect);
418
+ const vitest = (await fromProject('vitest'));
419
+ // `collectTests` is exported for Vitest's browser runner from an internal subpath: if a Vitest
420
+ // moves or drops it, the import fails and the fresh-worker path decides, as on Vitest 4.
421
+ const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
422
+ const { collectTests } = browser;
423
+ const { vi } = vitest;
424
+ if (!collectTests || !vi)
425
+ return fallback('this Vitest has no public collect'); // Vitest 4: the fresh path decides
426
+ const evaluate = async (armed) => {
427
+ ns.activeMutant = armed;
428
+ ns.hitCount = armed ? 0 : undefined;
429
+ ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
430
+ try {
431
+ vi.resetModules();
432
+ const [file] = await collectTests([filepath], this);
433
+ const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
434
+ return { ...result, timeout: ranAway() };
435
+ }
436
+ finally {
437
+ ns.activeMutant = undefined;
438
+ ns.hitCount = undefined;
439
+ ns.hitLimit = undefined;
440
+ }
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
+ };
455
+ while (rest.length) {
456
+ const id = rest.shift();
457
+ if (progressPath)
458
+ writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
459
+ held.push({ id, verdict: await evaluate(id) });
460
+ const left = snapshotDiff(before, snapshot());
461
+ if (left.length)
462
+ return back('the mutant left a trace', left);
463
+ if (held.length < every && rest.length)
464
+ continue;
465
+ const unarmed = await evaluate(undefined);
466
+ emit({ type: 'recollection' });
467
+ if (unarmed.failed.length > 0)
468
+ return back('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
469
+ const after = snapshotDiff(before, snapshot());
470
+ if (after.length)
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);
476
+ }
477
+ if (progressPath)
478
+ writeFileSync(progressPath, '');
479
+ vi.resetModules();
480
+ }
223
481
  /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
224
482
  async retryReset(test, attempt) {
225
483
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
@@ -262,7 +520,8 @@ export default class OxmutateRunner extends Base {
262
520
  if (f.result?.state === 'fail' && !failed.length)
263
521
  failed.push(f.id);
264
522
  }
265
- emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0, failed, ran });
523
+ const timeout = ranAway();
524
+ emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }) });
266
525
  }
267
526
  return super.onAfterRunFiles?.(files);
268
527
  }
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);