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.
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/coordinator.d.ts +4 -0
- package/dist/coordinator.js +58 -3
- package/dist/diskguard.js +92 -0
- package/dist/reports.js +1 -0
- package/dist/scheduler.js +1 -1
- package/package.json +1 -1
|
Binary file
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -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. */
|
package/dist/coordinator.js
CHANGED
|
@@ -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
|
+
"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": {
|