oxmutate 2.1.0 → 2.3.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 +17 -0
- package/dist/config.js +12 -1
- package/dist/coordinator.d.ts +14 -0
- package/dist/coordinator.js +58 -12
- package/dist/diskguard.js +2 -2
- package/dist/reports.js +6 -2
- 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,10 @@ 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']));
|
|
65
|
+
if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
|
|
66
|
+
overrides.staticBudgetMs = Math.floor(o['staticBudget']);
|
|
63
67
|
if (o['incremental'] === true)
|
|
64
68
|
overrides.incremental = true;
|
|
65
69
|
if (typeof o['flakyReruns'] === 'number')
|
|
@@ -90,6 +94,8 @@ const runCommand = defineCommand({
|
|
|
90
94
|
mutantTimeout: { type: 'number', description: 'absolute slack per mutant, ms (default 500; Stryker 5000)' },
|
|
91
95
|
strykerCompat: { type: 'boolean', description: 'reproduce Stryker 10.0.0 skips (no mutants inside `as` casts or enums)' },
|
|
92
96
|
ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
|
|
97
|
+
staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
|
|
98
|
+
staticBudget: { type: 'number', description: 'the most the static phase may take, ms; static mutants still undecided then are left out of the score' },
|
|
93
99
|
incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
|
|
94
100
|
flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
|
|
95
101
|
reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
|
|
@@ -123,6 +129,10 @@ const runCommand = defineCommand({
|
|
|
123
129
|
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
130
|
data['exitCode'] = ExitCode.RUNTIME;
|
|
125
131
|
}
|
|
132
|
+
if (result.stats.staticAuditDisagreed > 0) {
|
|
133
|
+
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`);
|
|
134
|
+
data['exitCode'] = ExitCode.RUNTIME;
|
|
135
|
+
}
|
|
126
136
|
const floor = options_.thresholds.break;
|
|
127
137
|
if (floor !== null && result.score.mutationScore < floor) {
|
|
128
138
|
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 when `static.budgetMs` ran out before it was decided; the summary counts them by it. */
|
|
10
|
+
export declare const STATIC_BUDGET_REASON = "static mutant (static budget spent)";
|
|
9
11
|
/** Why a static mutant is Ignored under `ignoreStatic`; the summary counts them by it. */
|
|
10
12
|
export declare const IGNORE_STATIC_REASON = "static mutant (ignoreStatic)";
|
|
11
13
|
export interface Options {
|
|
@@ -19,6 +21,18 @@ export interface Options {
|
|
|
19
21
|
excludedMutations: string[];
|
|
20
22
|
/** Score static mutants (Stryker skips them with `ignoreStatic`). */
|
|
21
23
|
ignoreStatic: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* How many static mutants are armed together in a test file that is not their first (`static.group`).
|
|
26
|
+
* 1, the default, arms each alone. A larger group is faster where most of them survive, and is a
|
|
27
|
+
* heuristic: two mutants can hide each other, so one in twenty group survivors is run again alone.
|
|
28
|
+
*/
|
|
29
|
+
staticGroup: number;
|
|
30
|
+
/**
|
|
31
|
+
* The most the static phase may take, in milliseconds (`static.budgetMs`). No new static unit
|
|
32
|
+
* starts once it is spent; a static mutant still undecided then is Ignored, with a reason, and
|
|
33
|
+
* left out of the score. A kill found before that stands. Unset: no limit.
|
|
34
|
+
*/
|
|
35
|
+
staticBudgetMs?: number | undefined;
|
|
22
36
|
strykerCompat: boolean;
|
|
23
37
|
reporters: string[];
|
|
24
38
|
jsonReport: string;
|
|
@@ -97,6 +111,9 @@ export interface MutateConfig {
|
|
|
97
111
|
excludedMutations?: string[];
|
|
98
112
|
};
|
|
99
113
|
ignoreStatic?: boolean;
|
|
114
|
+
static?: {
|
|
115
|
+
group?: number;
|
|
116
|
+
};
|
|
100
117
|
reporters?: ('clear-text' | 'json' | 'html' | 'sarif' | 'progress' | 'progress-append-only' | 'ci' | 'dots')[];
|
|
101
118
|
jsonReporter?: {
|
|
102
119
|
fileName?: string;
|
package/dist/config.js
CHANGED
|
@@ -3,6 +3,15 @@ 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
|
+
function staticBudgetOf(ms) {
|
|
11
|
+
const asked = Math.floor(Number(ms));
|
|
12
|
+
return asked >= 1 ? asked : undefined;
|
|
13
|
+
}
|
|
14
|
+
export const STATIC_BUDGET_REASON = 'static mutant (static budget spent)';
|
|
6
15
|
export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
|
|
7
16
|
const NOT_NEEDED = {
|
|
8
17
|
inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
|
|
@@ -34,7 +43,7 @@ const DEFAULT_TIMEOUT_FACTOR = 1.5;
|
|
|
34
43
|
const DEFAULT_THRESHOLD_HIGH = 80;
|
|
35
44
|
const DEFAULT_THRESHOLD_LOW = 60;
|
|
36
45
|
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']);
|
|
46
|
+
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
47
|
async function loadFile(file) {
|
|
39
48
|
if (file.endsWith('.json')) {
|
|
40
49
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -100,6 +109,8 @@ export async function loadOptions(root, overrides = {}, configFile) {
|
|
|
100
109
|
thresholds: { high: thresholds.high ?? DEFAULT_THRESHOLD_HIGH, low: thresholds.low ?? DEFAULT_THRESHOLD_LOW, break: thresholds.break ?? null },
|
|
101
110
|
excludedMutations: mutator.excludedMutations ?? [],
|
|
102
111
|
ignoreStatic: raw['ignoreStatic'] === true,
|
|
112
|
+
staticGroup: staticGroupOf(raw['static']?.group),
|
|
113
|
+
staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
|
|
103
114
|
strykerCompat: raw['strykerCompat'] === true,
|
|
104
115
|
reporters: raw['reporters'] ?? ['clear-text', 'json'],
|
|
105
116
|
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 { IGNORE_STATIC_REASON, disables } from './config.js';
|
|
8
|
+
import { IGNORE_STATIC_REASON, STATIC_BUDGET_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,8 @@ const verdictOptions = (o) => ({
|
|
|
41
42
|
timeoutFactor: o.timeoutFactor,
|
|
42
43
|
excludedMutations: [...o.excludedMutations].sort(),
|
|
43
44
|
ignoreStatic: o.ignoreStatic,
|
|
45
|
+
staticGroup: o.staticGroup,
|
|
46
|
+
staticBudgetMs: o.staticBudgetMs,
|
|
44
47
|
strykerCompat: o.strykerCompat,
|
|
45
48
|
testOverrides: o.testOverrides,
|
|
46
49
|
vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
|
|
@@ -59,6 +62,22 @@ function timeoutReason(v) {
|
|
|
59
62
|
return 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
60
63
|
}
|
|
61
64
|
const slowKill = (v) => v?.status === 'Killed' && v.slow === true;
|
|
65
|
+
const before = (x, y) => x.line < y.line || (x.line === y.line && x.column < y.column);
|
|
66
|
+
function overlap(a, b) {
|
|
67
|
+
return a.file === b.file && before(a.start, b.end) && before(b.start, a.end);
|
|
68
|
+
}
|
|
69
|
+
const GROUPABLE = new Set(['StringLiteral', 'NumericLiteral', 'Regex']);
|
|
70
|
+
export function disjointGroups(mutants, size) {
|
|
71
|
+
const groups = [];
|
|
72
|
+
for (const m of mutants) {
|
|
73
|
+
const home = GROUPABLE.has(m.mutatorName) ? groups.find((g) => g.length < size && GROUPABLE.has(g[0]?.mutatorName ?? '') && !g.some((other) => overlap(m, other))) : undefined;
|
|
74
|
+
if (home)
|
|
75
|
+
home.push(m);
|
|
76
|
+
else
|
|
77
|
+
groups.push([m]);
|
|
78
|
+
}
|
|
79
|
+
return groups.map((g) => g.map((m) => m.id));
|
|
80
|
+
}
|
|
62
81
|
const RERUN_SLACK = 3;
|
|
63
82
|
export const MAX_TEST_MS = 5000;
|
|
64
83
|
const MS_PER_MINUTE = 60_000;
|
|
@@ -76,8 +95,8 @@ export async function run(options) {
|
|
|
76
95
|
const { root } = options;
|
|
77
96
|
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
78
97
|
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,
|
|
98
|
+
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 },
|
|
99
|
+
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
100
|
};
|
|
82
101
|
let unitSeq = 0;
|
|
83
102
|
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
@@ -378,6 +397,9 @@ export async function run(options) {
|
|
|
378
397
|
const dirty = change !== undefined && !isClean(change);
|
|
379
398
|
if (dirty) {
|
|
380
399
|
stats.diskWrites++;
|
|
400
|
+
for (const p of [...change.changed, ...change.added, ...change.removed])
|
|
401
|
+
if (stats.diskWritePaths.length < 5 && !stats.diskWritePaths.includes(p))
|
|
402
|
+
stats.diskWritePaths.push(p);
|
|
381
403
|
if (!disk?.restore(change)) {
|
|
382
404
|
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
405
|
}
|
|
@@ -572,15 +594,19 @@ export async function run(options) {
|
|
|
572
594
|
if (items.length === 0)
|
|
573
595
|
return [];
|
|
574
596
|
const STATIC_BATCH = 25;
|
|
575
|
-
const unitsFor = (filesOf) => {
|
|
597
|
+
const unitsFor = (filesOf, grouped = false) => {
|
|
576
598
|
const byFile = new Map();
|
|
577
599
|
for (const m of items)
|
|
578
600
|
for (const f of filesOf(m))
|
|
579
601
|
(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
|
-
|
|
602
|
+
return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) => {
|
|
603
|
+
const mine = ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH);
|
|
604
|
+
return {
|
|
605
|
+
mode: 'static', files: [file], results: fileFor('static', 'jsonl'), progress: fileFor('static', 'progress'), staticIds: mine,
|
|
606
|
+
...(grouped && options.staticGroup > 1 ? { staticGroups: disjointGroups(mine.map((id) => byId.get(id)), options.staticGroup) } : {}),
|
|
607
|
+
hitLimits: Object.fromEntries(mine.map((id) => [id, hitLimitOf(id)])),
|
|
608
|
+
};
|
|
609
|
+
}));
|
|
584
610
|
};
|
|
585
611
|
const killed = new Map();
|
|
586
612
|
const timedOut = new Set();
|
|
@@ -594,6 +620,16 @@ export async function run(options) {
|
|
|
594
620
|
for (const r of readRecords(unit.results)) {
|
|
595
621
|
if (r.type === 'recollection')
|
|
596
622
|
stats.staticRecollections++;
|
|
623
|
+
if (r.type === 'static-group') {
|
|
624
|
+
stats.staticGroupRuns++;
|
|
625
|
+
if (r.passed !== true)
|
|
626
|
+
stats.staticGroupSplits++;
|
|
627
|
+
}
|
|
628
|
+
if (r.type === 'static-audit') {
|
|
629
|
+
stats.staticAuditSampled++;
|
|
630
|
+
if (r.disagreed === true)
|
|
631
|
+
stats.staticAuditDisagreed++;
|
|
632
|
+
}
|
|
597
633
|
if (r.type === 'static-fallback')
|
|
598
634
|
for (const id of r.ids ?? [])
|
|
599
635
|
back.add(id);
|
|
@@ -614,14 +650,20 @@ export async function run(options) {
|
|
|
614
650
|
back.add(id);
|
|
615
651
|
return [];
|
|
616
652
|
};
|
|
653
|
+
const deadline = options.staticBudgetMs === undefined ? Infinity : performance.now() + options.staticBudgetMs;
|
|
654
|
+
const spent = () => performance.now() >= deadline;
|
|
617
655
|
const keepUndecided = (unit) => {
|
|
656
|
+
if (spent())
|
|
657
|
+
return false;
|
|
618
658
|
unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
|
|
659
|
+
if (unit.staticGroups)
|
|
660
|
+
unit.staticGroups = unit.staticGroups.map((g) => g.filter((id) => !killed.has(id) && !back.has(id))).filter((g) => g.length > 0);
|
|
619
661
|
return unit.staticIds.length > 0;
|
|
620
662
|
};
|
|
621
663
|
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
622
664
|
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);
|
|
665
|
+
for (const [i, round] of rounds.entries())
|
|
666
|
+
await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
|
|
625
667
|
const undecided = [];
|
|
626
668
|
for (const m of items) {
|
|
627
669
|
const k = killed.get(m.id);
|
|
@@ -634,6 +676,10 @@ export async function run(options) {
|
|
|
634
676
|
m.status = 'Survived';
|
|
635
677
|
m.testsCompleted = ran.get(m.id) ?? 0;
|
|
636
678
|
}
|
|
679
|
+
else if (spent()) {
|
|
680
|
+
m.status = 'Ignored';
|
|
681
|
+
m.statusReason = STATIC_BUDGET_REASON;
|
|
682
|
+
}
|
|
637
683
|
else
|
|
638
684
|
undecided.push(m);
|
|
639
685
|
}
|
|
@@ -819,6 +865,6 @@ export async function run(options) {
|
|
|
819
865
|
if (env['OXMUTATE_KEEP'])
|
|
820
866
|
stderr.write(`oxmutate: work directory kept at ${work}\n`);
|
|
821
867
|
else
|
|
822
|
-
|
|
868
|
+
stderr.write(removeWorkDir(work) ?? '');
|
|
823
869
|
}
|
|
824
870
|
}
|
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,7 +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
|
+
import { IGNORE_STATIC_REASON, STATIC_BUDGET_REASON } from './config.js';
|
|
5
5
|
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
6
6
|
const REPLACEMENT_WIDTH = 60;
|
|
7
7
|
const ELLIPSIS = '...';
|
|
@@ -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));
|
|
@@ -127,6 +128,9 @@ function staticLine(r) {
|
|
|
127
128
|
if (ignored > 0)
|
|
128
129
|
return [` static mutants: ${String(ignored)} ignored (ignoreStatic), not scored`];
|
|
129
130
|
const share = r.stats.wallMs > 0 ? r.stats.staticMs / r.stats.wallMs : 0;
|
|
131
|
+
const unspent = statics.filter((m) => m.statusReason === STATIC_BUDGET_REASON).length;
|
|
132
|
+
if (unspent > 0)
|
|
133
|
+
return [` static mutants: ${String(statics.length - unspent)} decided in ${(r.stats.staticMs / MS_PER_S).toFixed(1)} s, ${String(unspent)} left undecided when the static budget ran out, not scored`];
|
|
130
134
|
const hint = share >= STATIC_SHARE_WORTH_SAYING ? '; --ignore-static skips them, and the score then leaves them out' : '';
|
|
131
135
|
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}`];
|
|
132
136
|
}
|
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.3.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>",
|