oxmutate 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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;
@@ -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,39 +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';
28
- import { BEAT_ENV, markTest, startHeartbeat } from './heartbeat.js';
4
+ import { BEAT_ENV, markTest, realNow, startHeartbeat } from './heartbeat.js';
29
5
  import { resolveEsm } from './resolve.js';
30
6
  import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
31
- /**
32
- * The project's own Vitest, never the one beside this file: two Vitest instances in one worker
33
- * disagree about the current test, the snapshot state and `expect`.
34
- */
35
7
  const fromProject = async (specifier, dir = cwd()) => import(resolveEsm(specifier, dir));
36
- /** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
37
8
  export class PeerMissingError extends Error {
38
9
  code = 'E_PEER_MISSING';
39
10
  fix = "npm install vitest — oxmutate runs the project's own Vitest, so the project brings it";
@@ -46,7 +17,6 @@ const missing = (cause) => {
46
17
  const code = cause?.code;
47
18
  return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
48
19
  };
49
- /** Vitest 5 exports `TestRunner` from `vitest`; Vitest 4 from `vitest/runners` + `@vitest/runner`. */
50
20
  async function loadBase() {
51
21
  const vitest = (await fromProject('vitest').catch((cause) => {
52
22
  if (missing(cause))
@@ -66,39 +36,27 @@ const { Base, statics } = await loadBase();
66
36
  const mode = env['OXMUTATE_MODE'] ?? 'dry';
67
37
  const resultsPath = env['OXMUTATE_RESULTS'];
68
38
  const progressPath = env['OXMUTATE_PROGRESS'];
69
- startHeartbeat(env[BEAT_ENV]); // the watchdog tells a wait from a loop by it (heartbeat.ts)
39
+ startHeartbeat(env[BEAT_ENV]);
70
40
  const ns = (globalThis[NAMESPACE] ??= {});
71
41
  ns.mutantCoverage ??= { static: {}, perTest: {} };
72
42
  const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
73
43
  const decided = new Set(plan.skip);
74
- /** The replay tests' own bail: a mutant the tests killed is still replayed, and the reverse. */
75
44
  const specTests = new Set(plan.specTests ?? []);
76
45
  const decidedBySpec = new Set();
77
- /** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
78
46
  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
47
  const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
81
- /** `static`: each mutant's hit-count limit. */
82
48
  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
49
  if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
87
50
  ns.hitCount = 0;
88
51
  ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
89
52
  }
90
- /** Did the armed mutant's switch pass its limit, i.e. was this run stopped as a runaway? */
91
53
  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
54
  const PROTOTYPES = {
95
55
  Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
96
56
  Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
97
57
  Set: Set.prototype, RegExp: RegExp.prototype, Date: Date.prototype, Error: Error.prototype,
98
58
  };
99
- /** Keys Vitest and oxmutate own: they change by design while a file runs. */
100
59
  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
60
  function snapshot() {
103
61
  const out = new Map();
104
62
  const g = globalThis;
@@ -116,30 +74,20 @@ function snapshot() {
116
74
  }
117
75
  return out;
118
76
  }
119
- /** How many differing keys a `static-fallback` record names. */
120
77
  const DIFF_KEYS = 8;
121
- /** The keys whose presence or value differs between two snapshots (the first few, for a report). */
122
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);
123
79
  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
80
  let pristine;
126
- /** Set when a mutant left state behind; the rest of this context runs no mutants. */
127
81
  let polluted = false;
128
- /** `static` mode: the most mutants decided between two unarmed re-collections (staticwarm.qnt `grouped`). */
129
82
  const STATIC_CANARY_MAX = 4;
130
- /** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
131
83
  let canaryEvery = 1;
132
84
  const MAX_CANARY_EVERY = 8;
133
- /** How much of a test's error a `polluted` record keeps, and of a file's collection error. */
134
85
  const POLLUTION_ERROR_CHARS = 300;
135
86
  const FILE_ERROR_CHARS = 500;
136
87
  function emit(record) {
137
88
  if (resultsPath)
138
89
  appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
139
90
  }
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.
143
91
  function chain(test) {
144
92
  const suites = [];
145
93
  for (let s = test.suite; s; s = s.suite)
@@ -169,11 +117,6 @@ async function afterEach(test, context, cleanups) {
169
117
  await cleanup();
170
118
  }
171
119
  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
120
  async function runCollected(file, runnable) {
178
121
  const failed = [];
179
122
  let error;
@@ -219,7 +162,7 @@ async function runCollected(file, runnable) {
219
162
  await runSuite(file);
220
163
  }
221
164
  catch (e) {
222
- failed.push(file.id); // a hook threw: the file failed under the mutant, as Stryker scores it
165
+ failed.push(file.id);
223
166
  note(file, e);
224
167
  }
225
168
  return { failed, ran, ...(error !== undefined && { error }) };
@@ -231,11 +174,6 @@ function fullName(t) {
231
174
  return parts.join(' > ');
232
175
  }
233
176
  export default class OxmutateRunner extends Base {
234
- /**
235
- * Import-time hits (static mutants) are attributed to the test file being collected, so a static
236
- * mutant is later re-run against the files that load its module, not the whole suite (Stryker
237
- * re-runs everything for each one).
238
- */
239
177
  onCollectStart(file) {
240
178
  if (mode === 'dry')
241
179
  ns.currentTestId = `${FILE_KEY}${file.filepath ?? file.file?.filepath ?? ''}`;
@@ -253,7 +191,6 @@ export default class OxmutateRunner extends Base {
253
191
  if (mode !== 'mutant') {
254
192
  if (only && !only.has(test.id))
255
193
  return;
256
- // Only a wait inside a test body can be an idle stall; collection at low CPU is a slow machine.
257
194
  markTest(true);
258
195
  try {
259
196
  await fn();
@@ -264,8 +201,6 @@ export default class OxmutateRunner extends Base {
264
201
  return;
265
202
  }
266
203
  const context = test.context;
267
- // Once a mutant has polluted this context, no further verdict from it can be trusted: the
268
- // rest of the unit is left unrun, and the coordinator re-queues it into a fresh context.
269
204
  const spec = specTests.has(test.id);
270
205
  const done = spec ? decidedBySpec : decided;
271
206
  const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
@@ -283,8 +218,6 @@ export default class OxmutateRunner extends Base {
283
218
  await teardown();
284
219
  await setup();
285
220
  };
286
- // Mutants run since the last clean canary. The first of them started from a clean state, so its
287
- // verdict stands whatever the next canary says; the rest are suspects if it fails.
288
221
  let unchecked = [];
289
222
  const canary = async () => {
290
223
  if (!unchecked.length)
@@ -303,11 +236,6 @@ export default class OxmutateRunner extends Base {
303
236
  return false;
304
237
  }
305
238
  };
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
239
  const runArmed = async (id) => {
312
240
  await teardown();
313
241
  const t0 = performance.now();
@@ -333,13 +261,8 @@ export default class OxmutateRunner extends Base {
333
261
  if (done.has(id))
334
262
  continue;
335
263
  if (progressPath)
336
- writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
264
+ writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
337
265
  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
266
  if (status !== 'Survived' && unchecked.length > 0) {
344
267
  if (!(await canary()))
345
268
  break;
@@ -349,10 +272,6 @@ export default class OxmutateRunner extends Base {
349
272
  done.add(id);
350
273
  emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
351
274
  unchecked.push(id);
352
- // The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
353
- // behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
354
- // mutant at first, then every 2, 4, 8 while it stays clean: pollution is rare per test, and the
355
- // canary was half of all executions (burgee/paratext, 2026-10-27).
356
275
  if (unchecked.length >= canaryEvery && !(await canary()))
357
276
  break;
358
277
  }
@@ -370,11 +289,6 @@ export default class OxmutateRunner extends Base {
370
289
  await cleanup();
371
290
  }
372
291
  }
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
292
  importFile(filepath, source) {
379
293
  if (mode === 'static' && source === 'collect' && !pristine)
380
294
  pristine = snapshot();
@@ -385,18 +299,8 @@ export default class OxmutateRunner extends Base {
385
299
  await this.decideStatic(suite);
386
300
  return super.onBeforeRunSuite?.(suite);
387
301
  }
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
302
  async decideStatic(suite) {
397
303
  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
304
  const runnable = new Set();
401
305
  const visit = (t) => {
402
306
  if (t.type === 'test' && t.mode === 'run')
@@ -416,13 +320,11 @@ export default class OxmutateRunner extends Base {
416
320
  if (atCollect.length)
417
321
  return fallback('the file changed globals while it was collected', atCollect);
418
322
  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
323
  const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
422
324
  const { collectTests } = browser;
423
325
  const { vi } = vitest;
424
326
  if (!collectTests || !vi)
425
- return fallback('this Vitest has no public collect'); // Vitest 4: the fresh path decides
327
+ return fallback('this Vitest has no public collect');
426
328
  const evaluate = async (armed) => {
427
329
  ns.activeMutant = armed;
428
330
  ns.hitCount = armed ? 0 : undefined;
@@ -439,12 +341,6 @@ export default class OxmutateRunner extends Base {
439
341
  ns.hitLimit = undefined;
440
342
  }
441
343
  };
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
344
  let held = [];
449
345
  let every = 1;
450
346
  const back = (reason, keys = []) => {
@@ -455,7 +351,7 @@ export default class OxmutateRunner extends Base {
455
351
  while (rest.length) {
456
352
  const id = rest.shift();
457
353
  if (progressPath)
458
- writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
354
+ writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
459
355
  held.push({ id, verdict: await evaluate(id) });
460
356
  const left = snapshotDiff(before, snapshot());
461
357
  if (left.length)
@@ -478,7 +374,6 @@ export default class OxmutateRunner extends Base {
478
374
  writeFileSync(progressPath, '');
479
375
  vi.resetModules();
480
376
  }
481
- /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
482
377
  async retryReset(test, attempt) {
483
378
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
484
379
  }
@@ -495,7 +390,6 @@ export default class OxmutateRunner extends Base {
495
390
  for (const f of files) {
496
391
  const filepath = (f.file?.filepath ?? f.filepath);
497
392
  visit(f, filepath);
498
- // A file that fails to collect has no failing test to show for it: report the file.
499
393
  const result = f.result;
500
394
  if (result?.state === 'fail' && !f.tasks?.length)
501
395
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
@@ -515,8 +409,6 @@ export default class OxmutateRunner extends Base {
515
409
  };
516
410
  for (const f of files) {
517
411
  visit(f);
518
- // A file that failed with no failing test (it threw at import, or in a hook) failed under
519
- // the mutant: a kill, as Stryker scores it. Its id stands in for the test that failed.
520
412
  if (f.result?.state === 'fail' && !failed.length)
521
413
  failed.push(f.id);
522
414
  }
@@ -526,4 +418,3 @@ export default class OxmutateRunner extends Base {
526
418
  return super.onAfterRunFiles?.(files);
527
419
  }
528
420
  }
529
- //# sourceMappingURL=runner.js.map
package/dist/runtime.js CHANGED
@@ -1,25 +1,9 @@
1
- /**
2
- * The contract between instrumented code and the runner. The native core emits calls to
3
- * `oxmAct(id)` and `oxmCov(...ids)` that read and write this namespace on `globalThis`
4
- * (`native/src/instrument.rs`, `NAMESPACE`). Changing a field here without changing the emitter
5
- * breaks every instrumented file.
6
- */
7
1
  export const NAMESPACE = '__oxmutate__';
8
- /**
9
- * Coverage key prefix for hits that happen while a test file is being collected (imports): the
10
- * runner writes `file:<path>` keys, and the coordinator reads them back as static mutants' loaders.
11
- */
12
2
  export const FILE_KEY = 'file:';
13
- /** Arms one mutant from process start, for static mutants and isolated re-runs. */
14
3
  export const ACTIVE_MUTANT_ENV = '__OXMUTATE_ACTIVE_MUTANT__';
15
- // ── The process seam ────────────────────────────────────────────────────────────────────────────
16
- // The one file in the package that names the process (burgee's `process-reference-lock.test.ts`):
17
- // the coordinator, the servers, the runner and the CLI read it here, so a test can see every
18
- // place the package touches it. `env` is the live object: a write here is a write to the process.
19
4
  export const env = process.env;
20
5
  export const argv = process.argv;
21
6
  export const pid = process.pid;
22
7
  export const cwd = () => process.cwd();
23
8
  export const stderr = process.stderr;
24
9
  export const exit = (code) => process.exit(code);
25
- //# sourceMappingURL=runtime.js.map