oxmutate 2.0.2 → 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 +10 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +7 -1
- package/dist/coordinator.d.ts +14 -0
- package/dist/coordinator.js +50 -13
- package/dist/diskguard.js +2 -2
- package/dist/reports.js +20 -3
- 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
|
@@ -58,6 +58,10 @@ function overridesFrom(o, root) {
|
|
|
58
58
|
overrides.timeoutMS = o['mutantTimeout'];
|
|
59
59
|
if (o['strykerCompat'] === true)
|
|
60
60
|
overrides.strykerCompat = true;
|
|
61
|
+
if (o['ignoreStatic'] === true)
|
|
62
|
+
overrides.ignoreStatic = true;
|
|
63
|
+
if (typeof o['staticGroup'] === 'number')
|
|
64
|
+
overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
|
|
61
65
|
if (o['incremental'] === true)
|
|
62
66
|
overrides.incremental = true;
|
|
63
67
|
if (typeof o['flakyReruns'] === 'number')
|
|
@@ -87,6 +91,8 @@ const runCommand = defineCommand({
|
|
|
87
91
|
concurrency: { type: 'number', description: 'worker processes (default: cores - 1, at most 8)' },
|
|
88
92
|
mutantTimeout: { type: 'number', description: 'absolute slack per mutant, ms (default 500; Stryker 5000)' },
|
|
89
93
|
strykerCompat: { type: 'boolean', description: 'reproduce Stryker 10.0.0 skips (no mutants inside `as` casts or enums)' },
|
|
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)' },
|
|
90
96
|
incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
|
|
91
97
|
flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
|
|
92
98
|
reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
|
|
@@ -120,6 +126,10 @@ const runCommand = defineCommand({
|
|
|
120
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`);
|
|
121
127
|
data['exitCode'] = ExitCode.RUNTIME;
|
|
122
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
|
+
}
|
|
123
133
|
const floor = options_.thresholds.break;
|
|
124
134
|
if (floor !== null && result.score.mutationScore < floor) {
|
|
125
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
|
@@ -6,6 +6,8 @@ export interface Thresholds {
|
|
|
6
6
|
/** Fail the run (exit 1) below this score. `null`: never fail on score. */
|
|
7
7
|
break: number | null;
|
|
8
8
|
}
|
|
9
|
+
/** Why a static mutant is Ignored under `ignoreStatic`; the summary counts them by it. */
|
|
10
|
+
export declare const IGNORE_STATIC_REASON = "static mutant (ignoreStatic)";
|
|
9
11
|
export interface Options {
|
|
10
12
|
root: string;
|
|
11
13
|
mutate: string[];
|
|
@@ -17,6 +19,12 @@ export interface Options {
|
|
|
17
19
|
excludedMutations: string[];
|
|
18
20
|
/** Score static mutants (Stryker skips them with `ignoreStatic`). */
|
|
19
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;
|
|
20
28
|
strykerCompat: boolean;
|
|
21
29
|
reporters: string[];
|
|
22
30
|
jsonReport: string;
|
|
@@ -95,6 +103,9 @@ export interface MutateConfig {
|
|
|
95
103
|
excludedMutations?: string[];
|
|
96
104
|
};
|
|
97
105
|
ignoreStatic?: boolean;
|
|
106
|
+
static?: {
|
|
107
|
+
group?: number;
|
|
108
|
+
};
|
|
98
109
|
reporters?: ('clear-text' | 'json' | 'html' | 'sarif' | 'progress' | 'progress-append-only' | 'ci' | 'dots')[];
|
|
99
110
|
jsonReporter?: {
|
|
100
111
|
fileName?: string;
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,11 @@ 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
|
+
}
|
|
10
|
+
export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
|
|
6
11
|
const NOT_NEEDED = {
|
|
7
12
|
inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
|
|
8
13
|
tempDirName: 'no sandbox directory exists',
|
|
@@ -33,7 +38,7 @@ const DEFAULT_TIMEOUT_FACTOR = 1.5;
|
|
|
33
38
|
const DEFAULT_THRESHOLD_HIGH = 80;
|
|
34
39
|
const DEFAULT_THRESHOLD_LOW = 60;
|
|
35
40
|
const DEFAULT_DRY_RUN_MINUTES = 5;
|
|
36
|
-
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']);
|
|
37
42
|
async function loadFile(file) {
|
|
38
43
|
if (file.endsWith('.json')) {
|
|
39
44
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -99,6 +104,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
99
104
|
thresholds: { high: thresholds.high ?? DEFAULT_THRESHOLD_HIGH, low: thresholds.low ?? DEFAULT_THRESHOLD_LOW, break: thresholds.break ?? null },
|
|
100
105
|
excludedMutations: mutator.excludedMutations ?? [],
|
|
101
106
|
ignoreStatic: raw['ignoreStatic'] === true,
|
|
107
|
+
staticGroup: staticGroupOf(raw['static']?.group),
|
|
102
108
|
strykerCompat: raw['strykerCompat'] === true,
|
|
103
109
|
reporters: raw['reporters'] ?? ['clear-text', 'json'],
|
|
104
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,11 +1,11 @@
|
|
|
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';
|
|
6
6
|
import { checkSurvivors } from './checker.js';
|
|
7
7
|
import { runCommandMutants } from './command-runner.js';
|
|
8
|
-
import { disables } from './config.js';
|
|
8
|
+
import { IGNORE_STATIC_REASON, disables } from './config.js';
|
|
9
9
|
import { rangeDisables } from './directives.js';
|
|
10
10
|
import { guardDisk, isClean } from './diskguard.js';
|
|
11
11
|
import { globToRegExp, matchFiles } from './files.js';
|
|
@@ -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}`);
|
|
@@ -271,7 +289,7 @@ export async function run(options) {
|
|
|
271
289
|
m.static = true;
|
|
272
290
|
if (options.ignoreStatic) {
|
|
273
291
|
m.status = 'Ignored';
|
|
274
|
-
m.statusReason =
|
|
292
|
+
m.statusReason = IGNORE_STATIC_REASON;
|
|
275
293
|
}
|
|
276
294
|
}
|
|
277
295
|
}
|
|
@@ -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { IGNORE_STATIC_REASON } from './config.js';
|
|
4
5
|
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
5
6
|
const REPLACEMENT_WIDTH = 60;
|
|
6
7
|
const ELLIPSIS = '...';
|
|
@@ -96,12 +97,13 @@ function corrections(s) {
|
|
|
96
97
|
return [
|
|
97
98
|
s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
|
|
98
99
|
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`,
|
|
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)` : ''}`,
|
|
100
101
|
s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
|
|
101
102
|
s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
|
|
102
103
|
s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
|
|
103
104
|
s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
|
|
104
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`,
|
|
105
107
|
s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
|
|
106
108
|
s.crashes && `${String(s.crashes)} worker crash(es)`,
|
|
107
109
|
].filter((c) => Boolean(c));
|
|
@@ -116,10 +118,25 @@ function frozenLine(r) {
|
|
|
116
118
|
const sign = f.delta >= 0 ? '+' : '';
|
|
117
119
|
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
120
|
}
|
|
119
|
-
|
|
121
|
+
const STATIC_SHARE_WORTH_SAYING = 0.5;
|
|
122
|
+
const PERCENT = 100;
|
|
123
|
+
function staticLine(r) {
|
|
124
|
+
const statics = r.mutants.filter((m) => m.static);
|
|
125
|
+
if (statics.length === 0)
|
|
126
|
+
return [];
|
|
127
|
+
const ignored = statics.filter((m) => m.statusReason === IGNORE_STATIC_REASON).length;
|
|
128
|
+
if (ignored > 0)
|
|
129
|
+
return [` static mutants: ${String(ignored)} ignored (ignoreStatic), not scored`];
|
|
130
|
+
const share = r.stats.wallMs > 0 ? r.stats.staticMs / r.stats.wallMs : 0;
|
|
131
|
+
const hint = share >= STATIC_SHARE_WORTH_SAYING ? '; --ignore-static skips them, and the score then leaves them out' : '';
|
|
132
|
+
return [` static mutants: ${String(statics.length)} decided in ${(r.stats.staticMs / MS_PER_S).toFixed(1)} s, ${(share * PERCENT).toFixed(0)}% of the run${hint}`];
|
|
133
|
+
}
|
|
134
|
+
function whereItWent(r) {
|
|
135
|
+
const s = r.stats;
|
|
120
136
|
const lines = [];
|
|
121
137
|
if (s.warmUnitMs > 0)
|
|
122
138
|
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`);
|
|
139
|
+
lines.push(...staticLine(r));
|
|
123
140
|
const c = s.isolatedByCause;
|
|
124
141
|
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
142
|
if (s.isolatedRuns > 0)
|
|
@@ -142,7 +159,7 @@ export function textSummary(r, opts = {}) {
|
|
|
142
159
|
const corrected = corrections(s);
|
|
143
160
|
if (corrected.length)
|
|
144
161
|
lines.push(` corrected: ${corrected.join(' · ')}`);
|
|
145
|
-
lines.push(...whereItWent(
|
|
162
|
+
lines.push(...whereItWent(r));
|
|
146
163
|
lines.push(...frozenLine(r));
|
|
147
164
|
lines.push(...specLines(r));
|
|
148
165
|
const survivors = r.mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
|
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.0
|
|
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>",
|