oxmutate 0.0.0-stage → 1.0.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,225 @@
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
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ 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
+ 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
+ const REPLACEMENT_WIDTH = 60;
14
+ const ELLIPSIS = '...';
15
+ const MS_PER_S = 1000;
16
+ /** Survivors the terminal summary lists before it says how many it left out. */
17
+ const DEFAULT_SURVIVORS_SHOWN = 10;
18
+ /** `mutation-testing-report-schema` 2, the format Stryker writes (`jsonReporter`). */
19
+ export function strykerReport(r) {
20
+ const files = {};
21
+ for (const [file, source] of Object.entries(r.sources)) {
22
+ const mutants = r.mutants.filter((m) => m.file === file);
23
+ if (!mutants.length)
24
+ continue;
25
+ files[file] = {
26
+ language: /\.(ts|tsx|mts|cts)$/.test(file) ? 'typescript' : 'javascript',
27
+ source,
28
+ mutants: mutants.map((m) => ({
29
+ id: m.id,
30
+ mutatorName: m.mutatorName,
31
+ replacement: m.replacement,
32
+ location: { start: m.start, end: m.end },
33
+ status: m.status,
34
+ statusReason: m.statusReason,
35
+ coveredBy: m.coveredBy,
36
+ killedBy: m.killedBy,
37
+ testsCompleted: m.testsCompleted,
38
+ static: m.static ?? false,
39
+ })),
40
+ };
41
+ }
42
+ const testFiles = new Map();
43
+ for (const t of r.tests) {
44
+ // Forward slashes on every OS, as Stryker's report keys its files.
45
+ 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
+ (testFiles.get(file) ?? testFiles.set(file, { tests: [] }).get(file))?.tests.push({ id: t.id, name: t.name });
48
+ }
49
+ return {
50
+ schemaVersion: '2',
51
+ thresholds: { high: r.options.thresholds.high, low: r.options.thresholds.low },
52
+ projectRoot: r.root,
53
+ files,
54
+ testFiles: Object.fromEntries(testFiles),
55
+ framework: { name: 'oxmutate', version: VERSION, branding: { homepageUrl: 'https://www.npmjs.com/package/oxmutate' } },
56
+ 'x-oxmutate': { stats: r.stats, score: r.score, unusedConfig: r.options.unused, frozenOracle: r.frozen, specOracles: r.spec },
57
+ };
58
+ }
59
+ /** SARIF 2.1.0: one result per surviving or uncovered mutant, fingerprinted by stable id. */
60
+ export function sarifReport(r) {
61
+ const reported = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
62
+ const rules = [...new Set(reported.map((m) => m.mutatorName))].sort();
63
+ return {
64
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
65
+ version: '2.1.0',
66
+ runs: [
67
+ {
68
+ tool: {
69
+ driver: {
70
+ name: 'oxmutate',
71
+ version: VERSION,
72
+ informationUri: 'https://www.npmjs.com/package/oxmutate',
73
+ rules: rules.map((id) => ({
74
+ id,
75
+ name: id,
76
+ shortDescription: { text: `${id} mutant no test detected` },
77
+ helpUri: 'https://stryker-mutator.io/docs/mutation-testing-elements/supported-mutators/',
78
+ defaultConfiguration: { level: 'warning' },
79
+ })),
80
+ },
81
+ },
82
+ results: reported.map((m) => ({
83
+ ruleId: m.mutatorName,
84
+ ruleIndex: rules.indexOf(m.mutatorName),
85
+ level: m.status === 'Survived' ? 'warning' : 'note',
86
+ message: { text: describe(m) },
87
+ locations: [
88
+ {
89
+ physicalLocation: {
90
+ artifactLocation: { uri: m.file },
91
+ region: { startLine: m.start.line, startColumn: m.start.column, endLine: m.end.line, endColumn: m.end.column },
92
+ },
93
+ },
94
+ ],
95
+ partialFingerprints: { mutationId: m.id },
96
+ properties: { status: m.status, replacement: m.replacement, coveredBy: m.coveredBy.length },
97
+ })),
98
+ },
99
+ ],
100
+ };
101
+ }
102
+ export function describe(m) {
103
+ const change = m.replacement.length > REPLACEMENT_WIDTH ? `${m.replacement.slice(0, REPLACEMENT_WIDTH - ELLIPSIS.length)}${ELLIPSIS}` : m.replacement;
104
+ if (m.status === 'NoCoverage')
105
+ 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
+ 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
+ }
108
+ /** What the run had to correct, one phrase per kind of correction that happened. */
109
+ function corrections(s) {
110
+ return [
111
+ s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
112
+ s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
113
+ s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
114
+ s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
115
+ s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
116
+ s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
117
+ s.reused && `${String(s.reused)} verdict(s) reused`,
118
+ s.crashes && `${String(s.crashes)} worker crash(es)`,
119
+ ].filter((c) => Boolean(c));
120
+ }
121
+ /** One line per spec oracle: its F metrics (metrics.md §6). */
122
+ function specLines(r) {
123
+ 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
+ }
125
+ /** The frozen oracle's line, when the change edited tests. */
126
+ function frozenLine(r) {
127
+ const f = r.frozen;
128
+ if (!f?.changedTests.length)
129
+ return [];
130
+ const sign = f.delta >= 0 ? '+' : '';
131
+ 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
+ }
133
+ /** The terminal summary: the score, what to look at first, what the run corrected. */
134
+ export function textSummary(r, opts = {}) {
135
+ const count = (s) => r.mutants.filter((m) => m.status === s).length;
136
+ const s = r.stats;
137
+ if (r.dryRunOnly) {
138
+ const covered = r.mutants.filter((m) => m.static || m.coveredBy.length > 0).length;
139
+ return `oxmutate ${VERSION}: dry run only, ${(s.wallMs / MS_PER_S).toFixed(1)} s\n ${String(r.tests.length)} tests passed with every mutant compiled in and none active; ${String(covered)} of ${String(r.mutants.length)} mutants are reached by a test\n`;
140
+ }
141
+ const lines = [
142
+ `oxmutate ${VERSION}: ${String(r.mutants.length)} mutants in ${String(s.files)} files, ${(s.wallMs / MS_PER_S).toFixed(1)} s`,
143
+ ` mutation score ${r.score.mutationScore.toFixed(2)}% (covered ${r.score.covered.toFixed(2)}%)`,
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`,
146
+ ];
147
+ const corrected = corrections(s);
148
+ if (corrected.length)
149
+ lines.push(` corrected: ${corrected.join(' · ')}`);
150
+ lines.push(...frozenLine(r));
151
+ lines.push(...specLines(r));
152
+ const survivors = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
153
+ const limit = opts.survivors ?? DEFAULT_SURVIVORS_SHOWN;
154
+ if (survivors.length) {
155
+ lines.push('', `Survivors (${String(survivors.length)}${survivors.length > limit ? `, first ${String(limit)}` : ''}):`);
156
+ for (const m of survivors.slice(0, limit))
157
+ lines.push(` ${m.file}:${String(m.start.line)}:${String(m.start.column)} ${m.mutatorName} → ${m.replacement.replace(/\s+/g, ' ').slice(0, REPLACEMENT_WIDTH)}${m.flaky ? ' (flaky)' : ''}`);
158
+ lines.push('', 'fix: run `oxmutate survivors --json` to hand the list to an agent, or kill the first one above with a test.');
159
+ }
160
+ if (r.options.unused.length && r.options.warnUnused)
161
+ lines.push('', `Config keys not used: ${r.options.unused.map((u) => u.key).join(', ')} (see the JSON report for why)`);
162
+ return `${lines.join('\n')}\n`;
163
+ }
164
+ /** The web component Stryker's HTML report renders with, at the version Stryker 10.0.0 ships. */
165
+ const ELEMENTS = 'mutation-testing-elements/dist/mutation-test-elements.js';
166
+ 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
+ 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
+ export function htmlReport(r) {
176
+ let script;
177
+ try {
178
+ 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
+ const component = readFileSync(elements, 'utf8');
181
+ script = `<script>${component}</script>`;
182
+ }
183
+ catch {
184
+ script = `<script src="${ELEMENTS_CDN}" integrity="${ELEMENTS_SRI}" crossorigin="anonymous"></script>`;
185
+ }
186
+ // `<` as \u003c: still the same JSON, and no `</script>` inside a source file can end the tag.
187
+ const report = JSON.stringify(strykerReport(r)).replaceAll('<', '\\u003c');
188
+ return `<!DOCTYPE html>
189
+ <html>
190
+ <head>
191
+ <meta charset="utf-8">
192
+ <title>oxmutate report</title>
193
+ ${script}
194
+ </head>
195
+ <body>
196
+ <mutation-test-report-app titlePostfix="oxmutate">Your browser does not support custom elements.</mutation-test-report-app>
197
+ <script>
198
+ const app = document.querySelector('mutation-test-report-app');
199
+ app.report = ${report};
200
+ const theme = () => { document.body.style.backgroundColor = app.themeBackgroundColor; };
201
+ app.addEventListener('theme-changed', theme);
202
+ theme();
203
+ </script>
204
+ </body>
205
+ </html>
206
+ `;
207
+ }
208
+ export function writeReports(r) {
209
+ if (r.dryRunOnly)
210
+ return [];
211
+ const written = [];
212
+ const write = (file, body) => {
213
+ mkdirSync(path.dirname(file), { recursive: true });
214
+ writeFileSync(file, typeof body === 'string' ? body : JSON.stringify(body, null, 2));
215
+ written.push(file);
216
+ };
217
+ if (r.options.reporters.includes('json'))
218
+ write(r.options.jsonReport, strykerReport(r));
219
+ if (r.options.reporters.includes('sarif'))
220
+ write(r.options.sarifReport, sarifReport(r));
221
+ if (r.options.reporters.includes('html'))
222
+ write(r.options.htmlReport, htmlReport(r));
223
+ return written;
224
+ }
225
+ //# sourceMappingURL=reports.js.map
@@ -0,0 +1,43 @@
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
+ import { readFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ function pick(entry) {
11
+ if (typeof entry === 'string')
12
+ 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
+ if (!entry || typeof entry !== 'object')
15
+ return undefined;
16
+ for (const condition of ['import', 'node', 'default']) {
17
+ const found = pick(entry[condition]);
18
+ if (found)
19
+ return found;
20
+ }
21
+ return undefined;
22
+ }
23
+ export function resolveEsm(specifier, fromDir) {
24
+ const parts = specifier.split('/');
25
+ const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] ?? specifier);
26
+ const subpath = `.${specifier.slice(name.length)}`;
27
+ const req = createRequire(path.join(fromDir, 'package.json'));
28
+ 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
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf8'));
33
+ const exportsMap = pkg.exports;
34
+ let target;
35
+ if (exportsMap)
36
+ target = pick(exportsMap[subpath]);
37
+ else
38
+ target = subpath === '.' ? (pkg.module ?? pkg.main) : subpath;
39
+ if (!target)
40
+ throw new Error(`oxmutate: cannot resolve ${specifier} from ${fromDir}`);
41
+ return pathToFileURL(path.join(path.dirname(pkgJson), target)).href;
42
+ }
43
+ //# sourceMappingURL=resolve.js.map
@@ -0,0 +1,49 @@
1
+ type AnyTask = {
2
+ id: string;
3
+ name: string;
4
+ type: string;
5
+ file?: {
6
+ filepath: string;
7
+ };
8
+ /** A file task's own path. */
9
+ filepath?: string;
10
+ suite?: AnyTask | undefined;
11
+ result?: {
12
+ state?: string;
13
+ duration?: number;
14
+ };
15
+ tasks?: AnyTask[] | undefined;
16
+ };
17
+ /** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
18
+ export declare class PeerMissingError extends Error {
19
+ readonly code = "E_PEER_MISSING";
20
+ readonly fix = "npm install vitest \u2014 oxmutate runs the project's own Vitest, so the project brings it";
21
+ constructor(cause: unknown);
22
+ }
23
+ declare const OxmutateRunner_base: new (config: unknown) => {
24
+ onCollectStart?(file: unknown): unknown;
25
+ onBeforeTryTask?(test: AnyTask, options: {
26
+ retry: number;
27
+ repeats: number;
28
+ }): unknown;
29
+ onAfterRunFiles?(files: AnyTask[]): unknown;
30
+ };
31
+ export default class OxmutateRunner extends OxmutateRunner_base {
32
+ /**
33
+ * Import-time hits (static mutants) are attributed to the test file being collected, so a static
34
+ * mutant is later re-run against the files that load its module, not the whole suite (Stryker
35
+ * re-runs everything for each one).
36
+ */
37
+ onCollectStart(file: AnyTask): unknown;
38
+ onBeforeTryTask(test: AnyTask, options: {
39
+ retry: number;
40
+ repeats: number;
41
+ }): unknown;
42
+ runTask(test: AnyTask & {
43
+ context?: unknown;
44
+ }): Promise<void>;
45
+ /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
46
+ private retryReset;
47
+ onAfterRunFiles(files: AnyTask[]): unknown;
48
+ }
49
+ export {};
package/dist/runner.js ADDED
@@ -0,0 +1,270 @@
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
+ *
17
+ * Before each mutant the runner writes a progress line synchronously, so the coordinator can
18
+ * attribute and kill a worker stuck in a synchronous loop that the hit counter cannot break.
19
+ */
20
+ import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import path from 'node:path';
23
+ import { resolveEsm } from './resolve.js';
24
+ import { ACTIVE_MUTANT_ENV, FILE_KEY, NAMESPACE, cwd, env } from './runtime.js';
25
+ /**
26
+ * The project's own Vitest, never the one beside this file: two Vitest instances in one worker
27
+ * disagree about the current test, the snapshot state and `expect`.
28
+ */
29
+ const fromProject = async (specifier, dir = cwd()) => import(resolveEsm(specifier, dir));
30
+ /** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
31
+ export class PeerMissingError extends Error {
32
+ code = 'E_PEER_MISSING';
33
+ fix = "npm install vitest — oxmutate runs the project's own Vitest, so the project brings it";
34
+ constructor(cause) {
35
+ super('oxmutate/runner needs `vitest`, an optional peer that is not installed', { cause });
36
+ this.name = 'PeerMissingError';
37
+ }
38
+ }
39
+ const missing = (cause) => {
40
+ const code = cause?.code;
41
+ return code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND';
42
+ };
43
+ /** Vitest 5 exports `TestRunner` from `vitest`; Vitest 4 from `vitest/runners` + `@vitest/runner`. */
44
+ async function loadBase() {
45
+ const vitest = (await fromProject('vitest').catch((cause) => {
46
+ if (missing(cause))
47
+ throw new PeerMissingError(cause);
48
+ throw new Error('oxmutate/runner could not load the project\'s Vitest', { cause });
49
+ }));
50
+ const v5 = vitest['TestRunner'];
51
+ if (v5 && typeof v5.getTestFn === 'function' && typeof v5.getSuiteHooks === 'function') {
52
+ return { Base: v5, statics: { getTestFn: v5.getTestFn, getSuiteHooks: v5.getSuiteHooks } };
53
+ }
54
+ const runners = (await fromProject('vitest/runners'));
55
+ const vitestDir = path.dirname(createRequire(path.join(cwd(), 'package.json')).resolve('vitest/package.json'));
56
+ const core = (await fromProject('@vitest/runner', vitestDir));
57
+ return { Base: runners.VitestTestRunner, statics: { getTestFn: core.getFn, getSuiteHooks: core.getHooks } };
58
+ }
59
+ const { Base, statics } = await loadBase();
60
+ const mode = env['OXMUTATE_MODE'] ?? 'dry';
61
+ const resultsPath = env['OXMUTATE_RESULTS'];
62
+ const progressPath = env['OXMUTATE_PROGRESS'];
63
+ const ns = (globalThis[NAMESPACE] ??= {});
64
+ ns.mutantCoverage ??= { static: {}, perTest: {} };
65
+ const plan = mode === 'mutant' && env['OXMUTATE_PLAN'] ? JSON.parse(readFileSync(env['OXMUTATE_PLAN'], 'utf8')) : { tests: {}, hitLimits: {}, skip: [] };
66
+ const decided = new Set(plan.skip);
67
+ /** The replay tests' own bail: a mutant the tests killed is still replayed, and the reverse. */
68
+ const specTests = new Set(plan.specTests ?? []);
69
+ const decidedBySpec = new Set();
70
+ /** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
71
+ const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
72
+ /** Set when a mutant left state behind; the rest of this context runs no mutants. */
73
+ let polluted = false;
74
+ /** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
75
+ let canaryEvery = 1;
76
+ const MAX_CANARY_EVERY = 8;
77
+ /** How much of a test's error a `polluted` record keeps, and of a file's collection error. */
78
+ const POLLUTION_ERROR_CHARS = 300;
79
+ const FILE_ERROR_CHARS = 500;
80
+ function emit(record) {
81
+ if (resultsPath)
82
+ appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
83
+ }
84
+ function chain(test) {
85
+ const suites = [];
86
+ for (let s = test.suite; s; s = s.suite)
87
+ suites.unshift(s);
88
+ return suites;
89
+ }
90
+ async function beforeEach(test, context) {
91
+ const cleanups = [];
92
+ for (const suite of chain(test)) {
93
+ for (const hook of statics.getSuiteHooks(suite).beforeEach) {
94
+ const cleanup = await hook(context, suite);
95
+ if (typeof cleanup === 'function')
96
+ cleanups.push(cleanup);
97
+ }
98
+ }
99
+ return cleanups;
100
+ }
101
+ async function afterEach(test, context, cleanups) {
102
+ for (const suite of chain(test).reverse()) {
103
+ for (const hook of statics.getSuiteHooks(suite).afterEach)
104
+ await hook(context, suite);
105
+ }
106
+ for (const cleanup of cleanups.reverse())
107
+ await cleanup();
108
+ }
109
+ const isHitLimit = (e) => e instanceof Error && /Hit count limit/.test(e.message);
110
+ function fullName(t) {
111
+ const parts = [t.name];
112
+ for (let s = t.suite; s && s.type === 'suite' && s.name; s = s.suite)
113
+ parts.unshift(s.name);
114
+ return parts.join(' > ');
115
+ }
116
+ export default class OxmutateRunner extends Base {
117
+ /**
118
+ * Import-time hits (static mutants) are attributed to the test file being collected, so a static
119
+ * mutant is later re-run against the files that load its module, not the whole suite (Stryker
120
+ * re-runs everything for each one).
121
+ */
122
+ onCollectStart(file) {
123
+ if (mode === 'dry')
124
+ ns.currentTestId = `${FILE_KEY}${file.filepath ?? file.file?.filepath ?? ''}`;
125
+ return super.onCollectStart?.call(this, file);
126
+ }
127
+ onBeforeTryTask(test, options) {
128
+ if (mode === 'dry')
129
+ ns.currentTestId = test.id;
130
+ return super.onBeforeTryTask?.(test, options);
131
+ }
132
+ async runTask(test) {
133
+ const fn = statics.getTestFn(test);
134
+ if (!fn)
135
+ throw new Error(`oxmutate: no function for test ${test.name}`);
136
+ if (mode !== 'mutant') {
137
+ if (only && !only.has(test.id))
138
+ return;
139
+ await fn();
140
+ return;
141
+ }
142
+ const context = test.context;
143
+ // Once a mutant has polluted this context, no further verdict from it can be trusted: the
144
+ // rest of the unit is left unrun, and the coordinator re-queues it into a fresh context.
145
+ const spec = specTests.has(test.id);
146
+ const done = spec ? decidedBySpec : decided;
147
+ const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
148
+ let cleanups = [];
149
+ let attempt = 0;
150
+ const fresh = async () => {
151
+ await afterEach(test, context, cleanups);
152
+ cleanups = await beforeEach(test, context);
153
+ await this.retryReset(test, ++attempt);
154
+ };
155
+ // Mutants run since the last clean canary. The first of them started from a clean state, so its
156
+ // verdict stands whatever the next canary says; the rest are suspects if it fails.
157
+ let unchecked = [];
158
+ const canary = async () => {
159
+ if (!unchecked.length)
160
+ return true;
161
+ await fresh();
162
+ try {
163
+ await fn();
164
+ unchecked = [];
165
+ canaryEvery = Math.min(MAX_CANARY_EVERY, canaryEvery * 2);
166
+ return true;
167
+ }
168
+ catch (e) {
169
+ polluted = true;
170
+ canaryEvery = 1;
171
+ emit({ type: 'polluted', id: unchecked[0], suspects: unchecked.slice(1), test: test.id, file: test.file?.filepath, error: e instanceof Error ? e.message.slice(0, POLLUTION_ERROR_CHARS) : String(e) });
172
+ return false;
173
+ }
174
+ };
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`);
182
+ const t0 = performance.now();
183
+ ns.activeMutant = id;
184
+ ns.hitCount = 0;
185
+ ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
186
+ let status = 'Survived';
187
+ try {
188
+ await fn();
189
+ }
190
+ catch (e) {
191
+ status = isHitLimit(e) ? 'Timeout' : 'Killed';
192
+ }
193
+ finally {
194
+ ns.activeMutant = undefined;
195
+ ns.hitCount = undefined;
196
+ ns.hitLimit = undefined;
197
+ }
198
+ if (status !== 'Survived')
199
+ done.add(id);
200
+ emit({ type: 'mutant', id, test: test.id, status, ms: performance.now() - t0, ...(spec ? { spec: true } : {}) });
201
+ unchecked.push(id);
202
+ // The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
203
+ // behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
204
+ // mutant at first, then every 2, 4, 8 while it stays clean: pollution is rare per test, and the
205
+ // canary was half of all executions (burgee/paratext, 2026-10-27).
206
+ if (unchecked.length >= canaryEvery && !(await canary()))
207
+ break;
208
+ }
209
+ if (!polluted)
210
+ await canary();
211
+ if (progressPath && ids.length)
212
+ writeFileSync(progressPath, '');
213
+ if (attempt > 0)
214
+ await fresh();
215
+ try {
216
+ await fn();
217
+ }
218
+ finally {
219
+ for (const cleanup of cleanups.reverse())
220
+ await cleanup();
221
+ }
222
+ }
223
+ /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
224
+ async retryReset(test, attempt) {
225
+ await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
226
+ }
227
+ onAfterRunFiles(files) {
228
+ if (mode === 'dry') {
229
+ const tests = [];
230
+ const visit = (t, file) => {
231
+ if (t.type === 'test')
232
+ tests.push({ id: t.id, file, name: fullName(t), state: t.result?.state ?? 'skip', ms: t.result?.duration ?? 0 });
233
+ for (const c of t.tasks ?? [])
234
+ visit(c, file);
235
+ };
236
+ const fileErrors = [];
237
+ for (const f of files) {
238
+ const filepath = (f.file?.filepath ?? f.filepath);
239
+ visit(f, filepath);
240
+ // A file that fails to collect has no failing test to show for it: report the file.
241
+ const result = f.result;
242
+ if (result?.state === 'fail' && !f.tasks?.length)
243
+ fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
244
+ }
245
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors });
246
+ }
247
+ else if (mode === 'isolated') {
248
+ const failed = [];
249
+ let ran = 0;
250
+ const visit = (t) => {
251
+ if (t.type === 'test' && (t.result?.state === 'pass' || t.result?.state === 'fail'))
252
+ ran++;
253
+ if (t.type === 'test' && t.result?.state === 'fail')
254
+ failed.push(t.id);
255
+ for (const c of t.tasks ?? [])
256
+ visit(c);
257
+ };
258
+ for (const f of files) {
259
+ visit(f);
260
+ // A file that failed with no failing test (it threw at import, or in a hook) failed under
261
+ // the mutant: a kill, as Stryker scores it. Its id stands in for the test that failed.
262
+ if (f.result?.state === 'fail' && !failed.length)
263
+ failed.push(f.id);
264
+ }
265
+ emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0, failed, ran });
266
+ }
267
+ return super.onAfterRunFiles?.(files);
268
+ }
269
+ }
270
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1,24 @@
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
+ 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
+ export const FILE_KEY = 'file:';
13
+ /** Arms one mutant from process start, for static mutants and isolated re-runs. */
14
+ 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
+ export const env = process.env;
20
+ export const argv = process.argv;
21
+ export const cwd = () => process.cwd();
22
+ export const stderr = process.stderr;
23
+ export const exit = (code) => process.exit(code);
24
+ //# sourceMappingURL=runtime.js.map