oxmutate 1.3.0 → 1.5.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.
@@ -23,6 +23,8 @@ export interface MutantResult extends NativeMutant {
23
23
  flaky?: boolean | undefined;
24
24
  /** Reused from the incremental cache, not run. */
25
25
  reused?: boolean | undefined;
26
+ /** Paths this mutant's run wrote outside the sandbox, found in its unit and put back (diskguard.ts). */
27
+ wrote?: string[] | undefined;
26
28
  }
27
29
  export interface TestInfo {
28
30
  id: string;
@@ -48,8 +50,13 @@ export interface RunStats {
48
50
  idleStalls: number;
49
51
  crashes: number;
50
52
  canaryFiles: number;
53
+ /** 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. */
54
+ auditSampled: number;
55
+ auditDisagreed: number;
51
56
  /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
52
57
  pollutionRestarts: number;
58
+ /** 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. */
59
+ diskWrites: number;
53
60
  /** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
54
61
  survivorsOverturned: number;
55
62
  /** Watchdog timeouts that finished when re-run with 3x the budget: the machine was slow, not the mutant. */
@@ -7,10 +7,12 @@ import { checkSurvivors } from './checker.js';
7
7
  import { runCommandMutants } from './command-runner.js';
8
8
  import { disables } from './config.js';
9
9
  import { rangeDisables } from './directives.js';
10
+ import { guardDisk, isClean } from './diskguard.js';
10
11
  import { globToRegExp, matchFiles } from './files.js';
11
12
  import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
12
13
  import { native } from './native.js';
13
14
  import { startProgress } from './progress.js';
15
+ import { VERSION } from './reports.js';
14
16
  import { FILE_KEY, env, stderr } from './runtime.js';
15
17
  import { createPool } from './scheduler.js';
16
18
  import { summarizeSpec, tracesFor } from './spec-oracle.js';
@@ -26,7 +28,26 @@ function readRecords(file) {
26
28
  .map((l) => JSON.parse(l));
27
29
  }
28
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
+ });
29
48
  const UNIT_PAIRS = 400;
49
+ const detected = (s) => s === 'Killed' || s === 'Timeout';
50
+ const AUDIT_SHARE = 0.05;
30
51
  const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
31
52
  function timeoutReason(v) {
32
53
  if (v.deadlock)
@@ -53,7 +74,7 @@ export async function run(options) {
53
74
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
54
75
  const stats = {
55
76
  files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
56
- watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 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,
57
78
  };
58
79
  let unitSeq = 0;
59
80
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -168,7 +189,7 @@ export async function run(options) {
168
189
  if (fileErrors.length) {
169
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('; ')}`);
170
191
  }
171
- return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []) };
192
+ return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
172
193
  };
173
194
  progress.phase('dry run');
174
195
  const d0 = performance.now();
@@ -262,13 +283,23 @@ export async function run(options) {
262
283
  }
263
284
  return h;
264
285
  };
265
- const fingerprint = (m) => sha([sha(sources[m.file] ?? ''), ...[...new Set(m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''))].sort().map(fileHash)].join(':'));
286
+ const envKey = sha(JSON.stringify({ oxmutate: VERSION, node: process.version, lock: lockfileHash(root), options: verdictOptions(options) }));
287
+ const fingerprint = (m) => {
288
+ const testFiles = new Set([...m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''), ...(staticFiles.get(m.id) ?? [])]);
289
+ const graph = new Set([...testFiles].flatMap((f) => dry.modules[f] ?? []).filter((f) => !testFiles.has(f) && f !== path.join(root, m.file)));
290
+ return sha([envKey, sha(sources[m.file] ?? ''), ...[...testFiles].sort().map(fileHash), '|', ...[...graph].sort().map((f) => `${f}=${fileHash(f)}`)].join(':'));
291
+ };
292
+ const audited = new Map();
266
293
  const cache = options.incremental && existsSync(options.incrementalFile) ? JSON.parse(readFileSync(options.incrementalFile, 'utf8')).mutants : {};
267
294
  for (const m of mutants) {
268
295
  if (m.status === 'Ignored' || (!m.static && m.coveredBy.length === 0))
269
296
  continue;
270
297
  const hit = cache[m.id];
271
298
  if (hit && hit.fp === fingerprint(m)) {
299
+ if (Math.random() < AUDIT_SHARE) {
300
+ audited.set(m.id, hit.status);
301
+ continue;
302
+ }
272
303
  m.status = hit.status;
273
304
  m.killedBy = hit.killedBy;
274
305
  m.testsCompleted = hit.testsCompleted;
@@ -332,12 +363,58 @@ export async function run(options) {
332
363
  return units.sort((a, b) => weight(b) - weight(a));
333
364
  };
334
365
  const records = [];
366
+ const disk = env['OXMUTATE_NO_DISKGUARD'] ? undefined : await guardDisk(root);
367
+ const inFlight = new Set();
368
+ const tainted = new Set();
369
+ const writers = new Map();
370
+ const diskWrittenDuring = async (unit) => {
371
+ const change = await disk?.check();
372
+ const dirty = change !== undefined && !isClean(change);
373
+ if (dirty) {
374
+ stats.diskWrites++;
375
+ if (!disk?.restore(change)) {
376
+ throw new InstrumentFault(`a mutant wrote outside the sandbox and the project could not be put back: ${[...change.changed, ...change.added, ...change.removed].join(', ')}. fix: restore them (git checkout / git clean), and see which test writes into the project`);
377
+ }
378
+ for (const other of inFlight)
379
+ tainted.add(other);
380
+ }
381
+ if (!dirty && !tainted.delete(unit.results))
382
+ return undefined;
383
+ const entry = planUnits.get(unit.results);
384
+ if (!entry)
385
+ return [];
386
+ const ids = [...new Set(Object.values(entry.plan).flat())];
387
+ const paths = change ? [...change.changed, ...change.added, ...change.removed] : [];
388
+ if (dirty && ids.length === 1) {
389
+ const id = ids[0];
390
+ writers.set(id, paths);
391
+ if (inFlight.size === 0)
392
+ records.push(...readRecords(unit.results));
393
+ else
394
+ unfinished.add(id);
395
+ return [];
396
+ }
397
+ if (entry.depth >= 40) {
398
+ for (const id of ids)
399
+ unfinished.add(id);
400
+ return [];
401
+ }
402
+ if (ids.length < 2)
403
+ return [makeUnit(entry.file, entry.plan, entry.depth + 1)];
404
+ const half = new Set(ids.slice(0, Math.ceil(ids.length / 2)));
405
+ const pick = (inHalf) => Object.fromEntries(Object.entries(entry.plan).map(([t, l]) => [t, l.filter((id) => half.has(id) === inHalf)]).filter(([, l]) => l.length > 0));
406
+ return [makeUnit(entry.file, pick(true), entry.depth + 1), makeUnit(entry.file, pick(false), entry.depth + 1)];
407
+ };
335
408
  const runaways = new Set();
336
409
  const crashedIds = new Set();
337
410
  const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
338
411
  pools.push(pool);
339
412
  const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
340
- const afterUnit = (unit, outcome) => {
413
+ const afterUnit = async (unit, outcome) => {
414
+ inFlight.delete(unit.results);
415
+ const wrote = await diskWrittenDuring(unit);
416
+ if (wrote)
417
+ return wrote;
341
418
  const raw = readRecords(unit.results);
342
419
  const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
343
420
  const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
@@ -380,6 +457,7 @@ export async function run(options) {
380
457
  progress.phase('warm runs', killedCount);
381
458
  const m0 = performance.now();
382
459
  const beforeUnit = (unit) => {
460
+ inFlight.add(unit.results);
383
461
  const entry = planUnits.get(unit.results);
384
462
  if (!entry)
385
463
  return true;
@@ -391,8 +469,10 @@ export async function run(options) {
391
469
  if (keep.length)
392
470
  left[t] = keep;
393
471
  }
394
- if (Object.keys(left).length === 0)
472
+ if (Object.keys(left).length === 0) {
473
+ inFlight.delete(unit.results);
395
474
  return false;
475
+ }
396
476
  entry.plan = left;
397
477
  writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(left) }));
398
478
  return true;
@@ -670,11 +750,20 @@ export async function run(options) {
670
750
  stats.isolatedMs = performance.now() - i0;
671
751
  stats.watchdogKills = pool.stats.killed;
672
752
  stats.idleStalls = pool.stats.idle;
753
+ for (const [id, paths] of writers) {
754
+ const m = byId.get(id);
755
+ if (m)
756
+ m.wrote = paths;
757
+ }
673
758
  stats.deadlocks = mutants.filter((m) => m.statusReason === DEADLOCK_REASON).length;
674
759
  stats.crashes = pool.stats.crashed;
675
760
  if (options.incremental) {
676
761
  mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
677
- const next = { ...cache };
762
+ stats.auditSampled = audited.size;
763
+ stats.auditDisagreed = [...audited].filter(([id, was]) => detected(byId.get(id)?.status) !== detected(was)).length;
764
+ if (stats.auditDisagreed > 0)
765
+ 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`);
766
+ const next = stats.auditDisagreed > 0 ? {} : { ...cache };
678
767
  for (const m of mutants) {
679
768
  if (['Killed', 'Survived', 'Timeout'].includes(m.status) && !m.flaky)
680
769
  next[m.id] = { fp: fingerprint(m), status: m.status, killedBy: m.killedBy, testsCompleted: m.testsCompleted };
@@ -0,0 +1,92 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ambientRuntime, run } from 'bellpull';
4
+ export const MAX_SNAPSHOT_BYTES = 64 * 1024 * 1024;
5
+ export const isClean = (c) => c.changed.length + c.added.length + c.removed.length === 0;
6
+ async function git(cwd, args) {
7
+ const r = await run('git', args, { cwd, runtime: ambientRuntime() }).catch(() => undefined);
8
+ return r?.ok ? r.stdout : undefined;
9
+ }
10
+ export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
11
+ const listed = await git(root, ['ls-files', '-z', '--cached', '--', '.']);
12
+ if (listed === undefined)
13
+ return undefined;
14
+ const files = listed.split('\0').filter(Boolean);
15
+ const tracked = new Map();
16
+ let bytes = 0;
17
+ for (const rel of files) {
18
+ const abs = path.join(root, rel);
19
+ if (!existsSync(abs))
20
+ continue;
21
+ const st = statSync(abs);
22
+ if (!st.isFile())
23
+ continue;
24
+ bytes += st.size;
25
+ tracked.set(rel, { size: st.size, mtimeMs: st.mtimeMs });
26
+ }
27
+ const restorable = bytes <= maxSnapshotBytes;
28
+ if (restorable)
29
+ for (const [rel, t] of tracked)
30
+ t.content = readFileSync(path.join(root, rel));
31
+ const dirs = new Map();
32
+ for (const rel of tracked.keys()) {
33
+ const dir = path.dirname(rel);
34
+ if (!dirs.has(dir))
35
+ dirs.set(dir, new Set(readdirSync(path.join(root, dir))));
36
+ }
37
+ const knownIgnored = new Map();
38
+ const ignored = async (rels) => {
39
+ const ask = rels.filter((rel) => !knownIgnored.has(rel));
40
+ if (ask.length) {
41
+ const r = await run('git', ['check-ignore', '--', ...ask], { cwd: root, runtime: ambientRuntime() }).catch(() => undefined);
42
+ const yes = new Set((r?.stdout ?? '').split('\n').filter(Boolean));
43
+ for (const rel of ask)
44
+ knownIgnored.set(rel, yes.has(rel));
45
+ }
46
+ return new Set(rels.filter((rel) => knownIgnored.get(rel)));
47
+ };
48
+ return {
49
+ async check() {
50
+ const changed = [];
51
+ const removed = [];
52
+ for (const [rel, t] of tracked) {
53
+ const abs = path.join(root, rel);
54
+ if (!existsSync(abs)) {
55
+ removed.push(rel);
56
+ continue;
57
+ }
58
+ const st = statSync(abs);
59
+ if (st.size !== t.size || st.mtimeMs !== t.mtimeMs)
60
+ changed.push(rel);
61
+ }
62
+ const fresh = [];
63
+ for (const [dir, before] of dirs) {
64
+ const abs = path.join(root, dir);
65
+ if (!existsSync(abs))
66
+ continue;
67
+ for (const name of readdirSync(abs))
68
+ if (!before.has(name))
69
+ fresh.push(path.join(dir, name));
70
+ }
71
+ const skip = await ignored(fresh);
72
+ return { changed, added: fresh.filter((rel) => !skip.has(rel)), removed };
73
+ },
74
+ restore(change) {
75
+ if (!restorable)
76
+ return false;
77
+ for (const rel of [...change.changed, ...change.removed]) {
78
+ const t = tracked.get(rel);
79
+ if (!t?.content)
80
+ return false;
81
+ mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
82
+ writeFileSync(path.join(root, rel), t.content);
83
+ const st = statSync(path.join(root, rel));
84
+ t.mtimeMs = st.mtimeMs;
85
+ t.size = st.size;
86
+ }
87
+ for (const rel of change.added)
88
+ rmSync(path.join(root, rel), { recursive: true, force: true });
89
+ return true;
90
+ },
91
+ };
92
+ }
package/dist/reports.js CHANGED
@@ -96,12 +96,13 @@ function corrections(s) {
96
96
  return [
97
97
  s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
98
98
  s.deadlocks && `${String(s.deadlocks)} deadlock(s) called by their worker`,
99
+ s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run`,
99
100
  s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
100
101
  s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
101
102
  s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
102
103
  s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
103
104
  s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
104
- 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)` : ''}`,
105
106
  s.crashes && `${String(s.crashes)} worker crash(es)`,
106
107
  ].filter((c) => Boolean(c));
107
108
  }
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;
package/dist/runner.js CHANGED
@@ -120,6 +120,7 @@ function takeCopy(test) {
120
120
  const file = collections.get(test.file?.filepath ?? '')?.[n];
121
121
  return file && find(file, test.id);
122
122
  }
123
+ const dryModules = new Map();
123
124
  const PROBE_ID = '__oxmutate_probe__';
124
125
  async function probe(test, fn) {
125
126
  const file = test.file?.filepath;
@@ -425,6 +426,10 @@ export default class OxmutateRunner extends Base {
425
426
  return super.importFile?.(filepath, source);
426
427
  }
427
428
  async onAfterRunSuite(suite) {
429
+ if (mode === 'dry' && suite.filepath) {
430
+ const files = [...(this.workerState?.evaluatedModules?.fileToModulesMap?.keys() ?? [])];
431
+ dryModules.set(suite.filepath, files.filter((f) => path.isAbsolute(f) && !f.split(path.sep).includes('node_modules')));
432
+ }
428
433
  const copies = suite.filepath ? collections.get(suite.filepath) : undefined;
429
434
  if (suite.filepath && copies) {
430
435
  collections.delete(suite.filepath);
@@ -574,7 +579,7 @@ export default class OxmutateRunner extends Base {
574
579
  if (result?.state === 'fail' && !f.tasks?.length)
575
580
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
576
581
  }
577
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce] });
582
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules) });
578
583
  }
579
584
  else if (mode === 'isolated') {
580
585
  const failed = [];
package/dist/scheduler.js CHANGED
@@ -172,7 +172,7 @@ export function createPool(config, size, workDir, tag) {
172
172
  if (outcome.killedFor || outcome.crashed) {
173
173
  servers[slot] = replacement();
174
174
  }
175
- const more = after(unit, outcome);
175
+ const more = await after(unit, outcome);
176
176
  queue.unshift(...more);
177
177
  busy--;
178
178
  signal();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.3.0",
3
+ "version": "1.5.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
6
  "repository": {