oxmutate 2.1.0 → 2.2.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.darwin-arm64.node +0 -0
- package/binding/oxmutate.darwin-x64.node +0 -0
- package/binding/oxmutate.linux-arm64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-gnu.node +0 -0
- package/binding/oxmutate.linux-x64-musl.node +0 -0
- package/binding/oxmutate.win32-x64-msvc.node +0 -0
- package/dist/child.js +4 -0
- package/dist/cli.js +7 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +6 -1
- package/dist/coordinator.d.ts +14 -0
- package/dist/coordinator.js +48 -11
- package/dist/diskguard.js +2 -2
- package/dist/reports.js +2 -1
- package/dist/runner.js +50 -8
- package/dist/work-dir.js +12 -0
- package/package.json +1 -1
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/child.js
CHANGED
|
@@ -83,6 +83,10 @@ async function runUnit(unit) {
|
|
|
83
83
|
env['OXMUTATE_STATIC'] = JSON.stringify(unit.staticIds);
|
|
84
84
|
else
|
|
85
85
|
delete env['OXMUTATE_STATIC'];
|
|
86
|
+
if (unit.staticGroups)
|
|
87
|
+
env['OXMUTATE_STATIC_GROUPS'] = JSON.stringify(unit.staticGroups);
|
|
88
|
+
else
|
|
89
|
+
delete env['OXMUTATE_STATIC_GROUPS'];
|
|
86
90
|
if (unit.hitLimits)
|
|
87
91
|
env['OXMUTATE_HIT_LIMITS'] = JSON.stringify(unit.hitLimits);
|
|
88
92
|
else
|
package/dist/cli.js
CHANGED
|
@@ -60,6 +60,8 @@ function overridesFrom(o, root) {
|
|
|
60
60
|
overrides.strykerCompat = true;
|
|
61
61
|
if (o['ignoreStatic'] === true)
|
|
62
62
|
overrides.ignoreStatic = true;
|
|
63
|
+
if (typeof o['staticGroup'] === 'number')
|
|
64
|
+
overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
|
|
63
65
|
if (o['incremental'] === true)
|
|
64
66
|
overrides.incremental = true;
|
|
65
67
|
if (typeof o['flakyReruns'] === 'number')
|
|
@@ -90,6 +92,7 @@ const runCommand = defineCommand({
|
|
|
90
92
|
mutantTimeout: { type: 'number', description: 'absolute slack per mutant, ms (default 500; Stryker 5000)' },
|
|
91
93
|
strykerCompat: { type: 'boolean', description: 'reproduce Stryker 10.0.0 skips (no mutants inside `as` casts or enums)' },
|
|
92
94
|
ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
|
|
95
|
+
staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
|
|
93
96
|
incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
|
|
94
97
|
flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
|
|
95
98
|
reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
|
|
@@ -123,6 +126,10 @@ const runCommand = defineCommand({
|
|
|
123
126
|
stderr.write(`oxmutate: the change weakened its tests: ${String(weakened.length)} mutant(s) the tests at ${result.frozen?.base ?? ''} detect now survive (${weakened.slice(0, WEAKENED_SHOWN).map((w) => `${w.file}:${String(w.line)} ${w.mutatorName}`).join('; ')}). fix: restore the assertions the change removed, or replace them with ones that kill these mutants\n`);
|
|
124
127
|
data['exitCode'] = ExitCode.RUNTIME;
|
|
125
128
|
}
|
|
129
|
+
if (result.stats.staticAuditDisagreed > 0) {
|
|
130
|
+
stderr.write(`oxmutate: ${String(result.stats.staticAuditDisagreed)} mutant(s) reported as survivors by a group were killed when run alone; they are reported Killed. fix: run without static.group (--static-group 1); two of this file's mutants hide each other when armed together\n`);
|
|
131
|
+
data['exitCode'] = ExitCode.RUNTIME;
|
|
132
|
+
}
|
|
126
133
|
const floor = options_.thresholds.break;
|
|
127
134
|
if (floor !== null && result.score.mutationScore < floor) {
|
|
128
135
|
stderr.write(`oxmutate: mutation score ${result.score.mutationScore.toFixed(2)}% is below the break threshold ${String(floor)}%. fix: kill the survivors listed above, or lower thresholds.break deliberately\n`);
|
package/dist/config.d.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface Options {
|
|
|
19
19
|
excludedMutations: string[];
|
|
20
20
|
/** Score static mutants (Stryker skips them with `ignoreStatic`). */
|
|
21
21
|
ignoreStatic: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* How many static mutants are armed together in a test file that is not their first (`static.group`).
|
|
24
|
+
* 1, the default, arms each alone. A larger group is faster where most of them survive, and is a
|
|
25
|
+
* heuristic: two mutants can hide each other, so one in twenty group survivors is run again alone.
|
|
26
|
+
*/
|
|
27
|
+
staticGroup: number;
|
|
22
28
|
strykerCompat: boolean;
|
|
23
29
|
reporters: string[];
|
|
24
30
|
jsonReport: string;
|
|
@@ -97,6 +103,9 @@ export interface MutateConfig {
|
|
|
97
103
|
excludedMutations?: string[];
|
|
98
104
|
};
|
|
99
105
|
ignoreStatic?: boolean;
|
|
106
|
+
static?: {
|
|
107
|
+
group?: number;
|
|
108
|
+
};
|
|
100
109
|
reporters?: ('clear-text' | 'json' | 'html' | 'sarif' | 'progress' | 'progress-append-only' | 'ci' | 'dots')[];
|
|
101
110
|
jsonReporter?: {
|
|
102
111
|
fileName?: string;
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,10 @@ import { availableParallelism } from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { resolveEsm } from './resolve.js';
|
|
6
|
+
function staticGroupOf(group) {
|
|
7
|
+
const asked = Math.floor(Number(group));
|
|
8
|
+
return asked >= 2 ? asked : 1;
|
|
9
|
+
}
|
|
6
10
|
export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
|
|
7
11
|
const NOT_NEEDED = {
|
|
8
12
|
inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
|
|
@@ -34,7 +38,7 @@ const DEFAULT_TIMEOUT_FACTOR = 1.5;
|
|
|
34
38
|
const DEFAULT_THRESHOLD_HIGH = 80;
|
|
35
39
|
const DEFAULT_THRESHOLD_LOW = 60;
|
|
36
40
|
const DEFAULT_DRY_RUN_MINUTES = 5;
|
|
37
|
-
const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
|
|
41
|
+
const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'static', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
|
|
38
42
|
async function loadFile(file) {
|
|
39
43
|
if (file.endsWith('.json')) {
|
|
40
44
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -100,6 +104,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
100
104
|
thresholds: { high: thresholds.high ?? DEFAULT_THRESHOLD_HIGH, low: thresholds.low ?? DEFAULT_THRESHOLD_LOW, break: thresholds.break ?? null },
|
|
101
105
|
excludedMutations: mutator.excludedMutations ?? [],
|
|
102
106
|
ignoreStatic: raw['ignoreStatic'] === true,
|
|
107
|
+
staticGroup: staticGroupOf(raw['static']?.group),
|
|
103
108
|
strykerCompat: raw['strykerCompat'] === true,
|
|
104
109
|
reporters: raw['reporters'] ?? ['clear-text', 'json'],
|
|
105
110
|
jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
|
package/dist/coordinator.d.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface RunStats {
|
|
|
42
42
|
staticMs: number;
|
|
43
43
|
/** Unarmed re-collections in that phase, the guard each live static verdict waits on (runner.ts). */
|
|
44
44
|
staticRecollections: number;
|
|
45
|
+
/** Groups of static mutants armed together (`static.group`), and how many had to be split. */
|
|
46
|
+
staticGroupRuns: number;
|
|
47
|
+
staticGroupSplits: number;
|
|
48
|
+
/** Group survivors run again alone, and those that failed alone: kills a group hid, reported Killed. */
|
|
49
|
+
staticAuditSampled: number;
|
|
50
|
+
staticAuditDisagreed: number;
|
|
45
51
|
isolatedMs: number;
|
|
46
52
|
wallMs: number;
|
|
47
53
|
testExecutions: number;
|
|
@@ -70,6 +76,8 @@ export interface RunStats {
|
|
|
70
76
|
pollutionRestarts: number;
|
|
71
77
|
/** 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. */
|
|
72
78
|
diskWrites: number;
|
|
79
|
+
/** The first few paths those writes touched, so a suite's own scratch in a tracked folder can be told from a mutant's write. */
|
|
80
|
+
diskWritePaths: string[];
|
|
73
81
|
/** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
|
|
74
82
|
survivorsOverturned: number;
|
|
75
83
|
/** Watchdog timeouts that finished when re-run with 3x the budget: the machine was slow, not the mutant. */
|
|
@@ -108,6 +116,12 @@ export interface RunResult {
|
|
|
108
116
|
}
|
|
109
117
|
export declare class InstrumentFault extends Error {
|
|
110
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Mutants as groups of at most `size`. Only value mutants are grouped (GROUPABLE), and no two in a
|
|
121
|
+
* group touch the same source text. First fit, in the order given; every other mutant is a group of
|
|
122
|
+
* one.
|
|
123
|
+
*/
|
|
124
|
+
export declare function disjointGroups(mutants: readonly MutantResult[], size: number): string[][];
|
|
111
125
|
/**
|
|
112
126
|
* No single test execution is waited on past this (owner, 2026-11-04: "we should not wait longer than
|
|
113
127
|
* that"). Every watchdog budget is capped at it per test the run executes, the 3x re-run of a busy stop
|
package/dist/coordinator.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
@@ -17,6 +17,7 @@ import { FILE_KEY, env, stderr } from './runtime.js';
|
|
|
17
17
|
import { createPool } from './scheduler.js';
|
|
18
18
|
import { summarizeSpec, tracesFor } from './spec-oracle.js';
|
|
19
19
|
import { SPEC_TRACES_ENV } from './spec.js';
|
|
20
|
+
import { removeWorkDir } from './work-dir.js';
|
|
20
21
|
export class InstrumentFault extends Error {
|
|
21
22
|
}
|
|
22
23
|
function readRecords(file) {
|
|
@@ -41,6 +42,7 @@ const verdictOptions = (o) => ({
|
|
|
41
42
|
timeoutFactor: o.timeoutFactor,
|
|
42
43
|
excludedMutations: [...o.excludedMutations].sort(),
|
|
43
44
|
ignoreStatic: o.ignoreStatic,
|
|
45
|
+
staticGroup: o.staticGroup,
|
|
44
46
|
strykerCompat: o.strykerCompat,
|
|
45
47
|
testOverrides: o.testOverrides,
|
|
46
48
|
vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
|
|
@@ -59,6 +61,22 @@ function timeoutReason(v) {
|
|
|
59
61
|
return 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
60
62
|
}
|
|
61
63
|
const slowKill = (v) => v?.status === 'Killed' && v.slow === true;
|
|
64
|
+
const before = (x, y) => x.line < y.line || (x.line === y.line && x.column < y.column);
|
|
65
|
+
function overlap(a, b) {
|
|
66
|
+
return a.file === b.file && before(a.start, b.end) && before(b.start, a.end);
|
|
67
|
+
}
|
|
68
|
+
const GROUPABLE = new Set(['StringLiteral', 'NumericLiteral', 'Regex']);
|
|
69
|
+
export function disjointGroups(mutants, size) {
|
|
70
|
+
const groups = [];
|
|
71
|
+
for (const m of mutants) {
|
|
72
|
+
const home = GROUPABLE.has(m.mutatorName) ? groups.find((g) => g.length < size && GROUPABLE.has(g[0]?.mutatorName ?? '') && !g.some((other) => overlap(m, other))) : undefined;
|
|
73
|
+
if (home)
|
|
74
|
+
home.push(m);
|
|
75
|
+
else
|
|
76
|
+
groups.push([m]);
|
|
77
|
+
}
|
|
78
|
+
return groups.map((g) => g.map((m) => m.id));
|
|
79
|
+
}
|
|
62
80
|
const RERUN_SLACK = 3;
|
|
63
81
|
export const MAX_TEST_MS = 5000;
|
|
64
82
|
const MS_PER_MINUTE = 60_000;
|
|
@@ -76,8 +94,8 @@ export async function run(options) {
|
|
|
76
94
|
const { root } = options;
|
|
77
95
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
78
96
|
const stats = {
|
|
79
|
-
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 },
|
|
80
|
-
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,
|
|
97
|
+
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, staticGroupRuns: 0, staticGroupSplits: 0, staticAuditSampled: 0, staticAuditDisagreed: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0, warmUnitMs: 0, warmArmedMs: 0, isolatedByCause: { static: 0, stopped: 0, unfinished: 0, rerun: 0, confirm: 0, flaky: 0 },
|
|
98
|
+
watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, diskWritePaths: [], auditSampled: 0, auditDisagreed: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
|
|
81
99
|
};
|
|
82
100
|
let unitSeq = 0;
|
|
83
101
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -378,6 +396,9 @@ export async function run(options) {
|
|
|
378
396
|
const dirty = change !== undefined && !isClean(change);
|
|
379
397
|
if (dirty) {
|
|
380
398
|
stats.diskWrites++;
|
|
399
|
+
for (const p of [...change.changed, ...change.added, ...change.removed])
|
|
400
|
+
if (stats.diskWritePaths.length < 5 && !stats.diskWritePaths.includes(p))
|
|
401
|
+
stats.diskWritePaths.push(p);
|
|
381
402
|
if (!disk?.restore(change)) {
|
|
382
403
|
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`);
|
|
383
404
|
}
|
|
@@ -572,15 +593,19 @@ export async function run(options) {
|
|
|
572
593
|
if (items.length === 0)
|
|
573
594
|
return [];
|
|
574
595
|
const STATIC_BATCH = 25;
|
|
575
|
-
const unitsFor = (filesOf) => {
|
|
596
|
+
const unitsFor = (filesOf, grouped = false) => {
|
|
576
597
|
const byFile = new Map();
|
|
577
598
|
for (const m of items)
|
|
578
599
|
for (const f of filesOf(m))
|
|
579
600
|
(byFile.get(f) ?? byFile.set(f, []).get(f))?.push(m.id);
|
|
580
|
-
return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) =>
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
601
|
+
return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) => {
|
|
602
|
+
const mine = ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH);
|
|
603
|
+
return {
|
|
604
|
+
mode: 'static', files: [file], results: fileFor('static', 'jsonl'), progress: fileFor('static', 'progress'), staticIds: mine,
|
|
605
|
+
...(grouped && options.staticGroup > 1 ? { staticGroups: disjointGroups(mine.map((id) => byId.get(id)), options.staticGroup) } : {}),
|
|
606
|
+
hitLimits: Object.fromEntries(mine.map((id) => [id, hitLimitOf(id)])),
|
|
607
|
+
};
|
|
608
|
+
}));
|
|
584
609
|
};
|
|
585
610
|
const killed = new Map();
|
|
586
611
|
const timedOut = new Set();
|
|
@@ -594,6 +619,16 @@ export async function run(options) {
|
|
|
594
619
|
for (const r of readRecords(unit.results)) {
|
|
595
620
|
if (r.type === 'recollection')
|
|
596
621
|
stats.staticRecollections++;
|
|
622
|
+
if (r.type === 'static-group') {
|
|
623
|
+
stats.staticGroupRuns++;
|
|
624
|
+
if (r.passed !== true)
|
|
625
|
+
stats.staticGroupSplits++;
|
|
626
|
+
}
|
|
627
|
+
if (r.type === 'static-audit') {
|
|
628
|
+
stats.staticAuditSampled++;
|
|
629
|
+
if (r.disagreed === true)
|
|
630
|
+
stats.staticAuditDisagreed++;
|
|
631
|
+
}
|
|
597
632
|
if (r.type === 'static-fallback')
|
|
598
633
|
for (const id of r.ids ?? [])
|
|
599
634
|
back.add(id);
|
|
@@ -616,12 +651,14 @@ export async function run(options) {
|
|
|
616
651
|
};
|
|
617
652
|
const keepUndecided = (unit) => {
|
|
618
653
|
unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
|
|
654
|
+
if (unit.staticGroups)
|
|
655
|
+
unit.staticGroups = unit.staticGroups.map((g) => g.filter((id) => !killed.has(id) && !back.has(id))).filter((g) => g.length > 0);
|
|
619
656
|
return unit.staticIds.length > 0;
|
|
620
657
|
};
|
|
621
658
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
622
659
|
const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
|
|
623
|
-
for (const round of rounds)
|
|
624
|
-
await staticPool.drain(unitsFor(round), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
660
|
+
for (const [i, round] of rounds.entries())
|
|
661
|
+
await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
625
662
|
const undecided = [];
|
|
626
663
|
for (const m of items) {
|
|
627
664
|
const k = killed.get(m.id);
|
|
@@ -819,6 +856,6 @@ export async function run(options) {
|
|
|
819
856
|
if (env['OXMUTATE_KEEP'])
|
|
820
857
|
stderr.write(`oxmutate: work directory kept at ${work}\n`);
|
|
821
858
|
else
|
|
822
|
-
|
|
859
|
+
stderr.write(removeWorkDir(work) ?? '');
|
|
823
860
|
}
|
|
824
861
|
}
|
package/dist/diskguard.js
CHANGED
|
@@ -7,7 +7,7 @@ async function git(cwd, args) {
|
|
|
7
7
|
const r = await run('git', args, { cwd, runtime: ambientRuntime() }).catch(() => undefined);
|
|
8
8
|
return r?.ok ? r.stdout : undefined;
|
|
9
9
|
}
|
|
10
|
-
export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
|
|
10
|
+
export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES, stillThere = existsSync) {
|
|
11
11
|
const listed = await git(root, ['ls-files', '-z', '--cached', '--', '.']);
|
|
12
12
|
if (listed === undefined)
|
|
13
13
|
return undefined;
|
|
@@ -69,7 +69,7 @@ export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
|
|
|
69
69
|
fresh.push(path.join(dir, name));
|
|
70
70
|
}
|
|
71
71
|
const skip = await ignored(fresh);
|
|
72
|
-
return { changed, added: fresh.filter((rel) => !skip.has(rel)), removed };
|
|
72
|
+
return { changed, added: fresh.filter((rel) => !skip.has(rel) && stillThere(path.join(root, rel))), removed };
|
|
73
73
|
},
|
|
74
74
|
restore(change) {
|
|
75
75
|
if (!restorable)
|
package/dist/reports.js
CHANGED
|
@@ -97,12 +97,13 @@ function corrections(s) {
|
|
|
97
97
|
return [
|
|
98
98
|
s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
|
|
99
99
|
s.deadlocks && `${String(s.deadlocks)} deadlock(s) called by their worker`,
|
|
100
|
-
s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run`,
|
|
100
|
+
s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run${s.diskWritePaths.length ? ` (${s.diskWritePaths.join(', ')}; if the tests themselves write there, have git ignore it)` : ''}`,
|
|
101
101
|
s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
|
|
102
102
|
s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
|
|
103
103
|
s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
|
|
104
104
|
s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
|
|
105
105
|
s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
|
|
106
|
+
s.staticAuditDisagreed && `${String(s.staticAuditDisagreed)} kill(s) a static group hid, found by the audit`,
|
|
106
107
|
s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
|
|
107
108
|
s.crashes && `${String(s.crashes)} worker crash(es)`,
|
|
108
109
|
].filter((c) => Boolean(c));
|
package/dist/runner.js
CHANGED
|
@@ -48,6 +48,10 @@ const specTests = new Set(plan.specTests ?? []);
|
|
|
48
48
|
const decidedBySpec = new Set();
|
|
49
49
|
const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
|
|
50
50
|
const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
|
|
51
|
+
const staticGroups = mode === 'static' && env['OXMUTATE_STATIC_GROUPS'] ? JSON.parse(env['OXMUTATE_STATIC_GROUPS']) : [];
|
|
52
|
+
const GROUP_AUDIT_EVERY = 20;
|
|
53
|
+
const auditAll = env['OXMUTATE_STATIC_AUDIT'] === 'all';
|
|
54
|
+
const audited = (id) => auditAll || [...id].reduce((sum, c) => sum + (c.codePointAt(0) ?? 0), 0) % GROUP_AUDIT_EVERY === 0;
|
|
51
55
|
const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
|
|
52
56
|
if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
|
|
53
57
|
ns.hitCount = 0;
|
|
@@ -500,8 +504,10 @@ export default class OxmutateRunner extends Base {
|
|
|
500
504
|
const { vi } = vitest;
|
|
501
505
|
if (!collectTests || !vi)
|
|
502
506
|
return fallback('this Vitest has no public collect');
|
|
503
|
-
const evaluate = async (
|
|
507
|
+
const evaluate = async (arm) => {
|
|
508
|
+
const armed = typeof arm === 'string' ? arm : undefined;
|
|
504
509
|
ns.activeMutant = armed;
|
|
510
|
+
ns.activeGroup = arm !== undefined && typeof arm !== 'string' ? Object.fromEntries(arm.map((id) => [id, true])) : undefined;
|
|
505
511
|
ns.hitCount = armed ? 0 : undefined;
|
|
506
512
|
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
507
513
|
deadlocked = false;
|
|
@@ -517,6 +523,7 @@ export default class OxmutateRunner extends Base {
|
|
|
517
523
|
}
|
|
518
524
|
finally {
|
|
519
525
|
ns.activeMutant = undefined;
|
|
526
|
+
ns.activeGroup = undefined;
|
|
520
527
|
ns.hitCount = undefined;
|
|
521
528
|
ns.hitLimit = undefined;
|
|
522
529
|
}
|
|
@@ -528,16 +535,51 @@ export default class OxmutateRunner extends Base {
|
|
|
528
535
|
held = [];
|
|
529
536
|
fallback(reason, keys);
|
|
530
537
|
};
|
|
531
|
-
|
|
532
|
-
const
|
|
538
|
+
const auditMember = async (member, ran) => {
|
|
539
|
+
const passed = { failed: [], ran, timeout: false };
|
|
540
|
+
if (!audited(member))
|
|
541
|
+
return passed;
|
|
542
|
+
const alone = await evaluate(member);
|
|
543
|
+
if (alone.deadlock)
|
|
544
|
+
return undefined;
|
|
545
|
+
const hid = alone.failed.length > 0 || alone.timeout;
|
|
546
|
+
emit({ type: 'static-audit', id: member, disagreed: hid });
|
|
547
|
+
return hid ? alone : passed;
|
|
548
|
+
};
|
|
549
|
+
const queue = staticGroups.length ? staticGroups.map((g) => [...g]) : rest.map((id) => [id]);
|
|
550
|
+
const decided = (ids) => {
|
|
551
|
+
for (const id of ids)
|
|
552
|
+
rest.splice(rest.indexOf(id), 1);
|
|
553
|
+
};
|
|
554
|
+
while (queue.length) {
|
|
555
|
+
const group = queue.shift();
|
|
556
|
+
const [id = ''] = group;
|
|
533
557
|
mark(`${id} ${String(realNow())}\n`);
|
|
534
|
-
|
|
535
|
-
if (
|
|
558
|
+
const verdict = await evaluate(group.length === 1 ? id : group);
|
|
559
|
+
if (verdict.deadlock)
|
|
536
560
|
return back('torn down: a deadlocked run is still pending in this worker');
|
|
537
561
|
const left = snapshotDiff(before, snapshot());
|
|
538
562
|
if (left.length)
|
|
539
563
|
return back('the mutant left a trace', left);
|
|
540
|
-
if (
|
|
564
|
+
if (group.length > 1) {
|
|
565
|
+
emit({ type: 'static-group', size: group.length, passed: verdict.failed.length === 0 && !verdict.timeout });
|
|
566
|
+
if (verdict.failed.length > 0 || verdict.timeout) {
|
|
567
|
+
const half = Math.ceil(group.length / 2);
|
|
568
|
+
queue.unshift(group.slice(0, half), group.slice(half));
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
for (const member of group) {
|
|
572
|
+
const own = await auditMember(member, verdict.ran);
|
|
573
|
+
if (own === undefined)
|
|
574
|
+
return back('torn down: a deadlocked run is still pending in this worker');
|
|
575
|
+
held.push({ id: member, verdict: own });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
held.push({ id, verdict });
|
|
580
|
+
}
|
|
581
|
+
decided(group);
|
|
582
|
+
if (held.length < every && queue.length)
|
|
541
583
|
continue;
|
|
542
584
|
const unarmed = await evaluate(undefined);
|
|
543
585
|
emit({ type: 'recollection' });
|
|
@@ -546,8 +588,8 @@ export default class OxmutateRunner extends Base {
|
|
|
546
588
|
const after = snapshotDiff(before, snapshot());
|
|
547
589
|
if (after.length)
|
|
548
590
|
return back('the unarmed re-collection left a trace', after);
|
|
549
|
-
for (const { id: done, verdict } of held)
|
|
550
|
-
emit({ type: 'isolated', id: done, killed:
|
|
591
|
+
for (const { id: done, verdict: v } of held)
|
|
592
|
+
emit({ type: 'isolated', id: done, killed: v.failed.length > 0 && !v.timeout, failed: v.failed, ran: v.ran, ...(v.timeout && { timeout: true }), ...(v.deadlock && { deadlock: true }) });
|
|
551
593
|
held = [];
|
|
552
594
|
every = Math.min(STATIC_CANARY_MAX, every * 2);
|
|
553
595
|
}
|
package/dist/work-dir.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
const RETRIES = 10;
|
|
3
|
+
const RETRY_DELAY_MS = 100;
|
|
4
|
+
export function removeWorkDir(dir, remove = rmSync) {
|
|
5
|
+
try {
|
|
6
|
+
remove(dir, { recursive: true, force: true, maxRetries: RETRIES, retryDelay: RETRY_DELAY_MS });
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
return `oxmutate: the work directory ${dir} could not be removed (${error instanceof Error ? error.message : String(error)}). The run's results are not affected. fix: remove it by hand\n`;
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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
|
"author": "Ofri Peretz <ofriperetzdev@gmail.com>",
|