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/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) => this.onMessage?.(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
- if (Date.now() - Number(since) > budget(id)) {
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
- if (outcome.killedFor)
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
@@ -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
@@ -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
@@ -1,3 +1 @@
1
- /** Types a `mutate` block written apart from `defineConfig` (in a shared file, say); returns it unchanged. */
2
1
  export const defineMutate = (config) => config;
3
- //# sourceMappingURL=vite.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.1.0",
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",