oxmutate 1.3.0 → 1.4.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;
@@ -50,6 +52,8 @@ export interface RunStats {
50
52
  canaryFiles: number;
51
53
  /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
52
54
  pollutionRestarts: number;
55
+ /** 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. */
56
+ diskWrites: number;
53
57
  /** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
54
58
  survivorsOverturned: number;
55
59
  /** Watchdog timeouts that finished when re-run with 3x the budget: the machine was slow, not the mutant. */
@@ -7,6 +7,7 @@ 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';
@@ -53,7 +54,7 @@ export async function run(options) {
53
54
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
54
55
  const stats = {
55
56
  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,
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,
57
58
  };
58
59
  let unitSeq = 0;
59
60
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -332,12 +333,58 @@ export async function run(options) {
332
333
  return units.sort((a, b) => weight(b) - weight(a));
333
334
  };
334
335
  const records = [];
336
+ const disk = env['OXMUTATE_NO_DISKGUARD'] ? undefined : await guardDisk(root);
337
+ const inFlight = new Set();
338
+ const tainted = new Set();
339
+ const writers = new Map();
340
+ const diskWrittenDuring = async (unit) => {
341
+ const change = await disk?.check();
342
+ const dirty = change !== undefined && !isClean(change);
343
+ if (dirty) {
344
+ stats.diskWrites++;
345
+ if (!disk?.restore(change)) {
346
+ 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`);
347
+ }
348
+ for (const other of inFlight)
349
+ tainted.add(other);
350
+ }
351
+ if (!dirty && !tainted.delete(unit.results))
352
+ return undefined;
353
+ const entry = planUnits.get(unit.results);
354
+ if (!entry)
355
+ return [];
356
+ const ids = [...new Set(Object.values(entry.plan).flat())];
357
+ const paths = change ? [...change.changed, ...change.added, ...change.removed] : [];
358
+ if (dirty && ids.length === 1) {
359
+ const id = ids[0];
360
+ writers.set(id, paths);
361
+ if (inFlight.size === 0)
362
+ records.push(...readRecords(unit.results));
363
+ else
364
+ unfinished.add(id);
365
+ return [];
366
+ }
367
+ if (entry.depth >= 40) {
368
+ for (const id of ids)
369
+ unfinished.add(id);
370
+ return [];
371
+ }
372
+ if (ids.length < 2)
373
+ return [makeUnit(entry.file, entry.plan, entry.depth + 1)];
374
+ const half = new Set(ids.slice(0, Math.ceil(ids.length / 2)));
375
+ 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));
376
+ return [makeUnit(entry.file, pick(true), entry.depth + 1), makeUnit(entry.file, pick(false), entry.depth + 1)];
377
+ };
335
378
  const runaways = new Set();
336
379
  const crashedIds = new Set();
337
380
  const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
338
381
  pools.push(pool);
339
382
  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) => {
383
+ const afterUnit = async (unit, outcome) => {
384
+ inFlight.delete(unit.results);
385
+ const wrote = await diskWrittenDuring(unit);
386
+ if (wrote)
387
+ return wrote;
341
388
  const raw = readRecords(unit.results);
342
389
  const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
343
390
  const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
@@ -380,6 +427,7 @@ export async function run(options) {
380
427
  progress.phase('warm runs', killedCount);
381
428
  const m0 = performance.now();
382
429
  const beforeUnit = (unit) => {
430
+ inFlight.add(unit.results);
383
431
  const entry = planUnits.get(unit.results);
384
432
  if (!entry)
385
433
  return true;
@@ -391,8 +439,10 @@ export async function run(options) {
391
439
  if (keep.length)
392
440
  left[t] = keep;
393
441
  }
394
- if (Object.keys(left).length === 0)
442
+ if (Object.keys(left).length === 0) {
443
+ inFlight.delete(unit.results);
395
444
  return false;
445
+ }
396
446
  entry.plan = left;
397
447
  writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()], runOnce: dry.runOnce, testMs: testMsOf(left) }));
398
448
  return true;
@@ -670,6 +720,11 @@ export async function run(options) {
670
720
  stats.isolatedMs = performance.now() - i0;
671
721
  stats.watchdogKills = pool.stats.killed;
672
722
  stats.idleStalls = pool.stats.idle;
723
+ for (const [id, paths] of writers) {
724
+ const m = byId.get(id);
725
+ if (m)
726
+ m.wrote = paths;
727
+ }
673
728
  stats.deadlocks = mutants.filter((m) => m.statusReason === DEADLOCK_REASON).length;
674
729
  stats.crashes = pool.stats.crashed;
675
730
  if (options.incremental) {
@@ -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,6 +96,7 @@ 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`,
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.4.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": {