oxmutate 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
package/dist/lanes.js CHANGED
@@ -1,17 +1,7 @@
1
- /**
2
- * The CI lanes' selection rules: which mutants a run owns.
3
- *
4
- * - `since <ref>` (the PR lane): mutants on lines the change added or modified, from
5
- * `git diff -U0 <ref>`, working tree included, so a local run and CI select the same set.
6
- * - `shard i/n` (a CI matrix): a mutant belongs to shard `fnv(id) mod n`, so a shard's share
7
- * does not move when unrelated code changes, and the shards partition the run exactly.
8
- */
9
1
  import { ambientRuntime, run } from 'bellpull';
10
2
  const NEW_FILE = '+++ ';
11
- /** FNV-1a 32-bit parameters. */
12
3
  const FNV_OFFSET = 0x811c9dc5;
13
4
  const FNV_PRIME = 0x01000193;
14
- /** Added or changed line ranges per repository-relative path, new side. */
15
5
  export async function changedLines(root, base) {
16
6
  const diff = await run('git', ['diff', '-U0', '--no-color', '--no-ext-diff', base, '--'], { cwd: root, runtime: ambientRuntime() });
17
7
  if (!diff.ok)
@@ -33,23 +23,17 @@ export function parseDiff(diff) {
33
23
  const start = Number(hunk[1]);
34
24
  const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
35
25
  if (count === 0)
36
- continue; // a pure deletion adds no line to mutate
37
- // Stryker disable next-line OptionalChaining: the get after a set always finds the list; `?.` is only for the type
26
+ continue;
38
27
  (out.get(file) ?? out.set(file, []).get(file))?.push([start, start + count - 1]);
39
28
  }
40
29
  return out;
41
30
  }
42
31
  export function touches(ranges, file, startLine, endLine) {
43
- // Stryker disable next-line ArrayDeclaration: a string element destructures to letters, and a number compared with a letter is false, as with no ranges
44
32
  return (ranges.get(file) ?? []).some(([a, b]) => startLine <= b && endLine >= a);
45
33
  }
46
- /** FNV-1a 32 over the stable id: the shard a mutant belongs to. */
47
34
  export function shardOf(id, count) {
48
35
  let h = FNV_OFFSET;
49
- // Ids are hex (`native/src/stable_id.rs`), so a code point is a code unit and the shards are
50
- // the ones `charCodeAt` gave.
51
36
  for (let i = 0; i < id.length; i++) {
52
- // Stryker disable next-line NumericLiteral: `i < id.length`, so codePointAt never returns undefined here
53
37
  h ^= id.codePointAt(i) ?? 0;
54
38
  h = Math.imul(h, FNV_PRIME) >>> 0;
55
39
  }
@@ -59,30 +43,18 @@ export function parseShard(value) {
59
43
  const m = /^(\d+)\/(\d+)$/.exec(value);
60
44
  const index = Number(m?.[1]);
61
45
  const count = Number(m?.[2]);
62
- // `count < 1` needs no check of its own: with 1 <= index <= count it cannot hold.
63
46
  if (!m || index < 1 || index > count)
64
47
  throw new Error(`--shard must be i/n with 1 <= i <= n, got "${value}"`);
65
48
  return { index: index - 1, count };
66
49
  }
67
- /**
68
- * The range end of Stryker's pattern (`mutate-pattern-constants.js`): `<glob>:<line>[:<col>]-<line>[:<col>]`.
69
- * Only the suffix is matched; the glob is what comes before it. Stryker's lazy `^(.*?):` glob group
70
- * backtracks polynomially (CodeQL js/polynomial-redos), and the leftmost match is the same split.
71
- */
72
50
  const RANGE = /:(\d+)(?::(\d+))?-(\d+)(?::(\d+))?$/;
73
- /** A `mutate` entry split into its glob and, when it names one, its range. */
74
51
  export function parseMutateSpec(spec) {
75
52
  const m = RANGE.exec(spec);
76
53
  if (!m)
77
54
  return { glob: spec };
78
- // Stryker disable next-line StringLiteral: Number('') is 0 as Number('0') is
79
55
  const [, startLine, startColumn = '0', endLine, endColumn = String(Number.MAX_SAFE_INTEGER)] = m;
80
56
  return { glob: spec.slice(0, m.index), range: { start: { line: Number(startLine), column: Number(startColumn) }, end: { line: Number(endLine), column: Number(endColumn) } } };
81
57
  }
82
- /**
83
- * Is the mutant wholly inside the range, as Stryker decides it (`locationIncluded`)? The mutant's
84
- * columns are the report's, 1-based with the end exclusive; the range's are Stryker's, 0-based.
85
- */
86
58
  export function withinRange(range, start, end) {
87
59
  const from = start.column - 1;
88
60
  const to = end.column - 1;
@@ -90,4 +62,3 @@ export function withinRange(range, start, end) {
90
62
  const endIn = range.end.line > end.line || (range.end.line === end.line && range.end.column >= to);
91
63
  return startIn && endIn;
92
64
  }
93
- //# sourceMappingURL=lanes.js.map
package/dist/native.js CHANGED
@@ -1,16 +1,9 @@
1
- /**
2
- * The native core (`native/`, Rust on oxc), loaded for this platform. It ships as one prebuilt
3
- * `.node` per platform (PRINCIPLES rule 9, `native-engine`): 0.1 bundles all six in the tarball as
4
- * `binding/oxmutate.<tag>.node` (`platform.ts` names the tag); development builds
5
- * `binding/oxmutate.<platform>-<arch>.node`; the `oxmutate-<tag>` platform package comes last.
6
- */
7
1
  import { existsSync } from 'node:fs';
8
2
  import path from 'node:path';
9
3
  import { fileURLToPath } from 'node:url';
10
4
  import { bindingNames, platformPackage } from './platform.js';
11
5
  const here = path.dirname(fileURLToPath(import.meta.url));
12
6
  const target = `${process.platform}-${process.arch}`;
13
- /** This machine, as `platform.ts` reads it: glibc's runtime version from `process.report` (absent on musl). */
14
7
  function machine() {
15
8
  const glibc = process.platform === 'linux' ? process.report?.getReport()?.header?.glibcVersionRuntime : undefined;
16
9
  return { platform: process.platform, arch: process.arch, glibc };
@@ -20,13 +13,8 @@ export function native() {
20
13
  if (binding)
21
14
  return binding;
22
15
  const self = machine();
23
- // `binding/` beside `dist/` (published) or `src/` (development), then one level up.
24
16
  const candidates = [path.join(here, '..', 'binding'), path.join(here, '..', '..', 'binding')].flatMap((dir) => bindingNames(self).map((name) => path.join(dir, name)));
25
17
  try {
26
- // `process.dlopen` loads a native addon straight from its path, the way `require` does it
27
- // underneath. Since 0.1 the six release cores ship inside the tarball as
28
- // `binding/oxmutate.<tag>.node`; the platform package (its `main`, `oxmutate.node`) is last,
29
- // for when those publish.
30
18
  const file = candidates.find((c) => existsSync(c)) ?? fileURLToPath(import.meta.resolve(platformPackage(self)));
31
19
  const addon = { exports: {} };
32
20
  process.dlopen(addon, file);
@@ -37,4 +25,3 @@ export function native() {
37
25
  }
38
26
  return binding;
39
27
  }
40
- //# sourceMappingURL=native.js.map
package/dist/platform.js CHANGED
@@ -1,10 +1,3 @@
1
- /**
2
- * Which native core a machine needs, as napi-rs names it: `<platform>-<arch>[-gnu|-musl|-msvc]`.
3
- * The tag names the bundled core (`binding/oxmutate.<tag>.node`), the platform package
4
- * (`oxmutate-<tag>`, `npm/<tag>`) and the release matrix leg, so all three agree by construction.
5
- * Pure: the machine is passed in, so every branch is testable on any one machine.
6
- */
7
- /** `darwin-arm64`, `linux-x64-gnu`, `linux-x64-musl`, `win32-x64-msvc`, … */
8
1
  export function platformTag({ platform, arch, glibc }) {
9
2
  if (platform === 'win32')
10
3
  return `win32-${arch}-msvc`;
@@ -12,16 +5,9 @@ export function platformTag({ platform, arch, glibc }) {
12
5
  return `linux-${arch}-${glibc ? 'gnu' : 'musl'}`;
13
6
  return `${platform}-${arch}`;
14
7
  }
15
- /** The npm package that carries the tag's core once the platform packages publish. */
16
8
  export function platformPackage(machine) {
17
9
  return `oxmutate-${platformTag(machine)}`;
18
10
  }
19
- /**
20
- * The files to try, in order, under each `binding/` directory: the bundled release core for this
21
- * tag, then the development build `scripts/copy-binding.mjs` writes (`<platform>-<arch>`, which is
22
- * the same name on macOS).
23
- */
24
11
  export function bindingNames(machine) {
25
12
  return [...new Set([`oxmutate.${platformTag(machine)}.node`, `oxmutate.${machine.platform}-${machine.arch}.node`])];
26
13
  }
27
- //# sourceMappingURL=platform.js.map
package/dist/progress.js CHANGED
@@ -1,11 +1,4 @@
1
- /**
2
- * Stryker's `progress` reporters, one implementation: the phase, its own count and the time so
3
- * far, on stderr. On a terminal the line rewrites itself every second (`progress`); anywhere else,
4
- * or with `progress-append-only` (Stryker's `ci` alias), a new line every few seconds, so a CI log
5
- * shows the run moving without a carriage-return mess. Off unless a progress reporter is named.
6
- */
7
1
  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
9
2
  const TTY_EVERY_MS = 1000;
10
3
  const APPEND_EVERY_MS = 10_000;
11
4
  const MS_PER_S = 1000;
@@ -13,7 +6,6 @@ export function startProgress(reporters, out, now = () => performance.now()) {
13
6
  const enabled = reporters.some((r) => REPORTERS.has(r));
14
7
  const rewrite = Boolean(out.isTTY) && reporters.includes('progress');
15
8
  const start = now();
16
- // Stryker disable next-line StringLiteral: line() runs only after phase() has set the name, so the initial value is never printed
17
9
  let name = '';
18
10
  let detail;
19
11
  let timer;
@@ -38,4 +30,3 @@ export function startProgress(reporters, out, now = () => performance.now()) {
38
30
  },
39
31
  };
40
32
  }
41
- //# sourceMappingURL=progress.js.map
package/dist/reports.js CHANGED
@@ -1,21 +1,11 @@
1
- /**
2
- * The three readers of a run: Stryker's JSON report (dashboards, the HTML elements, burgee's
3
- * ratchet), SARIF 2.1.0 (code scanning annotates the pull request), and a terminal summary that
4
- * ends with what to do next.
5
- */
6
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
2
  import { createRequire } from 'node:module';
8
3
  import path from 'node:path';
9
- /** The package's own version, read from its manifest so `--version` and the reports never drift from it. */
10
- // Stryker disable next-line StringLiteral: with no encoding readFileSync returns a Buffer, which JSON.parse reads as the same UTF-8 text
11
4
  export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
- /** A replacement longer than this is cut, in the SARIF message and in the terminal list. */
13
5
  const REPLACEMENT_WIDTH = 60;
14
6
  const ELLIPSIS = '...';
15
7
  const MS_PER_S = 1000;
16
- /** Survivors the terminal summary lists before it says how many it left out. */
17
8
  const DEFAULT_SURVIVORS_SHOWN = 10;
18
- /** `mutation-testing-report-schema` 2, the format Stryker writes (`jsonReporter`). */
19
9
  export function strykerReport(r) {
20
10
  const files = {};
21
11
  for (const [file, source] of Object.entries(r.sources)) {
@@ -41,9 +31,7 @@ export function strykerReport(r) {
41
31
  }
42
32
  const testFiles = new Map();
43
33
  for (const t of r.tests) {
44
- // Forward slashes on every OS, as Stryker's report keys its files.
45
34
  const file = path.relative(r.root, t.file).split(path.sep).join('/');
46
- // Stryker disable next-line OptionalChaining: the get after a set always finds the entry; `?.` is only for the type
47
35
  (testFiles.get(file) ?? testFiles.set(file, { tests: [] }).get(file))?.tests.push({ id: t.id, name: t.name });
48
36
  }
49
37
  return {
@@ -56,7 +44,6 @@ export function strykerReport(r) {
56
44
  'x-oxmutate': { stats: r.stats, score: r.score, unusedConfig: r.options.unused, frozenOracle: r.frozen, specOracles: r.spec },
57
45
  };
58
46
  }
59
- /** SARIF 2.1.0: one result per surviving or uncovered mutant, fingerprinted by stable id. */
60
47
  export function sarifReport(r) {
61
48
  const reported = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
62
49
  const rules = [...new Set(reported.map((m) => m.mutatorName))].sort();
@@ -105,7 +92,6 @@ export function describe(m) {
105
92
  return `No test reaches this code: changing it to \`${change}\` (${m.mutatorName}) would go unnoticed. fix: add a test that executes ${m.file}:${String(m.start.line)}.`;
106
93
  return `${String(m.coveredBy.length)} test(s) run this code, and all still pass when it becomes \`${change}\` (${m.mutatorName}). fix: assert the behaviour this line decides.`;
107
94
  }
108
- /** What the run had to correct, one phrase per kind of correction that happened. */
109
95
  function corrections(s) {
110
96
  return [
111
97
  s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
@@ -118,11 +104,9 @@ function corrections(s) {
118
104
  s.crashes && `${String(s.crashes)} worker crash(es)`,
119
105
  ].filter((c) => Boolean(c));
120
106
  }
121
- /** One line per spec oracle: its F metrics (metrics.md §6). */
122
107
  function specLines(r) {
123
108
  return (r.spec ?? []).map((o) => ` spec ${o.name} (${o.backend}, ${String(o.traces)} traces): kills ${o.specKillShare.toFixed(1)}% of ${String(o.modelled)} modelled mutants, reaches ${o.mappingCoverage.toFixed(1)}%; ${String(o.testSpecGap.length)} test-spec gap(s), ${String(o.specEscapes.length)} escape(s)`);
124
109
  }
125
- /** The frozen oracle's line, when the change edited tests. */
126
110
  function frozenLine(r) {
127
111
  const f = r.frozen;
128
112
  if (!f?.changedTests.length)
@@ -130,7 +114,6 @@ function frozenLine(r) {
130
114
  const sign = f.delta >= 0 ? '+' : '';
131
115
  return [` frozen oracle vs ${f.base}: ${f.headScore.toFixed(2)}% with the change's tests, ${f.baseScore.toFixed(2)}% with the base's (${sign}${f.delta.toFixed(2)}), ${String(f.weakened.length)} mutant(s) the change stopped detecting`];
132
116
  }
133
- /** The terminal summary: the score, what to look at first, what the run corrected. */
134
117
  export function textSummary(r, opts = {}) {
135
118
  const count = (s) => r.mutants.filter((m) => m.status === s).length;
136
119
  const s = r.stats;
@@ -142,7 +125,7 @@ export function textSummary(r, opts = {}) {
142
125
  `oxmutate ${VERSION}: ${String(r.mutants.length)} mutants in ${String(s.files)} files, ${(s.wallMs / MS_PER_S).toFixed(1)} s`,
143
126
  ` mutation score ${r.score.mutationScore.toFixed(2)}% (covered ${r.score.covered.toFixed(2)}%)`,
144
127
  ` 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`,
128
+ ` 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
129
  ];
147
130
  const corrected = corrections(s);
148
131
  if (corrected.length)
@@ -161,29 +144,19 @@ export function textSummary(r, opts = {}) {
161
144
  lines.push('', `Config keys not used: ${r.options.unused.map((u) => u.key).join(', ')} (see the JSON report for why)`);
162
145
  return `${lines.join('\n')}\n`;
163
146
  }
164
- /** The web component Stryker's HTML report renders with, at the version Stryker 10.0.0 ships. */
165
147
  const ELEMENTS = 'mutation-testing-elements/dist/mutation-test-elements.js';
166
148
  const ELEMENTS_CDN = 'https://cdn.jsdelivr.net/npm/mutation-testing-elements@3.8.4/dist/mutation-test-elements.js';
167
- /** sha384 of that file, the same bytes as the npm tarball's (both measured 2026-10-27). */
168
149
  const ELEMENTS_SRI = 'sha384-xebNisAYQHHbrYIm991U9KOZ96oO+vRACR8kd+zHzWkU3pcRuigrsI7XS2tj3rqq';
169
- /**
170
- * Stryker's HTML report: the JSON report rendered by `mutation-testing-elements`. The component is
171
- * inlined from the project's own install when it has one (Stryker brings it), so the page works
172
- * offline as Stryker's does; otherwise the page loads that exact version from a CDN. oxmutate does
173
- * not depend on it.
174
- */
175
150
  export function htmlReport(r) {
176
151
  let script;
177
152
  try {
178
153
  const elements = createRequire(path.join(r.root, 'package.json')).resolve(ELEMENTS);
179
- // Stryker disable next-line StringLiteral: without an encoding readFileSync returns a Buffer, which the template turns into the same UTF-8 text
180
154
  const component = readFileSync(elements, 'utf8');
181
155
  script = `<script>${component}</script>`;
182
156
  }
183
157
  catch {
184
158
  script = `<script src="${ELEMENTS_CDN}" integrity="${ELEMENTS_SRI}" crossorigin="anonymous"></script>`;
185
159
  }
186
- // `<` as \u003c: still the same JSON, and no `</script>` inside a source file can end the tag.
187
160
  const report = JSON.stringify(strykerReport(r)).replaceAll('<', '\\u003c');
188
161
  return `<!DOCTYPE html>
189
162
  <html>
@@ -222,4 +195,3 @@ export function writeReports(r) {
222
195
  write(r.options.htmlReport, htmlReport(r));
223
196
  return written;
224
197
  }
225
- //# sourceMappingURL=reports.js.map
package/dist/resolve.js CHANGED
@@ -1,8 +1,3 @@
1
- /**
2
- * Resolve a bare specifier the way `import` would, from the project under test (not from this
3
- * package). `createRequire().resolve` follows the `require` condition and lands on Vitest's
4
- * CommonJS entry, which Vitest refuses to load; this follows `import`, then `default`.
5
- */
6
1
  import { readFileSync } from 'node:fs';
7
2
  import { createRequire } from 'node:module';
8
3
  import path from 'node:path';
@@ -10,7 +5,6 @@ import { pathToFileURL } from 'node:url';
10
5
  function pick(entry) {
11
6
  if (typeof entry === 'string')
12
7
  return entry;
13
- // Stryker disable next-line ConditionalExpression: a number or boolean entry has no condition properties, so the loop below returns undefined too
14
8
  if (!entry || typeof entry !== 'object')
15
9
  return undefined;
16
10
  for (const condition of ['import', 'node', 'default']) {
@@ -26,9 +20,6 @@ export function resolveEsm(specifier, fromDir) {
26
20
  const subpath = `.${specifier.slice(name.length)}`;
27
21
  const req = createRequire(path.join(fromDir, 'package.json'));
28
22
  const pkgJson = req.resolve(`${name}/package.json`);
29
- // A string `exports` exports no `./package.json`, so the resolve above has already thrown: here
30
- // `exports` is a map or absent.
31
- // Stryker disable next-line StringLiteral: without an encoding readFileSync returns a Buffer, which JSON.parse reads as UTF-8 text all the same
32
23
  const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
33
24
  const exportsMap = pkg.exports;
34
25
  let target;
@@ -40,4 +31,3 @@ export function resolveEsm(specifier, fromDir) {
40
31
  throw new Error(`oxmutate: cannot resolve ${specifier} from ${fromDir}`);
41
32
  return pathToFileURL(path.join(path.dirname(pkgJson), target)).href;
42
33
  }
43
- //# sourceMappingURL=resolve.js.map
package/dist/runner.js CHANGED
@@ -1,38 +1,10 @@
1
- /**
2
- * oxmutate's Vitest runner: loaded by Vitest (`test.runner`) inside its own worker, so hooks,
3
- * `expect`, `vi`, `vi.mock` and the environment stay Vitest's.
4
- *
5
- * Three modes, set by the coordinator through the environment (`OXMUTATE_MODE`):
6
- *
7
- * - `dry`: run the suite unmutated once, recording which tests reach which mutant (the
8
- * instrumented code counts hits into the namespace while `currentTestId` is set).
9
- * - `mutant`: for each test, re-invoke the test's own function once per covering mutant with
10
- * the switch flipped, each invocation bracketed by the suite's `beforeEach`/`afterEach` and the
11
- * runner's own per-try reset (the mechanism Vitest uses for retries, so snapshot counters
12
- * start over). Then run the test once more, unmutated: that run is what Vitest reports, and a
13
- * failure there means an earlier mutant polluted shared state (the canary).
14
- * - `isolated`: one mutant was armed before import (static mutants and canary re-runs). Run
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`).
21
- *
22
- * Before each mutant the runner writes a progress line synchronously, so the coordinator can
23
- * attribute and kill a worker stuck in a synchronous loop that the hit counter cannot break.
24
- */
25
1
  import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
26
2
  import { createRequire } from 'node:module';
27
3
  import path from 'node:path';
4
+ import { BEAT_ENV, markTest, realNow, startHeartbeat } from './heartbeat.js';
28
5
  import { resolveEsm } from './resolve.js';
29
6
  import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
30
- /**
31
- * The project's own Vitest, never the one beside this file: two Vitest instances in one worker
32
- * disagree about the current test, the snapshot state and `expect`.
33
- */
34
7
  const fromProject = async (specifier, dir = cwd()) => import(resolveEsm(specifier, dir));
35
- /** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
36
8
  export class PeerMissingError extends Error {
37
9
  code = 'E_PEER_MISSING';
38
10
  fix = "npm install vitest — oxmutate runs the project's own Vitest, so the project brings it";
@@ -45,7 +17,6 @@ const missing = (cause) => {
45
17
  const code = cause?.code;
46
18
  return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
47
19
  };
48
- /** Vitest 5 exports `TestRunner` from `vitest`; Vitest 4 from `vitest/runners` + `@vitest/runner`. */
49
20
  async function loadBase() {
50
21
  const vitest = (await fromProject('vitest').catch((cause) => {
51
22
  if (missing(cause))
@@ -65,38 +36,27 @@ const { Base, statics } = await loadBase();
65
36
  const mode = env['OXMUTATE_MODE'] ?? 'dry';
66
37
  const resultsPath = env['OXMUTATE_RESULTS'];
67
38
  const progressPath = env['OXMUTATE_PROGRESS'];
39
+ startHeartbeat(env[BEAT_ENV]);
68
40
  const ns = (globalThis[NAMESPACE] ??= {});
69
41
  ns.mutantCoverage ??= { static: {}, perTest: {} };
70
42
  const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
71
43
  const decided = new Set(plan.skip);
72
- /** The replay tests' own bail: a mutant the tests killed is still replayed, and the reverse. */
73
44
  const specTests = new Set(plan.specTests ?? []);
74
45
  const decidedBySpec = new Set();
75
- /** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
76
46
  const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
77
- /** `static`: the static mutants this file run decides in the live worker. */
78
47
  const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
79
- /** `static`: each mutant's hit-count limit. */
80
48
  const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
81
- // `isolated`: the mutant is armed from process start, so its hit counter is too. A loop it causes
82
- // stops at the limit, as in warm runs, instead of holding the worker until the watchdog's budget:
83
- // on oxmutate's own suite 17 runaways left the isolated phase idle 140 of 186 s (2026-10-30).
84
49
  if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
85
50
  ns.hitCount = 0;
86
51
  ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
87
52
  }
88
- /** Did the armed mutant's switch pass its limit, i.e. was this run stopped as a runaway? */
89
53
  const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
90
- // ── `static` mode: what a registry reset does not reset ─────────────────────────────────────────
91
- /** Built-ins a module may patch in place; their own methods are compared by identity. */
92
54
  const PROTOTYPES = {
93
55
  Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
94
56
  Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
95
57
  Set: Set.prototype, RegExp: RegExp.prototype, Date: Date.prototype, Error: Error.prototype,
96
58
  };
97
- /** Keys Vitest and oxmutate own: they change by design while a file runs. */
98
59
  const OWN_GLOBAL = /^(__vitest|__oxmutate__$|__VITEST|__vite)/;
99
- /** globalThis, process.env and the built-in prototypes, as a map a later look can be compared with. */
100
60
  function snapshot() {
101
61
  const out = new Map();
102
62
  const g = globalThis;
@@ -114,28 +74,20 @@ function snapshot() {
114
74
  }
115
75
  return out;
116
76
  }
117
- /** How many differing keys a `static-fallback` record names. */
118
77
  const DIFF_KEYS = 8;
119
- /** The keys whose presence or value differs between two snapshots (the first few, for a report). */
120
78
  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);
121
79
  const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
122
- /** The state before this worker evaluated the test file: the baseline every later look compares with. */
123
80
  let pristine;
124
- /** Set when a mutant left state behind; the rest of this context runs no mutants. */
125
81
  let polluted = false;
126
- /** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
82
+ const STATIC_CANARY_MAX = 4;
127
83
  let canaryEvery = 1;
128
84
  const MAX_CANARY_EVERY = 8;
129
- /** How much of a test's error a `polluted` record keeps, and of a file's collection error. */
130
85
  const POLLUTION_ERROR_CHARS = 300;
131
86
  const FILE_ERROR_CHARS = 500;
132
87
  function emit(record) {
133
88
  if (resultsPath)
134
89
  appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
135
90
  }
136
- // The suites whose hooks wrap `test`, outermost first. Vitest 5 leaves a top-level `describe`'s
137
- // `suite` unset and keeps file-level hooks on `test.file`, so the walk adds the file itself: without
138
- // it a file-level `beforeEach` reset never re-ran between mutants and every canary read as pollution.
139
91
  function chain(test) {
140
92
  const suites = [];
141
93
  for (let s = test.suite; s; s = s.suite)
@@ -165,11 +117,6 @@ async function afterEach(test, context, cleanups) {
165
117
  await cleanup();
166
118
  }
167
119
  const isHitLimit = (e) => e instanceof Error && /Hit count limit/.test(e.message);
168
- /**
169
- * Runs a file collected outside Vitest's own run (`static` mode): each suite's beforeAll/afterAll,
170
- * each test with its beforeEach/afterEach chain, stopping at the first failure, which is all a
171
- * mutant's verdict needs.
172
- */
173
120
  async function runCollected(file, runnable) {
174
121
  const failed = [];
175
122
  let error;
@@ -215,7 +162,7 @@ async function runCollected(file, runnable) {
215
162
  await runSuite(file);
216
163
  }
217
164
  catch (e) {
218
- failed.push(file.id); // a hook threw: the file failed under the mutant, as Stryker scores it
165
+ failed.push(file.id);
219
166
  note(file, e);
220
167
  }
221
168
  return { failed, ran, ...(error !== undefined && { error }) };
@@ -227,11 +174,6 @@ function fullName(t) {
227
174
  return parts.join(' > ');
228
175
  }
229
176
  export default class OxmutateRunner extends Base {
230
- /**
231
- * Import-time hits (static mutants) are attributed to the test file being collected, so a static
232
- * mutant is later re-run against the files that load its module, not the whole suite (Stryker
233
- * re-runs everything for each one).
234
- */
235
177
  onCollectStart(file) {
236
178
  if (mode === 'dry')
237
179
  ns.currentTestId = `${FILE_KEY}${file.filepath ?? file.file?.filepath ?? ''}`;
@@ -249,12 +191,16 @@ export default class OxmutateRunner extends Base {
249
191
  if (mode !== 'mutant') {
250
192
  if (only && !only.has(test.id))
251
193
  return;
252
- await fn();
194
+ markTest(true);
195
+ try {
196
+ await fn();
197
+ }
198
+ finally {
199
+ markTest(false);
200
+ }
253
201
  return;
254
202
  }
255
203
  const context = test.context;
256
- // Once a mutant has polluted this context, no further verdict from it can be trusted: the
257
- // rest of the unit is left unrun, and the coordinator re-queues it into a fresh context.
258
204
  const spec = specTests.has(test.id);
259
205
  const done = spec ? decidedBySpec : decided;
260
206
  const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
@@ -272,8 +218,6 @@ export default class OxmutateRunner extends Base {
272
218
  await teardown();
273
219
  await setup();
274
220
  };
275
- // Mutants run since the last clean canary. The first of them started from a clean state, so its
276
- // verdict stands whatever the next canary says; the rest are suspects if it fails.
277
221
  let unchecked = [];
278
222
  const canary = async () => {
279
223
  if (!unchecked.length)
@@ -292,11 +236,6 @@ export default class OxmutateRunner extends Base {
292
236
  return false;
293
237
  }
294
238
  };
295
- // The mutant is armed through the test's own `beforeEach`, as Stryker arms it: the dry run credits
296
- // code a hook runs to the test, so a mutant in a fixture builder or a seed is planned here, and with
297
- // the hooks run unarmed it always survived, to be killed later by an isolated run that costs a
298
- // fresh process (58 of 400 on a Mongo-backed suite, 2026-10-29). The previous run's `afterEach`
299
- // runs first, unarmed: its cleanup belongs to that run.
300
239
  const runArmed = async (id) => {
301
240
  await teardown();
302
241
  const t0 = performance.now();
@@ -322,13 +261,8 @@ export default class OxmutateRunner extends Base {
322
261
  if (done.has(id))
323
262
  continue;
324
263
  if (progressPath)
325
- writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
264
+ writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
326
265
  let { status, ms } = await runArmed(id);
327
- // A kill that follows unchecked runs may be one of their leftovers, which this run's own cleanup
328
- // (a `finally`) then wipes, so the batch canary would pass and vouch for a wrong kill. Survivors
329
- // are confirmed in isolation later; kills are checked here: a failing canary is pollution as
330
- // usual (this verdict goes back with the unit), a passing one leaves the state clean to decide
331
- // the mutant again from (.sdlc/intents/oxmutate/canary.qnt).
332
266
  if (status !== 'Survived' && unchecked.length > 0) {
333
267
  if (!(await canary()))
334
268
  break;
@@ -338,10 +272,6 @@ export default class OxmutateRunner extends Base {
338
272
  done.add(id);
339
273
  emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
340
274
  unchecked.push(id);
341
- // The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
342
- // behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
343
- // mutant at first, then every 2, 4, 8 while it stays clean: pollution is rare per test, and the
344
- // canary was half of all executions (burgee/paratext, 2026-10-27).
345
275
  if (unchecked.length >= canaryEvery && !(await canary()))
346
276
  break;
347
277
  }
@@ -359,11 +289,6 @@ export default class OxmutateRunner extends Base {
359
289
  await cleanup();
360
290
  }
361
291
  }
362
- /**
363
- * The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
364
- * files: those run before every collection, re-collections included, and write the same values
365
- * each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
366
- */
367
292
  importFile(filepath, source) {
368
293
  if (mode === 'static' && source === 'collect' && !pristine)
369
294
  pristine = snapshot();
@@ -374,18 +299,8 @@ export default class OxmutateRunner extends Base {
374
299
  await this.decideStatic(suite);
375
300
  return super.onBeforeRunSuite?.(suite);
376
301
  }
377
- /**
378
- * `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
379
- * this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
380
- * 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
381
- * collection must leave globalThis, process.env and the built-in prototypes as they were; so must
382
- * each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
383
- * rest of the batch back to the fresh-worker path.
384
- */
385
302
  async decideStatic(suite) {
386
303
  const filepath = suite.filepath;
387
- // The tests this run will execute, after Vitest's own filters (a name pattern, `.only`, `.skip`):
388
- // a re-collected file has the same test ids, and runs exactly these.
389
304
  const runnable = new Set();
390
305
  const visit = (t) => {
391
306
  if (t.type === 'test' && t.mode === 'run')
@@ -405,13 +320,11 @@ export default class OxmutateRunner extends Base {
405
320
  if (atCollect.length)
406
321
  return fallback('the file changed globals while it was collected', atCollect);
407
322
  const vitest = (await fromProject('vitest'));
408
- // `collectTests` is exported for Vitest's browser runner from an internal subpath: if a Vitest
409
- // moves or drops it, the import fails and the fresh-worker path decides, as on Vitest 4.
410
323
  const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
411
324
  const { collectTests } = browser;
412
325
  const { vi } = vitest;
413
326
  if (!collectTests || !vi)
414
- return fallback('this Vitest has no public collect'); // Vitest 4: the fresh path decides
327
+ return fallback('this Vitest has no public collect');
415
328
  const evaluate = async (armed) => {
416
329
  ns.activeMutant = armed;
417
330
  ns.hitCount = armed ? 0 : undefined;
@@ -428,28 +341,39 @@ export default class OxmutateRunner extends Base {
428
341
  ns.hitLimit = undefined;
429
342
  }
430
343
  };
344
+ let held = [];
345
+ let every = 1;
346
+ const back = (reason, keys = []) => {
347
+ rest.unshift(...held.map((h) => h.id));
348
+ held = [];
349
+ fallback(reason, keys);
350
+ };
431
351
  while (rest.length) {
432
- const id = rest[0];
352
+ const id = rest.shift();
433
353
  if (progressPath)
434
- writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
435
- const verdict = await evaluate(id);
354
+ writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
355
+ held.push({ id, verdict: await evaluate(id) });
436
356
  const left = snapshotDiff(before, snapshot());
437
357
  if (left.length)
438
- return fallback('the mutant left a trace', left);
358
+ return back('the mutant left a trace', left);
359
+ if (held.length < every && rest.length)
360
+ continue;
439
361
  const unarmed = await evaluate(undefined);
362
+ emit({ type: 'recollection' });
440
363
  if (unarmed.failed.length > 0)
441
- return fallback('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
364
+ return back('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
442
365
  const after = snapshotDiff(before, snapshot());
443
366
  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 }) });
367
+ return back('the unarmed re-collection left a trace', after);
368
+ for (const { id: done, verdict } of held)
369
+ emit({ type: 'isolated', id: done, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
370
+ held = [];
371
+ every = Math.min(STATIC_CANARY_MAX, every * 2);
447
372
  }
448
373
  if (progressPath)
449
374
  writeFileSync(progressPath, '');
450
375
  vi.resetModules();
451
376
  }
452
- /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
453
377
  async retryReset(test, attempt) {
454
378
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
455
379
  }
@@ -466,7 +390,6 @@ export default class OxmutateRunner extends Base {
466
390
  for (const f of files) {
467
391
  const filepath = (f.file?.filepath ?? f.filepath);
468
392
  visit(f, filepath);
469
- // A file that fails to collect has no failing test to show for it: report the file.
470
393
  const result = f.result;
471
394
  if (result?.state === 'fail' && !f.tasks?.length)
472
395
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
@@ -486,8 +409,6 @@ export default class OxmutateRunner extends Base {
486
409
  };
487
410
  for (const f of files) {
488
411
  visit(f);
489
- // A file that failed with no failing test (it threw at import, or in a hook) failed under
490
- // the mutant: a kill, as Stryker scores it. Its id stands in for the test that failed.
491
412
  if (f.result?.state === 'fail' && !failed.length)
492
413
  failed.push(f.id);
493
414
  }
@@ -497,4 +418,3 @@ export default class OxmutateRunner extends Base {
497
418
  return super.onAfterRunFiles?.(files);
498
419
  }
499
420
  }
500
- //# sourceMappingURL=runner.js.map