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/dist/runtime.js
CHANGED
|
@@ -1,24 +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;
|
|
6
|
+
export const pid = process.pid;
|
|
21
7
|
export const cwd = () => process.cwd();
|
|
22
8
|
export const stderr = process.stderr;
|
|
23
9
|
export const exit = (code) => process.exit(code);
|
|
24
|
-
//# sourceMappingURL=runtime.js.map
|
package/dist/scheduler.js
CHANGED
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The server pool and its work-stealing queue.
|
|
3
|
-
*
|
|
4
|
-
* A server is a child process holding one warm Vitest (`child.ts`). Work is a queue of units;
|
|
5
|
-
* an idle server takes the next one. A unit whose mutant runs away is killed with its server,
|
|
6
|
-
* its unfinished part is split and put back at the front of the queue for the other servers,
|
|
7
|
-
* and a spare server, already warm, takes the dead one's place. One unlucky test file can no
|
|
8
|
-
* longer serialize a run behind its runaways (es-toolkit, 2026-10-26: 12 runaways in one static
|
|
9
|
-
* shard set the whole mutation phase at 42.9 s while the other shards finished in 8 s).
|
|
10
|
-
*/
|
|
11
1
|
import { fork } from 'node:child_process';
|
|
12
2
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
3
|
import path from 'node:path';
|
|
14
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
|
|
6
|
+
import { env, stderr } from './runtime.js';
|
|
15
7
|
const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
|
|
16
|
-
/** The tail of a server's stderr kept for an error message. */
|
|
17
8
|
const STDERR_TAIL = 4000;
|
|
18
|
-
/** How often the watchdog reads a running unit's progress line. */
|
|
19
9
|
const WATCHDOG_POLL_MS = 50;
|
|
20
|
-
/** How long a server asked to close gets before it is killed (it gives itself the same). */
|
|
21
10
|
const CLOSE_GRACE_MS = 2000;
|
|
22
|
-
/** The mutant a unit's progress file says is running, if any. */
|
|
23
11
|
function inFlight(progress) {
|
|
24
12
|
try {
|
|
25
13
|
return readFileSync(progress, 'utf8').trim().split(' ')[0] || undefined;
|
|
@@ -28,15 +16,20 @@ function inFlight(progress) {
|
|
|
28
16
|
return undefined;
|
|
29
17
|
}
|
|
30
18
|
}
|
|
19
|
+
const PERCENT = 100;
|
|
20
|
+
const CPU_SAMPLES_KEPT = 8;
|
|
31
21
|
class Server {
|
|
32
22
|
child;
|
|
33
23
|
ready;
|
|
34
24
|
stderr = '';
|
|
25
|
+
beat;
|
|
26
|
+
cpu = [];
|
|
35
27
|
onMessage;
|
|
36
28
|
onExit;
|
|
37
29
|
exited = false;
|
|
38
|
-
constructor(configFile, cwd, execArgv) {
|
|
30
|
+
constructor(configFile, cwd, execArgv, workDir) {
|
|
39
31
|
this.child = fork(childJs, [configFile], { cwd, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv });
|
|
32
|
+
this.beat = beatFile(workDir, this.child.pid);
|
|
40
33
|
this.child.stderr?.on('data', (d) => (this.stderr = (this.stderr + d.toString()).slice(-STDERR_TAIL)));
|
|
41
34
|
this.ready = new Promise((resolve) => {
|
|
42
35
|
const first = (m) => {
|
|
@@ -48,7 +41,15 @@ class Server {
|
|
|
48
41
|
this.child.on('message', first);
|
|
49
42
|
this.child.once('exit', () => resolve(false));
|
|
50
43
|
});
|
|
51
|
-
this.child.on('message', (m) =>
|
|
44
|
+
this.child.on('message', (m) => {
|
|
45
|
+
if (m.type === 'cpu') {
|
|
46
|
+
this.cpu.push({ at: m.at ?? 0, us: m.us ?? 0 });
|
|
47
|
+
if (this.cpu.length > CPU_SAMPLES_KEPT)
|
|
48
|
+
this.cpu.shift();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
this.onMessage?.(m);
|
|
52
|
+
});
|
|
52
53
|
this.child.once('exit', (code) => {
|
|
53
54
|
this.exited = true;
|
|
54
55
|
this.onExit?.(code);
|
|
@@ -78,9 +79,17 @@ class Server {
|
|
|
78
79
|
if (!line)
|
|
79
80
|
return;
|
|
80
81
|
const [id = '', since = '0'] = line.split(' ');
|
|
81
|
-
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (now - Number(since) > budget(id)) {
|
|
84
|
+
const beat = readBeat(this.beat, now);
|
|
85
|
+
const share = cpuShare(this.cpu, now);
|
|
86
|
+
const stall = classifyStall({ beat, cpuShare: share });
|
|
87
|
+
if (env['OXMUTATE_DEBUG_WATCHDOG']) {
|
|
88
|
+
const cpu = share === undefined ? '?' : `${String(Math.round(share * PERCENT))}%`;
|
|
89
|
+
stderr.write(`oxmutate watchdog: stop ${id} after ${String(now - Number(since))} ms; ${stall}; beat ${Number.isFinite(beat.age) ? `${String(beat.age)} ms ago` : 'never'}${beat.inTest ? ' in a test' : ''}; cpu ${cpu} over ${String(CPU_WINDOW_MS)} ms\n`);
|
|
90
|
+
}
|
|
82
91
|
this.child.kill('SIGKILL');
|
|
83
|
-
finish({ killedFor: id, stderr: this.stderr });
|
|
92
|
+
finish({ killedFor: id, stall, stderr: this.stderr });
|
|
84
93
|
}
|
|
85
94
|
}, WATCHDOG_POLL_MS);
|
|
86
95
|
this.onMessage = (m) => {
|
|
@@ -104,13 +113,21 @@ class Server {
|
|
|
104
113
|
export function createPool(config, size, workDir, tag) {
|
|
105
114
|
const configFile = path.join(workDir, `server-${tag}.json`);
|
|
106
115
|
writeFileSync(configFile, JSON.stringify(config));
|
|
107
|
-
const stats = { spawned: 0, killed: 0, crashed: 0 };
|
|
116
|
+
const stats = { spawned: 0, killed: 0, idle: 0, crashed: 0 };
|
|
108
117
|
const spawn = () => {
|
|
109
118
|
stats.spawned++;
|
|
110
|
-
return new Server(configFile, config.root, config.nodeArgs ?? []);
|
|
119
|
+
return new Server(configFile, config.root, config.nodeArgs ?? [], workDir);
|
|
111
120
|
};
|
|
112
121
|
const servers = Array.from({ length: size }, spawn);
|
|
113
122
|
let spare = spawn();
|
|
123
|
+
const tally = (outcome) => {
|
|
124
|
+
if (outcome.killedFor)
|
|
125
|
+
stats.killed++;
|
|
126
|
+
if (outcome.stall === 'idle')
|
|
127
|
+
stats.idle++;
|
|
128
|
+
if (outcome.crashed)
|
|
129
|
+
stats.crashed++;
|
|
130
|
+
};
|
|
114
131
|
const replacement = () => {
|
|
115
132
|
const next = spare ?? spawn();
|
|
116
133
|
spare = spawn();
|
|
@@ -126,7 +143,6 @@ export function createPool(config, size, workDir, tag) {
|
|
|
126
143
|
for (const w of waiters.splice(0))
|
|
127
144
|
w();
|
|
128
145
|
};
|
|
129
|
-
/** The next unit `prepare` keeps, or undefined when the queue is empty. */
|
|
130
146
|
const take = () => {
|
|
131
147
|
for (let unit = queue.shift(); unit; unit = queue.shift())
|
|
132
148
|
if (!prepare || prepare(unit))
|
|
@@ -152,10 +168,7 @@ export function createPool(config, size, workDir, tag) {
|
|
|
152
168
|
await server.ready;
|
|
153
169
|
}
|
|
154
170
|
const outcome = await server.run(unit, (id) => budget(unit, id));
|
|
155
|
-
|
|
156
|
-
stats.killed++;
|
|
157
|
-
if (outcome.crashed)
|
|
158
|
-
stats.crashed++;
|
|
171
|
+
tally(outcome);
|
|
159
172
|
if (outcome.killedFor || outcome.crashed) {
|
|
160
173
|
servers[slot] = replacement();
|
|
161
174
|
}
|
|
@@ -174,4 +187,3 @@ export function createPool(config, size, workDir, tag) {
|
|
|
174
187
|
},
|
|
175
188
|
};
|
|
176
189
|
}
|
|
177
|
-
//# sourceMappingURL=scheduler.js.map
|
package/dist/spec-oracle.js
CHANGED
|
@@ -1,35 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Spec oracles (roadmap M3.5): a formal model's traces, replayed against the code with each mutant
|
|
3
|
-
* armed, as a second and independent verdict beside the tests.
|
|
4
|
-
*
|
|
5
|
-
* The contract is the trace, not the method. A backend's only job is to leave `*.itf.json` files
|
|
6
|
-
* (the Informal Trace Format) in a directory:
|
|
7
|
-
*
|
|
8
|
-
* - `quint`: `quint run --mbt` simulates the model and writes the traces, with each step's action
|
|
9
|
-
* and nondeterministic picks, so a replay test knows what to call.
|
|
10
|
-
* - `itf`: a directory some other tool already filled (Apalache writes ITF too; so can a
|
|
11
|
-
* property-based generator). This is also how a test pins its traces.
|
|
12
|
-
*
|
|
13
|
-
* The project's replay tests (spec.ts) turn a trace into calls and assertions. Which mutants a
|
|
14
|
-
* replay kills is reported per oracle as the F metrics of metrics.md: spec-kill share (F1), the
|
|
15
|
-
* test-spec gap (F2: the spec kills, the tests miss, with the trace that did it), spec escapes
|
|
16
|
-
* (F3), spec cost per mutant (F4) and mapping coverage (F6).
|
|
17
|
-
*/
|
|
18
1
|
import { mkdirSync } from 'node:fs';
|
|
19
2
|
import path from 'node:path';
|
|
20
3
|
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
21
4
|
const DEFAULT_TRACES = 100;
|
|
22
5
|
const DEFAULT_STEPS = 20;
|
|
23
6
|
const DEFAULT_QUINT = 'npx -y @informalsystems/quint@0.33.0';
|
|
24
|
-
/** How much of a failing quint run's output the error keeps. */
|
|
25
7
|
const OUTPUT_TAIL = 1000;
|
|
26
|
-
/** The oracle's traces on disk: generated into `work` for `quint`, the given directory for `itf`. */
|
|
27
8
|
export async function tracesFor(oracle, root, work) {
|
|
28
9
|
if (oracle.backend === 'itf')
|
|
29
10
|
return path.resolve(root, oracle.dir ?? '');
|
|
30
11
|
const dir = path.join(work, `spec-${oracle.name}`);
|
|
31
12
|
mkdirSync(dir, { recursive: true });
|
|
32
|
-
// Stryker disable next-line StringLiteral: split always returns at least one element, so the default is never taken; it is there for the type
|
|
33
13
|
const [command = '', ...prefix] = (oracle.quint ?? DEFAULT_QUINT).split(/\s+/);
|
|
34
14
|
const n = String(oracle.traces ?? DEFAULT_TRACES);
|
|
35
15
|
const args = [...prefix, 'run', path.resolve(root, oracle.model ?? ''), '--mbt', '--n-traces', n, '--max-samples', n, '--max-steps', String(oracle.maxSteps ?? DEFAULT_STEPS), '--out-itf', path.join(dir, 't{seq}.itf.json')];
|
|
@@ -45,7 +25,6 @@ const share = (n, of) => (of ? (PERCENT * n) / of : 0);
|
|
|
45
25
|
const located = ({ id, file, line, mutatorName, replacement }) => ({ id, file, line, mutatorName, replacement });
|
|
46
26
|
const median = (xs) => {
|
|
47
27
|
const sorted = [...xs].sort((a, b) => a - b);
|
|
48
|
-
// Stryker disable next-line NumericLiteral: guarded by `sorted.length`, so the fallback never runs
|
|
49
28
|
return sorted.length ? (sorted[Math.floor(sorted.length / 2)] ?? 0) : 0;
|
|
50
29
|
};
|
|
51
30
|
const testsMiss = (m) => m.status === 'Survived' || m.status === 'NoCoverage';
|
|
@@ -60,10 +39,8 @@ export function summarizeSpec({ oracle, modelledFiles, traces, mutants, testName
|
|
|
60
39
|
modelled: here.length,
|
|
61
40
|
mappingCoverage: share(reached.length, here.length),
|
|
62
41
|
specKillShare: share(killed.length, here.length),
|
|
63
|
-
// Stryker disable next-line OptionalChaining: every mutant in `killed` has spec.status Killed, so spec is defined; `?.` is only for the type
|
|
64
42
|
testSpecGap: killed.filter(testsMiss).map((m) => ({ ...located(m), trace: testName(m.spec?.killedBy?.[0] ?? '') })),
|
|
65
43
|
specEscapes: here.filter((m) => testsMiss(m) && m.spec?.status !== 'Killed').map(located),
|
|
66
44
|
msPerSpecExecution: median(specMs),
|
|
67
45
|
};
|
|
68
46
|
}
|
|
69
|
-
//# sourceMappingURL=spec-oracle.js.map
|
package/dist/spec.js
CHANGED
|
@@ -1,31 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `oxmutate/spec`: what a replay test imports to drive the code under test through a formal
|
|
3
|
-
* model's traces.
|
|
4
|
-
*
|
|
5
|
-
* A spec oracle (spec-oracle.ts) asks a backend for traces of a model: Quint's `quint run --mbt`,
|
|
6
|
-
* or a directory of ITF files any tool wrote (Apalache emits the same format). A replay test,
|
|
7
|
-
* written once per modelled component, takes each trace, performs each step's action on the real
|
|
8
|
-
* code, and asserts the model's state. Under oxmutate those tests run with each mutant armed, on a
|
|
9
|
-
* verdict track of their own: a mutant a replay test catches is `killedBySpec`, whatever the
|
|
10
|
-
* ordinary tests say about it.
|
|
11
|
-
*
|
|
12
|
-
* import { loadTraces } from 'oxmutate/spec';
|
|
13
|
-
* const traces = loadTraces('save-restore');
|
|
14
|
-
* it.skipIf(traces.length === 0).each(traces)('replays $name', (trace) => { … });
|
|
15
|
-
*
|
|
16
|
-
* Outside an oxmutate run there are no traces, so the replay tests skip.
|
|
17
|
-
*/
|
|
18
1
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
19
2
|
import path from 'node:path';
|
|
20
3
|
import { env } from './runtime.js';
|
|
21
|
-
/** JSON map of oracle name → directory of `*.itf.json` traces, set by oxmutate for its servers. */
|
|
22
4
|
export const SPEC_TRACES_ENV = 'OXMUTATE_SPEC_TRACES';
|
|
23
5
|
const META = new Set(['#meta', 'mbt::actionTaken', 'mbt::nondetPicks']);
|
|
24
|
-
/**
|
|
25
|
-
* ITF values as plain JavaScript: `#bigint` as a number (a bigint past 2^53), `#set` and `#tup`
|
|
26
|
-
* as arrays, `#map` as a Map. A `JSON.parse` reviver, so it runs bottom-up with no recursion of
|
|
27
|
-
* its own: children are already decoded when their parent is.
|
|
28
|
-
*/
|
|
29
6
|
function revive(_key, v) {
|
|
30
7
|
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
31
8
|
return v;
|
|
@@ -42,7 +19,6 @@ function revive(_key, v) {
|
|
|
42
19
|
return new Map(o['#map']);
|
|
43
20
|
return v;
|
|
44
21
|
}
|
|
45
|
-
/** One ITF value, decoded as in a trace file. */
|
|
46
22
|
export const decode = (v) => JSON.parse(JSON.stringify(v), revive);
|
|
47
23
|
const pick = (v) => {
|
|
48
24
|
if (!v || typeof v !== 'object' || !('tag' in v))
|
|
@@ -50,9 +26,7 @@ const pick = (v) => {
|
|
|
50
26
|
const variant = v;
|
|
51
27
|
return variant.tag === 'Some' ? variant.value : undefined;
|
|
52
28
|
};
|
|
53
|
-
/** One ITF file as steps. */
|
|
54
29
|
export function readTrace(file) {
|
|
55
|
-
// Stryker disable next-line StringLiteral: without an encoding readFileSync returns a Buffer, which JSON.parse reads as UTF-8 text all the same
|
|
56
30
|
const itf = JSON.parse(readFileSync(file, 'utf8'), revive);
|
|
57
31
|
const steps = itf.states.map((s) => ({
|
|
58
32
|
action: String(s['mbt::actionTaken'] ?? ''),
|
|
@@ -61,7 +35,6 @@ export function readTrace(file) {
|
|
|
61
35
|
}));
|
|
62
36
|
return { name: path.basename(file), steps };
|
|
63
37
|
}
|
|
64
|
-
/** The traces oxmutate generated for this oracle, in file order; none outside an oxmutate run. */
|
|
65
38
|
export function loadTraces(oracle) {
|
|
66
39
|
const dirs = env[SPEC_TRACES_ENV] ? JSON.parse(env[SPEC_TRACES_ENV]) : {};
|
|
67
40
|
const dir = dirs[oracle];
|
|
@@ -72,4 +45,3 @@ export function loadTraces(oracle) {
|
|
|
72
45
|
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
|
|
73
46
|
.map((f) => readTrace(path.join(dir, f)));
|
|
74
47
|
}
|
|
75
|
-
//# sourceMappingURL=spec.js.map
|
package/dist/vite-plugin.js
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serves instrumented sources to Vitest from memory, in place of the files on disk. No sandbox
|
|
3
|
-
* copy: the project is run where it is, and only the mutated files' contents differ.
|
|
4
|
-
*/
|
|
5
1
|
import { readFileSync } from 'node:fs';
|
|
6
2
|
import path from 'node:path';
|
|
7
3
|
export function instrumentedSources(mapPath) {
|
|
@@ -15,4 +11,3 @@ export function instrumentedSources(mapPath) {
|
|
|
15
11
|
},
|
|
16
12
|
};
|
|
17
13
|
}
|
|
18
|
-
//# sourceMappingURL=vite-plugin.js.map
|
package/dist/vite.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"!dist/**/*.test.*"
|
|
65
65
|
],
|
|
66
66
|
"scripts": {
|
|
67
|
-
"build": "tsc -p tsconfig.build.json && node ../../scripts/declaration-reach.mts --prune",
|
|
67
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/strip-comments.mjs dist && node ../../scripts/declaration-reach.mts --prune",
|
|
68
68
|
"build:native": "cd native && cargo build --release --features napi --lib && node ../scripts/copy-binding.mjs",
|
|
69
69
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
70
70
|
"test": "vitest run && vitest run --config compat/vitest.config.ts",
|