oxmutate 0.0.0 → 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.
- package/LICENSE +21 -0
- package/README.md +376 -4
- package/binding/oxmutate.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.linux-arm64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-musl.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/bin.js +5 -0
- package/dist/checker.js +122 -0
- package/dist/child.js +125 -0
- package/dist/cli.js +165 -0
- package/dist/command-runner.js +83 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.js +172 -0
- package/dist/coordinator.d.ts +83 -0
- package/dist/coordinator.js +653 -0
- package/dist/files.js +62 -0
- package/dist/frozen.d.ts +19 -0
- package/dist/frozen.js +47 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lanes.d.ts +38 -0
- package/dist/lanes.js +92 -0
- package/dist/native.d.ts +36 -0
- package/dist/native.js +40 -0
- package/dist/platform.js +27 -0
- package/dist/progress.js +40 -0
- package/dist/reports.d.ts +20 -0
- package/dist/reports.js +225 -0
- package/dist/resolve.js +43 -0
- package/dist/runner.d.ts +49 -0
- package/dist/runner.js +270 -0
- package/dist/runtime.js +24 -0
- package/dist/scheduler.js +170 -0
- package/dist/spec-oracle.d.ts +69 -0
- package/dist/spec-oracle.js +68 -0
- package/dist/spec.d.ts +21 -0
- package/dist/spec.js +75 -0
- package/dist/vite-plugin.js +18 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +3 -0
- package/package.json +81 -6
- package/index.js +0 -1
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
import { fork } from 'node:child_process';
|
|
12
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
|
|
16
|
+
/** The tail of a server's stderr kept for an error message. */
|
|
17
|
+
const STDERR_TAIL = 4000;
|
|
18
|
+
/** How often the watchdog reads a running unit's progress line. */
|
|
19
|
+
const WATCHDOG_POLL_MS = 50;
|
|
20
|
+
/** How long a server asked to close gets before it is killed (it gives itself the same). */
|
|
21
|
+
const CLOSE_GRACE_MS = 2000;
|
|
22
|
+
/** The mutant a unit's progress file says is running, if any. */
|
|
23
|
+
function inFlight(progress) {
|
|
24
|
+
try {
|
|
25
|
+
return readFileSync(progress, 'utf8').trim().split(' ')[0] || undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class Server {
|
|
32
|
+
child;
|
|
33
|
+
ready;
|
|
34
|
+
stderr = '';
|
|
35
|
+
onMessage;
|
|
36
|
+
onExit;
|
|
37
|
+
exited = false;
|
|
38
|
+
constructor(configFile, cwd, execArgv) {
|
|
39
|
+
this.child = fork(childJs, [configFile], { cwd, stdio: ['ignore', 'ignore', 'pipe', 'ipc'], execArgv });
|
|
40
|
+
this.child.stderr?.on('data', (d) => (this.stderr = (this.stderr + d.toString()).slice(-STDERR_TAIL)));
|
|
41
|
+
this.ready = new Promise((resolve) => {
|
|
42
|
+
const first = (m) => {
|
|
43
|
+
if (m.type === 'ready') {
|
|
44
|
+
this.child.off('message', first);
|
|
45
|
+
resolve(true);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
this.child.on('message', first);
|
|
49
|
+
this.child.once('exit', () => resolve(false));
|
|
50
|
+
});
|
|
51
|
+
this.child.on('message', (m) => this.onMessage?.(m));
|
|
52
|
+
this.child.once('exit', (code) => {
|
|
53
|
+
this.exited = true;
|
|
54
|
+
this.onExit?.(code);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
run(unit, budget) {
|
|
58
|
+
writeFileSync(unit.progress, '');
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let settled = false;
|
|
61
|
+
const finish = (o) => {
|
|
62
|
+
if (settled)
|
|
63
|
+
return;
|
|
64
|
+
settled = true;
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
this.onMessage = undefined;
|
|
67
|
+
this.onExit = undefined;
|
|
68
|
+
resolve(o);
|
|
69
|
+
};
|
|
70
|
+
const timer = setInterval(() => {
|
|
71
|
+
let line = '';
|
|
72
|
+
try {
|
|
73
|
+
line = readFileSync(unit.progress, 'utf8').trim();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!line)
|
|
79
|
+
return;
|
|
80
|
+
const [id = '', since = '0'] = line.split(' ');
|
|
81
|
+
if (Date.now() - Number(since) > budget(id)) {
|
|
82
|
+
this.child.kill('SIGKILL');
|
|
83
|
+
finish({ killedFor: id, stderr: this.stderr });
|
|
84
|
+
}
|
|
85
|
+
}, WATCHDOG_POLL_MS);
|
|
86
|
+
this.onMessage = (m) => {
|
|
87
|
+
if (m.type === 'done')
|
|
88
|
+
finish({ stderr: this.stderr });
|
|
89
|
+
};
|
|
90
|
+
this.onExit = () => finish({ crashed: true, crashedFor: inFlight(unit.progress), stderr: this.stderr });
|
|
91
|
+
if (this.exited)
|
|
92
|
+
finish({ crashed: true, stderr: this.stderr });
|
|
93
|
+
else
|
|
94
|
+
this.child.send({ type: 'run', unit });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
close() {
|
|
98
|
+
if (this.exited)
|
|
99
|
+
return;
|
|
100
|
+
this.child.send({ type: 'close' });
|
|
101
|
+
setTimeout(() => this.child.kill('SIGKILL'), CLOSE_GRACE_MS).unref();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function createPool(config, size, workDir, tag) {
|
|
105
|
+
const configFile = path.join(workDir, `server-${tag}.json`);
|
|
106
|
+
writeFileSync(configFile, JSON.stringify(config));
|
|
107
|
+
const stats = { spawned: 0, killed: 0, crashed: 0 };
|
|
108
|
+
const spawn = () => {
|
|
109
|
+
stats.spawned++;
|
|
110
|
+
return new Server(configFile, config.root, config.nodeArgs ?? []);
|
|
111
|
+
};
|
|
112
|
+
const servers = Array.from({ length: size }, spawn);
|
|
113
|
+
let spare = spawn();
|
|
114
|
+
const replacement = () => {
|
|
115
|
+
const next = spare ?? spawn();
|
|
116
|
+
spare = spawn();
|
|
117
|
+
return next;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
stats,
|
|
121
|
+
async drain(units, budget, after) {
|
|
122
|
+
const queue = [...units];
|
|
123
|
+
let busy = 0;
|
|
124
|
+
const waiters = [];
|
|
125
|
+
const signal = () => {
|
|
126
|
+
for (const w of waiters.splice(0))
|
|
127
|
+
w();
|
|
128
|
+
};
|
|
129
|
+
await Promise.all(servers.map(async (_, slot) => {
|
|
130
|
+
for (;;) {
|
|
131
|
+
const unit = queue.shift();
|
|
132
|
+
if (!unit) {
|
|
133
|
+
if (busy === 0) {
|
|
134
|
+
signal();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await new Promise((r) => waiters.push(r));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
busy++;
|
|
141
|
+
let server = servers[slot];
|
|
142
|
+
if (!(await server.ready)) {
|
|
143
|
+
server = replacement();
|
|
144
|
+
servers[slot] = server;
|
|
145
|
+
await server.ready;
|
|
146
|
+
}
|
|
147
|
+
const outcome = await server.run(unit, (id) => budget(unit, id));
|
|
148
|
+
if (outcome.killedFor)
|
|
149
|
+
stats.killed++;
|
|
150
|
+
if (outcome.crashed)
|
|
151
|
+
stats.crashed++;
|
|
152
|
+
if (outcome.killedFor || outcome.crashed) {
|
|
153
|
+
servers[slot] = replacement();
|
|
154
|
+
}
|
|
155
|
+
const more = after(unit, outcome);
|
|
156
|
+
queue.unshift(...more);
|
|
157
|
+
busy--;
|
|
158
|
+
signal();
|
|
159
|
+
}
|
|
160
|
+
}));
|
|
161
|
+
},
|
|
162
|
+
close() {
|
|
163
|
+
for (const s of servers)
|
|
164
|
+
s.close();
|
|
165
|
+
spare?.close();
|
|
166
|
+
spare = undefined;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface SpecOracle {
|
|
2
|
+
name: string;
|
|
3
|
+
backend: 'quint' | 'itf';
|
|
4
|
+
/** `quint`: the model file, relative to the project root. */
|
|
5
|
+
model?: string | undefined;
|
|
6
|
+
/** `quint`: traces to generate (default 100), steps per trace (default 20), and a seed. */
|
|
7
|
+
traces?: number | undefined;
|
|
8
|
+
maxSteps?: number | undefined;
|
|
9
|
+
seed?: string | undefined;
|
|
10
|
+
/** `quint`: how to run it (default the version burgee's models.yml pins, through npx). */
|
|
11
|
+
quint?: string | undefined;
|
|
12
|
+
/** `itf`: the directory of traces, relative to the project root. */
|
|
13
|
+
dir?: string | undefined;
|
|
14
|
+
/** The replay test files (globs, relative to the root): their tests are this oracle's. */
|
|
15
|
+
replay: string[];
|
|
16
|
+
/** The source files the model describes (globs): the denominator of F1, F3 and F6. */
|
|
17
|
+
files: string[];
|
|
18
|
+
}
|
|
19
|
+
/** The oracle's traces on disk: generated into `work` for `quint`, the given directory for `itf`. */
|
|
20
|
+
export declare function tracesFor(oracle: SpecOracle, root: string, work: string): Promise<string>;
|
|
21
|
+
interface Located {
|
|
22
|
+
id: string;
|
|
23
|
+
file: string;
|
|
24
|
+
line: number;
|
|
25
|
+
mutatorName: string;
|
|
26
|
+
replacement: string;
|
|
27
|
+
}
|
|
28
|
+
/** One oracle's F metrics (metrics.md §6), over the mutants in the files its model describes. */
|
|
29
|
+
export interface SpecSummary {
|
|
30
|
+
name: string;
|
|
31
|
+
backend: SpecOracle['backend'];
|
|
32
|
+
traces: number;
|
|
33
|
+
/** Mutants in the modelled files that are scored (not ignored). */
|
|
34
|
+
modelled: number;
|
|
35
|
+
/** F6: share of those a replay test reaches. */
|
|
36
|
+
mappingCoverage: number;
|
|
37
|
+
/** F1: share of those the spec kills. */
|
|
38
|
+
specKillShare: number;
|
|
39
|
+
/** F2: the spec kills them, the tests do not — each a missing test, with the trace that found it. */
|
|
40
|
+
testSpecGap: (Located & {
|
|
41
|
+
trace: string;
|
|
42
|
+
})[];
|
|
43
|
+
/** F3: neither kills them — gaps in the model itself, or equivalent mutants. */
|
|
44
|
+
specEscapes: Located[];
|
|
45
|
+
/** F4: median ms of one replay execution with a mutant armed. */
|
|
46
|
+
msPerSpecExecution: number;
|
|
47
|
+
}
|
|
48
|
+
export interface SpecMutant extends Located {
|
|
49
|
+
status: string;
|
|
50
|
+
specCoveredBy?: string[] | undefined;
|
|
51
|
+
spec?: {
|
|
52
|
+
oracle: string;
|
|
53
|
+
status: string;
|
|
54
|
+
killedBy?: string[] | undefined;
|
|
55
|
+
} | undefined;
|
|
56
|
+
}
|
|
57
|
+
export interface SpecInput {
|
|
58
|
+
oracle: SpecOracle;
|
|
59
|
+
/** Repository-relative paths of the files the model describes. */
|
|
60
|
+
modelledFiles: Set<string>;
|
|
61
|
+
traces: number;
|
|
62
|
+
mutants: SpecMutant[];
|
|
63
|
+
/** A replay test's name (it names the trace) from its id. */
|
|
64
|
+
testName: (id: string) => string;
|
|
65
|
+
/** Each replay execution's ms, mutant armed. */
|
|
66
|
+
specMs: number[];
|
|
67
|
+
}
|
|
68
|
+
export declare function summarizeSpec({ oracle, modelledFiles, traces, mutants, testName, specMs }: SpecInput): SpecSummary;
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
import { mkdirSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
21
|
+
const DEFAULT_TRACES = 100;
|
|
22
|
+
const DEFAULT_STEPS = 20;
|
|
23
|
+
const DEFAULT_QUINT = 'npx -y @informalsystems/quint@0.33.0';
|
|
24
|
+
/** How much of a failing quint run's output the error keeps. */
|
|
25
|
+
const OUTPUT_TAIL = 1000;
|
|
26
|
+
/** The oracle's traces on disk: generated into `work` for `quint`, the given directory for `itf`. */
|
|
27
|
+
export async function tracesFor(oracle, root, work) {
|
|
28
|
+
if (oracle.backend === 'itf')
|
|
29
|
+
return path.resolve(root, oracle.dir ?? '');
|
|
30
|
+
const dir = path.join(work, `spec-${oracle.name}`);
|
|
31
|
+
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
|
+
const [command = '', ...prefix] = (oracle.quint ?? DEFAULT_QUINT).split(/\s+/);
|
|
34
|
+
const n = String(oracle.traces ?? DEFAULT_TRACES);
|
|
35
|
+
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')];
|
|
36
|
+
if (oracle.seed)
|
|
37
|
+
args.push('--seed', oracle.seed);
|
|
38
|
+
const r = await spawn(command, args, { cwd: root, runtime: ambientRuntime() });
|
|
39
|
+
if (!r.ok)
|
|
40
|
+
throw new Error(`spec oracle ${oracle.name}: quint run exited ${String(r.code)}: ${(r.stderr || r.stdout).trim().slice(-OUTPUT_TAIL)}. fix: run the same quint command by hand on ${oracle.model ?? '(no model)'}`);
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
const PERCENT = 100;
|
|
44
|
+
const share = (n, of) => (of ? (PERCENT * n) / of : 0);
|
|
45
|
+
const located = ({ id, file, line, mutatorName, replacement }) => ({ id, file, line, mutatorName, replacement });
|
|
46
|
+
const median = (xs) => {
|
|
47
|
+
const sorted = [...xs].sort((a, b) => a - b);
|
|
48
|
+
return sorted.length ? (sorted[Math.floor(sorted.length / 2)] ?? 0) : 0;
|
|
49
|
+
};
|
|
50
|
+
const testsMiss = (m) => m.status === 'Survived' || m.status === 'NoCoverage';
|
|
51
|
+
export function summarizeSpec({ oracle, modelledFiles, traces, mutants, testName, specMs }) {
|
|
52
|
+
const here = mutants.filter((m) => modelledFiles.has(m.file) && m.status !== 'Ignored');
|
|
53
|
+
const reached = here.filter((m) => m.specCoveredBy?.length);
|
|
54
|
+
const killed = here.filter((m) => m.spec?.status === 'Killed');
|
|
55
|
+
return {
|
|
56
|
+
name: oracle.name,
|
|
57
|
+
backend: oracle.backend,
|
|
58
|
+
traces,
|
|
59
|
+
modelled: here.length,
|
|
60
|
+
mappingCoverage: share(reached.length, here.length),
|
|
61
|
+
specKillShare: share(killed.length, here.length),
|
|
62
|
+
// Stryker disable next-line OptionalChaining: every mutant in `killed` has spec.status Killed, so spec is defined; `?.` is only for the type
|
|
63
|
+
testSpecGap: killed.filter(testsMiss).map((m) => ({ ...located(m), trace: testName(m.spec?.killedBy?.[0] ?? '') })),
|
|
64
|
+
specEscapes: here.filter((m) => testsMiss(m) && m.spec?.status !== 'Killed').map(located),
|
|
65
|
+
msPerSpecExecution: median(specMs),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=spec-oracle.js.map
|
package/dist/spec.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** JSON map of oracle name → directory of `*.itf.json` traces, set by oxmutate for its servers. */
|
|
2
|
+
export declare const SPEC_TRACES_ENV = "OXMUTATE_SPEC_TRACES";
|
|
3
|
+
export interface Step {
|
|
4
|
+
/** The model action that produced this state (`mbt::actionTaken`); `init` for the first. */
|
|
5
|
+
action: string;
|
|
6
|
+
/** The nondeterministic choices the action made (`mbt::nondetPicks`), `None` as undefined. */
|
|
7
|
+
picks: Record<string, unknown>;
|
|
8
|
+
/** The model's variables after the action, ITF values decoded. */
|
|
9
|
+
state: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export interface Trace {
|
|
12
|
+
/** The trace file's name, which the report quotes as the counterexample. */
|
|
13
|
+
name: string;
|
|
14
|
+
steps: Step[];
|
|
15
|
+
}
|
|
16
|
+
/** One ITF value, decoded as in a trace file. */
|
|
17
|
+
export declare const decode: (v: unknown) => unknown;
|
|
18
|
+
/** One ITF file as steps. */
|
|
19
|
+
export declare function readTrace(file: string): Trace;
|
|
20
|
+
/** The traces oxmutate generated for this oracle, in file order; none outside an oxmutate run. */
|
|
21
|
+
export declare function loadTraces(oracle: string): Trace[];
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { env } from './runtime.js';
|
|
21
|
+
/** JSON map of oracle name → directory of `*.itf.json` traces, set by oxmutate for its servers. */
|
|
22
|
+
export const SPEC_TRACES_ENV = 'OXMUTATE_SPEC_TRACES';
|
|
23
|
+
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
|
+
function revive(_key, v) {
|
|
30
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
31
|
+
return v;
|
|
32
|
+
const o = v;
|
|
33
|
+
if ('#bigint' in o) {
|
|
34
|
+
const n = BigInt(String(o['#bigint']));
|
|
35
|
+
return n <= BigInt(Number.MAX_SAFE_INTEGER) && n >= BigInt(Number.MIN_SAFE_INTEGER) ? Number(n) : n;
|
|
36
|
+
}
|
|
37
|
+
if ('#set' in o)
|
|
38
|
+
return o['#set'];
|
|
39
|
+
if ('#tup' in o)
|
|
40
|
+
return o['#tup'];
|
|
41
|
+
if ('#map' in o)
|
|
42
|
+
return new Map(o['#map']);
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
/** One ITF value, decoded as in a trace file. */
|
|
46
|
+
export const decode = (v) => JSON.parse(JSON.stringify(v), revive);
|
|
47
|
+
const pick = (v) => {
|
|
48
|
+
if (!v || typeof v !== 'object' || !('tag' in v))
|
|
49
|
+
return v;
|
|
50
|
+
const variant = v;
|
|
51
|
+
return variant.tag === 'Some' ? variant.value : undefined;
|
|
52
|
+
};
|
|
53
|
+
/** One ITF file as steps. */
|
|
54
|
+
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
|
+
const itf = JSON.parse(readFileSync(file, 'utf8'), revive);
|
|
57
|
+
const steps = itf.states.map((s) => ({
|
|
58
|
+
action: String(s['mbt::actionTaken'] ?? ''),
|
|
59
|
+
picks: Object.fromEntries(Object.entries((s['mbt::nondetPicks'] ?? {})).map(([n, p]) => [n, pick(p)])),
|
|
60
|
+
state: Object.fromEntries(Object.entries(s).filter(([k]) => !META.has(k))),
|
|
61
|
+
}));
|
|
62
|
+
return { name: path.basename(file), steps };
|
|
63
|
+
}
|
|
64
|
+
/** The traces oxmutate generated for this oracle, in file order; none outside an oxmutate run. */
|
|
65
|
+
export function loadTraces(oracle) {
|
|
66
|
+
const dirs = env[SPEC_TRACES_ENV] ? JSON.parse(env[SPEC_TRACES_ENV]) : {};
|
|
67
|
+
const dir = dirs[oracle];
|
|
68
|
+
if (!dir)
|
|
69
|
+
return [];
|
|
70
|
+
return readdirSync(dir)
|
|
71
|
+
.filter((f) => f.endsWith('.itf.json'))
|
|
72
|
+
.sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
|
|
73
|
+
.map((f) => readTrace(path.join(dir, f)));
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
export function instrumentedSources(mapPath) {
|
|
8
|
+
const map = new Map(Object.entries(JSON.parse(readFileSync(mapPath, 'utf8'))).map(([k, v]) => [path.resolve(k), v]));
|
|
9
|
+
return {
|
|
10
|
+
name: 'oxmutate:instrumented-sources',
|
|
11
|
+
enforce: 'pre',
|
|
12
|
+
load(id) {
|
|
13
|
+
const file = id.split('?')[0] ?? id;
|
|
14
|
+
return map.get(path.resolve(file));
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=vite-plugin.js.map
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** `oxmutate/vite`: the `mutate` block on Vite's config type, so `vite.config.ts` holds it beside `test` (`/// <reference types="oxmutate/vite" />`). */
|
|
2
|
+
import type { UserConfig } from 'vite';
|
|
3
|
+
import type { MutateConfig } from './config.js';
|
|
4
|
+
declare module 'vite' {
|
|
5
|
+
interface UserConfig {
|
|
6
|
+
/** oxmutate's settings: read by `oxmutate run` from this file when no oxmutate.config.* exists. */
|
|
7
|
+
mutate?: MutateConfig;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/** A Vite config with oxmutate's block: what `defineConfig` accepts once this module is referenced. */
|
|
11
|
+
export type MutateViteConfig = UserConfig & {
|
|
12
|
+
mutate?: MutateConfig;
|
|
13
|
+
};
|
|
14
|
+
export type { MutateConfig };
|
|
15
|
+
/** Types a `mutate` block written apart from `defineConfig` (in a shared file, say); returns it unchanged. */
|
|
16
|
+
export declare const defineMutate: (config: MutateConfig) => MutateConfig;
|
package/dist/vite.js
ADDED
package/package.json
CHANGED
|
@@ -1,10 +1,85 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Mutation testing on the oxc engine
|
|
5
|
-
"keywords": ["mutation-testing", "oxc", "vitest", "stryker-alternative"],
|
|
6
|
-
"author": "Ofri Peretz",
|
|
3
|
+
"version": "1.0.0",
|
|
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.",
|
|
7
5
|
"license": "MIT",
|
|
8
|
-
"
|
|
9
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ofri-peretz/burgee.git",
|
|
9
|
+
"directory": "packages/oxmutate"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/ofri-peretz/burgee/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://oxmutate.interlace.tools",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"private": false,
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": "^20.19.0 || >=22.13.0"
|
|
19
|
+
},
|
|
20
|
+
"bin": {
|
|
21
|
+
"oxmutate": "./dist/bin.js"
|
|
22
|
+
},
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./runner": {
|
|
31
|
+
"types": "./dist/runner.d.ts",
|
|
32
|
+
"import": "./dist/runner.js",
|
|
33
|
+
"default": "./dist/runner.js"
|
|
34
|
+
},
|
|
35
|
+
"./spec": {
|
|
36
|
+
"types": "./dist/spec.d.ts",
|
|
37
|
+
"import": "./dist/spec.js",
|
|
38
|
+
"default": "./dist/spec.js"
|
|
39
|
+
},
|
|
40
|
+
"./vite": {
|
|
41
|
+
"types": "./dist/vite.d.ts",
|
|
42
|
+
"import": "./dist/vite.js",
|
|
43
|
+
"default": "./dist/vite.js"
|
|
44
|
+
},
|
|
45
|
+
"./package.json": "./package.json"
|
|
46
|
+
},
|
|
47
|
+
"typesVersions": {
|
|
48
|
+
"*": {
|
|
49
|
+
"runner": ["./dist/runner.d.ts"],
|
|
50
|
+
"spec": ["./dist/spec.d.ts"],
|
|
51
|
+
"vite": ["./dist/vite.d.ts"]
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"binding/oxmutate.*.node",
|
|
57
|
+
"!dist/**/*.map",
|
|
58
|
+
"!dist/**/*.test.*"
|
|
59
|
+
],
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsc -p tsconfig.build.json && node ../../scripts/declaration-reach.mts --prune",
|
|
62
|
+
"build:native": "cd native && cargo build --release --features napi --lib && node ../scripts/copy-binding.mjs",
|
|
63
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:native": "cd native && cargo test --release",
|
|
66
|
+
"lint": "eslint src",
|
|
67
|
+
"coverage": "vitest run --coverage.enabled"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"bellpull": "*",
|
|
71
|
+
"burgee": "*"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"vitest": ">=4.1.0"
|
|
75
|
+
},
|
|
76
|
+
"devDependencies": {
|
|
77
|
+
"vitest": "^5.0.1"
|
|
78
|
+
},
|
|
79
|
+
"//optionalDependencies": "Written at publish time by the release job (scripts/sync-platform-versions.mjs --publish): the public oxmutate-<platform> packages under npm/, at this version; none while they are private, as in 0.1, which ships all six cores in binding/ instead. Never committed: npm ci refuses a lockfile that cannot record an optional dependency npm does not serve (EUSAGE). See RELEASING.md.",
|
|
80
|
+
"peerDependenciesMeta": {
|
|
81
|
+
"vitest": {
|
|
82
|
+
"optional": true
|
|
83
|
+
}
|
|
84
|
+
}
|
|
10
85
|
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
throw new Error('oxmutate is in development and has no release yet. Follow the README for status.');
|