oxmutate 1.0.2 → 1.1.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/README.md +16 -8
- 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/checker.js +1 -0
- package/dist/child.js +15 -0
- package/dist/coordinator.js +168 -18
- package/dist/lanes.js +1 -0
- package/dist/progress.js +1 -0
- package/dist/runner.d.ts +21 -0
- package/dist/runner.js +240 -10
- package/dist/scheduler.js +9 -2
- package/dist/spec-oracle.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ oxc, not affiliated with the oxc project.
|
|
|
45
45
|
|
|
46
46
|
| | Stryker 10 | oxmutate 1.0 |
|
|
47
47
|
| :-- | :-- | :-- |
|
|
48
|
-
| Mutant set | 17 mutator families | the same 17, the same mutants (720 of 720 on es-toolkit) |
|
|
48
|
+
| Mutant set | 17 mutator families | the same 17, the same mutants (720 of 720 on es-toolkit), plus `NumericLiteral` (0 → 1, 1 → 0, n → n + 1): +6.6% mutants on burgee/paratext, 19 of them survivors Stryker cannot report. `--stryker-compat` gives Stryker's set exactly |
|
|
49
49
|
| Verdicts | — | the same as Stryker's on 99.9% of mutants ([measured](#compatibility)) |
|
|
50
50
|
| Instrumentation | Babel, file by file | Rust on oxc, every file in parallel: 720 mutants in 35 ms |
|
|
51
51
|
| Test runs | a Vitest run per mutant | each covering test re-run per mutant inside a warm Vitest worker |
|
|
@@ -187,10 +187,10 @@ setting a value inside the worker that already has the tests loaded.
|
|
|
187
187
|
| :-- | :-- |
|
|
188
188
|
| The dry run must pass | A suite that fails with no mutant armed scores nothing (exit 3) |
|
|
189
189
|
| Re-parse | Every instrumented file is parsed again before it runs; one that does not parse stops the run (exit 3) instead of scoring a broken file |
|
|
190
|
-
| Canary | The unmutated test must still pass after mutants run in a context; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again |
|
|
190
|
+
| Canary | The unmutated test must still pass after mutants run in a context, with its `beforeEach`/`afterEach` hooks re-run around every run, file-level ones included; if a mutant left a cache, a registry or a fixture behind, the context restarts and the suspect verdicts run again. A kill that follows unchecked runs is checked at once and decided again from clean state, so no kill rests on another mutant's leftovers |
|
|
191
191
|
| Survivor confirmation | A survivor is re-run with the mutant armed across its covering tests in one file, as Stryker arms it, so a mutant that only fails across tests is killed |
|
|
192
192
|
| Watchdog | A synchronous runaway is stopped and its server replaced; the stop is re-run at 3x the budget before it is reported as a timeout |
|
|
193
|
-
| Static mutants | Code that runs at import is
|
|
193
|
+
| Static mutants | Code that runs at import is decided in a live worker: the mutant is armed, the module registry reset and the test file collected again, against the files that load it. A file whose collection changes globals, `process.env` or a built-in prototype, or a mutant that leaves a trace, goes back to a fresh worker per mutant (`OXMUTATE_ISOLATE_STATIC=1` sends all of them there) |
|
|
194
194
|
| Flaky tests | Tests that behave differently across two dry runs have their mutants re-run; a verdict that changes is reported `flaky` |
|
|
195
195
|
| No orphans | Every Vitest server exits when its run does, killed or not |
|
|
196
196
|
|
|
@@ -249,11 +249,19 @@ Of the two disagreements left, one is a Stryker timeout on a loop that ends (its
|
|
|
249
249
|
it; the covering tests pass). The other is a mutant Stryker scored as a runtime error, so it has
|
|
250
250
|
no Stryker verdict to compare.
|
|
251
251
|
|
|
252
|
-
**Speed
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
252
|
+
**Speed, measured on a laptop, not yet a published number** (the published ones live at
|
|
253
|
+
[burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks)). Medians of 3
|
|
254
|
+
head-to-head runs (`bench/burgee-pair.mts`, concurrency 4, one tool after the other, load 6–19, 2026-10-30):
|
|
255
|
+
|
|
256
|
+
| Subject | Stryker 10 | oxmutate | Wall | CPU | Tests a mutant |
|
|
257
|
+
| :-- | --: | --: | --: | --: | --: |
|
|
258
|
+
| burgee/paratext | 108.3 s | 19.0 s | 5.70x | 5.92x | 31.7 → 16.8 |
|
|
259
|
+
| burgee/controlroom | 63.4 s | 45.6 s | 1.39x | 2.55x | 7.3 → 9.2 |
|
|
260
|
+
| oxmutate itself (Stryker's mutant set) | 508 s | 334 s | 1.52x | — | — |
|
|
261
|
+
|
|
262
|
+
The ceiling depends on the suite as much as the tool: controlroom's own `vitest run` takes ~8 s and
|
|
263
|
+
every fresh worker imports React and Ink, so even zero overhead would make it ~6x. The figures to
|
|
264
|
+
publish will come from the `oxmutate-bench.yml` lane on a runner nothing else shares.
|
|
257
265
|
|
|
258
266
|
## Benchmarks
|
|
259
267
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/checker.js
CHANGED
|
@@ -29,6 +29,7 @@ export function applyMutant(source, m) {
|
|
|
29
29
|
const starts = [0];
|
|
30
30
|
for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1))
|
|
31
31
|
starts.push(i + 1);
|
|
32
|
+
// Stryker disable next-line NumericLiteral: `starts` holds every line the checker reports, so the fallback never runs
|
|
32
33
|
const at = (p) => (starts[p.line - 1] ?? 0) + p.column - 1;
|
|
33
34
|
return source.slice(0, at(m.start)) + m.replacement + source.slice(at(m.end));
|
|
34
35
|
}
|
package/dist/child.js
CHANGED
|
@@ -42,6 +42,7 @@ const vitest = await vitestNode.createVitest('test', {
|
|
|
42
42
|
// spellings (top-level in Vitest 5, `experimental` in 4.x).
|
|
43
43
|
fsModuleCache: false,
|
|
44
44
|
experimental: { fsModuleCache: false },
|
|
45
|
+
...(config.inlineDeps && { server: { deps: { inline: true } } }),
|
|
45
46
|
},
|
|
46
47
|
// A private Vite cache per run, so no cache outside this run can answer for a mutated file.
|
|
47
48
|
{ plugins: [instrumentedSources(config.instrumentedMap)], cacheDir: path.join(path.dirname(config.instrumentedMap), 'vite-cache') });
|
|
@@ -72,6 +73,11 @@ async function runIsolated(unit) {
|
|
|
72
73
|
try {
|
|
73
74
|
for (const item of unit.batch ?? []) {
|
|
74
75
|
env[ACTIVE_MUTANT_ENV] = item.id;
|
|
76
|
+
// The runaway guard warm runs have: a loop the mutant causes stops at the limit, not the watchdog.
|
|
77
|
+
if (item.hitLimit)
|
|
78
|
+
env['OXMUTATE_HIT_LIMIT'] = String(item.hitLimit);
|
|
79
|
+
else
|
|
80
|
+
delete env['OXMUTATE_HIT_LIMIT'];
|
|
75
81
|
if (item.tests)
|
|
76
82
|
env['OXMUTATE_ONLY_TESTS'] = JSON.stringify(item.tests);
|
|
77
83
|
else
|
|
@@ -87,6 +93,7 @@ async function runIsolated(unit) {
|
|
|
87
93
|
}
|
|
88
94
|
delete env[ACTIVE_MUTANT_ENV];
|
|
89
95
|
delete env['OXMUTATE_ONLY_TESTS'];
|
|
96
|
+
delete env['OXMUTATE_HIT_LIMIT'];
|
|
90
97
|
}
|
|
91
98
|
finally {
|
|
92
99
|
vitest.config.bail = 0;
|
|
@@ -101,6 +108,14 @@ async function runUnit(unit) {
|
|
|
101
108
|
env['OXMUTATE_PROGRESS'] = unit.progress;
|
|
102
109
|
if (unit.plan)
|
|
103
110
|
env['OXMUTATE_PLAN'] = unit.plan;
|
|
111
|
+
if (unit.staticIds)
|
|
112
|
+
env['OXMUTATE_STATIC'] = JSON.stringify(unit.staticIds);
|
|
113
|
+
else
|
|
114
|
+
delete env['OXMUTATE_STATIC'];
|
|
115
|
+
if (unit.hitLimits)
|
|
116
|
+
env['OXMUTATE_HIT_LIMITS'] = JSON.stringify(unit.hitLimits);
|
|
117
|
+
else
|
|
118
|
+
delete env['OXMUTATE_HIT_LIMITS'];
|
|
104
119
|
delete env[ACTIVE_MUTANT_ENV];
|
|
105
120
|
if (unit.mode === 'isolated')
|
|
106
121
|
await runIsolated(unit);
|
package/dist/coordinator.js
CHANGED
|
@@ -36,8 +36,14 @@ function readRecords(file) {
|
|
|
36
36
|
.map((l) => JSON.parse(l));
|
|
37
37
|
}
|
|
38
38
|
const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Work units of at most this many (mutant, test) executions: small enough to steal, large enough
|
|
41
|
+
* that each unit's fresh worker, which imports the test file's whole graph, is paid for few units.
|
|
42
|
+
* At 120, module loading was most of the CPU on burgee/controlroom (React/Ink in every worker); 400
|
|
43
|
+
* cut units 172 -> 93 and wall 44 -> 39 s (paratext 39 -> 35 s), and 1000 gained nothing more
|
|
44
|
+
* (2026-10-30). Smaller stays better for a pollution restart, which re-runs the rest of a unit.
|
|
45
|
+
*/
|
|
46
|
+
const UNIT_PAIRS = 400;
|
|
41
47
|
const MS_PER_MINUTE = 60_000;
|
|
42
48
|
const PERCENT = 100;
|
|
43
49
|
/** Every test that reaches a mutant: the score's, then a spec oracle's replay tests. */
|
|
@@ -214,8 +220,10 @@ export async function run(options) {
|
|
|
214
220
|
for (const [testId, perMutant] of Object.entries(dry.perTest)) {
|
|
215
221
|
if (testId.startsWith(FILE_KEY)) {
|
|
216
222
|
const file = testId.slice(FILE_KEY.length);
|
|
217
|
-
for (const id of Object.
|
|
223
|
+
for (const [id, n] of Object.entries(perMutant)) {
|
|
218
224
|
(staticFiles.get(id) ?? staticFiles.set(id, new Set()).get(id))?.add(file);
|
|
225
|
+
hits.set(id, (hits.get(id) ?? 0) + n);
|
|
226
|
+
}
|
|
219
227
|
continue;
|
|
220
228
|
}
|
|
221
229
|
for (const [id, n] of Object.entries(perMutant)) {
|
|
@@ -294,7 +302,9 @@ export async function run(options) {
|
|
|
294
302
|
// false kills on burgee/paratext (an import-time registration the tests then read).
|
|
295
303
|
const toRun = mutants.filter((m) => !m.reused && m.status !== 'Ignored' && !m.static && allCovering(m).length > 0);
|
|
296
304
|
const byId = new Map(mutants.map((m) => [m.id, m]));
|
|
297
|
-
|
|
305
|
+
/** The switch-taken count past which a run is a runaway: 100x what the dry run saw, at least 1000. */
|
|
306
|
+
const hitLimitOf = (id) => Math.max(1000, (hits.get(id) ?? 1) * 100);
|
|
307
|
+
const hitLimits = Object.fromEntries(toRun.map((m) => [m.id, hitLimitOf(m.id)]));
|
|
298
308
|
const maxTestMs = (id) => { const m = byId.get(id); return Math.max(0, ...(m ? allCovering(m) : []).map((t) => testInfo.get(t)?.ms ?? 0)); };
|
|
299
309
|
// The watchdog is for runaways, which loop in fast code; a slow test under a loaded machine is
|
|
300
310
|
// not one. 3x the covering test's dry-run time, not Stryker's 1.5x: at 1.5x, subprocess-heavy
|
|
@@ -357,7 +367,8 @@ export async function run(options) {
|
|
|
357
367
|
const records = [];
|
|
358
368
|
const runaways = new Set();
|
|
359
369
|
const crashedIds = new Set();
|
|
360
|
-
|
|
370
|
+
// Dependencies inlined here too, so the static phase (5b) reuses these servers instead of starting its own.
|
|
371
|
+
const pool = createPool({ root, vitestConfig: options.vitestConfig, workers: 1, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs, inlineDeps: true }, options.concurrency, work, 'mutant');
|
|
361
372
|
pools.push(pool);
|
|
362
373
|
/** Mutants decided on one verdict track: the tests', or a spec oracle's replay tests'. */
|
|
363
374
|
const killedSoFar = (spec) => new Set([...runaways, ...crashedIds, ...records.filter((r) => r.type === 'mutant' && r.status !== 'Survived' && Boolean(r.spec) === spec).map((r) => r.id ?? '')]);
|
|
@@ -410,7 +421,29 @@ export async function run(options) {
|
|
|
410
421
|
// One round, every covering file at once. A two-round plan (cheapest file first, survivors
|
|
411
422
|
// to the rest) cut executions 23% on burgee/paratext but took 2.9x longer: the second round
|
|
412
423
|
// waits for the first to drain, and the expensive files then run as a tail (2026-10-27).
|
|
413
|
-
|
|
424
|
+
// Kills are filtered out at dispatch, not when a unit is built: most units wait in the queue while
|
|
425
|
+
// others run, and a mutant another unit has killed (vouched for by its canary) needs no more of its
|
|
426
|
+
// covering tests. 1,022 of 6,550 warm executions on burgee/paratext ran after the mutant's kill
|
|
427
|
+
// (2026-10-30). Verdicts are unchanged: a Killed mutant needs no other pair (dispatch.qnt).
|
|
428
|
+
const beforeUnit = (unit) => {
|
|
429
|
+
const entry = planUnits.get(unit.results);
|
|
430
|
+
if (!entry)
|
|
431
|
+
return true;
|
|
432
|
+
const decided = { tests: killedSoFar(false), spec: killedSoFar(true) };
|
|
433
|
+
const left = {};
|
|
434
|
+
for (const [t, ids] of Object.entries(entry.plan)) {
|
|
435
|
+
const done = specOf.has(t) ? decided.spec : decided.tests;
|
|
436
|
+
const keep = ids.filter((id) => !done.has(id));
|
|
437
|
+
if (keep.length)
|
|
438
|
+
left[t] = keep;
|
|
439
|
+
}
|
|
440
|
+
if (Object.keys(left).length === 0)
|
|
441
|
+
return false;
|
|
442
|
+
entry.plan = left;
|
|
443
|
+
writeFileSync(unit.plan, JSON.stringify({ tests: left, hitLimits, skip: [], specTests: [...specOf.keys()] }));
|
|
444
|
+
return true;
|
|
445
|
+
};
|
|
446
|
+
await pool.drain(unitsForRound(toRun, (m) => fileOrder.get(m.id) ?? []), (_, id) => warmBudget(id), afterUnit, beforeUnit);
|
|
414
447
|
stats.mutationMs = performance.now() - m0;
|
|
415
448
|
// ── 5. Verdicts from the warm run ─────────────────────────────────────────────────────────
|
|
416
449
|
const byMutant = new Map();
|
|
@@ -449,9 +482,7 @@ export async function run(options) {
|
|
|
449
482
|
isolatedQueue.push(m); // unfinished: a crash left work undone
|
|
450
483
|
}
|
|
451
484
|
}
|
|
452
|
-
|
|
453
|
-
if (m.static && m.status !== 'Ignored' && !m.reused)
|
|
454
|
-
isolatedQueue.push(m);
|
|
485
|
+
const staticMutants = mutants.filter((m) => m.static && m.status !== 'Ignored' && !m.reused);
|
|
455
486
|
// ── 6. Isolated re-runs: the switch armed before import, Vitest run normally ──────────────
|
|
456
487
|
const allTestFiles = [...new Set(dry.tests.map((t) => t.file))];
|
|
457
488
|
// A static mutant needs the files that load it (plus any whose tests reach it); only a hit
|
|
@@ -460,6 +491,21 @@ export async function run(options) {
|
|
|
460
491
|
for (const t of dry.tests)
|
|
461
492
|
fileCost.set(t.file, (fileCost.get(t.file) ?? 0) + t.ms);
|
|
462
493
|
const byCost = (files) => [...files].sort((a, b) => (fileCost.get(a) ?? 0) - (fileCost.get(b) ?? 0));
|
|
494
|
+
// Test file -> source file -> how many of that source's mutants its tests reach. A static mutant
|
|
495
|
+
// rarely has covering tests of its own, so its loaders were tried cheapest first, and the cheapest
|
|
496
|
+
// seldom tests the module: on burgee/controlroom each tabs.ts static mutant ran 75 tests in two
|
|
497
|
+
// files where Stryker needed 37 (2026-10-30). The file that exercises the module goes first.
|
|
498
|
+
const affinity = new Map();
|
|
499
|
+
for (const mm of mutants) {
|
|
500
|
+
for (const t of mm.coveredBy) {
|
|
501
|
+
const f = testInfo.get(t)?.file;
|
|
502
|
+
if (!f)
|
|
503
|
+
continue;
|
|
504
|
+
const bySource = affinity.get(f) ?? affinity.set(f, new Map()).get(f);
|
|
505
|
+
bySource.set(mm.file, (bySource.get(mm.file) ?? 0) + 1);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const byAffinity = (source, files) => byCost(files).sort((a, b) => (affinity.get(b)?.get(source) ?? 0) - (affinity.get(a)?.get(source) ?? 0));
|
|
463
509
|
const filesFor = (m) => {
|
|
464
510
|
const tests = m.coveredBy.map((t) => testInfo.get(t)?.file ?? '');
|
|
465
511
|
if (!m.static)
|
|
@@ -471,27 +517,119 @@ export async function run(options) {
|
|
|
471
517
|
// ran a file that only imports it first, and 288 of 349 isolated kills on burgee/paratext
|
|
472
518
|
// came from a later file (2026-10-27).
|
|
473
519
|
const covering = byCost([...new Set(tests)]);
|
|
474
|
-
return [...covering, ...
|
|
520
|
+
return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
|
|
475
521
|
};
|
|
476
522
|
const isolatedBudget = (id) => {
|
|
477
523
|
const m = byId.get(id);
|
|
478
524
|
const fs = m ? filesFor(m) : allTestFiles;
|
|
479
|
-
|
|
525
|
+
// A non-static mutant's isolated run executes only its covering tests (`tests` below), so they
|
|
526
|
+
// are its budget; every test of its files made a hang wait 30-40 s on oxmutate's own suite,
|
|
527
|
+
// where its covering tests take a fraction of that (2026-10-30). A static mutant runs them all.
|
|
528
|
+
const covering = new Set(m && !m.static ? m.coveredBy : []);
|
|
529
|
+
const ms = dry.tests.filter((t) => fs.includes(t.file) && (covering.size === 0 || covering.has(t.id))).reduce((sum, t) => sum + t.ms, 0);
|
|
480
530
|
return 1500 + options.timeoutMS + options.timeoutFactor * ms * 2;
|
|
481
531
|
};
|
|
482
532
|
// A non-static mutant runs only its covering tests, armed across all of them in file order:
|
|
483
533
|
// Stryker's semantics, so cross-test effects still show (a no-op reset() lets state pile up
|
|
484
534
|
// for the next covering test). A static mutant runs every test of its files, because an
|
|
485
535
|
// import-time change reaches tests that never execute the mutated line.
|
|
536
|
+
/**
|
|
537
|
+
* Static mutants decided in live workers, one unit per (test file, batch of its static mutants),
|
|
538
|
+
* on a pool that inlines dependencies. Returns the mutants this path could not decide.
|
|
539
|
+
*/
|
|
540
|
+
const decideStaticLive = async (items) => {
|
|
541
|
+
if (items.length === 0)
|
|
542
|
+
return [];
|
|
543
|
+
const STATIC_BATCH = 25;
|
|
544
|
+
/** Units of up to STATIC_BATCH mutants per test file, for the files each mutant gets this round. */
|
|
545
|
+
const unitsFor = (filesOf) => {
|
|
546
|
+
const byFile = new Map();
|
|
547
|
+
for (const m of items)
|
|
548
|
+
for (const f of filesOf(m))
|
|
549
|
+
(byFile.get(f) ?? byFile.set(f, []).get(f))?.push(m.id);
|
|
550
|
+
return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) => ({
|
|
551
|
+
mode: 'static', files: [file], results: fileFor('static', 'jsonl'), progress: fileFor('static', 'progress'), staticIds: ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH),
|
|
552
|
+
hitLimits: Object.fromEntries(ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH).map((id) => [id, hitLimitOf(id)])),
|
|
553
|
+
})));
|
|
554
|
+
};
|
|
555
|
+
const killed = new Map();
|
|
556
|
+
/** Decided by the hit-count limit: a runaway, Timeout like a warm one. */
|
|
557
|
+
const timedOut = new Set();
|
|
558
|
+
const passed = new Map(); // mutant -> files that ran it clean
|
|
559
|
+
const back = new Set();
|
|
560
|
+
const ran = new Map();
|
|
561
|
+
const staticPool = pool;
|
|
562
|
+
const afterStatic = (unit, outcome) => {
|
|
563
|
+
const file = unit.files[0] ?? '';
|
|
564
|
+
const decided = new Set();
|
|
565
|
+
for (const r of readRecords(unit.results)) {
|
|
566
|
+
if (r.type === 'static-fallback')
|
|
567
|
+
for (const id of r.ids ?? [])
|
|
568
|
+
back.add(id);
|
|
569
|
+
if (r.type !== 'isolated' || !r.id)
|
|
570
|
+
continue;
|
|
571
|
+
decided.add(r.id);
|
|
572
|
+
ran.set(r.id, (ran.get(r.id) ?? 0) + (r.ran ?? 0));
|
|
573
|
+
if (r.timeout)
|
|
574
|
+
timedOut.add(r.id);
|
|
575
|
+
if (r.killed || r.timeout)
|
|
576
|
+
killed.set(r.id, { killedBy: [...(killed.get(r.id)?.killedBy ?? []), ...(r.failed ?? [])], ran: 0 });
|
|
577
|
+
else
|
|
578
|
+
(passed.get(r.id) ?? passed.set(r.id, new Set()).get(r.id))?.add(file);
|
|
579
|
+
}
|
|
580
|
+
// A stop or a crash leaves the rest of the batch undecided: the fresh-worker path decides it.
|
|
581
|
+
if (outcome.killedFor || outcome.crashed)
|
|
582
|
+
for (const id of unit.staticIds ?? [])
|
|
583
|
+
if (!decided.has(id))
|
|
584
|
+
back.add(id);
|
|
585
|
+
return [];
|
|
586
|
+
};
|
|
587
|
+
const keepUndecided = (unit) => {
|
|
588
|
+
unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
|
|
589
|
+
return unit.staticIds.length > 0;
|
|
590
|
+
};
|
|
591
|
+
// Two rounds: each mutant first in the file most likely to kill it (filesFor puts the files whose
|
|
592
|
+
// tests reach it first, cheapest first), then its other files only if it survived. In one round
|
|
593
|
+
// every loading file ran a mutant at once, and a kill in one did not spare the others: 41 tests a
|
|
594
|
+
// static kill on burgee/controlroom against Stryker's 33.6 (2026-10-30).
|
|
595
|
+
const open = (m) => !killed.has(m.id) && !back.has(m.id);
|
|
596
|
+
const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
|
|
597
|
+
// the mutant's run and the unarmed canary after it
|
|
598
|
+
for (const round of rounds)
|
|
599
|
+
await staticPool.drain(unitsFor(round), (_, id) => 2 * isolatedBudget(id), afterStatic, keepUndecided);
|
|
600
|
+
const undecided = [];
|
|
601
|
+
for (const m of items) {
|
|
602
|
+
const k = killed.get(m.id);
|
|
603
|
+
if (k) {
|
|
604
|
+
m.status = timedOut.has(m.id) && !k.killedBy.length ? 'Timeout' : 'Killed';
|
|
605
|
+
m.killedBy = [...new Set(k.killedBy)];
|
|
606
|
+
m.testsCompleted = ran.get(m.id) ?? 0;
|
|
607
|
+
}
|
|
608
|
+
else if (!back.has(m.id) && filesFor(m).every((f) => passed.get(m.id)?.has(f))) {
|
|
609
|
+
m.status = 'Survived';
|
|
610
|
+
m.testsCompleted = ran.get(m.id) ?? 0;
|
|
611
|
+
}
|
|
612
|
+
else
|
|
613
|
+
undecided.push(m);
|
|
614
|
+
}
|
|
615
|
+
return undecided;
|
|
616
|
+
};
|
|
486
617
|
const runIsolated = async (items, slack = 1) => {
|
|
487
618
|
const verdicts = new Map();
|
|
488
619
|
if (items.length === 0)
|
|
489
620
|
return verdicts;
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
621
|
+
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, hitLimit: hitLimitOf(m.id) })) });
|
|
622
|
+
// Packed by estimated cost, heaviest first into the lightest bin, and the heaviest bins run
|
|
623
|
+
// first. Equal mutant counts left one unit at 8.4 s while the others finished in under 3 s on
|
|
624
|
+
// burgee/controlroom (2026-10-30): a mutant's cost is its files' dry-run time, not one.
|
|
625
|
+
const cost = (m) => isolatedBudget(m.id);
|
|
626
|
+
const bins = Array.from({ length: Math.min(items.length, options.concurrency * 2) }, () => ({ load: 0, batch: [] }));
|
|
627
|
+
for (const m of [...items].sort((x, y) => cost(y) - cost(x))) {
|
|
628
|
+
const bin = bins.reduce((lightest, b) => (b.load < lightest.load ? b : lightest));
|
|
629
|
+
bin.load += cost(m);
|
|
630
|
+
bin.batch.push(m);
|
|
631
|
+
}
|
|
632
|
+
const units = bins.sort((x, y) => y.load - x.load).map((b) => mk(b.batch));
|
|
495
633
|
stats.isolatedRuns += items.length;
|
|
496
634
|
await pool.drain(units, (_, id) => slack * isolatedBudget(id), (unit, outcome) => {
|
|
497
635
|
// Vitest reports per test file: a mutant is killed if any of its files failed.
|
|
@@ -500,7 +638,9 @@ export async function run(options) {
|
|
|
500
638
|
continue;
|
|
501
639
|
const prev = verdicts.get(rec.id);
|
|
502
640
|
const testsCompleted = (prev?.testsCompleted ?? 0) + (rec.ran ?? 0);
|
|
503
|
-
if (rec.
|
|
641
|
+
if (rec.timeout)
|
|
642
|
+
verdicts.set(rec.id, { status: 'Timeout', testsCompleted });
|
|
643
|
+
else if (rec.killed)
|
|
504
644
|
verdicts.set(rec.id, { status: 'Killed', killedBy: [...(prev?.killedBy ?? []), ...(rec.failed ?? [])], testsCompleted });
|
|
505
645
|
else
|
|
506
646
|
verdicts.set(rec.id, { status: prev?.status ?? 'Survived', killedBy: prev?.killedBy, testsCompleted });
|
|
@@ -520,6 +660,13 @@ export async function run(options) {
|
|
|
520
660
|
verdicts.set(m.id, { status: 'RuntimeError' });
|
|
521
661
|
return verdicts;
|
|
522
662
|
};
|
|
663
|
+
// ── 5b. Static mutants in live workers (.sdlc/intents/oxmutate/staticwarm.qnt) ─────────────
|
|
664
|
+
// A fresh worker per (static mutant, file) cost ~80 ms of start-up around tests that take a few
|
|
665
|
+
// ms: 29 of 35 s on burgee/paratext (2026-10-30). A live worker re-evaluates the project's modules
|
|
666
|
+
// per mutant instead; whatever it cannot vouch for comes back here for the fresh-worker path.
|
|
667
|
+
// OXMUTATE_ISOLATE_STATIC=1 sends every static mutant down the fresh-worker path, as before.
|
|
668
|
+
const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
|
|
669
|
+
isolatedQueue.push(...staticBack);
|
|
523
670
|
progress.phase(`isolated runs (static mutants and stragglers: ${String(isolatedQueue.length)})`);
|
|
524
671
|
const i0 = performance.now();
|
|
525
672
|
const first = await runIsolated(isolatedQueue);
|
|
@@ -551,7 +698,10 @@ export async function run(options) {
|
|
|
551
698
|
// survives warm and is killed when armed for the whole file, the way Stryker runs it (burgee/
|
|
552
699
|
// paratext plugin.ts, 2026-10-27). Survivors are what a person acts on, so each one is
|
|
553
700
|
// re-run once with the mutant armed across its covering files before it is reported.
|
|
554
|
-
|
|
701
|
+
// A survivor with one covering test is not re-run: confirmation runs only covering tests, so there
|
|
702
|
+
// is no later test for its leftover state to reach, and the warm run already armed that test
|
|
703
|
+
// through its hooks from clean state (.sdlc/intents/oxmutate/confirm.qnt).
|
|
704
|
+
const survivors = toRun.filter((m) => m.status === 'Survived' && new Set(m.coveredBy).size > 1);
|
|
555
705
|
const confirmed = await runIsolated(survivors);
|
|
556
706
|
// A timeout here is a watchdog stop like any other, and a loaded machine makes them: two
|
|
557
707
|
// closeout survivors were reported killed this way while a docs build shared the machine
|
package/dist/lanes.js
CHANGED
|
@@ -49,6 +49,7 @@ export function shardOf(id, count) {
|
|
|
49
49
|
// Ids are hex (`native/src/stable_id.rs`), so a code point is a code unit and the shards are
|
|
50
50
|
// the ones `charCodeAt` gave.
|
|
51
51
|
for (let i = 0; i < id.length; i++) {
|
|
52
|
+
// Stryker disable next-line NumericLiteral: `i < id.length`, so codePointAt never returns undefined here
|
|
52
53
|
h ^= id.codePointAt(i) ?? 0;
|
|
53
54
|
h = Math.imul(h, FNV_PRIME) >>> 0;
|
|
54
55
|
}
|
package/dist/progress.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* shows the run moving without a carriage-return mess. Off unless a progress reporter is named.
|
|
6
6
|
*/
|
|
7
7
|
const REPORTERS = new Set(['progress', 'progress-append-only', 'ci', 'dots']);
|
|
8
|
+
// Stryker disable next-line NumericLiteral: a repaint interval; no test can see 1,000 ms against 1,001
|
|
8
9
|
const TTY_EVERY_MS = 1000;
|
|
9
10
|
const APPEND_EVERY_MS = 10_000;
|
|
10
11
|
const MS_PER_S = 1000;
|
package/dist/runner.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ type AnyTask = {
|
|
|
13
13
|
duration?: number;
|
|
14
14
|
};
|
|
15
15
|
tasks?: AnyTask[] | undefined;
|
|
16
|
+
mode?: string;
|
|
17
|
+
context?: unknown;
|
|
16
18
|
};
|
|
17
19
|
/** The family's refusal shape for a missing optional peer (as `controlroom/ink`'s): a code and a fix. */
|
|
18
20
|
export declare class PeerMissingError extends Error {
|
|
@@ -21,6 +23,9 @@ export declare class PeerMissingError extends Error {
|
|
|
21
23
|
constructor(cause: unknown);
|
|
22
24
|
}
|
|
23
25
|
declare const OxmutateRunner_base: new (config: unknown) => {
|
|
26
|
+
onBeforeCollect?(paths: string[]): unknown;
|
|
27
|
+
importFile?(filepath: string, source: string): unknown;
|
|
28
|
+
onBeforeRunSuite?(suite: AnyTask): unknown;
|
|
24
29
|
onCollectStart?(file: unknown): unknown;
|
|
25
30
|
onBeforeTryTask?(test: AnyTask, options: {
|
|
26
31
|
retry: number;
|
|
@@ -42,6 +47,22 @@ export default class OxmutateRunner extends OxmutateRunner_base {
|
|
|
42
47
|
runTask(test: AnyTask & {
|
|
43
48
|
context?: unknown;
|
|
44
49
|
}): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
|
|
52
|
+
* files: those run before every collection, re-collections included, and write the same values
|
|
53
|
+
* each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
|
|
54
|
+
*/
|
|
55
|
+
importFile(filepath: string, source: string): unknown;
|
|
56
|
+
onBeforeRunSuite(suite: AnyTask): Promise<unknown>;
|
|
57
|
+
/**
|
|
58
|
+
* `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
|
|
59
|
+
* this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
|
|
60
|
+
* 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
|
|
61
|
+
* collection must leave globalThis, process.env and the built-in prototypes as they were; so must
|
|
62
|
+
* each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
|
|
63
|
+
* rest of the batch back to the fresh-worker path.
|
|
64
|
+
*/
|
|
65
|
+
private decideStatic;
|
|
45
66
|
/** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
|
|
46
67
|
private retryReset;
|
|
47
68
|
onAfterRunFiles(files: AnyTask[]): unknown;
|
package/dist/runner.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* failure there means an earlier mutant polluted shared state (the canary).
|
|
14
14
|
* - `isolated`: one mutant was armed before import (static mutants and canary re-runs). Run
|
|
15
15
|
* normally; any failed test kills it.
|
|
16
|
+
* - `static`: a batch of static mutants decided in this live worker (staticwarm.qnt). Before the file
|
|
17
|
+
* runs, each mutant is armed, the module registry is reset and the file is collected again, so
|
|
18
|
+
* its tests close over modules evaluated under the mutant; they run with their hooks, then an
|
|
19
|
+
* unarmed re-collection must pass. A file whose own collection touches globals, or a mutant that
|
|
20
|
+
* leaves a trace, sends the rest of the batch back to the fresh-worker path (`static-fallback`).
|
|
16
21
|
*
|
|
17
22
|
* Before each mutant the runner writes a progress line synchronously, so the coordinator can
|
|
18
23
|
* attribute and kill a worker stuck in a synchronous loop that the hit counter cannot break.
|
|
@@ -69,6 +74,53 @@ const specTests = new Set(plan.specTests ?? []);
|
|
|
69
74
|
const decidedBySpec = new Set();
|
|
70
75
|
/** `isolated`: when set, only these tests run (the mutant's covering tests); the rest pass untouched. */
|
|
71
76
|
const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
|
|
77
|
+
/** `static`: the static mutants this file run decides in the live worker. */
|
|
78
|
+
const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
|
|
79
|
+
/** `static`: each mutant's hit-count limit. */
|
|
80
|
+
const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
|
|
81
|
+
// `isolated`: the mutant is armed from process start, so its hit counter is too. A loop it causes
|
|
82
|
+
// stops at the limit, as in warm runs, instead of holding the worker until the watchdog's budget:
|
|
83
|
+
// on oxmutate's own suite 17 runaways left the isolated phase idle 140 of 186 s (2026-10-30).
|
|
84
|
+
if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
|
|
85
|
+
ns.hitCount = 0;
|
|
86
|
+
ns.hitLimit = Number(env['OXMUTATE_HIT_LIMIT']);
|
|
87
|
+
}
|
|
88
|
+
/** Did the armed mutant's switch pass its limit, i.e. was this run stopped as a runaway? */
|
|
89
|
+
const ranAway = () => ns.hitCount !== undefined && ns.hitLimit !== undefined && ns.hitCount > ns.hitLimit;
|
|
90
|
+
// ── `static` mode: what a registry reset does not reset ─────────────────────────────────────────
|
|
91
|
+
/** Built-ins a module may patch in place; their own methods are compared by identity. */
|
|
92
|
+
const PROTOTYPES = {
|
|
93
|
+
Object: Object.prototype, Array: Array.prototype, String: String.prototype, Number: Number.prototype,
|
|
94
|
+
Boolean: Boolean.prototype, Function: Function.prototype, Promise: Promise.prototype, Map: Map.prototype,
|
|
95
|
+
Set: Set.prototype, RegExp: RegExp.prototype, Date: Date.prototype, Error: Error.prototype,
|
|
96
|
+
};
|
|
97
|
+
/** Keys Vitest and oxmutate own: they change by design while a file runs. */
|
|
98
|
+
const OWN_GLOBAL = /^(__vitest|__oxmutate__$|__VITEST|__vite)/;
|
|
99
|
+
/** globalThis, process.env and the built-in prototypes, as a map a later look can be compared with. */
|
|
100
|
+
function snapshot() {
|
|
101
|
+
const out = new Map();
|
|
102
|
+
const g = globalThis;
|
|
103
|
+
for (const k of Object.getOwnPropertyNames(g)) {
|
|
104
|
+
if (OWN_GLOBAL.test(k))
|
|
105
|
+
continue;
|
|
106
|
+
const d = Object.getOwnPropertyDescriptor(g, k);
|
|
107
|
+
out.set(`g:${k}`, d && 'value' in d ? d.value : d?.get);
|
|
108
|
+
}
|
|
109
|
+
for (const [k, v] of Object.entries(env))
|
|
110
|
+
out.set(`env:${k}`, v);
|
|
111
|
+
for (const [name, proto] of Object.entries(PROTOTYPES)) {
|
|
112
|
+
for (const k of Object.getOwnPropertyNames(proto))
|
|
113
|
+
out.set(`proto:${name}.${k}`, Object.getOwnPropertyDescriptor(proto, k)?.value);
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** How many differing keys a `static-fallback` record names. */
|
|
118
|
+
const DIFF_KEYS = 8;
|
|
119
|
+
/** The keys whose presence or value differs between two snapshots (the first few, for a report). */
|
|
120
|
+
const snapshotDiff = (a, b) => [...new Set([...a.keys(), ...b.keys()])].filter((k) => !(a.has(k) && b.has(k) && Object.is(a.get(k), b.get(k)))).slice(0, DIFF_KEYS);
|
|
121
|
+
const sameSnapshot = (a, b) => snapshotDiff(a, b).length === 0;
|
|
122
|
+
/** The state before this worker evaluated the test file: the baseline every later look compares with. */
|
|
123
|
+
let pristine;
|
|
72
124
|
/** Set when a mutant left state behind; the rest of this context runs no mutants. */
|
|
73
125
|
let polluted = false;
|
|
74
126
|
/** Mutants between canaries; doubles while the canary stays clean, back to 1 when it fails. */
|
|
@@ -81,10 +133,16 @@ function emit(record) {
|
|
|
81
133
|
if (resultsPath)
|
|
82
134
|
appendFileSync(resultsPath, `${JSON.stringify(record)}\n`);
|
|
83
135
|
}
|
|
136
|
+
// The suites whose hooks wrap `test`, outermost first. Vitest 5 leaves a top-level `describe`'s
|
|
137
|
+
// `suite` unset and keeps file-level hooks on `test.file`, so the walk adds the file itself: without
|
|
138
|
+
// it a file-level `beforeEach` reset never re-ran between mutants and every canary read as pollution.
|
|
84
139
|
function chain(test) {
|
|
85
140
|
const suites = [];
|
|
86
141
|
for (let s = test.suite; s; s = s.suite)
|
|
87
142
|
suites.unshift(s);
|
|
143
|
+
const file = test.file;
|
|
144
|
+
if (file && suites[0] !== file)
|
|
145
|
+
suites.unshift(file);
|
|
88
146
|
return suites;
|
|
89
147
|
}
|
|
90
148
|
async function beforeEach(test, context) {
|
|
@@ -107,6 +165,61 @@ async function afterEach(test, context, cleanups) {
|
|
|
107
165
|
await cleanup();
|
|
108
166
|
}
|
|
109
167
|
const isHitLimit = (e) => e instanceof Error && /Hit count limit/.test(e.message);
|
|
168
|
+
/**
|
|
169
|
+
* Runs a file collected outside Vitest's own run (`static` mode): each suite's beforeAll/afterAll,
|
|
170
|
+
* each test with its beforeEach/afterEach chain, stopping at the first failure, which is all a
|
|
171
|
+
* mutant's verdict needs.
|
|
172
|
+
*/
|
|
173
|
+
async function runCollected(file, runnable) {
|
|
174
|
+
const failed = [];
|
|
175
|
+
let error;
|
|
176
|
+
const note = (t, e) => {
|
|
177
|
+
error ??= `${fullName(t)}: ${e instanceof Error ? e.message : String(e)}`.slice(0, POLLUTION_ERROR_CHARS);
|
|
178
|
+
};
|
|
179
|
+
let ran = 0;
|
|
180
|
+
const runSuite = async (suite) => {
|
|
181
|
+
const hooks = statics.getSuiteHooks(suite);
|
|
182
|
+
for (const hook of hooks.beforeAll)
|
|
183
|
+
await hook(suite);
|
|
184
|
+
try {
|
|
185
|
+
for (const t of suite.tasks ?? []) {
|
|
186
|
+
if (failed.length)
|
|
187
|
+
return;
|
|
188
|
+
if (t.type === 'suite') {
|
|
189
|
+
await runSuite(t);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const fn = runnable.has(t.id) ? statics.getTestFn(t) : undefined;
|
|
193
|
+
if (!fn)
|
|
194
|
+
continue;
|
|
195
|
+
const cleanups = await beforeEach(t, t.context);
|
|
196
|
+
ran++;
|
|
197
|
+
try {
|
|
198
|
+
await fn();
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
failed.push(t.id);
|
|
202
|
+
note(t, e);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await afterEach(t, t.context, cleanups);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
for (const hook of hooks.afterAll)
|
|
211
|
+
await hook(suite);
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
await runSuite(file);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
failed.push(file.id); // a hook threw: the file failed under the mutant, as Stryker scores it
|
|
219
|
+
note(file, e);
|
|
220
|
+
}
|
|
221
|
+
return { failed, ran, ...(error !== undefined && { error }) };
|
|
222
|
+
}
|
|
110
223
|
function fullName(t) {
|
|
111
224
|
const parts = [t.name];
|
|
112
225
|
for (let s = t.suite; s && s.type === 'suite' && s.name; s = s.suite)
|
|
@@ -147,11 +260,18 @@ export default class OxmutateRunner extends Base {
|
|
|
147
260
|
const ids = polluted ? [] : (plan.tests[test.id] ?? []).filter((id) => !done.has(id));
|
|
148
261
|
let cleanups = [];
|
|
149
262
|
let attempt = 0;
|
|
150
|
-
const
|
|
263
|
+
const teardown = async () => {
|
|
151
264
|
await afterEach(test, context, cleanups);
|
|
265
|
+
cleanups = [];
|
|
266
|
+
};
|
|
267
|
+
const setup = async () => {
|
|
152
268
|
cleanups = await beforeEach(test, context);
|
|
153
269
|
await this.retryReset(test, ++attempt);
|
|
154
270
|
};
|
|
271
|
+
const fresh = async () => {
|
|
272
|
+
await teardown();
|
|
273
|
+
await setup();
|
|
274
|
+
};
|
|
155
275
|
// Mutants run since the last clean canary. The first of them started from a clean state, so its
|
|
156
276
|
// verdict stands whatever the next canary says; the rest are suspects if it fails.
|
|
157
277
|
let unchecked = [];
|
|
@@ -172,19 +292,20 @@ export default class OxmutateRunner extends Base {
|
|
|
172
292
|
return false;
|
|
173
293
|
}
|
|
174
294
|
};
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
295
|
+
// The mutant is armed through the test's own `beforeEach`, as Stryker arms it: the dry run credits
|
|
296
|
+
// code a hook runs to the test, so a mutant in a fixture builder or a seed is planned here, and with
|
|
297
|
+
// the hooks run unarmed it always survived, to be killed later by an isolated run that costs a
|
|
298
|
+
// fresh process (58 of 400 on a Mongo-backed suite, 2026-10-29). The previous run's `afterEach`
|
|
299
|
+
// runs first, unarmed: its cleanup belongs to that run.
|
|
300
|
+
const runArmed = async (id) => {
|
|
301
|
+
await teardown();
|
|
182
302
|
const t0 = performance.now();
|
|
183
303
|
ns.activeMutant = id;
|
|
184
304
|
ns.hitCount = 0;
|
|
185
305
|
ns.hitLimit = plan.hitLimits[id] ?? Number.MAX_SAFE_INTEGER;
|
|
186
306
|
let status = 'Survived';
|
|
187
307
|
try {
|
|
308
|
+
await setup();
|
|
188
309
|
await fn();
|
|
189
310
|
}
|
|
190
311
|
catch (e) {
|
|
@@ -195,9 +316,27 @@ export default class OxmutateRunner extends Base {
|
|
|
195
316
|
ns.hitCount = undefined;
|
|
196
317
|
ns.hitLimit = undefined;
|
|
197
318
|
}
|
|
319
|
+
return { status, ms: performance.now() - t0 };
|
|
320
|
+
};
|
|
321
|
+
for (const id of ids) {
|
|
322
|
+
if (done.has(id))
|
|
323
|
+
continue;
|
|
324
|
+
if (progressPath)
|
|
325
|
+
writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
|
|
326
|
+
let { status, ms } = await runArmed(id);
|
|
327
|
+
// A kill that follows unchecked runs may be one of their leftovers, which this run's own cleanup
|
|
328
|
+
// (a `finally`) then wipes, so the batch canary would pass and vouch for a wrong kill. Survivors
|
|
329
|
+
// are confirmed in isolation later; kills are checked here: a failing canary is pollution as
|
|
330
|
+
// usual (this verdict goes back with the unit), a passing one leaves the state clean to decide
|
|
331
|
+
// the mutant again from (.sdlc/intents/oxmutate/canary.qnt).
|
|
332
|
+
if (status !== 'Survived' && unchecked.length > 0) {
|
|
333
|
+
if (!(await canary()))
|
|
334
|
+
break;
|
|
335
|
+
({ status, ms } = await runArmed(id));
|
|
336
|
+
}
|
|
198
337
|
if (status !== 'Survived')
|
|
199
338
|
done.add(id);
|
|
200
|
-
emit({ type: 'mutant', id, test: test.id, status, ms
|
|
339
|
+
emit({ type: 'mutant', id, test: test.id, status, ms, ...(spec ? { spec: true } : {}) });
|
|
201
340
|
unchecked.push(id);
|
|
202
341
|
// The canary: the same test, unmutated, must still pass. If it fails, a mutant left state
|
|
203
342
|
// behind (a cache, a registry, a fixture) that would decide the next verdicts. Run after every
|
|
@@ -220,6 +359,96 @@ export default class OxmutateRunner extends Base {
|
|
|
220
359
|
await cleanup();
|
|
221
360
|
}
|
|
222
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* The baseline is taken as the test file itself is imported (`source: 'collect'`), after setup
|
|
364
|
+
* files: those run before every collection, re-collections included, and write the same values
|
|
365
|
+
* each time (the family's colour setup sets TERM, COLORTERM and NO_COLOR), so they are not leaks.
|
|
366
|
+
*/
|
|
367
|
+
importFile(filepath, source) {
|
|
368
|
+
if (mode === 'static' && source === 'collect' && !pristine)
|
|
369
|
+
pristine = snapshot();
|
|
370
|
+
return super.importFile?.(filepath, source);
|
|
371
|
+
}
|
|
372
|
+
async onBeforeRunSuite(suite) {
|
|
373
|
+
if (mode === 'static' && suite.filepath && staticIds.length)
|
|
374
|
+
await this.decideStatic(suite);
|
|
375
|
+
return super.onBeforeRunSuite?.(suite);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* `static` mode (.sdlc/intents/oxmutate/staticwarm.qnt). Re-evaluating the project's modules in
|
|
379
|
+
* this worker costs ~0.5 ms where a fresh worker per (mutant, file) cost ~80 ms (burgee/paratext,
|
|
380
|
+
* 2026-10-30). What a registry reset leaves behind is guarded three ways: the file's own unarmed
|
|
381
|
+
* collection must leave globalThis, process.env and the built-in prototypes as they were; so must
|
|
382
|
+
* each mutant; and an unarmed re-collection after each mutant must pass. Anything else hands the
|
|
383
|
+
* rest of the batch back to the fresh-worker path.
|
|
384
|
+
*/
|
|
385
|
+
async decideStatic(suite) {
|
|
386
|
+
const filepath = suite.filepath;
|
|
387
|
+
// The tests this run will execute, after Vitest's own filters (a name pattern, `.only`, `.skip`):
|
|
388
|
+
// a re-collected file has the same test ids, and runs exactly these.
|
|
389
|
+
const runnable = new Set();
|
|
390
|
+
const visit = (t) => {
|
|
391
|
+
if (t.type === 'test' && t.mode === 'run')
|
|
392
|
+
runnable.add(t.id);
|
|
393
|
+
for (const c of t.tasks ?? [])
|
|
394
|
+
visit(c);
|
|
395
|
+
};
|
|
396
|
+
visit(suite);
|
|
397
|
+
const rest = [...staticIds];
|
|
398
|
+
const fallback = (reason, keys = []) => {
|
|
399
|
+
emit({ type: 'static-fallback', ids: rest.splice(0), reason, keys });
|
|
400
|
+
};
|
|
401
|
+
const before = pristine;
|
|
402
|
+
if (!before)
|
|
403
|
+
return fallback('no baseline');
|
|
404
|
+
const atCollect = snapshotDiff(before, snapshot());
|
|
405
|
+
if (atCollect.length)
|
|
406
|
+
return fallback('the file changed globals while it was collected', atCollect);
|
|
407
|
+
const vitest = (await fromProject('vitest'));
|
|
408
|
+
// `collectTests` is exported for Vitest's browser runner from an internal subpath: if a Vitest
|
|
409
|
+
// moves or drops it, the import fails and the fresh-worker path decides, as on Vitest 4.
|
|
410
|
+
const browser = (await fromProject('vitest/internal/browser').catch(() => ({})));
|
|
411
|
+
const { collectTests } = browser;
|
|
412
|
+
const { vi } = vitest;
|
|
413
|
+
if (!collectTests || !vi)
|
|
414
|
+
return fallback('this Vitest has no public collect'); // Vitest 4: the fresh path decides
|
|
415
|
+
const evaluate = async (armed) => {
|
|
416
|
+
ns.activeMutant = armed;
|
|
417
|
+
ns.hitCount = armed ? 0 : undefined;
|
|
418
|
+
ns.hitLimit = armed ? (staticLimits[armed] ?? Number.MAX_SAFE_INTEGER) : undefined;
|
|
419
|
+
try {
|
|
420
|
+
vi.resetModules();
|
|
421
|
+
const [file] = await collectTests([filepath], this);
|
|
422
|
+
const result = file ? await runCollected(file, runnable) : { failed: [filepath], ran: 0 };
|
|
423
|
+
return { ...result, timeout: ranAway() };
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
ns.activeMutant = undefined;
|
|
427
|
+
ns.hitCount = undefined;
|
|
428
|
+
ns.hitLimit = undefined;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
while (rest.length) {
|
|
432
|
+
const id = rest[0];
|
|
433
|
+
if (progressPath)
|
|
434
|
+
writeFileSync(progressPath, `${id} ${String(Date.now())}\n`);
|
|
435
|
+
const verdict = await evaluate(id);
|
|
436
|
+
const left = snapshotDiff(before, snapshot());
|
|
437
|
+
if (left.length)
|
|
438
|
+
return fallback('the mutant left a trace', left);
|
|
439
|
+
const unarmed = await evaluate(undefined);
|
|
440
|
+
if (unarmed.failed.length > 0)
|
|
441
|
+
return fallback('the unarmed re-collection failed', unarmed.error ? [unarmed.error] : []);
|
|
442
|
+
const after = snapshotDiff(before, snapshot());
|
|
443
|
+
if (after.length)
|
|
444
|
+
return fallback('the unarmed re-collection left a trace', after);
|
|
445
|
+
rest.shift();
|
|
446
|
+
emit({ type: 'isolated', id, killed: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }) });
|
|
447
|
+
}
|
|
448
|
+
if (progressPath)
|
|
449
|
+
writeFileSync(progressPath, '');
|
|
450
|
+
vi.resetModules();
|
|
451
|
+
}
|
|
223
452
|
/** Reset per-try state (snapshot counters, assertion counts) the way Vitest does for a retry. */
|
|
224
453
|
async retryReset(test, attempt) {
|
|
225
454
|
await super.onBeforeTryTask?.(test, { retry: attempt, repeats: 0 });
|
|
@@ -262,7 +491,8 @@ export default class OxmutateRunner extends Base {
|
|
|
262
491
|
if (f.result?.state === 'fail' && !failed.length)
|
|
263
492
|
failed.push(f.id);
|
|
264
493
|
}
|
|
265
|
-
|
|
494
|
+
const timeout = ranAway();
|
|
495
|
+
emit({ type: 'isolated', id: env[ACTIVE_MUTANT_ENV] ?? ns.activeMutant, killed: failed.length > 0 && !timeout, failed, ran, ...(timeout && { timeout }) });
|
|
266
496
|
}
|
|
267
497
|
return super.onAfterRunFiles?.(files);
|
|
268
498
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -118,7 +118,7 @@ export function createPool(config, size, workDir, tag) {
|
|
|
118
118
|
};
|
|
119
119
|
return {
|
|
120
120
|
stats,
|
|
121
|
-
async drain(units, budget, after) {
|
|
121
|
+
async drain(units, budget, after, prepare) {
|
|
122
122
|
const queue = [...units];
|
|
123
123
|
let busy = 0;
|
|
124
124
|
const waiters = [];
|
|
@@ -126,9 +126,16 @@ export function createPool(config, size, workDir, tag) {
|
|
|
126
126
|
for (const w of waiters.splice(0))
|
|
127
127
|
w();
|
|
128
128
|
};
|
|
129
|
+
/** The next unit `prepare` keeps, or undefined when the queue is empty. */
|
|
130
|
+
const take = () => {
|
|
131
|
+
for (let unit = queue.shift(); unit; unit = queue.shift())
|
|
132
|
+
if (!prepare || prepare(unit))
|
|
133
|
+
return unit;
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
129
136
|
await Promise.all(servers.map(async (_, slot) => {
|
|
130
137
|
for (;;) {
|
|
131
|
-
const unit =
|
|
138
|
+
const unit = take();
|
|
132
139
|
if (!unit) {
|
|
133
140
|
if (busy === 0) {
|
|
134
141
|
signal();
|
package/dist/spec-oracle.js
CHANGED
|
@@ -45,6 +45,7 @@ const share = (n, of) => (of ? (PERCENT * n) / of : 0);
|
|
|
45
45
|
const located = ({ id, file, line, mutatorName, replacement }) => ({ id, file, line, mutatorName, replacement });
|
|
46
46
|
const median = (xs) => {
|
|
47
47
|
const sorted = [...xs].sort((a, b) => a - b);
|
|
48
|
+
// Stryker disable next-line NumericLiteral: guarded by `sorted.length`, so the fallback never runs
|
|
48
49
|
return sorted.length ? (sorted[Math.floor(sorted.length / 2)] ?? 0) : 0;
|
|
49
50
|
};
|
|
50
51
|
const testsMiss = (m) => m.status === 'Survived' || m.status === 'NoCoverage';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxmutate",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|