oxmutate 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +378 -2
- 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/bin.js +5 -0
- package/dist/checker.js +122 -0
- package/dist/child.js +125 -0
- package/dist/cli.js +165 -0
- package/dist/command-runner.js +83 -0
- package/dist/config.d.ts +141 -0
- package/dist/config.js +172 -0
- package/dist/coordinator.d.ts +83 -0
- package/dist/coordinator.js +653 -0
- package/dist/files.js +62 -0
- package/dist/frozen.d.ts +19 -0
- package/dist/frozen.js +47 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +9 -0
- package/dist/lanes.d.ts +38 -0
- package/dist/lanes.js +92 -0
- package/dist/native.d.ts +36 -0
- package/dist/native.js +40 -0
- package/dist/platform.js +27 -0
- package/dist/progress.js +40 -0
- package/dist/reports.d.ts +20 -0
- package/dist/reports.js +225 -0
- package/dist/resolve.js +43 -0
- package/dist/runner.d.ts +49 -0
- package/dist/runner.js +270 -0
- package/dist/runtime.js +24 -0
- package/dist/scheduler.js +170 -0
- package/dist/spec-oracle.d.ts +69 -0
- package/dist/spec-oracle.js +68 -0
- package/dist/spec.d.ts +21 -0
- package/dist/spec.js +75 -0
- package/dist/vite-plugin.js +18 -0
- package/dist/vite.d.ts +16 -0
- package/dist/vite.js +3 -0
- package/package.json +83 -4
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One mutation run, end to end:
|
|
3
|
+
*
|
|
4
|
+
* instrument (native, parallel) → dry run ×2 (coverage, timings, nondeterministic tests)
|
|
5
|
+
* → incremental reuse → plan → warm units on a work-stealing server pool, under a watchdog
|
|
6
|
+
* → isolated re-runs (static mutants, canary-flagged files, unfinished work, flaky verdicts)
|
|
7
|
+
* → verdicts
|
|
8
|
+
*
|
|
9
|
+
* Every decision that can make a verdict wrong is guarded and counted in `stats`, so a run says
|
|
10
|
+
* what it had to correct, not just what it concluded.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { ambientRuntime, run as spawn } from 'bellpull';
|
|
17
|
+
import { checkSurvivors } from './checker.js';
|
|
18
|
+
import { runCommandMutants } from './command-runner.js';
|
|
19
|
+
import { disables } from './config.js';
|
|
20
|
+
import { globToRegExp, matchFiles } from './files.js';
|
|
21
|
+
import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
|
|
22
|
+
import { native } from './native.js';
|
|
23
|
+
import { startProgress } from './progress.js';
|
|
24
|
+
import { FILE_KEY, env, stderr } from './runtime.js';
|
|
25
|
+
import { createPool } from './scheduler.js';
|
|
26
|
+
import { summarizeSpec, tracesFor } from './spec-oracle.js';
|
|
27
|
+
import { SPEC_TRACES_ENV } from './spec.js';
|
|
28
|
+
export class InstrumentFault extends Error {
|
|
29
|
+
}
|
|
30
|
+
function readRecords(file) {
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return [];
|
|
33
|
+
return readFileSync(file, 'utf8')
|
|
34
|
+
.split('\n')
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((l) => JSON.parse(l));
|
|
37
|
+
}
|
|
38
|
+
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
39
|
+
/** Work units of at most this many (mutant, test) executions: small enough to steal. */
|
|
40
|
+
const UNIT_PAIRS = 120;
|
|
41
|
+
const MS_PER_MINUTE = 60_000;
|
|
42
|
+
const PERCENT = 100;
|
|
43
|
+
/** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
|
|
44
|
+
const allCovering = (m) => [...m.coveredBy, ...(m.specCoveredBy ?? [])];
|
|
45
|
+
const percent = (n, of) => (of ? (PERCENT * n) / of : PERCENT);
|
|
46
|
+
/** Stryker's score: detected over everything valid and not ignored; `covered` leaves out no-coverage. */
|
|
47
|
+
function scoreOf(mutants) {
|
|
48
|
+
const scored = mutants.filter((m) => !['Ignored', 'CompileError', 'RuntimeError'].includes(m.status));
|
|
49
|
+
const detected = scored.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
|
|
50
|
+
const noCoverage = scored.filter((m) => m.status === 'NoCoverage').length;
|
|
51
|
+
return { detected, undetected: scored.length - detected, total: scored.length, mutationScore: percent(detected, scored.length), covered: percent(detected, scored.length - noCoverage) };
|
|
52
|
+
}
|
|
53
|
+
export async function run(options) {
|
|
54
|
+
const wall0 = performance.now();
|
|
55
|
+
const { root } = options;
|
|
56
|
+
const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
|
|
57
|
+
const stats = {
|
|
58
|
+
files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
|
|
59
|
+
watchdogKills: 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,
|
|
60
|
+
};
|
|
61
|
+
let unitSeq = 0;
|
|
62
|
+
const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
|
|
63
|
+
const pools = [];
|
|
64
|
+
const progress = startProgress(options.reporters, stderr);
|
|
65
|
+
try {
|
|
66
|
+
progress.phase('instrumenting');
|
|
67
|
+
// ── 1. Instrument ─────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// Stryker's `file:line-line` entries select files by their glob and mutants by their range.
|
|
69
|
+
const specs = options.mutate.map(parseMutateSpec);
|
|
70
|
+
const files = matchFiles(root, specs.map((x) => x.glob));
|
|
71
|
+
const rangesFor = (file) => {
|
|
72
|
+
const here = specs.filter((x) => !x.glob.startsWith('!') && globToRegExp(x.glob).test(file));
|
|
73
|
+
return here.some((x) => !x.range) ? undefined : here.flatMap((x) => (x.range ? [x.range] : []));
|
|
74
|
+
};
|
|
75
|
+
const sources = {};
|
|
76
|
+
for (const f of files)
|
|
77
|
+
sources[f] = readFileSync(path.join(root, f), 'utf8');
|
|
78
|
+
const t0 = performance.now();
|
|
79
|
+
const instrumented = native().instrumentFiles(files.map((f) => ({ path: f, source: sources[f] ?? '' })), { strykerCompat: options.strykerCompat, ignore: options.ignore });
|
|
80
|
+
stats.instrumentMs = performance.now() - t0;
|
|
81
|
+
const parseErrors = instrumented.filter((f) => f.error).map((f) => f.error);
|
|
82
|
+
if (parseErrors.length)
|
|
83
|
+
throw new InstrumentFault(`cannot parse ${String(parseErrors.length)} file(s): ${parseErrors.slice(0, 3).join('; ')}`);
|
|
84
|
+
const changed = options.since ? await changedLines(root, options.since) : undefined;
|
|
85
|
+
const mutants = [];
|
|
86
|
+
const codeMap = {};
|
|
87
|
+
const directiveErrors = [];
|
|
88
|
+
for (const f of instrumented) {
|
|
89
|
+
const { disables: offs, errors } = disables(sources[f.path] ?? '', f.path);
|
|
90
|
+
const ranges = rangesFor(f.path);
|
|
91
|
+
directiveErrors.push(...errors);
|
|
92
|
+
let any = false;
|
|
93
|
+
for (const m of f.mutants) {
|
|
94
|
+
if (changed && !touches(changed, f.path, m.start.line, m.end.line))
|
|
95
|
+
continue;
|
|
96
|
+
if (ranges && !ranges.some((r) => withinRange(r, m.start, m.end)))
|
|
97
|
+
continue;
|
|
98
|
+
if (options.shard && shardOf(m.id, options.shard.count) !== options.shard.index)
|
|
99
|
+
continue;
|
|
100
|
+
const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
|
|
101
|
+
const excluded = options.excludedMutations.includes(m.mutatorName);
|
|
102
|
+
mutants.push({
|
|
103
|
+
...m,
|
|
104
|
+
file: f.path,
|
|
105
|
+
coveredBy: [],
|
|
106
|
+
status: off || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
|
|
107
|
+
statusReason: off?.reason ?? m.ignoredReason ?? (excluded ? `excluded mutator ${m.mutatorName}` : undefined),
|
|
108
|
+
});
|
|
109
|
+
any = true;
|
|
110
|
+
}
|
|
111
|
+
if (any && f.code)
|
|
112
|
+
codeMap[path.join(root, f.path)] = f.code;
|
|
113
|
+
}
|
|
114
|
+
if (directiveErrors.length)
|
|
115
|
+
throw new InstrumentFault(directiveErrors.join('\n'));
|
|
116
|
+
stats.files = files.length;
|
|
117
|
+
stats.mutants = mutants.length;
|
|
118
|
+
const mapFile = path.join(work, 'instrumented.json');
|
|
119
|
+
// The frozen oracle's base-ref test files ride along in the same map (frozen.ts).
|
|
120
|
+
writeFileSync(mapFile, JSON.stringify({ ...codeMap, ...options.testOverrides }));
|
|
121
|
+
// ── 1b. The build, once, before anything runs (Stryker's `buildCommand`) ─────────────────
|
|
122
|
+
if (options.buildCommand) {
|
|
123
|
+
const [command = '', ...args] = options.buildCommand.trim().split(/\s+/);
|
|
124
|
+
const built = await spawn(command, args, { cwd: root, runtime: ambientRuntime() });
|
|
125
|
+
if (!built.ok)
|
|
126
|
+
throw new InstrumentFault(`buildCommand \`${options.buildCommand}\` exited ${String(built.code)}: ${(built.stderr || built.stdout).trim().slice(-1500)}. fix: make the build pass on its own first`);
|
|
127
|
+
}
|
|
128
|
+
// ── 1c. The command runner: any test command, one process per mutant (command-runner.ts) ──
|
|
129
|
+
if (options.commandRunner) {
|
|
130
|
+
const m0 = performance.now();
|
|
131
|
+
try {
|
|
132
|
+
const ran = await runCommandMutants({ root, command: options.commandRunner, codeMap, concurrency: options.concurrency, timeoutMS: options.timeoutMS, timeoutFactor: options.timeoutFactor, dryRunTimeoutMinutes: options.dryRunTimeoutMinutes }, mutants);
|
|
133
|
+
stats.dryRunMs = ran.dryMs;
|
|
134
|
+
stats.testExecutions = ran.executions;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new InstrumentFault(error instanceof Error ? error.message : String(error));
|
|
138
|
+
}
|
|
139
|
+
stats.mutationMs = performance.now() - m0;
|
|
140
|
+
stats.wallMs = performance.now() - wall0;
|
|
141
|
+
return { root, options, sources, mutants, tests: [], stats, score: scoreOf(mutants) };
|
|
142
|
+
}
|
|
143
|
+
// ── 2. Dry run, twice (the second only to find nondeterministic tests) ────────────────────
|
|
144
|
+
// Spec oracles' traces, generated once; servers inherit where they are (spec.ts reads it).
|
|
145
|
+
const traceDirs = {};
|
|
146
|
+
for (const oracle of options.specOracles)
|
|
147
|
+
traceDirs[oracle.name] = await tracesFor(oracle, root, work);
|
|
148
|
+
if (options.specOracles.length)
|
|
149
|
+
env[SPEC_TRACES_ENV] = JSON.stringify(traceDirs);
|
|
150
|
+
const dryPool = createPool({ root, vitestConfig: options.vitestConfig, workers: options.concurrency, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, 1, work, 'dry');
|
|
151
|
+
pools.push(dryPool);
|
|
152
|
+
const dryRun = async () => {
|
|
153
|
+
const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress') };
|
|
154
|
+
let failure;
|
|
155
|
+
let overBudget = false;
|
|
156
|
+
await dryPool.drain([unit], () => options.dryRunTimeoutMinutes * MS_PER_MINUTE, (_, o) => {
|
|
157
|
+
if (o.crashed)
|
|
158
|
+
failure = o;
|
|
159
|
+
if (o.killedFor)
|
|
160
|
+
overBudget = true;
|
|
161
|
+
return [];
|
|
162
|
+
});
|
|
163
|
+
if (overBudget)
|
|
164
|
+
throw new InstrumentFault(`the dry run took longer than dryRunTimeoutMinutes (${String(options.dryRunTimeoutMinutes)}). fix: raise dryRunTimeoutMinutes, or find the test that hangs with no mutant active`);
|
|
165
|
+
const recs = readRecords(unit.results).filter((r) => r.type === 'dry');
|
|
166
|
+
if (failure && recs.length === 0)
|
|
167
|
+
throw new InstrumentFault(`the dry run did not complete: ${failure.stderr.slice(-1500)}`);
|
|
168
|
+
const perTest = {};
|
|
169
|
+
const stat = {};
|
|
170
|
+
for (const r of recs) {
|
|
171
|
+
Object.assign(perTest, r.coverage?.perTest ?? {});
|
|
172
|
+
for (const [k, v] of Object.entries(r.coverage?.static ?? {}))
|
|
173
|
+
stat[k] = (stat[k] ?? 0) + v;
|
|
174
|
+
}
|
|
175
|
+
const fileErrors = recs.flatMap((r) => r.fileErrors ?? []);
|
|
176
|
+
if (fileErrors.length) {
|
|
177
|
+
throw new InstrumentFault(`${String(fileErrors.length)} test file(s) failed to load during the dry run, so their tests would be missing from every verdict: ${fileErrors.slice(0, 3).map((e) => `${path.relative(root, e.file)}: ${e.error}`).join('; ')}`);
|
|
178
|
+
}
|
|
179
|
+
return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat };
|
|
180
|
+
};
|
|
181
|
+
progress.phase('dry run');
|
|
182
|
+
const d0 = performance.now();
|
|
183
|
+
const dry = await dryRun();
|
|
184
|
+
stats.dryRunMs = performance.now() - d0;
|
|
185
|
+
const failing = dry.tests.filter((t) => t.state === 'fail');
|
|
186
|
+
if (failing.length)
|
|
187
|
+
throw new InstrumentFault(`${String(failing.length)} test(s) fail with no mutant active, so no verdict can be trusted: ${failing.slice(0, 5).map((t) => t.name).join('; ')}. fix: make the suite pass first`);
|
|
188
|
+
if (dry.tests.length === 0 && !options.allowEmpty)
|
|
189
|
+
throw new InstrumentFault('the dry run ran no tests. fix: check the Vitest config the run used, or set allowEmpty');
|
|
190
|
+
// The second dry run only finds nondeterministic tests, which matter at step 7: it runs beside
|
|
191
|
+
// the warm phase instead of before it (6 s of a 107 s burgee/controlroom run, 2026-10-27).
|
|
192
|
+
const findFlakyTests = async () => {
|
|
193
|
+
const flaky = new Set();
|
|
194
|
+
if (options.flakyReruns > 0) {
|
|
195
|
+
const second = await dryRun();
|
|
196
|
+
const keys = (cov) => Object.keys(cov ?? {}).sort().join(',');
|
|
197
|
+
const again = new Map(second.tests.map((t) => [t.id, t]));
|
|
198
|
+
for (const t of dry.tests) {
|
|
199
|
+
const b = again.get(t.id);
|
|
200
|
+
if (!b || b.state !== t.state || keys(dry.perTest[t.id]) !== keys(second.perTest[t.id]))
|
|
201
|
+
flaky.add(t.id);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
dryPool.close();
|
|
205
|
+
return flaky;
|
|
206
|
+
};
|
|
207
|
+
const flakyCheck = findFlakyTests();
|
|
208
|
+
flakyCheck.catch(() => undefined); // awaited at step 7; a run that fails first must not also reject unhandled
|
|
209
|
+
const testInfo = new Map(dry.tests.map((t) => [t.id, t]));
|
|
210
|
+
const coveringTests = new Map();
|
|
211
|
+
const hits = new Map();
|
|
212
|
+
/** Static mutant → the test files whose collection (imports, beforeAll) reached it. */
|
|
213
|
+
const staticFiles = new Map();
|
|
214
|
+
for (const [testId, perMutant] of Object.entries(dry.perTest)) {
|
|
215
|
+
if (testId.startsWith(FILE_KEY)) {
|
|
216
|
+
const file = testId.slice(FILE_KEY.length);
|
|
217
|
+
for (const id of Object.keys(perMutant))
|
|
218
|
+
(staticFiles.get(id) ?? staticFiles.set(id, new Set()).get(id))?.add(file);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
for (const [id, n] of Object.entries(perMutant)) {
|
|
222
|
+
if (!coveringTests.has(id))
|
|
223
|
+
coveringTests.set(id, []);
|
|
224
|
+
coveringTests.get(id)?.push(testId);
|
|
225
|
+
hits.set(id, (hits.get(id) ?? 0) + n);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const staticIds = new Set([...Object.keys(dry.static), ...staticFiles.keys()]);
|
|
229
|
+
// A spec oracle's replay tests, by test id → oracle name. They are not tests of the score:
|
|
230
|
+
// their kills are reported as the spec's, on a track of their own.
|
|
231
|
+
const specOf = new Map();
|
|
232
|
+
for (const oracle of options.specOracles) {
|
|
233
|
+
const replay = new Set(matchFiles(root, oracle.replay).map((f) => path.join(root, f)));
|
|
234
|
+
for (const t of dry.tests)
|
|
235
|
+
if (replay.has(t.file))
|
|
236
|
+
specOf.set(t.id, oracle.name);
|
|
237
|
+
}
|
|
238
|
+
const specVerdict = (m, recs) => {
|
|
239
|
+
const oracle = specOf.get(m.specCoveredBy?.[0] ?? '') ?? '';
|
|
240
|
+
const killers = recs.filter((r) => r.status === 'Killed' || r.status === 'Timeout');
|
|
241
|
+
if (killers.length)
|
|
242
|
+
return { oracle, status: 'Killed', killedBy: [...new Set(killers.map((r) => r.test ?? ''))] };
|
|
243
|
+
return { oracle, status: recs.length >= (m.specCoveredBy?.length ?? 0) && !unfinished.has(m.id) ? 'Survived' : 'Unknown' };
|
|
244
|
+
};
|
|
245
|
+
const byCostOf = (a, b) => (testInfo.get(a)?.ms ?? 0) - (testInfo.get(b)?.ms ?? 0);
|
|
246
|
+
for (const m of mutants) {
|
|
247
|
+
if (m.status === 'Ignored')
|
|
248
|
+
continue;
|
|
249
|
+
const covering = coveringTests.get(m.id) ?? [];
|
|
250
|
+
m.coveredBy = covering.filter((t) => !specOf.has(t)).sort(byCostOf);
|
|
251
|
+
const specCovering = covering.filter((t) => specOf.has(t)).sort(byCostOf);
|
|
252
|
+
if (specCovering.length)
|
|
253
|
+
m.specCoveredBy = specCovering;
|
|
254
|
+
if (staticIds.has(m.id)) {
|
|
255
|
+
m.static = true;
|
|
256
|
+
if (options.ignoreStatic) {
|
|
257
|
+
m.status = 'Ignored';
|
|
258
|
+
m.statusReason = 'static mutant (ignoreStatic)';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (options.dryRunOnly) {
|
|
263
|
+
stats.wallMs = performance.now() - wall0;
|
|
264
|
+
const tests = dry.tests.map(({ id, file, name, ms }) => ({ id, file, name, ms }));
|
|
265
|
+
return { root, options, sources, mutants, tests, stats, score: { detected: 0, undetected: 0, total: 0, mutationScore: 0, covered: 0 }, dryRunOnly: true };
|
|
266
|
+
}
|
|
267
|
+
// ── 3. Incremental reuse ──────────────────────────────────────────────────────────────────
|
|
268
|
+
const fileHashes = new Map();
|
|
269
|
+
const fileHash = (abs) => {
|
|
270
|
+
let h = fileHashes.get(abs);
|
|
271
|
+
if (h === undefined) {
|
|
272
|
+
h = existsSync(abs) ? sha(readFileSync(abs, 'utf8')) : 'missing';
|
|
273
|
+
fileHashes.set(abs, h);
|
|
274
|
+
}
|
|
275
|
+
return h;
|
|
276
|
+
};
|
|
277
|
+
const fingerprint = (m) => sha([sha(sources[m.file] ?? ''), ...[...new Set(m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''))].sort().map(fileHash)].join(':'));
|
|
278
|
+
const cache = options.incremental && existsSync(options.incrementalFile) ? JSON.parse(readFileSync(options.incrementalFile, 'utf8')).mutants : {};
|
|
279
|
+
for (const m of mutants) {
|
|
280
|
+
if (m.status === 'Ignored' || (!m.static && m.coveredBy.length === 0))
|
|
281
|
+
continue;
|
|
282
|
+
const hit = cache[m.id];
|
|
283
|
+
if (hit && hit.fp === fingerprint(m)) {
|
|
284
|
+
m.status = hit.status;
|
|
285
|
+
m.killedBy = hit.killedBy;
|
|
286
|
+
m.testsCompleted = hit.testsCompleted;
|
|
287
|
+
m.reused = true;
|
|
288
|
+
stats.reused++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ── 4. Warm units on the server pool ──────────────────────────────────────────────────────
|
|
292
|
+
// Static mutants, hybrids included, never run warm: a module evaluated without the mutant
|
|
293
|
+
// cannot stand in for one evaluated with it. Tried 2026-10-27: warm kills of hybrids gave 3
|
|
294
|
+
// false kills on burgee/paratext (an import-time registration the tests then read).
|
|
295
|
+
const toRun = mutants.filter((m) => !m.reused && m.status !== 'Ignored' && !m.static && allCovering(m).length > 0);
|
|
296
|
+
const byId = new Map(mutants.map((m) => [m.id, m]));
|
|
297
|
+
const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, Math.max(1000, (hits.get(m.id) ?? 1) * 100)]));
|
|
298
|
+
const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
|
|
299
|
+
// The watchdog is for runaways, which loop in fast code; a slow test under a loaded machine is
|
|
300
|
+
// not one. 3x the covering test's dry-run time, not Stryker's 1.5x: at 1.5x, subprocess-heavy
|
|
301
|
+
// burgee/paratext tests were stopped as false timeouts (2026-10-27).
|
|
302
|
+
const warmBudget = (id) => options.timeoutMS + Math.max(options.timeoutFactor, 3) * maxTestMs(id) + 250;
|
|
303
|
+
// Files a mutant's covering tests live in, cheapest first (by the covering tests' time there).
|
|
304
|
+
const fileOrder = new Map();
|
|
305
|
+
for (const m of toRun) {
|
|
306
|
+
const cost = new Map();
|
|
307
|
+
for (const t of allCovering(m)) {
|
|
308
|
+
const info = testInfo.get(t);
|
|
309
|
+
if (info)
|
|
310
|
+
cost.set(info.file, (cost.get(info.file) ?? 0) + info.ms + 0.05);
|
|
311
|
+
}
|
|
312
|
+
fileOrder.set(m.id, [...cost].sort((a, b) => a[1] - b[1]).map(([f]) => f));
|
|
313
|
+
}
|
|
314
|
+
const planUnits = new Map();
|
|
315
|
+
const unfinished = new Set();
|
|
316
|
+
const makeUnit = (file, plan, depth = 0) => {
|
|
317
|
+
const unit = { mode: 'mutant', files: [file], results: fileFor('unit', 'jsonl'), progress: fileFor('unit', 'progress'), plan: fileFor('plan', 'json') };
|
|
318
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: plan, hitLimits, skip: [], specTests: [...specOf.keys()] }));
|
|
319
|
+
planUnits.set(unit.results, { file, plan, depth });
|
|
320
|
+
stats.units++;
|
|
321
|
+
return unit;
|
|
322
|
+
};
|
|
323
|
+
/** Split a file's plan into units of about UNIT_PAIRS executions, by mutant. */
|
|
324
|
+
const unitsFor = (file, plan) => {
|
|
325
|
+
const ids = [...new Set(Object.values(plan).flat())];
|
|
326
|
+
const pairs = Object.values(plan).reduce((sum, l) => sum + l.length, 0);
|
|
327
|
+
const parts = Math.max(1, Math.min(ids.length, Math.ceil(pairs / UNIT_PAIRS)));
|
|
328
|
+
return Array.from({ length: parts }, (_, i) => {
|
|
329
|
+
const mine = new Set(ids.filter((_, j) => j % parts === i));
|
|
330
|
+
return makeUnit(file, Object.fromEntries(Object.entries(plan).map(([t, l]) => [t, l.filter((id) => mine.has(id))]).filter(([, l]) => l.length > 0)));
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
/** Units for these mutants, each planned only in the files `pick` selects for it. */
|
|
334
|
+
const unitsForRound = (mutantsHere, pick) => {
|
|
335
|
+
const perFile = new Map();
|
|
336
|
+
for (const m of mutantsHere) {
|
|
337
|
+
const files = new Set(pick(m));
|
|
338
|
+
for (const t of allCovering(m)) {
|
|
339
|
+
const file = testInfo.get(t)?.file;
|
|
340
|
+
if (!file || !files.has(file))
|
|
341
|
+
continue;
|
|
342
|
+
if (!perFile.has(file))
|
|
343
|
+
perFile.set(file, new Map());
|
|
344
|
+
const tests = perFile.get(file);
|
|
345
|
+
if (!tests.has(t))
|
|
346
|
+
tests.set(t, []);
|
|
347
|
+
tests.get(t)?.push(m.id);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const units = [...perFile].flatMap(([file, tests]) => unitsFor(file, Object.fromEntries(tests)));
|
|
351
|
+
// Sizing and ordering units by dry-run time instead of pair count (longest first) was tried:
|
|
352
|
+
// 30% more units and ~10% more CPU on burgee/controlroom, and no wall-clock gain that this
|
|
353
|
+
// machine's load (average 21 to 76 across the A/B) could show (2026-10-27).
|
|
354
|
+
const weight = (u) => Object.values(planUnits.get(u.results)?.plan ?? {}).reduce((sum, l) => sum + l.length, 0);
|
|
355
|
+
return units.sort((a, b) => weight(b) - weight(a));
|
|
356
|
+
};
|
|
357
|
+
const records = [];
|
|
358
|
+
const runaways = new Set();
|
|
359
|
+
const crashedIds = new Set();
|
|
360
|
+
const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, options.concurrency, work, 'mutant');
|
|
361
|
+
pools.push(pool);
|
|
362
|
+
/** Mutants decided on one verdict track: the tests', or a spec oracle's replay tests'. */
|
|
363
|
+
const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
|
|
364
|
+
const afterUnit = (unit, outcome) => {
|
|
365
|
+
// Verdicts a failed canary cannot vouch for are dropped; their pairs are re-queued below.
|
|
366
|
+
const raw = readRecords(unit.results);
|
|
367
|
+
const suspect = new Set(raw.filter((r) => r.type === 'polluted').flatMap((r) => (r.suspects ?? []).map((id) => `${id}|${r.test ?? ''}`)));
|
|
368
|
+
const recs = raw.filter((r) => !(r.type === 'mutant' && suspect.has(`${r.id ?? ''}|${r.test ?? ''}`)));
|
|
369
|
+
records.push(...recs);
|
|
370
|
+
const wasPolluted = recs.some((r) => r.type === 'polluted');
|
|
371
|
+
if (wasPolluted)
|
|
372
|
+
stats.pollutionRestarts++;
|
|
373
|
+
if (!outcome.killedFor && !outcome.crashed && !wasPolluted)
|
|
374
|
+
return [];
|
|
375
|
+
const bad = outcome.killedFor ?? outcome.crashedFor;
|
|
376
|
+
if (outcome.killedFor)
|
|
377
|
+
runaways.add(outcome.killedFor);
|
|
378
|
+
else if (bad)
|
|
379
|
+
crashedIds.add(bad);
|
|
380
|
+
// The rest of the unit: pairs not yet run, mutants not yet decided, into a fresh context.
|
|
381
|
+
const ran = new Set(recs.filter((r) => r.type === 'mutant').map((r) => `${r.id ?? ''}|${r.test ?? ''}`));
|
|
382
|
+
const decided = { tests: killedSoFar(false), spec: killedSoFar(true) };
|
|
383
|
+
const { file, plan, depth } = planUnits.get(unit.results);
|
|
384
|
+
const rest = {};
|
|
385
|
+
for (const [t, ids] of Object.entries(plan)) {
|
|
386
|
+
const done = specOf.has(t) ? decided.spec : decided.tests;
|
|
387
|
+
const left = ids.filter((id) => !done.has(id) && !ran.has(`${id}|${t}`));
|
|
388
|
+
if (left.length)
|
|
389
|
+
rest[t] = left;
|
|
390
|
+
}
|
|
391
|
+
if (Object.keys(rest).length === 0)
|
|
392
|
+
return [];
|
|
393
|
+
// A unit that made no progress, or that keeps being polluted, is left to the isolated
|
|
394
|
+
// re-run rather than retried for ever.
|
|
395
|
+
if (ran.size === 0 || depth >= 40) {
|
|
396
|
+
for (const id of new Set(Object.values(rest).flat()))
|
|
397
|
+
unfinished.add(id);
|
|
398
|
+
return [];
|
|
399
|
+
}
|
|
400
|
+
const ids = [...new Set(Object.values(rest).flat())];
|
|
401
|
+
if (ids.length < 2)
|
|
402
|
+
return [makeUnit(file, rest, depth + 1)];
|
|
403
|
+
const half = new Set(ids.slice(0, Math.ceil(ids.length / 2)));
|
|
404
|
+
const pickHalf = (inHalf) => Object.fromEntries(Object.entries(rest).map(([t, l]) => [t, l.filter((id) => half.has(id) === inHalf)]).filter(([, l]) => l.length > 0));
|
|
405
|
+
return [makeUnit(file, pickHalf(true), depth + 1), makeUnit(file, pickHalf(false), depth + 1)];
|
|
406
|
+
};
|
|
407
|
+
const killedCount = () => `${String(killedSoFar(false).size)} of ${String(toRun.length)} mutants killed so far`;
|
|
408
|
+
progress.phase('warm runs', killedCount);
|
|
409
|
+
const m0 = performance.now();
|
|
410
|
+
// One round, every covering file at once. A two-round plan (cheapest file first, survivors
|
|
411
|
+
// to the rest) cut executions 23% on burgee/paratext but took 2.9x longer: the second round
|
|
412
|
+
// waits for the first to drain, and the expensive files then run as a tail (2026-10-27).
|
|
413
|
+
await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit);
|
|
414
|
+
stats.mutationMs = performance.now() - m0;
|
|
415
|
+
// ── 5. Verdicts from the warm run ─────────────────────────────────────────────────────────
|
|
416
|
+
const byMutant = new Map();
|
|
417
|
+
for (const r of records)
|
|
418
|
+
if (r.type === 'mutant' && r.id)
|
|
419
|
+
(byMutant.get(r.id) ?? byMutant.set(r.id, []).get(r.id))?.push(r);
|
|
420
|
+
stats.testExecutions = records.filter((r) => r.type === 'mutant').length;
|
|
421
|
+
stats.canaryFiles = new Set(records.filter((r) => r.type === 'polluted' && r.file).map((r) => r.file ?? '')).size;
|
|
422
|
+
const isolatedQueue = [];
|
|
423
|
+
for (const m of toRun) {
|
|
424
|
+
const all = byMutant.get(m.id) ?? [];
|
|
425
|
+
const recs = all.filter((r) => !r.spec);
|
|
426
|
+
const killers = recs.filter((r) => r.status === 'Killed' || r.status === 'Timeout');
|
|
427
|
+
if (m.specCoveredBy)
|
|
428
|
+
m.spec = specVerdict(m, all.filter((r) => r.spec));
|
|
429
|
+
if (m.coveredBy.length === 0 && !runaways.has(m.id) && !crashedIds.has(m.id)) {
|
|
430
|
+
continue; // reached only by a spec oracle: no test covers it, and its status says so
|
|
431
|
+
}
|
|
432
|
+
else if (runaways.has(m.id)) {
|
|
433
|
+
isolatedQueue.push(m); // a watchdog stop is confirmed before it is reported (6, below)
|
|
434
|
+
}
|
|
435
|
+
else if (crashedIds.has(m.id)) {
|
|
436
|
+
m.status = 'RuntimeError';
|
|
437
|
+
m.statusReason = 'the worker crashed while this mutant ran';
|
|
438
|
+
}
|
|
439
|
+
else if (killers.length) {
|
|
440
|
+
m.status = killers.some((r) => r.status === 'Killed') ? 'Killed' : 'Timeout';
|
|
441
|
+
m.killedBy = [...new Set(killers.map((r) => r.test ?? ''))];
|
|
442
|
+
m.testsCompleted = recs.length;
|
|
443
|
+
}
|
|
444
|
+
else if (!unfinished.has(m.id) && recs.length >= new Set(m.coveredBy).size) {
|
|
445
|
+
m.status = 'Survived';
|
|
446
|
+
m.testsCompleted = recs.length;
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
isolatedQueue.push(m); // unfinished: a crash left work undone
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
for (const m of mutants)
|
|
453
|
+
if (m.static && m.status !== 'Ignored' && !m.reused)
|
|
454
|
+
isolatedQueue.push(m);
|
|
455
|
+
// ── 6. Isolated re-runs: the switch armed before import, Vitest run normally ──────────────
|
|
456
|
+
const allTestFiles = [...new Set(dry.tests.map((t) => t.file))];
|
|
457
|
+
// A static mutant needs the files that load it (plus any whose tests reach it); only a hit
|
|
458
|
+
// with no file to attribute it to (a setup file outside collection) falls back to all files.
|
|
459
|
+
const fileCost = new Map();
|
|
460
|
+
for (const t of dry.tests)
|
|
461
|
+
fileCost.set(t.file, (fileCost.get(t.file) ?? 0) + t.ms);
|
|
462
|
+
const byCost = (files) => [...files].sort((a, b) => (fileCost.get(a) ?? 0) - (fileCost.get(b) ?? 0));
|
|
463
|
+
const filesFor = (m) => {
|
|
464
|
+
const tests = m.coveredBy.map((t) => testInfo.get(t)?.file ?? '');
|
|
465
|
+
if (!m.static)
|
|
466
|
+
return byCost([...new Set(tests)]);
|
|
467
|
+
const loaders = staticFiles.get(m.id);
|
|
468
|
+
if (!loaders || m.id in dry.static)
|
|
469
|
+
return byCost(allTestFiles);
|
|
470
|
+
// Files whose tests reach the mutant first: they kill it. Ordering all loaders by cost alone
|
|
471
|
+
// ran a file that only imports it first, and 288 of 349 isolated kills on burgee/paratext
|
|
472
|
+
// came from a later file (2026-10-27).
|
|
473
|
+
const covering = byCost([...new Set(tests)]);
|
|
474
|
+
return [...covering, ...byCost([...loaders].filter((f) => !covering.includes(f)))];
|
|
475
|
+
};
|
|
476
|
+
const isolatedBudget = (id) => {
|
|
477
|
+
const m = byId.get(id);
|
|
478
|
+
const fs = m ? filesFor(m) : allTestFiles;
|
|
479
|
+
const ms = dry.tests.filter((t) => fs.includes(t.file)).reduce((sum, t) => sum + t.ms, 0);
|
|
480
|
+
return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
|
|
481
|
+
};
|
|
482
|
+
// A non-static mutant runs only its covering tests, armed across all of them in file order:
|
|
483
|
+
// Stryker's semantics, so cross-test effects still show (a no-op reset() lets state pile up
|
|
484
|
+
// for the next covering test). A static mutant runs every test of its files, because an
|
|
485
|
+
// import-time change reaches tests that never execute the mutated line.
|
|
486
|
+
const runIsolated = async (items, slack = 1) => {
|
|
487
|
+
const verdicts = new Map();
|
|
488
|
+
if (items.length === 0)
|
|
489
|
+
return verdicts;
|
|
490
|
+
const chunk = Math.max(1, Math.ceil(items.length / (options.concurrency * 2)));
|
|
491
|
+
const mk = (batch) => ({ mode: 'isolated', files: [], results: fileFor('iso', 'jsonl'), progress: fileFor('iso', 'progress'), batch: batch.map((m) => ({ id: m.id, files: filesFor(m), tests: m.static ? undefined : m.coveredBy })) });
|
|
492
|
+
const units = [];
|
|
493
|
+
for (let i = 0; i < items.length; i += chunk)
|
|
494
|
+
units.push(mk(items.slice(i, i + chunk)));
|
|
495
|
+
stats.isolatedRuns += items.length;
|
|
496
|
+
await pool.drain(units, (_, id) => slack * isolatedBudget(id), (unit, outcome) => {
|
|
497
|
+
// Vitest reports per test file: a mutant is killed if any of its files failed.
|
|
498
|
+
for (const rec of readRecords(unit.results)) {
|
|
499
|
+
if (rec.type !== 'isolated' || !rec.id)
|
|
500
|
+
continue;
|
|
501
|
+
const prev = verdicts.get(rec.id);
|
|
502
|
+
const testsCompleted = (prev?.testsCompleted ?? 0) + (rec.ran ?? 0);
|
|
503
|
+
if (rec.killed)
|
|
504
|
+
verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted });
|
|
505
|
+
else
|
|
506
|
+
verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
|
|
507
|
+
}
|
|
508
|
+
if (outcome.killedFor)
|
|
509
|
+
verdicts.set(outcome.killedFor, { status: 'Timeout' });
|
|
510
|
+
if (outcome.crashed && outcome.crashedFor)
|
|
511
|
+
verdicts.set(outcome.crashedFor, { status: 'RuntimeError' });
|
|
512
|
+
if (!outcome.killedFor && !outcome.crashed)
|
|
513
|
+
return [];
|
|
514
|
+
const done = new Set([...verdicts.keys()]);
|
|
515
|
+
const rest = (unit.batch ?? []).filter((b) => !done.has(b.id)).map((b) => byId.get(b.id));
|
|
516
|
+
return rest.length ? [mk(rest)] : [];
|
|
517
|
+
});
|
|
518
|
+
for (const m of items)
|
|
519
|
+
if (!verdicts.has(m.id))
|
|
520
|
+
verdicts.set(m.id, { status: 'RuntimeError' });
|
|
521
|
+
return verdicts;
|
|
522
|
+
};
|
|
523
|
+
progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
|
|
524
|
+
const i0 = performance.now();
|
|
525
|
+
const first = await runIsolated(isolatedQueue);
|
|
526
|
+
// A watchdog stop on a loaded machine is a slow test, not a runaway: 31 of them on one
|
|
527
|
+
// burgee/paratext run while the load average sat at 40, all Survived for Stryker (2026-10-27).
|
|
528
|
+
// Each stop gets one more run with 3x the budget; only one that is stopped again is a Timeout.
|
|
529
|
+
const stopped = isolatedQueue.filter((m) => first.get(m.id)?.status === 'Timeout');
|
|
530
|
+
const again = await runIsolated(stopped, 3);
|
|
531
|
+
for (const m of stopped) {
|
|
532
|
+
const v = again.get(m.id);
|
|
533
|
+
if (v && v.status !== 'Timeout' && v.status !== 'RuntimeError') {
|
|
534
|
+
first.set(m.id, v);
|
|
535
|
+
stats.timeoutsOverturned++;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const m of isolatedQueue) {
|
|
539
|
+
const v = first.get(m.id);
|
|
540
|
+
if (v) {
|
|
541
|
+
m.status = v.status;
|
|
542
|
+
m.killedBy = v.killedBy;
|
|
543
|
+
m.testsCompleted = v.testsCompleted;
|
|
544
|
+
if (v.status === 'Timeout')
|
|
545
|
+
m.statusReason = 'stopped by the watchdog twice, the second time with 3x the budget';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// ── 6b. Confirm survivors under whole-file semantics ──────────────────────────────────────
|
|
549
|
+
// The warm runner arms a mutant for one test at a time, so a mutant whose effect only shows
|
|
550
|
+
// across tests (a `reset()` in beforeEach made a no-op, so state piles up for the next test)
|
|
551
|
+
// survives warm and is killed when armed for the whole file, the way Stryker runs it (burgee/
|
|
552
|
+
// paratext plugin.ts, 2026-10-27). Survivors are what a person acts on, so each one is
|
|
553
|
+
// re-run once with the mutant armed across its covering files before it is reported.
|
|
554
|
+
const survivors = toRun.filter((m) => m.status === 'Survived');
|
|
555
|
+
const confirmed = await runIsolated(survivors);
|
|
556
|
+
// A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
|
|
557
|
+
// closeout survivors were reported killed this way while a docs build shared the machine
|
|
558
|
+
// (2026-10-27). The same rule as section 6: one more run at 3x the budget before believing it.
|
|
559
|
+
const stoppedHere = survivors.filter((m) => confirmed.get(m.id)?.status === 'Timeout');
|
|
560
|
+
const recheck = await runIsolated(stoppedHere, 3);
|
|
561
|
+
for (const m of stoppedHere) {
|
|
562
|
+
const v = recheck.get(m.id);
|
|
563
|
+
if (v)
|
|
564
|
+
confirmed.set(m.id, v);
|
|
565
|
+
}
|
|
566
|
+
for (const m of survivors) {
|
|
567
|
+
const v = confirmed.get(m.id);
|
|
568
|
+
if (v && v.status !== 'Survived' && v.status !== 'RuntimeError') {
|
|
569
|
+
m.status = v.status;
|
|
570
|
+
m.killedBy = v.killedBy;
|
|
571
|
+
m.statusReason = 'killed only when the mutant stays active across the whole test file (cross-test effect)';
|
|
572
|
+
stats.survivorsOverturned++;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// ── 6c. Survivors that do not type-check are CompileErrors (checker.ts) ────────────────────
|
|
576
|
+
if (options.typeCheck) {
|
|
577
|
+
progress.phase('type-checking survivors');
|
|
578
|
+
// Every undetected mutant is one somebody would act on: survivors and the uncovered.
|
|
579
|
+
const survived = mutants.filter((m) => m.status === 'Survived' || m.status === 'NoCoverage');
|
|
580
|
+
const refused = await checkSurvivors({ root, tsconfig: options.tsconfigFile, command: options.typeChecker, concurrency: options.concurrency }, survived).catch((error) => {
|
|
581
|
+
throw new InstrumentFault(error instanceof Error ? error.message : String(error));
|
|
582
|
+
});
|
|
583
|
+
for (const m of survived.filter((x) => refused.has(x.id))) {
|
|
584
|
+
m.status = 'CompileError';
|
|
585
|
+
m.statusReason = `does not type-check: ${refused.get(m.id) ?? ''}`;
|
|
586
|
+
stats.compileErrors++;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── 7. Flaky verdicts: re-run mutants whose covering tests are nondeterministic ───────────
|
|
590
|
+
const flakyTests = await flakyCheck;
|
|
591
|
+
stats.flakyTests = flakyTests.size;
|
|
592
|
+
const flakyCandidates = toRun.filter((m) => m.coveredBy.some((t) => flakyTests.has(t)));
|
|
593
|
+
const seen = new Map(flakyCandidates.map((m) => [m.id, new Set([m.status === 'Timeout' ? 'Killed' : m.status])]));
|
|
594
|
+
for (let n = 1; n <= options.flakyReruns && flakyCandidates.length; n++) {
|
|
595
|
+
const again = await runIsolated(flakyCandidates);
|
|
596
|
+
for (const m of flakyCandidates) {
|
|
597
|
+
const v = again.get(m.id);
|
|
598
|
+
if (v)
|
|
599
|
+
seen.get(m.id)?.add(v.status === 'Timeout' ? 'Killed' : v.status);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
for (const m of flakyCandidates) {
|
|
603
|
+
const verdicts = seen.get(m.id) ?? new Set();
|
|
604
|
+
if (verdicts.size > 1) {
|
|
605
|
+
m.flaky = true;
|
|
606
|
+
m.statusReason = `verdict changed across ${String(options.flakyReruns + 1)} runs (${[...verdicts].join(', ')}); covering tests are nondeterministic`;
|
|
607
|
+
stats.flakyMutants++;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
stats.isolatedMs = performance.now() - i0;
|
|
611
|
+
stats.watchdogKills = pool.stats.killed;
|
|
612
|
+
stats.crashes = pool.stats.crashed;
|
|
613
|
+
// ── 8. Score, cache, result ───────────────────────────────────────────────────────────────
|
|
614
|
+
if (options.incremental) {
|
|
615
|
+
mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
|
|
616
|
+
const next = { ...cache };
|
|
617
|
+
for (const m of mutants) {
|
|
618
|
+
if (['Killed', 'Survived', 'Timeout'].includes(m.status) && !m.flaky)
|
|
619
|
+
next[m.id] = { fp: fingerprint(m), status: m.status, killedBy: m.killedBy, testsCompleted: m.testsCompleted };
|
|
620
|
+
}
|
|
621
|
+
writeFileSync(options.incrementalFile, JSON.stringify({ version: 1, mutants: next }));
|
|
622
|
+
}
|
|
623
|
+
const spec = options.specOracles.map((oracle) => {
|
|
624
|
+
const dir = traceDirs[oracle.name] ?? '';
|
|
625
|
+
const traces = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.itf.json')).length : 0;
|
|
626
|
+
const ms = records.filter((r) => r.type === 'mutant' && r.spec && specOf.get(r.test ?? '') === oracle.name).map((r) => r.ms ?? 0);
|
|
627
|
+
const located = mutants.map((m) => ({ ...m, line: m.start.line }));
|
|
628
|
+
return summarizeSpec({ oracle, modelledFiles: new Set(matchFiles(root, oracle.files)), traces, mutants: located, testName: (t) => testInfo.get(t)?.name ?? t, specMs: ms });
|
|
629
|
+
});
|
|
630
|
+
stats.serversSpawned = pools.reduce((s, p) => s + p.stats.spawned, 0);
|
|
631
|
+
stats.wallMs = performance.now() - wall0;
|
|
632
|
+
return {
|
|
633
|
+
root,
|
|
634
|
+
options,
|
|
635
|
+
sources,
|
|
636
|
+
mutants,
|
|
637
|
+
tests: dry.tests.map(({ id, file, name, ms }) => ({ id, file, name, ms })),
|
|
638
|
+
stats,
|
|
639
|
+
spec: spec.length ? spec : undefined,
|
|
640
|
+
score: scoreOf(mutants),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
progress.stop();
|
|
645
|
+
for (const p of pools)
|
|
646
|
+
p.close();
|
|
647
|
+
if (env['OXMUTATE_KEEP'])
|
|
648
|
+
stderr.write(`oxmutate: work directory kept at ${work}\n`);
|
|
649
|
+
else
|
|
650
|
+
rmSync(work, { recursive: true, force: true });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=coordinator.js.map
|