oxmutate 1.4.0 → 2.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/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  <a href="https://www.npmjs.com/package/oxmutate?activeTab=code"><img src="https://img.shields.io/npm/unpacked-size/oxmutate?style=flat-square" alt="Unpacked size of the latest oxmutate release on npm" /></a>
21
21
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/dependencies-2%20in%20family%2C%200%20outside-0a6b47?style=flat-square" alt="Two dependencies, all in the burgee family (bellpull, burgee), none outside it" /></a>
22
22
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/types-included-blue?style=flat-square" alt="TypeScript types included for every entry point" /></a>
23
- <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/Node.js-20.19%2B%20%7C%2022.13%2B-green?style=flat-square" alt="Node.js 20.19+ or 22.13+" /></a>
23
+ <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/package.json"><img src="https://img.shields.io/badge/Node.js-22.13%2B-green?style=flat-square" alt="Node.js 22.13+" /></a>
24
24
  <a href="https://github.com/ofri-peretz/burgee/blob/main/packages/oxmutate/LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=flat-square" alt="License: MIT" /></a>
25
25
  <a href="https://www.npmjs.com/package/oxmutate#provenance"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fregistry.npmjs.org%2Foxmutate%2Flatest&query=%24.dist.attestations.provenance~&label=npm&style=flat-square&color=0a6b47" alt="npm provenance of the latest release, read live from its registry attestation" /></a>
26
26
  </p>
@@ -335,7 +335,7 @@ The `oxmutate` bin is that API, declared once on burgee.
335
335
 
336
336
  ## Requirements
337
337
 
338
- Node 20.19 or later on the 20 line, or 22.13 or later. Vitest 4.1 or later in the project, for the
338
+ Node 22.13 or later. Vitest 4.1 or later in the project, for the
339
339
  default runner. macOS (arm64, x64), Linux (glibc x64 and arm64, musl x64) or Windows (x64).
340
340
 
341
341
  ## Where it sits
Binary file
Binary file
@@ -45,11 +45,27 @@ export interface RunStats {
45
45
  isolatedMs: number;
46
46
  wallMs: number;
47
47
  testExecutions: number;
48
+ /** Warm units, send to outcome, summed over servers: what `mutationMs` is in server time. */
49
+ warmUnitMs: number;
50
+ /** Of `warmUnitMs`, the time inside armed test runs. The rest is starting workers, collecting files, canaries and hooks. */
51
+ warmArmedMs: number;
52
+ /** Isolated runs by what sent the mutant there: the parts of `isolatedRuns`. */
53
+ isolatedByCause: {
54
+ static: number;
55
+ stopped: number;
56
+ unfinished: number;
57
+ rerun: number;
58
+ confirm: number;
59
+ flaky: number;
60
+ };
48
61
  watchdogKills: number;
49
62
  /** Of `watchdogKills`, the stops whose worker was idle, waiting (heartbeat.ts). In an isolated run each is a Timeout as it stands. */
50
63
  idleStalls: number;
51
64
  crashes: number;
52
65
  canaryFiles: number;
66
+ /** Of the verdicts the incremental cache would have reused, how many were run afresh to check it, and how many disagreed on detected or not. */
67
+ auditSampled: number;
68
+ auditDisagreed: number;
53
69
  /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
54
70
  pollutionRestarts: number;
55
71
  /** Units after which the project on disk had changed (diskguard.ts): put back, and their verdicts and those of the units running beside them re-run. */
@@ -12,6 +12,7 @@ import { globToRegExp, matchFiles } from './files.js';
12
12
  import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
13
13
  import { native } from './native.js';
14
14
  import { startProgress } from './progress.js';
15
+ import { VERSION } from './reports.js';
15
16
  import { FILE_KEY, env, stderr } from './runtime.js';
16
17
  import { createPool } from './scheduler.js';
17
18
  import { summarizeSpec, tracesFor } from './spec-oracle.js';
@@ -27,7 +28,26 @@ function readRecords(file) {
27
28
  .map((l) => JSON.parse(l));
28
29
  }
29
30
  const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
31
+ const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
32
+ function lockfileHash(root) {
33
+ const found = LOCKFILES.map((f) => path.join(root, f)).find((f) => existsSync(f));
34
+ if (found)
35
+ return sha(readFileSync(found, 'utf8'));
36
+ const above = path.dirname(root);
37
+ return above === root ? 'none' : lockfileHash(above);
38
+ }
39
+ const verdictOptions = (o) => ({
40
+ timeoutMS: o.timeoutMS,
41
+ timeoutFactor: o.timeoutFactor,
42
+ excludedMutations: [...o.excludedMutations].sort(),
43
+ ignoreStatic: o.ignoreStatic,
44
+ strykerCompat: o.strykerCompat,
45
+ testOverrides: o.testOverrides,
46
+ vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
47
+ });
30
48
  const UNIT_PAIRS = 400;
49
+ const detected = (s) => s === 'Killed' || s === 'Timeout';
50
+ const AUDIT_SHARE = 0.05;
31
51
  const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
32
52
  function timeoutReason(v) {
33
53
  if (v.deadlock)
@@ -53,8 +73,8 @@ export async function run(options) {
53
73
  const { root } = options;
54
74
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
55
75
  const stats = {
56
- files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
57
- watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
76
+ files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0, warmUnitMs: 0, warmArmedMs: 0, isolatedByCause: { static: 0, stopped: 0, unfinished: 0, rerun: 0, confirm: 0, flaky: 0 },
77
+ watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, auditSampled: 0, auditDisagreed: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
58
78
  };
59
79
  let unitSeq = 0;
60
80
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -169,7 +189,10 @@ export async function run(options) {
169
189
  if (fileErrors.length) {
170
190
  throw new InstrumentFault(`${String(fileErrors.length)} test file(s) failed to load during the dry run, so their tests would be missing from every verdict: ${fileErrors.slice(0, 3).map((e) => `${path.relative(root, e.file)}: ${e.error}`).join('; ')}`);
171
191
  }
172
- return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []) };
192
+ const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
193
+ if (unhandled > 0)
194
+ throw new InstrumentFault(`the suite raised ${String(unhandled)} unhandled error(s) with no mutant active, so no verdict can be trusted. fix: make \`vitest run\` pass with no "Unhandled Errors" first`);
195
+ return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
173
196
  };
174
197
  progress.phase('dry run');
175
198
  const d0 = performance.now();
@@ -263,13 +286,23 @@ export async function run(options) {
263
286
  }
264
287
  return h;
265
288
  };
266
- const fingerprint = (m) => sha([sha(sources[m.file] ?? ''), ...[...new Set(m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''))].sort().map(fileHash)].join(':'));
289
+ const envKey = sha(JSON.stringify({ oxmutate: VERSION, node: process.version, lock: lockfileHash(root), options: verdictOptions(options) }));
290
+ const fingerprint = (m) => {
291
+ const testFiles = new Set([...m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''), ...(staticFiles.get(m.id) ?? [])]);
292
+ const graph = new Set([...testFiles].flatMap((f) => dry.modules[f] ?? []).filter((f) => !testFiles.has(f) && f !== path.join(root, m.file)));
293
+ return sha([envKey, sha(sources[m.file] ?? ''), ...[...testFiles].sort().map(fileHash), '|', ...[...graph].sort().map((f) => `${f}=${fileHash(f)}`)].join(':'));
294
+ };
295
+ const audited = new Map();
267
296
  const cache = options.incremental && existsSync(options.incrementalFile) ? JSON.parse(readFileSync(options.incrementalFile, 'utf8')).mutants : {};
268
297
  for (const m of mutants) {
269
298
  if (m.status === 'Ignored' || (!m.static && m.coveredBy.length === 0))
270
299
  continue;
271
300
  const hit = cache[m.id];
272
301
  if (hit && hit.fp === fingerprint(m)) {
302
+ if (Math.random() < AUDIT_SHARE) {
303
+ audited.set(m.id, hit.status);
304
+ continue;
305
+ }
273
306
  m.status = hit.status;
274
307
  m.killedBy = hit.killedBy;
275
308
  m.testsCompleted = hit.testsCompleted;
@@ -382,6 +415,7 @@ export async function run(options) {
382
415
  const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
383
416
  const afterUnit = async (unit, outcome) => {
384
417
  inFlight.delete(unit.results);
418
+ stats.warmUnitMs += outcome.ms ?? 0;
385
419
  const wrote = await diskWrittenDuring(unit);
386
420
  if (wrote)
387
421
  return wrote;
@@ -454,6 +488,7 @@ export async function run(options) {
454
488
  if (r.type === 'mutant' && r.id)
455
489
  (byMutant.get(r.id) ?? byMutant.set(r.id, []).get(r.id))?.push(r);
456
490
  stats.testExecutions = records.filter((r) => r.type === 'mutant').length;
491
+ stats.warmArmedMs = records.reduce((sum, r) => sum + (r.type === 'mutant' ? (r.ms ?? 0) : 0), 0);
457
492
  stats.canaryFiles = new Set(records.filter((r) => r.type === 'polluted' && r.file).map((r) => r.file ?? '')).size;
458
493
  const isolatedQueue = [];
459
494
  for (const m of toRun) {
@@ -466,6 +501,7 @@ export async function run(options) {
466
501
  continue;
467
502
  }
468
503
  else if (runaways.has(m.id)) {
504
+ stats.isolatedByCause.stopped++;
469
505
  isolatedQueue.push(m);
470
506
  }
471
507
  else if (crashedIds.has(m.id)) {
@@ -484,6 +520,7 @@ export async function run(options) {
484
520
  m.testsCompleted = recs.length;
485
521
  }
486
522
  else {
523
+ stats.isolatedByCause.unfinished++;
487
524
  isolatedQueue.push(m);
488
525
  }
489
526
  }
@@ -644,11 +681,13 @@ export async function run(options) {
644
681
  const s0 = performance.now();
645
682
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
646
683
  stats.staticMs = performance.now() - s0;
684
+ stats.isolatedByCause.static = staticBack.length;
647
685
  isolatedQueue.push(...staticBack);
648
686
  progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
649
687
  const i0 = performance.now();
650
688
  const first = await runIsolated(isolatedQueue);
651
689
  const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout' && !first.get(m.id)?.idle);
690
+ stats.isolatedByCause.rerun += stopped.length;
652
691
  const again = await runIsolated(stopped, RERUN_SLACK);
653
692
  for (const m of stopped) {
654
693
  const v = again.get(m.id);
@@ -668,8 +707,10 @@ export async function run(options) {
668
707
  }
669
708
  }
670
709
  const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
710
+ stats.isolatedByCause.confirm += survivors.length;
671
711
  const confirmed = await runIsolated(survivors);
672
712
  const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout' && !confirmed.get(m.id)?.idle);
713
+ stats.isolatedByCause.rerun += stoppedHere.length;
673
714
  const recheck = await runIsolated(stoppedHere, RERUN_SLACK);
674
715
  for (const m of stoppedHere) {
675
716
  const v = recheck.get(m.id);
@@ -702,6 +743,7 @@ export async function run(options) {
702
743
  const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
703
744
  const seen = new Map(flakyCandidates.map((m) => [m.id, new Set([m.status === 'Timeout' ? 'Killed' : m.status])]));
704
745
  for (let n = 1; n <= options.flakyReruns && flakyCandidates.length; n++) {
746
+ stats.isolatedByCause.flaky += flakyCandidates.length;
705
747
  const again = await runIsolated(flakyCandidates);
706
748
  for (const m of flakyCandidates) {
707
749
  const v = again.get(m.id);
@@ -729,7 +771,11 @@ export async function run(options) {
729
771
  stats.crashes = pool.stats.crashed;
730
772
  if (options.incremental) {
731
773
  mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
732
- const next = { ...cache };
774
+ stats.auditSampled = audited.size;
775
+ stats.auditDisagreed = [...audited].filter(([id, was]) => detected(byId.get(id)?.status) !== detected(was)).length;
776
+ if (stats.auditDisagreed > 0)
777
+ stderr.write(`oxmutate: ${String(stats.auditDisagreed)} of ${String(audited.size)} audited cached verdicts disagree with a fresh run; the incremental cache is rebuilt from this run\n`);
778
+ const next = stats.auditDisagreed > 0 ? {} : { ...cache };
733
779
  for (const m of mutants) {
734
780
  if (['Killed', 'Survived', 'Timeout'].includes(m.status) && !m.flaky)
735
781
  next[m.id] = { fp: fingerprint(m), status: m.status, killedBy: m.killedBy, testsCompleted: m.testsCompleted };
package/dist/reports.d.ts CHANGED
@@ -6,7 +6,6 @@ export declare function strykerReport(r: RunResult): object;
6
6
  /** SARIF 2.1.0: one result per surviving or uncovered mutant, fingerprinted by stable id. */
7
7
  export declare function sarifReport(r: RunResult): object;
8
8
  export declare function describe(m: MutantResult): string;
9
- /** The terminal summary: the score, what to look at first, what the run corrected. */
10
9
  export declare function textSummary(r: RunResult, opts?: {
11
10
  survivors?: number;
12
11
  }): string;
package/dist/reports.js CHANGED
@@ -102,7 +102,7 @@ function corrections(s) {
102
102
  s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
103
103
  s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
104
104
  s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
105
- s.reused && `${String(s.reused)} verdict(s) reused`,
105
+ s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
106
106
  s.crashes && `${String(s.crashes)} worker crash(es)`,
107
107
  ].filter((c) => Boolean(c));
108
108
  }
@@ -116,6 +116,16 @@ function frozenLine(r) {
116
116
  const sign = f.delta >= 0 ? '+' : '';
117
117
  return [` frozen oracle vs ${f.base}: ${f.headScore.toFixed(2)}% with the change's tests, ${f.baseScore.toFixed(2)}% with the base's (${sign}${f.delta.toFixed(2)}), ${String(f.weakened.length)} mutant(s) the change stopped detecting`];
118
118
  }
119
+ function whereItWent(s) {
120
+ const lines = [];
121
+ if (s.warmUnitMs > 0)
122
+ lines.push(` warm servers ran ${(s.warmUnitMs / MS_PER_S).toFixed(1)} s, ${(s.warmArmedMs / MS_PER_S).toFixed(1)} s of it inside test runs`);
123
+ const c = s.isolatedByCause;
124
+ const causes = [`static ${String(c.static)}`, `stopped ${String(c.stopped)}`, `unfinished ${String(c.unfinished)}`, `re-run ${String(c.rerun)}`, `survivors confirmed ${String(c.confirm)}`, `flaky ${String(c.flaky)}`];
125
+ if (s.isolatedRuns > 0)
126
+ lines.push(` isolated runs: ${causes.join(' · ')}`);
127
+ return lines;
128
+ }
119
129
  export function textSummary(r, opts = {}) {
120
130
  const count = (s) => r.mutants.filter((m) => m.status === s).length;
121
131
  const s = r.stats;
@@ -132,6 +142,7 @@ export function textSummary(r, opts = {}) {
132
142
  const corrected = corrections(s);
133
143
  if (corrected.length)
134
144
  lines.push(` corrected: ${corrected.join(' · ')}`);
145
+ lines.push(...whereItWent(s));
135
146
  lines.push(...frozenLine(r));
136
147
  lines.push(...specLines(r));
137
148
  const survivors = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
package/dist/runner.d.ts CHANGED
@@ -35,6 +35,7 @@ declare const OxmutateRunner_base: new (config: unknown) => {
35
35
  evaluatedModules?: {
36
36
  getModuleById: (id: string) => unknown;
37
37
  invalidateModule: (node: unknown) => void;
38
+ fileToModulesMap?: Map<string, unknown>;
38
39
  };
39
40
  };
40
41
  _currentSpecification?: unknown;
@@ -94,6 +95,6 @@ export default class OxmutateRunner extends OxmutateRunner_base {
94
95
  private collectCopies;
95
96
  /** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
96
97
  private retryReset;
97
- onAfterRunFiles(files: AnyTask[]): unknown;
98
+ onAfterRunFiles(files: AnyTask[]): Promise<unknown>;
98
99
  }
99
100
  export {};
package/dist/runner.js CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, openSync, readFileSync, writeSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { availableParallelism, loadavg } from 'node:os';
4
4
  import path from 'node:path';
@@ -53,6 +53,11 @@ if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
53
53
  ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
54
54
  }
55
55
  const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
56
+ let unhandled = 0;
57
+ process.on('unhandledRejection', () => void unhandled++);
58
+ process.on('uncaughtException', () => void unhandled++);
59
+ const realSetImmediate = globalThis.setImmediate;
60
+ const settle = () => new Promise((resolve) => realSetImmediate(resolve));
56
61
  const deadlocks = mode === 'dry' ? undefined : watch();
57
62
  let deadlocked = false;
58
63
  let tornDown = false;
@@ -120,8 +125,9 @@ function takeCopy(test) {
120
125
  const file = collections.get(test.file?.filepath ?? '')?.[n];
121
126
  return file && find(file, test.id);
122
127
  }
128
+ const dryModules = new Map();
123
129
  const PROBE_ID = '__oxmutate_probe__';
124
- async function probe(test, fn) {
130
+ async function probe(test, fn, reset) {
125
131
  const file = test.file?.filepath;
126
132
  const scope = test.suite?.id ?? file;
127
133
  if (!file || !scope || probed.has(scope) || runOnce.has(file))
@@ -129,6 +135,7 @@ async function probe(test, fn) {
129
135
  probed.add(scope);
130
136
  await afterEach(test, test.context, []);
131
137
  const cleanups = await beforeEach(test, test.context);
138
+ await reset();
132
139
  ns.currentTestId = PROBE_ID;
133
140
  try {
134
141
  await fn();
@@ -146,9 +153,31 @@ async function probe(test, fn) {
146
153
  }
147
154
  const POLLUTION_ERROR_CHARS = 300;
148
155
  const FILE_ERROR_CHARS = 500;
156
+ let resultsFd;
157
+ let progressFd;
158
+ let markLength = 0;
149
159
  function emit(record) {
150
- if (resultsPath)
151
- appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
160
+ if (!resultsPath)
161
+ return;
162
+ resultsFd ??= openSync(resultsPath, 'a');
163
+ writeSync(resultsFd, `${JSON.stringify(record)}\n`);
164
+ }
165
+ function mark(text) {
166
+ if (!progressPath)
167
+ return;
168
+ progressFd ??= openSync(progressPath, 'w');
169
+ const padded = text.trimEnd().padEnd(markLength);
170
+ markLength = padded.length;
171
+ if (padded)
172
+ writeSync(progressFd, padded, 0);
173
+ }
174
+ function closeFiles() {
175
+ for (const fd of [resultsFd, progressFd])
176
+ if (fd !== undefined)
177
+ closeSync(fd);
178
+ resultsFd = undefined;
179
+ progressFd = undefined;
180
+ markLength = 0;
152
181
  }
153
182
  function chain(test) {
154
183
  const suites = [];
@@ -270,7 +299,7 @@ export default class OxmutateRunner extends Base {
270
299
  try {
271
300
  await guarded(test.id, fn);
272
301
  if (mode === 'dry')
273
- await probe(test, fn);
302
+ await probe(test, fn, () => this.retryReset(test, 1));
274
303
  }
275
304
  catch (e) {
276
305
  if (isDeadlock(e))
@@ -358,9 +387,13 @@ export default class OxmutateRunner extends Base {
358
387
  ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
359
388
  let status = 'Survived';
360
389
  let deadlock = false;
390
+ const before = unhandled;
361
391
  try {
362
392
  await setup();
363
393
  await guarded(current.id, run);
394
+ await settle();
395
+ if (unhandled > before)
396
+ status = 'Killed';
364
397
  }
365
398
  catch (e) {
366
399
  deadlock = isDeadlock(e);
@@ -376,8 +409,7 @@ export default class OxmutateRunner extends Base {
376
409
  for (const id of ids) {
377
410
  if (done.has(id))
378
411
  continue;
379
- if (progressPath)
380
- writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
412
+ mark(`${id} ${String(realNow())}\n`);
381
413
  let { status, ms, deadlock } = await runArmed(id);
382
414
  if (status === 'Polluted')
383
415
  break;
@@ -401,14 +433,13 @@ export default class OxmutateRunner extends Base {
401
433
  break;
402
434
  }
403
435
  if (tornDown) {
404
- if (progressPath)
405
- writeFileSync(progressPath, '');
436
+ mark('');
406
437
  return;
407
438
  }
408
439
  if (!polluted)
409
440
  await canary();
410
- if (progressPath && ids.length)
411
- writeFileSync(progressPath, '');
441
+ if (ids.length)
442
+ mark('');
412
443
  if (attempt > 0)
413
444
  await fresh();
414
445
  try {
@@ -425,6 +456,10 @@ export default class OxmutateRunner extends Base {
425
456
  return super.importFile?.(filepath, source);
426
457
  }
427
458
  async onAfterRunSuite(suite) {
459
+ if (mode === 'dry' && suite.filepath) {
460
+ const files = [...(this.workerState?.evaluatedModules?.fileToModulesMap?.keys() ?? [])];
461
+ dryModules.set(suite.filepath, files.filter((f) => path.isAbsolute(f) && !f.split(path.sep).includes('node_modules')));
462
+ }
428
463
  const copies = suite.filepath ? collections.get(suite.filepath) : undefined;
429
464
  if (suite.filepath && copies) {
430
465
  collections.delete(suite.filepath);
@@ -469,10 +504,14 @@ export default class OxmutateRunner extends Base {
469
504
  ns.hitCount = armed ? 0 : undefined;
470
505
  ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
471
506
  deadlocked = false;
507
+ const before = unhandled;
472
508
  try {
473
509
  vi.resetModules();
474
510
  const [file] = await collectTests([filepath], this);
475
511
  const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
512
+ await settle();
513
+ if (unhandled > before && result.failed.length === 0)
514
+ result.failed.push(filepath);
476
515
  return { ...result, timeout: ranAway() || deadlocked, ...(deadlocked ? { deadlock: true } : {}) };
477
516
  }
478
517
  finally {
@@ -490,8 +529,7 @@ export default class OxmutateRunner extends Base {
490
529
  };
491
530
  while (rest.length) {
492
531
  const id = rest.shift();
493
- if (progressPath)
494
- writeFileSync(progressPath, `${id} ${String(realNow())}\n`);
532
+ mark(`${id} ${String(realNow())}\n`);
495
533
  held.push({ id, verdict: await evaluate(id) });
496
534
  if (held.at(-1)?.verdict.deadlock)
497
535
  return back('torn down: a deadlocked run is still pending in this worker');
@@ -512,8 +550,7 @@ export default class OxmutateRunner extends Base {
512
550
  held = [];
513
551
  every = Math.min(STATIC_CANARY_MAX, every * 2);
514
552
  }
515
- if (progressPath)
516
- writeFileSync(progressPath, '');
553
+ mark('');
517
554
  vi.resetModules();
518
555
  }
519
556
  async onBeforeRunTask(test) {
@@ -557,7 +594,8 @@ export default class OxmutateRunner extends Base {
557
594
  async retryReset(test, attempt) {
558
595
  await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
559
596
  }
560
- onAfterRunFiles(files) {
597
+ async onAfterRunFiles(files) {
598
+ await settle();
561
599
  if (mode === 'dry') {
562
600
  const tests = [];
563
601
  const visit = (t, file) => {
@@ -574,7 +612,7 @@ export default class OxmutateRunner extends Base {
574
612
  if (result?.state === 'fail' && !f.tasks?.length)
575
613
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
576
614
  }
577
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce] });
615
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
578
616
  }
579
617
  else if (mode === 'isolated') {
580
618
  const failed = [];
@@ -592,9 +630,13 @@ export default class OxmutateRunner extends Base {
592
630
  if (f.result?.state === 'fail' && !failed.length)
593
631
  failed.push(f.id);
594
632
  }
633
+ const [first] = files;
634
+ if (unhandled > 0 && !failed.length && first)
635
+ failed.push(first.id);
595
636
  const timeout = ranAway() || deadlocked;
596
637
  emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }), ...(deadlocked && { deadlock: true }) });
597
638
  }
639
+ closeFiles();
598
640
  return super.onAfterRunFiles?.(files);
599
641
  }
600
642
  }
package/dist/scheduler.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { fork } from 'node:child_process';
2
- import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { closeSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { beatFile, classifyStall, CPU_WINDOW_MS, cpuShare, readBeat } from './heartbeat.js';
@@ -8,6 +8,7 @@ const childJs = fileURLToPath(new URL('./child.js', import.meta.url));
8
8
  const STDERR_TAIL = 4000;
9
9
  const WATCHDOG_POLL_MS = 50;
10
10
  const CLOSE_GRACE_MS = 2000;
11
+ const PROGRESS_BYTES = 256;
11
12
  function inFlight(progress) {
12
13
  try {
13
14
  return readFileSync(progress, 'utf8').trim().split(' ')[0] || undefined;
@@ -56,7 +57,9 @@ class Server {
56
57
  });
57
58
  }
58
59
  run(unit, budget) {
59
- writeFileSync(unit.progress, '');
60
+ const progress = openSync(unit.progress, 'w+');
61
+ const mark = Buffer.alloc(PROGRESS_BYTES);
62
+ const sent = performance.now();
60
63
  return new Promise((resolve) => {
61
64
  let settled = false;
62
65
  const finish = (o) => {
@@ -64,18 +67,13 @@ class Server {
64
67
  return;
65
68
  settled = true;
66
69
  clearInterval(timer);
70
+ closeSync(progress);
67
71
  this.onMessage = undefined;
68
72
  this.onExit = undefined;
69
- resolve(o);
73
+ resolve({ ...o, ms: performance.now() - sent });
70
74
  };
71
75
  const timer = setInterval(() => {
72
- let line = '';
73
- try {
74
- line = readFileSync(unit.progress, 'utf8').trim();
75
- }
76
- catch {
77
- return;
78
- }
76
+ const line = mark.toString('utf8', 0, readSync(progress, mark, 0, PROGRESS_BYTES, 0)).trim();
79
77
  if (!line)
80
78
  return;
81
79
  const [id = '', since = '0'] = line.split(' ');
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
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
+ "author": "Ofri Peretz <ofriperetzdev@gmail.com>",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ofri-peretz/burgee.git",
@@ -14,8 +15,13 @@
14
15
  "homepage": "https://oxmutate.interlace.tools",
15
16
  "type": "module",
16
17
  "private": false,
18
+ "sideEffects": [
19
+ "./dist/bin.js",
20
+ "./dist/child.js",
21
+ "./dist/runner.js"
22
+ ],
17
23
  "engines": {
18
- "node": "^20.19.0 || >=22.13.0"
24
+ "node": ">=22.13.0"
19
25
  },
20
26
  "bin": {
21
27
  "oxmutate": "./dist/bin.js"
@@ -57,6 +63,24 @@
57
63
  ]
58
64
  }
59
65
  },
66
+ "keywords": [
67
+ "mutation-testing",
68
+ "mutation",
69
+ "mutation-score",
70
+ "stryker",
71
+ "vitest",
72
+ "oxc",
73
+ "test-quality",
74
+ "sarif",
75
+ "typescript",
76
+ "javascript",
77
+ "ci",
78
+ "cli"
79
+ ],
80
+ "publishConfig": {
81
+ "access": "public",
82
+ "provenance": true
83
+ },
60
84
  "files": [
61
85
  "dist",
62
86
  "binding/oxmutate.*.node",
@@ -75,8 +99,8 @@
75
99
  "compat:mutation": "vitest run --config compat/vitest.config.ts"
76
100
  },
77
101
  "dependencies": {
78
- "bellpull": "^1.0.10",
79
- "burgee": "^1.9.2"
102
+ "bellpull": "^2.0.0",
103
+ "burgee": "^2.0.0"
80
104
  },
81
105
  "peerDependencies": {
82
106
  "vitest": ">=4.1.0"