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/README.md +3 -2
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/bin.js +0 -1
- package/dist/checker.js +1 -32
- package/dist/child.js +10 -43
- package/dist/cli.js +0 -10
- package/dist/command-runner.js +0 -27
- package/dist/config.js +0 -29
- package/dist/coordinator.d.ts +6 -0
- package/dist/coordinator.js +25 -135
- package/dist/directives.js +38 -0
- package/dist/files.js +0 -10
- package/dist/frozen.js +0 -15
- package/dist/heartbeat.js +62 -0
- package/dist/index.js +0 -4
- package/dist/lanes.js +1 -30
- package/dist/native.js +0 -13
- package/dist/platform.js +0 -14
- package/dist/progress.js +0 -9
- package/dist/reports.js +1 -29
- package/dist/resolve.js +0 -10
- package/dist/runner.js +33 -113
- package/dist/runtime.js +1 -16
- package/dist/scheduler.js +38 -26
- package/dist/spec-oracle.js +0 -23
- package/dist/spec.js +0 -28
- package/dist/vite-plugin.js +0 -5
- package/dist/vite.js +0 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -189,7 +189,7 @@ setting a value inside the worker that already has the tests loaded.
|
|
|
189
189
|
| Re-parse | Every instrumented file is parsed again before it runs; one that does not parse stops the run (exit 3) instead of scoring a broken file |
|
|
190
190
|
| Canary | The unmutated test must still pass after mutants run in a context, with its `beforeEach`/`afterEach` hooks re-run around every run, file-level ones included; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again. A kill that follows unchecked runs is checked at once and decided again from clean state, so no kill rests on another mutant's leftovers |
|
|
191
191
|
| Survivor confirmation | A survivor is re-run with the mutant armed across its covering tests in one file, as Stryker arms it, so a mutant that only fails across tests is killed |
|
|
192
|
-
| Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout |
|
|
192
|
+
| Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout. A stop whose worker was idle inside a test body (its event loop still turning, by a heartbeat, and its server under 25% of a core) is a hang, not a slow test: it is a Timeout as it stands, the way Stryker scores it, with no 3x re-run |
|
|
193
193
|
| Static mutants | Code that runs at import is decided in a live worker: the mutant is armed, the module registry reset and the test file collected again, against the files that load it. A file whose collection changes globals, `process.env` or a built-in prototype, or a mutant that leaves a trace, goes back to a fresh worker per mutant (`OXMUTATE_ISOLATE_STATIC=1` sends all of them there) |
|
|
194
194
|
| Flaky tests | Tests that behave differently across two dry runs have their mutants re-run; a verdict that changes is reported `flaky` |
|
|
195
195
|
| No orphans | Every Vitest server exits when its run does, killed or not |
|
|
@@ -261,7 +261,8 @@ head-to-head runs (`bench/burgee-pair.mts`, concurrency 4, one tool after the ot
|
|
|
261
261
|
|
|
262
262
|
The ceiling depends on the suite as much as the tool: controlroom's own `vitest run` takes ~8 s and
|
|
263
263
|
every fresh worker imports React and Ink, so even zero overhead would make it ~6x. The figures to
|
|
264
|
-
publish
|
|
264
|
+
publish come from `bench/burgee-pair.mts`, run locally on a quiet machine: no mutation run happens
|
|
265
|
+
in GitHub Actions (`D-20261101-no-mutation-in-gha`).
|
|
265
266
|
|
|
266
267
|
## Benchmarks
|
|
267
268
|
|
|
Binary file
|
package/dist/bin.js
CHANGED
package/dist/checker.js
CHANGED
|
@@ -1,46 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The type checker (Stryker's `checkers: ['typescript']`): a surviving mutant that does not
|
|
3
|
-
* type-check is a CompileError, not a survivor. A survivor is what someone writes a test for, and
|
|
4
|
-
* a test cannot kill a program the compiler refuses; Stryker's TypeScript checker exists for the
|
|
5
|
-
* same reason.
|
|
6
|
-
*
|
|
7
|
-
* It checks survivors only, where Stryker checks every mutant before running it. A killed mutant
|
|
8
|
-
* that would not have compiled changes nothing anyone acts on, and a full project type check per
|
|
9
|
-
* mutant is the costliest step there is. The difference: such a mutant is Killed here where Stryker
|
|
10
|
-
* would score it CompileError, which can move a score that enables the checker by its share.
|
|
11
|
-
*
|
|
12
|
-
* The project's own compiler, `tsgo` when installed and `tsc` otherwise, over its own tsconfig.
|
|
13
|
-
* Survivors are checked in batches with at most one mutant per file, and each error is laid at the
|
|
14
|
-
* mutant in the file it names. An error in a file no mutant of the batch touched (a type that
|
|
15
|
-
* flowed across files) sends that batch back one mutant at a time, so no verdict is a guess.
|
|
16
|
-
*/
|
|
17
1
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
2
|
import path from 'node:path';
|
|
19
3
|
import { ambientRuntime, isWindows, run as spawn } from 'bellpull';
|
|
20
4
|
import { makeSandbox } from './command-runner.js';
|
|
21
5
|
const OUTPUT_TAIL = 1500;
|
|
22
|
-
/**
|
|
23
|
-
* `src/a.ts(3,7): error TS2322: …` — tsc's and tsgo's `--pretty false` shape. No anchors: the lazy
|
|
24
|
-
* first group already makes the leftmost match start the line, and the greedy last one ends it.
|
|
25
|
-
*/
|
|
26
6
|
const DIAGNOSTIC = /(.+?)\((\d+),(\d+)\): error (TS\d+: .*)/;
|
|
27
|
-
/** The source with one mutant applied. Columns are UTF-16 and 1-based, as JavaScript counts. */
|
|
28
7
|
export function applyMutant(source, m) {
|
|
29
8
|
const starts = [0];
|
|
30
9
|
for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1))
|
|
31
10
|
starts.push(i + 1);
|
|
32
|
-
// Stryker disable next-line NumericLiteral: `starts` holds every line the checker reports, so the fallback never runs
|
|
33
11
|
const at = (p) => (starts[p.line - 1] ?? 0) + p.column - 1;
|
|
34
12
|
return source.slice(0, at(m.start)) + m.replacement + source.slice(at(m.end));
|
|
35
13
|
}
|
|
36
|
-
/** Errors per file (repository-relative), from the compiler's output. */
|
|
37
14
|
export function parseDiagnostics(output, cwd) {
|
|
38
15
|
const errors = new Map();
|
|
39
16
|
for (const line of output.split('\n')) {
|
|
40
17
|
const hit = DIAGNOSTIC.exec(line.trim());
|
|
41
18
|
if (!hit)
|
|
42
19
|
continue;
|
|
43
|
-
// Stryker disable next-line StringLiteral: all four groups take part in every match, so the defaults are never used; they are there for the type
|
|
44
20
|
const [, name = '', lineNo = '', , message = ''] = hit;
|
|
45
21
|
const file = path.relative(cwd, path.resolve(cwd, name)).split(path.sep).join('/');
|
|
46
22
|
if (!errors.has(file))
|
|
@@ -48,7 +24,6 @@ export function parseDiagnostics(output, cwd) {
|
|
|
48
24
|
}
|
|
49
25
|
return errors;
|
|
50
26
|
}
|
|
51
|
-
/** Batches of survivors with at most one per file, so each error names its mutant. */
|
|
52
27
|
export function batches(items) {
|
|
53
28
|
const out = [];
|
|
54
29
|
for (const m of items) {
|
|
@@ -60,7 +35,6 @@ export function batches(items) {
|
|
|
60
35
|
}
|
|
61
36
|
return out;
|
|
62
37
|
}
|
|
63
|
-
/** npm's .bin entry for tsgo: `tsgo.cmd` on Windows, where the extensionless file beside it is a shell script. */
|
|
64
38
|
export const tsgoBin = (windows) => (windows ? 'tsgo.cmd' : 'tsgo');
|
|
65
39
|
function compiler(o) {
|
|
66
40
|
if (o.command)
|
|
@@ -68,12 +42,10 @@ function compiler(o) {
|
|
|
68
42
|
const tsgo = path.join(o.root, 'node_modules', '.bin', tsgoBin(isWindows(ambientRuntime())));
|
|
69
43
|
return existsSync(tsgo) ? [tsgo] : ['npx', 'tsc'];
|
|
70
44
|
}
|
|
71
|
-
/** The undetected mutants that do not type-check, each with the compiler's first error in its file. */
|
|
72
45
|
export async function checkSurvivors(o, survivors) {
|
|
73
46
|
const failing = new Map();
|
|
74
47
|
if (survivors.length === 0)
|
|
75
48
|
return failing;
|
|
76
|
-
// Stryker disable next-line StringLiteral: compiler() never returns an empty list, so the default is never taken; it is there for the type
|
|
77
49
|
const [command = '', ...prefix] = compiler(o);
|
|
78
50
|
const args = [...prefix, '--noEmit', '--pretty', 'false', '-p', o.tsconfig];
|
|
79
51
|
const sandboxes = Array.from({ length: Math.max(1, Math.min(o.concurrency, survivors.length)) }, () => makeSandbox(o.root));
|
|
@@ -82,7 +54,6 @@ export async function checkSurvivors(o, survivors) {
|
|
|
82
54
|
const r = await spawn(command, args, { cwd: sandbox, runtime: ambientRuntime() });
|
|
83
55
|
return { ok: r.ok, errors: parseDiagnostics(`${r.stdout}\n${r.stderr}`, sandbox), output: `${r.stdout}${r.stderr}` };
|
|
84
56
|
};
|
|
85
|
-
// Stryker disable next-line StringLiteral: there is always at least one sandbox, so the fallback is never used; it is there for the type
|
|
86
57
|
const base = await check(sandboxes[0] ?? '');
|
|
87
58
|
if (!base.ok)
|
|
88
59
|
throw new Error(`the project does not type-check with no mutant applied (\`${[command, ...args].join(' ')}\`): ${base.output.trim().slice(-OUTPUT_TAIL)}. fix: make the type check pass first, or drop checkers`);
|
|
@@ -98,7 +69,7 @@ export async function checkSurvivors(o, survivors) {
|
|
|
98
69
|
const touched = new Set(batch.map((m) => m.file));
|
|
99
70
|
if (batch.length > 1 && [...r.errors.keys()].some((f) => !touched.has(f))) {
|
|
100
71
|
for (const m of batch)
|
|
101
|
-
await run(sandbox, [m]);
|
|
72
|
+
await run(sandbox, [m]);
|
|
102
73
|
return;
|
|
103
74
|
}
|
|
104
75
|
for (const m of batch) {
|
|
@@ -115,9 +86,7 @@ export async function checkSurvivors(o, survivors) {
|
|
|
115
86
|
return failing;
|
|
116
87
|
}
|
|
117
88
|
finally {
|
|
118
|
-
// No `force`: each sandbox was made above and nothing else removes it.
|
|
119
89
|
for (const s of sandboxes)
|
|
120
90
|
rmSync(s, { recursive: true });
|
|
121
91
|
}
|
|
122
92
|
}
|
|
123
|
-
//# sourceMappingURL=checker.js.map
|
package/dist/child.js
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A Vitest server in its own process. It starts the project's Vitest once and then runs work
|
|
3
|
-
* units sent by the coordinator over IPC, re-running the same instance (~0.2 s a run against
|
|
4
|
-
* ~1.5 s for a fresh Vitest). The coordinator kills the process when a mutant loops
|
|
5
|
-
* synchronously, a hang that no in-worker timeout and no `cancelCurrentRun()` can interrupt
|
|
6
|
-
* (both measured, 2026-10-26), and hands the rest of the unit to another server.
|
|
7
|
-
*
|
|
8
|
-
* node child.js <server.json> IPC: ← { type: 'run', unit } → { type: 'ready' | 'done' }
|
|
9
|
-
*
|
|
10
|
-
* Vitest is resolved from the project being tested, like Stryker's vitest-runner does, so the
|
|
11
|
-
* project's own Vitest version and config run its tests.
|
|
12
|
-
*/
|
|
13
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
2
|
import path from 'node:path';
|
|
15
3
|
import { fileURLToPath } from 'node:url';
|
|
16
4
|
import { ExitCode } from 'burgee';
|
|
5
|
+
import { BEAT_ENV, beatFile, CPU_SAMPLE_MS } from './heartbeat.js';
|
|
17
6
|
import { resolveEsm } from './resolve.js';
|
|
18
|
-
import { ACTIVE_MUTANT_ENV, argv, env, exit } from './runtime.js';
|
|
7
|
+
import { ACTIVE_MUTANT_ENV, argv, env, exit, pid } from './runtime.js';
|
|
19
8
|
import { instrumentedSources } from './vite-plugin.js';
|
|
20
9
|
const config = JSON.parse(readFileSync(argv[2] ?? '', 'utf8'));
|
|
21
10
|
env['NODE_ENV'] ??= 'test';
|
|
11
|
+
env[BEAT_ENV] = beatFile(path.dirname(config.instrumentedMap), pid);
|
|
22
12
|
const vitestNode = (await import(resolveEsm('vitest/node', config.root)));
|
|
23
13
|
const runner = fileURLToPath(new URL('./runner.js', import.meta.url));
|
|
24
14
|
const silent = { onInit() { }, onFinished() { } };
|
|
@@ -34,25 +24,13 @@ const vitest = await vitestNode.createVitest('test', {
|
|
|
34
24
|
reporters: env['OXMUTATE_DEBUG_VITEST'] ? ['default'] : [silent],
|
|
35
25
|
coverage: { enabled: false },
|
|
36
26
|
passWithNoTests: true,
|
|
37
|
-
// Bail is ours (per mutant); Vitest must run every test it is handed.
|
|
38
27
|
bail: 0,
|
|
39
|
-
// Vitest 5's persistent module cache keys on the file on disk, not on what a plugin serves,
|
|
40
|
-
// so it served a previous run's instrumentation: every mutant whose id had changed since got
|
|
41
|
-
// no coverage (burgee/paratext, 2026-10-27: 6% agreement until this was off). Off under both
|
|
42
|
-
// spellings (top-level in Vitest 5, `experimental` in 4.x).
|
|
43
28
|
fsModuleCache: false,
|
|
44
29
|
experimental: { fsModuleCache: false },
|
|
45
30
|
...(config.inlineDeps && { server: { deps: { inline: true } } }),
|
|
46
|
-
},
|
|
47
|
-
// A private Vite cache per run, so no cache outside this run can answer for a mutated file.
|
|
48
|
-
{ plugins: [instrumentedSources(config.instrumentedMap)], cacheDir: path.join(path.dirname(config.instrumentedMap), 'vite-cache') });
|
|
49
|
-
// A config with `projects` resolves each project's `runner` and `isolate` from its own inline
|
|
50
|
-
// config, never from these options, so its tests ran without oxmutate's runner and the dry run
|
|
51
|
-
// recorded none (burgee, weekly run 40783405734, 2026-10-28). Each project keeps its own pool:
|
|
52
|
-
// burgee's run in forks, and its process.env lock fails under threads.
|
|
31
|
+
}, { plugins: [instrumentedSources(config.instrumentedMap)], cacheDir: path.join(path.dirname(config.instrumentedMap), 'vite-cache') });
|
|
53
32
|
for (const project of vitest.projects)
|
|
54
33
|
Object.assign(project.config, { runner, isolate: true });
|
|
55
|
-
/** Has the runner recorded a kill for this mutant in this unit's results yet? */
|
|
56
34
|
function killed(results, id) {
|
|
57
35
|
let text = '';
|
|
58
36
|
try {
|
|
@@ -63,17 +41,11 @@ function killed(results, id) {
|
|
|
63
41
|
}
|
|
64
42
|
return text.split('\n').some((line) => line.includes(`"id":"${id}"`) && line.includes('"killed":true'));
|
|
65
43
|
}
|
|
66
|
-
/** An `isolated` unit: its mutants armed one after another, each across its files. */
|
|
67
44
|
async function runIsolated(unit) {
|
|
68
|
-
// Stop a file at its first failing test: one failure kills the mutant, and the rest of the
|
|
69
|
-
// file is wasted time (burgee/paratext hyperlinks.test.ts: 2.3 s a run, the first failure
|
|
70
|
-
// typically in its first tests). Read per start, so dry and warm runs still run everything
|
|
71
|
-
// (measured on Vitest 4.1, 2026-10-27).
|
|
72
45
|
vitest.config.bail = 1;
|
|
73
46
|
try {
|
|
74
47
|
for (const item of unit.batch ?? []) {
|
|
75
48
|
env[ACTIVE_MUTANT_ENV] = item.id;
|
|
76
|
-
// The runaway guard warm runs have: a loop the mutant causes stops at the limit, not the watchdog.
|
|
77
49
|
if (item.hitLimit)
|
|
78
50
|
env['OXMUTATE_HIT_LIMIT'] = String(item.hitLimit);
|
|
79
51
|
else
|
|
@@ -83,8 +55,6 @@ async function runIsolated(unit) {
|
|
|
83
55
|
else
|
|
84
56
|
delete env['OXMUTATE_ONLY_TESTS'];
|
|
85
57
|
writeFileSync(unit.progress, `${item.id} ${String(Date.now())}\n`);
|
|
86
|
-
// One file at a time, cheapest first (the coordinator orders them), stopping at the first
|
|
87
|
-
// file that kills the mutant: Stryker runs every file for every isolated mutant.
|
|
88
58
|
for (const file of item.files) {
|
|
89
59
|
await vitest.start([file]);
|
|
90
60
|
if (killed(unit.results, item.id))
|
|
@@ -101,8 +71,6 @@ async function runIsolated(unit) {
|
|
|
101
71
|
writeFileSync(unit.progress, '');
|
|
102
72
|
}
|
|
103
73
|
async function runUnit(unit) {
|
|
104
|
-
// Workers copy the environment when they start, so each run sees the unit's settings
|
|
105
|
-
// (verified across re-runs of one instance, 2026-10-26).
|
|
106
74
|
env['OXMUTATE_MODE'] = unit.mode;
|
|
107
75
|
env['OXMUTATE_RESULTS'] = unit.results;
|
|
108
76
|
env['OXMUTATE_PROGRESS'] = unit.progress;
|
|
@@ -120,27 +88,26 @@ async function runUnit(unit) {
|
|
|
120
88
|
if (unit.mode === 'isolated')
|
|
121
89
|
await runIsolated(unit);
|
|
122
90
|
else {
|
|
123
|
-
// A dry run names itself in the progress file, so the watchdog holds it to its budget.
|
|
124
91
|
if (unit.mode === 'dry')
|
|
125
92
|
writeFileSync(unit.progress, `dry ${String(Date.now())}\n`);
|
|
126
93
|
await vitest.start(unit.files.length ? unit.files : undefined);
|
|
127
94
|
}
|
|
128
95
|
}
|
|
129
|
-
/** How long `close` waits for `vitest.close()` before the server exits regardless. */
|
|
130
96
|
const CLOSE_GRACE_MS = 2000;
|
|
131
97
|
process.on('message', (msg) => {
|
|
132
98
|
if (msg.type === 'run' && msg.unit) {
|
|
133
99
|
runUnit(msg.unit).then(() => process.send?.({ type: 'done' }), (error) => process.send?.({ type: 'done', error: error instanceof Error ? error.message : String(error) }));
|
|
134
100
|
}
|
|
135
101
|
else if (msg.type === 'close') {
|
|
136
|
-
// A worker thread stuck in a runaway mutant never lets `vitest.close()` settle: exit anyway.
|
|
137
102
|
setTimeout(() => exit(ExitCode.OK), CLOSE_GRACE_MS).unref();
|
|
138
103
|
void vitest.close().finally(() => exit(ExitCode.OK));
|
|
139
104
|
}
|
|
140
105
|
});
|
|
141
|
-
// The coordinator is gone (finished, crashed or killed): nothing will read this server's results,
|
|
142
|
-
// and a worker thread may still be looping in a mutant. Two such servers were found spinning at
|
|
143
|
-
// 100% CPU 11 hours after their run ended, and slowed the next run 50x (2026-10-27).
|
|
144
106
|
process.on('disconnect', () => exit(ExitCode.OK));
|
|
107
|
+
const sendCpu = () => {
|
|
108
|
+
const { user, system } = process.cpuUsage();
|
|
109
|
+
process.send?.({ type: 'cpu', at: Date.now(), us: user + system });
|
|
110
|
+
};
|
|
111
|
+
sendCpu();
|
|
112
|
+
setInterval(sendCpu, CPU_SAMPLE_MS).unref();
|
|
145
113
|
process.send?.({ type: 'ready' });
|
|
146
|
-
//# sourceMappingURL=child.js.map
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* oxmutate's command line, declared once on burgee: help, `--json`, `--schema`, `--format=agent`,
|
|
3
|
-
* completions and the MCP server are projected from this declaration (spec R17). Handlers return
|
|
4
|
-
* data; the text a person reads rides along under burgee's text symbol, so the agent surfaces
|
|
5
|
-
* carry exactly the data.
|
|
6
|
-
*/
|
|
7
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
2
|
import path from 'node:path';
|
|
9
3
|
import { defineCommand, defineProgram, ExitCode } from 'burgee';
|
|
@@ -31,7 +25,6 @@ function survivorRecord(m) {
|
|
|
31
25
|
function summaryData(r, written) {
|
|
32
26
|
const count = (s) => r.mutants.filter((m) => m.status === s).length;
|
|
33
27
|
const data = {
|
|
34
|
-
// burgee N7: an idempotent command says whether it changed anything (reports were written).
|
|
35
28
|
changed: written.length > 0,
|
|
36
29
|
version: VERSION,
|
|
37
30
|
score: Number(r.score.mutationScore.toFixed(2)),
|
|
@@ -50,8 +43,6 @@ function summaryData(r, written) {
|
|
|
50
43
|
data[TEXT] = () => textSummary(r);
|
|
51
44
|
return data;
|
|
52
45
|
}
|
|
53
|
-
/** The `run` flags that override a config key, as the `Partial<Options>` loadOptions applies last. */
|
|
54
|
-
/** How many weakened mutants the error line names; the JSON report has them all. */
|
|
55
46
|
const WEAKENED_SHOWN = 3;
|
|
56
47
|
function overridesFrom(o, root) {
|
|
57
48
|
const overrides = {};
|
|
@@ -162,4 +153,3 @@ export const program = defineProgram({
|
|
|
162
153
|
description: 'Mutation testing on the oxc engine: Stryker-compatible, built for CI and coding agents',
|
|
163
154
|
commands: [runCommand, survivorsCommand],
|
|
164
155
|
});
|
|
165
|
-
//# sourceMappingURL=cli.js.map
|
package/dist/command-runner.js
CHANGED
|
@@ -1,27 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The command runner (Stryker's `testRunner: 'command'`): any test command, one process per
|
|
3
|
-
* mutant, the escape hatch for a suite that is not Vitest. A non-zero exit kills the mutant, a run
|
|
4
|
-
* past its budget is a timeout, and a zero exit is a survivor. There is no coverage, so every
|
|
5
|
-
* mutant runs the whole command, as with Stryker's.
|
|
6
|
-
*
|
|
7
|
-
* An arbitrary command cannot be served sources from memory, so this is the one path with a
|
|
8
|
-
* sandbox: a copy of the project under `<root>/.oxmutate-tmp/`, with the instrumented files
|
|
9
|
-
* written into it. Inside the project, as Stryker's `.stryker-tmp` is, so module resolution walks
|
|
10
|
-
* up to the same `node_modules` (a monorepo's hoisted ones included). The armed mutant reaches the
|
|
11
|
-
* instrumented code through `__OXMUTATE_ACTIVE_MUTANT__`, which the runtime header reads.
|
|
12
|
-
*/
|
|
13
1
|
import { cpSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
2
|
import path from 'node:path';
|
|
15
3
|
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
16
4
|
import { ACTIVE_MUTANT_ENV, env } from './runtime.js';
|
|
17
5
|
const SANDBOX_DIR = '.oxmutate-tmp';
|
|
18
|
-
/** Stryker's default `ignorePatterns`, plus this sandbox. */
|
|
19
6
|
const SKIP = new Set(['node_modules', '.git', 'reports', 'coverage', '.stryker-tmp', SANDBOX_DIR]);
|
|
20
|
-
/**
|
|
21
|
-
* A copy of the project under `<root>/.oxmutate-tmp/`, for the paths that need files on disk (the
|
|
22
|
-
* command runner, the type checker). Inside the project so module resolution walks up to the same
|
|
23
|
-
* `node_modules`; entry by entry, since a directory cannot be copied into itself.
|
|
24
|
-
*/
|
|
25
7
|
export function makeSandbox(root) {
|
|
26
8
|
const parent = path.join(root, SANDBOX_DIR);
|
|
27
9
|
mkdirSync(parent, { recursive: true });
|
|
@@ -32,21 +14,13 @@ export function makeSandbox(root) {
|
|
|
32
14
|
return sandbox;
|
|
33
15
|
}
|
|
34
16
|
const MS_PER_MINUTE = 60_000;
|
|
35
|
-
/** How much of a failing dry run's output the error keeps. */
|
|
36
17
|
const OUTPUT_TAIL = 1500;
|
|
37
|
-
/**
|
|
38
|
-
* The least slack a process-per-mutant run gets: Stryker's own default `timeoutMS`. oxmutate's
|
|
39
|
-
* 500 ms default is sized for a warm in-process run; a fresh process's start-up varies by seconds
|
|
40
|
-
* on a loaded machine, and at 500 ms the e2e fixture read false timeouts (2026-10-27).
|
|
41
|
-
*/
|
|
42
18
|
const PROCESS_SLACK_MS = 5000;
|
|
43
|
-
/** Runs the command over every mutant not already decided; returns the dry run's time. */
|
|
44
19
|
export async function runCommandMutants(o, mutants) {
|
|
45
20
|
const sandbox = makeSandbox(o.root);
|
|
46
21
|
try {
|
|
47
22
|
for (const [file, code] of Object.entries(o.codeMap))
|
|
48
23
|
writeFileSync(path.join(sandbox, path.relative(o.root, file)), code);
|
|
49
|
-
// Stryker splits the command on spaces and runs it without a shell; so does this.
|
|
50
24
|
const [command = '', ...args] = o.command.trim().split(/\s+/);
|
|
51
25
|
const once = (timeout, id) => spawn(command, args, { cwd: sandbox, runtime: ambientRuntime(), timeout, env: id ? { ...env, [ACTIVE_MUTANT_ENV]: id } : { ...env } });
|
|
52
26
|
const dryBudget = o.dryRunTimeoutMinutes * MS_PER_MINUTE;
|
|
@@ -80,4 +54,3 @@ export async function runCommandMutants(o, mutants) {
|
|
|
80
54
|
rmSync(sandbox, { recursive: true, force: true });
|
|
81
55
|
}
|
|
82
56
|
}
|
|
83
|
-
//# sourceMappingURL=command-runner.js.map
|
package/dist/config.js
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Options, read from `stryker.config.*` as it stands (migration is a no-op: the file Stryker reads
|
|
3
|
-
* is the file oxmutate reads) and from `oxmutate.config.*`, which wins where both set a key.
|
|
4
|
-
* Every key Stryker documents that oxmutate does not use is reported, never silently dropped
|
|
5
|
-
* (spec R15).
|
|
6
|
-
*/
|
|
7
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
2
|
import { availableParallelism } from 'node:os';
|
|
9
3
|
import path from 'node:path';
|
|
10
4
|
import { pathToFileURL } from 'node:url';
|
|
11
|
-
import {} from './native.js';
|
|
12
5
|
import { resolveEsm } from './resolve.js';
|
|
13
|
-
import {} from './spec-oracle.js';
|
|
14
6
|
const NOT_NEEDED = {
|
|
15
7
|
inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
|
|
16
8
|
tempDirName: 'no sandbox directory exists',
|
|
@@ -35,10 +27,6 @@ const NOT_NEEDED = {
|
|
|
35
27
|
force: 'use --no-incremental',
|
|
36
28
|
checkerNodeArgs: 'not implemented',
|
|
37
29
|
};
|
|
38
|
-
/**
|
|
39
|
-
* Defaults where the config sets nothing. Stryker's, except the per-mutant slack: Stryker's
|
|
40
|
-
* `timeoutMS` is 5000, and a warm run needs far less (cli.ts `--mutant-timeout`).
|
|
41
|
-
*/
|
|
42
30
|
const MAX_DEFAULT_CONCURRENCY = 8;
|
|
43
31
|
const DEFAULT_TIMEOUT_MS = 500;
|
|
44
32
|
const DEFAULT_TIMEOUT_FACTOR = 1.5;
|
|
@@ -48,7 +36,6 @@ const DEFAULT_DRY_RUN_MINUTES = 5;
|
|
|
48
36
|
const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
|
|
49
37
|
async function loadFile(file) {
|
|
50
38
|
if (file.endsWith('.json')) {
|
|
51
|
-
// Stryker disable next-line StringLiteral: without an encoding readFileSync returns a Buffer, which JSON.parse reads as UTF-8 text all the same
|
|
52
39
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
53
40
|
}
|
|
54
41
|
const mod = (await import(pathToFileURL(file).href));
|
|
@@ -63,17 +50,9 @@ function find(root, base) {
|
|
|
63
50
|
}
|
|
64
51
|
return undefined;
|
|
65
52
|
}
|
|
66
|
-
/** The Vite (and Vite+) config files, in the order Vite and Vitest look for them. */
|
|
67
53
|
const VITE_CONFIGS = ['vite.config', 'vitest.config'].flatMap((base) => ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map((ext) => `${base}${ext}`));
|
|
68
|
-
/**
|
|
69
|
-
* The `mutate` block of the project's Vite config — the Vite+ shape, one file for test, lint, fmt
|
|
70
|
-
* and mutate. Loaded with the project's own Vite (`loadConfigFromFile`), so a TypeScript config and
|
|
71
|
-
* its imports work as Vite sees them. Inside the block the file list is `include`, as Vitest names
|
|
72
|
-
* it; Stryker's `mutate` is accepted too. No Vite installed, or no block: nothing.
|
|
73
|
-
*/
|
|
74
54
|
async function fromViteConfig(root) {
|
|
75
55
|
const file = VITE_CONFIGS.map((f) => path.join(root, f)).find((f) => existsSync(f));
|
|
76
|
-
// Stryker disable next-line ConditionalExpression: without the guard Vite searches the root for the same names, finds none, and returns null, which returns undefined below
|
|
77
56
|
if (!file)
|
|
78
57
|
return undefined;
|
|
79
58
|
let vite;
|
|
@@ -83,14 +62,11 @@ async function fromViteConfig(root) {
|
|
|
83
62
|
catch {
|
|
84
63
|
return undefined;
|
|
85
64
|
}
|
|
86
|
-
// Stryker disable next-line StringLiteral: the log level only decides whether Vite prints, and a config that loads prints nothing at any level
|
|
87
65
|
const level = 'silent';
|
|
88
66
|
const loaded = await vite.loadConfigFromFile({ command: 'serve', mode: 'test' }, file, root, level);
|
|
89
|
-
// Stryker disable next-line OptionalChaining: given a file that exists, Vite returns a config or throws; null is its answer only when it found no file
|
|
90
67
|
const block = loaded?.config['mutate'];
|
|
91
68
|
if (!block || typeof block !== 'object')
|
|
92
69
|
return undefined;
|
|
93
|
-
// `include` is the Vitest spelling; Stryker's `mutate`, when given too, is spread over it.
|
|
94
70
|
const { include, ...rest } = block;
|
|
95
71
|
return { file, block: { mutate: include, ...rest } };
|
|
96
72
|
}
|
|
@@ -140,7 +116,6 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
140
116
|
allowEmpty: raw['allowEmpty'] === true,
|
|
141
117
|
specOracles: raw['specOracles'] ?? [],
|
|
142
118
|
ignore: raw['ignore'] ?? [],
|
|
143
|
-
// Stryker disable next-line ArrayDeclaration: a placeholder element is not 'typescript' either, so includes() is false as for an empty list
|
|
144
119
|
typeCheck: (raw['checkers'] ?? []).includes('typescript'),
|
|
145
120
|
tsconfigFile: raw['tsconfigFile'] ?? 'tsconfig.json',
|
|
146
121
|
typeChecker: typeof raw['typeChecker'] === 'string' ? raw['typeChecker'] : undefined,
|
|
@@ -149,17 +124,14 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
149
124
|
...overrides,
|
|
150
125
|
};
|
|
151
126
|
}
|
|
152
|
-
// One `\s` after next-line is enough: the list's class takes any further whitespace, and is trimmed.
|
|
153
127
|
const DIRECTIVE = /\/\/\s*(?:Stryker|oxmutate)\s+disable\s+next-line\s([\w,\s]+?|all)\s*:\s*(.*)/i;
|
|
154
128
|
const NO_REASON = /\/\/\s*(?:Stryker|oxmutate)\s+disable\s+next-line\s([\w,\s]+?|all)\s*$/i;
|
|
155
|
-
/** Disable directives in a file. A directive without a reason is an error (burgee's own rule). */
|
|
156
129
|
export function disables(source, file) {
|
|
157
130
|
const out = [];
|
|
158
131
|
const errors = [];
|
|
159
132
|
source.split('\n').forEach((line, i) => {
|
|
160
133
|
const m = DIRECTIVE.exec(line);
|
|
161
134
|
if (m) {
|
|
162
|
-
// Stryker disable next-line StringLiteral: both groups take part in every match, so the fallback is never used; it is there for the type
|
|
163
135
|
const [list, reason] = [(m[1] ?? '').trim(), (m[2] ?? '').trim()];
|
|
164
136
|
out.push({ line: i + 2, mutators: list.toLowerCase() === 'all' ? 'all' : list.split(',').map((s) => s.trim()).filter(Boolean), reason });
|
|
165
137
|
}
|
|
@@ -169,4 +141,3 @@ export function disables(source, file) {
|
|
|
169
141
|
});
|
|
170
142
|
return { disables: out, errors };
|
|
171
143
|
}
|
|
172
|
-
//# sourceMappingURL=config.js.map
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -36,10 +36,16 @@ export interface RunStats {
|
|
|
36
36
|
instrumentMs: number;
|
|
37
37
|
dryRunMs: number;
|
|
38
38
|
mutationMs: number;
|
|
39
|
+
/** Static mutants decided in live workers (section 5b), between the warm and the isolated phases. */
|
|
40
|
+
staticMs: number;
|
|
41
|
+
/** Unarmed re-collections in that phase, the guard each live static verdict waits on (runner.ts). */
|
|
42
|
+
staticRecollections: number;
|
|
39
43
|
isolatedMs: number;
|
|
40
44
|
wallMs: number;
|
|
41
45
|
testExecutions: number;
|
|
42
46
|
watchdogKills: number;
|
|
47
|
+
/** Of `watchdogKills`, the stops whose worker was idle, waiting (heartbeat.ts). In an isolated run each is a Timeout as it stands. */
|
|
48
|
+
idleStalls: number;
|
|
43
49
|
crashes: number;
|
|
44
50
|
canaryFiles: number;
|
|
45
51
|
/** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
|