oxmutate 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
Binary file
package/dist/child.js CHANGED
@@ -83,6 +83,10 @@ async function runUnit(unit) {
83
83
  env['OXMUTATE_STATIC'] = JSON.stringify(unit.staticIds);
84
84
  else
85
85
  delete env['OXMUTATE_STATIC'];
86
+ if (unit.staticGroups)
87
+ env['OXMUTATE_STATIC_GROUPS'] = JSON.stringify(unit.staticGroups);
88
+ else
89
+ delete env['OXMUTATE_STATIC_GROUPS'];
86
90
  if (unit.hitLimits)
87
91
  env['OXMUTATE_HIT_LIMITS'] = JSON.stringify(unit.hitLimits);
88
92
  else
package/dist/cli.js CHANGED
@@ -60,6 +60,8 @@ function overridesFrom(o, root) {
60
60
  overrides.strykerCompat = true;
61
61
  if (o['ignoreStatic'] === true)
62
62
  overrides.ignoreStatic = true;
63
+ if (typeof o['staticGroup'] === 'number')
64
+ overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
63
65
  if (o['incremental'] === true)
64
66
  overrides.incremental = true;
65
67
  if (typeof o['flakyReruns'] === 'number')
@@ -90,6 +92,7 @@ const runCommand = defineCommand({
90
92
  mutantTimeout: { type: 'number', description: 'absolute slack per mutant, ms (default 500; Stryker 5000)' },
91
93
  strykerCompat: { type: 'boolean', description: 'reproduce Stryker 10.0.0 skips (no mutants inside `as` casts or enums)' },
92
94
  ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
95
+ staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
93
96
  incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
94
97
  flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
95
98
  reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
@@ -123,6 +126,10 @@ const runCommand = defineCommand({
123
126
  stderr.write(`oxmutate: the change weakened its tests: ${String(weakened.length)} mutant(s) the tests at ${result.frozen?.base ?? ''} detect now survive (${weakened.slice(0, WEAKENED_SHOWN).map((w) => `${w.file}:${String(w.line)} ${w.mutatorName}`).join('; ')}). fix: restore the assertions the change removed, or replace them with ones that kill these mutants\n`);
124
127
  data['exitCode'] = ExitCode.RUNTIME;
125
128
  }
129
+ if (result.stats.staticAuditDisagreed > 0) {
130
+ stderr.write(`oxmutate: ${String(result.stats.staticAuditDisagreed)} mutant(s) reported as survivors by a group were killed when run alone; they are reported Killed. fix: run without static.group (--static-group 1); two of this file's mutants hide each other when armed together\n`);
131
+ data['exitCode'] = ExitCode.RUNTIME;
132
+ }
126
133
  const floor = options_.thresholds.break;
127
134
  if (floor !== null && result.score.mutationScore < floor) {
128
135
  stderr.write(`oxmutate: mutation score ${result.score.mutationScore.toFixed(2)}% is below the break threshold ${String(floor)}%. fix: kill the survivors listed above, or lower thresholds.break deliberately\n`);
package/dist/config.d.ts CHANGED
@@ -19,6 +19,12 @@ export interface Options {
19
19
  excludedMutations: string[];
20
20
  /** Score static mutants (Stryker skips them with `ignoreStatic`). */
21
21
  ignoreStatic: boolean;
22
+ /**
23
+ * How many static mutants are armed together in a test file that is not their first (`static.group`).
24
+ * 1, the default, arms each alone. A larger group is faster where most of them survive, and is a
25
+ * heuristic: two mutants can hide each other, so one in twenty group survivors is run again alone.
26
+ */
27
+ staticGroup: number;
22
28
  strykerCompat: boolean;
23
29
  reporters: string[];
24
30
  jsonReport: string;
@@ -97,6 +103,9 @@ export interface MutateConfig {
97
103
  excludedMutations?: string[];
98
104
  };
99
105
  ignoreStatic?: boolean;
106
+ static?: {
107
+ group?: number;
108
+ };
100
109
  reporters?: ('clear-text' | 'json' | 'html' | 'sarif' | 'progress' | 'progress-append-only' | 'ci' | 'dots')[];
101
110
  jsonReporter?: {
102
111
  fileName?: string;
package/dist/config.js CHANGED
@@ -3,6 +3,10 @@ import { availableParallelism } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { resolveEsm } from './resolve.js';
6
+ function staticGroupOf(group) {
7
+ const asked = Math.floor(Number(group));
8
+ return asked >= 2 ? asked : 1;
9
+ }
6
10
  export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
7
11
  const NOT_NEEDED = {
8
12
  inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
@@ -34,7 +38,7 @@ const DEFAULT_TIMEOUT_FACTOR = 1.5;
34
38
  const DEFAULT_THRESHOLD_HIGH = 80;
35
39
  const DEFAULT_THRESHOLD_LOW = 60;
36
40
  const DEFAULT_DRY_RUN_MINUTES = 5;
37
- const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
41
+ const USED = new Set(['mutate', 'vitest', 'concurrency', 'timeoutMS', 'timeoutFactor', 'thresholds', 'ignoreStatic', 'static', 'reporters', 'jsonReporter', 'htmlReporter', 'incremental', 'incrementalFile', '$schema', 'mutator', 'dryRunOnly', 'dryRunTimeoutMinutes', 'buildCommand', 'testRunnerNodeArgs', 'allowEmpty', 'warnings', 'testRunner', 'commandRunner', 'checkers', 'tsconfigFile']);
38
42
  async function loadFile(file) {
39
43
  if (file.endsWith('.json')) {
40
44
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -100,6 +104,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
100
104
  thresholds: { high: thresholds.high ?? DEFAULT_THRESHOLD_HIGH, low: thresholds.low ?? DEFAULT_THRESHOLD_LOW, break: thresholds.break ?? null },
101
105
  excludedMutations: mutator.excludedMutations ?? [],
102
106
  ignoreStatic: raw['ignoreStatic'] === true,
107
+ staticGroup: staticGroupOf(raw['static']?.group),
103
108
  strykerCompat: raw['strykerCompat'] === true,
104
109
  reporters: raw['reporters'] ?? ['clear-text', 'json'],
105
110
  jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
@@ -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
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { ambientRuntime, run as spawn } from 'bellpull';
@@ -17,6 +17,7 @@ import { FILE_KEY, env, stderr } from './runtime.js';
17
17
  import { createPool } from './scheduler.js';
18
18
  import { summarizeSpec, tracesFor } from './spec-oracle.js';
19
19
  import { SPEC_TRACES_ENV } from './spec.js';
20
+ import { removeWorkDir } from './work-dir.js';
20
21
  export class InstrumentFault extends Error {
21
22
  }
22
23
  function readRecords(file) {
@@ -41,6 +42,7 @@ const verdictOptions = (o) => ({
41
42
  timeoutFactor: o.timeoutFactor,
42
43
  excludedMutations: [...o.excludedMutations].sort(),
43
44
  ignoreStatic: o.ignoreStatic,
45
+ staticGroup: o.staticGroup,
44
46
  strykerCompat: o.strykerCompat,
45
47
  testOverrides: o.testOverrides,
46
48
  vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
@@ -59,6 +61,22 @@ function timeoutReason(v) {
59
61
  return 'stopped by the watchdog twice, the second time with 3x the budget';
60
62
  }
61
63
  const slowKill = (v) => v?.status === 'Killed' && v.slow === true;
64
+ const before = (x, y) => x.line < y.line || (x.line === y.line && x.column < y.column);
65
+ function overlap(a, b) {
66
+ return a.file === b.file && before(a.start, b.end) && before(b.start, a.end);
67
+ }
68
+ const GROUPABLE = new Set(['StringLiteral', 'NumericLiteral', 'Regex']);
69
+ export function disjointGroups(mutants, size) {
70
+ const groups = [];
71
+ for (const m of mutants) {
72
+ const home = GROUPABLE.has(m.mutatorName) ? groups.find((g) => g.length < size && GROUPABLE.has(g[0]?.mutatorName ?? '') && !g.some((other) => overlap(m, other))) : undefined;
73
+ if (home)
74
+ home.push(m);
75
+ else
76
+ groups.push([m]);
77
+ }
78
+ return groups.map((g) => g.map((m) => m.id));
79
+ }
62
80
  const RERUN_SLACK = 3;
63
81
  export const MAX_TEST_MS = 5000;
64
82
  const MS_PER_MINUTE = 60_000;
@@ -76,8 +94,8 @@ export async function run(options) {
76
94
  const { root } = options;
77
95
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
78
96
  const stats = {
79
- files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0, warmUnitMs: 0, warmArmedMs: 0, isolatedByCause: { static: 0, stopped: 0, unfinished: 0, rerun: 0, confirm: 0, flaky: 0 },
80
- watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, auditSampled: 0, auditDisagreed: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
97
+ files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, staticGroupRuns: 0, staticGroupSplits: 0, staticAuditSampled: 0, staticAuditDisagreed: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0, warmUnitMs: 0, warmArmedMs: 0, isolatedByCause: { static: 0, stopped: 0, unfinished: 0, rerun: 0, confirm: 0, flaky: 0 },
98
+ watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, diskWritePaths: [], auditSampled: 0, auditDisagreed: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
81
99
  };
82
100
  let unitSeq = 0;
83
101
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -378,6 +396,9 @@ export async function run(options) {
378
396
  const dirty = change !== undefined && !isClean(change);
379
397
  if (dirty) {
380
398
  stats.diskWrites++;
399
+ for (const p of [...change.changed, ...change.added, ...change.removed])
400
+ if (stats.diskWritePaths.length < 5 && !stats.diskWritePaths.includes(p))
401
+ stats.diskWritePaths.push(p);
381
402
  if (!disk?.restore(change)) {
382
403
  throw new InstrumentFault(`a mutant wrote outside the sandbox and the project could not be put back: ${[...change.changed, ...change.added, ...change.removed].join(', ')}. fix: restore them (git checkout / git clean), and see which test writes into the project`);
383
404
  }
@@ -572,15 +593,19 @@ export async function run(options) {
572
593
  if (items.length === 0)
573
594
  return [];
574
595
  const STATIC_BATCH = 25;
575
- const unitsFor = (filesOf) => {
596
+ const unitsFor = (filesOf, grouped = false) => {
576
597
  const byFile = new Map();
577
598
  for (const m of items)
578
599
  for (const f of filesOf(m))
579
600
  (byFile.get(f) ?? byFile.set(f, []).get(f))?.push(m.id);
580
- return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) => ({
581
- mode: 'static', files: [file], results: fileFor('static', 'jsonl'), progress: fileFor('static', 'progress'), staticIds: ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH),
582
- hitLimits: Object.fromEntries(ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH).map((id) => [id, hitLimitOf(id)])),
583
- })));
601
+ return [...byFile].flatMap(([file, ids]) => Array.from({ length: Math.ceil(ids.length / STATIC_BATCH) }, (_, i) => {
602
+ const mine = ids.slice(i * STATIC_BATCH, (i + 1) * STATIC_BATCH);
603
+ return {
604
+ mode: 'static', files: [file], results: fileFor('static', 'jsonl'), progress: fileFor('static', 'progress'), staticIds: mine,
605
+ ...(grouped && options.staticGroup > 1 ? { staticGroups: disjointGroups(mine.map((id) => byId.get(id)), options.staticGroup) } : {}),
606
+ hitLimits: Object.fromEntries(mine.map((id) => [id, hitLimitOf(id)])),
607
+ };
608
+ }));
584
609
  };
585
610
  const killed = new Map();
586
611
  const timedOut = new Set();
@@ -594,6 +619,16 @@ export async function run(options) {
594
619
  for (const r of readRecords(unit.results)) {
595
620
  if (r.type === 'recollection')
596
621
  stats.staticRecollections++;
622
+ if (r.type === 'static-group') {
623
+ stats.staticGroupRuns++;
624
+ if (r.passed !== true)
625
+ stats.staticGroupSplits++;
626
+ }
627
+ if (r.type === 'static-audit') {
628
+ stats.staticAuditSampled++;
629
+ if (r.disagreed === true)
630
+ stats.staticAuditDisagreed++;
631
+ }
597
632
  if (r.type === 'static-fallback')
598
633
  for (const id of r.ids ?? [])
599
634
  back.add(id);
@@ -616,12 +651,14 @@ export async function run(options) {
616
651
  };
617
652
  const keepUndecided = (unit) => {
618
653
  unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
654
+ if (unit.staticGroups)
655
+ unit.staticGroups = unit.staticGroups.map((g) => g.filter((id) => !killed.has(id) && !back.has(id))).filter((g) => g.length > 0);
619
656
  return unit.staticIds.length > 0;
620
657
  };
621
658
  const open = (m) => !killed.has(m.id) && !back.has(m.id);
622
659
  const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
623
- for (const round of rounds)
624
- await staticPool.drain(unitsFor(round), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
660
+ for (const [i, round] of rounds.entries())
661
+ await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
625
662
  const undecided = [];
626
663
  for (const m of items) {
627
664
  const k = killed.get(m.id);
@@ -819,6 +856,6 @@ export async function run(options) {
819
856
  if (env['OXMUTATE_KEEP'])
820
857
  stderr.write(`oxmutate: work directory kept at ${work}\n`);
821
858
  else
822
- rmSync(work, { recursive: true, force: true });
859
+ stderr.write(removeWorkDir(work) ?? '');
823
860
  }
824
861
  }
package/dist/diskguard.js CHANGED
@@ -7,7 +7,7 @@ async function git(cwd, args) {
7
7
  const r = await run('git', args, { cwd, runtime: ambientRuntime() }).catch(() => undefined);
8
8
  return r?.ok ? r.stdout : undefined;
9
9
  }
10
- export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
10
+ export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES, stillThere = existsSync) {
11
11
  const listed = await git(root, ['ls-files', '-z', '--cached', '--', '.']);
12
12
  if (listed === undefined)
13
13
  return undefined;
@@ -69,7 +69,7 @@ export async function guardDisk(root, maxSnapshotBytes = MAX_SNAPSHOT_BYTES) {
69
69
  fresh.push(path.join(dir, name));
70
70
  }
71
71
  const skip = await ignored(fresh);
72
- return { changed, added: fresh.filter((rel) => !skip.has(rel)), removed };
72
+ return { changed, added: fresh.filter((rel) => !skip.has(rel) && stillThere(path.join(root, rel))), removed };
73
73
  },
74
74
  restore(change) {
75
75
  if (!restorable)
package/dist/reports.js CHANGED
@@ -97,12 +97,13 @@ function corrections(s) {
97
97
  return [
98
98
  s.watchdogKills && `${String(s.watchdogKills)} runaway(s) stopped`,
99
99
  s.deadlocks && `${String(s.deadlocks)} deadlock(s) called by their worker`,
100
- s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run`,
100
+ s.diskWrites && `${String(s.diskWrites)} write(s) outside the sandbox put back, their units re-run${s.diskWritePaths.length ? ` (${s.diskWritePaths.join(', ')}; if the tests themselves write there, have git ignore it)` : ''}`,
101
101
  s.pollutionRestarts && `${String(s.pollutionRestarts)} polluted context(s) restarted (in ${String(s.canaryFiles)} file(s))`,
102
102
  s.flakyMutants && `${String(s.flakyMutants)} flaky verdict(s)`,
103
103
  s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
104
104
  s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
105
105
  s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
106
+ s.staticAuditDisagreed && `${String(s.staticAuditDisagreed)} kill(s) a static group hid, found by the audit`,
106
107
  s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
107
108
  s.crashes && `${String(s.crashes)} worker crash(es)`,
108
109
  ].filter((c) => Boolean(c));
package/dist/runner.js CHANGED
@@ -48,6 +48,10 @@ const specTests = new Set(plan.specTests ?? []);
48
48
  const decidedBySpec = new Set();
49
49
  const only = mode === 'isolated' && env['OXMUTATE_ONLY_TESTS'] ? new Set(JSON.parse(env['OXMUTATE_ONLY_TESTS'])) : undefined;
50
50
  const staticIds = mode === 'static' && env['OXMUTATE_STATIC'] ? JSON.parse(env['OXMUTATE_STATIC']) : [];
51
+ const staticGroups = mode === 'static' && env['OXMUTATE_STATIC_GROUPS'] ? JSON.parse(env['OXMUTATE_STATIC_GROUPS']) : [];
52
+ const GROUP_AUDIT_EVERY = 20;
53
+ const auditAll = env['OXMUTATE_STATIC_AUDIT'] === 'all';
54
+ const audited = (id) => auditAll || [...id].reduce((sum, c) => sum + (c.codePointAt(0) ?? 0), 0) % GROUP_AUDIT_EVERY === 0;
51
55
  const staticLimits = mode === 'static' && env['OXMUTATE_HIT_LIMITS'] ? JSON.parse(env['OXMUTATE_HIT_LIMITS']) : {};
52
56
  if (mode === 'isolated' && env['OXMUTATE_HIT_LIMIT']) {
53
57
  ns.hitCount = 0;
@@ -500,8 +504,10 @@ export default class OxmutateRunner extends Base {
500
504
  const { vi } = vitest;
501
505
  if (!collectTests || !vi)
502
506
  return fallback('this Vitest has no public collect');
503
- const evaluate = async (armed) => {
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
- while (rest.length) {
532
- const id = rest.shift();
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
- held.push({ id, verdict: await evaluate(id) });
535
- if (held.at(-1)?.verdict.deadlock)
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 (held.length < every && rest.length)
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: verdict.failed.length > 0 && !verdict.timeout, failed: verdict.failed, ran: verdict.ran, ...(verdict.timeout && { timeout: true }), ...(verdict.deadlock && { deadlock: true }) });
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
  }
@@ -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.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Mutation testing for JavaScript and TypeScript on the oxc engine, run warm inside the project's own Vitest, with CI lanes, SARIF and an agent surface. No dependency outside the burgee family. A Stryker migration path.",
5
5
  "license": "MIT",
6
6
  "author": "Ofri Peretz <ofriperetzdev@gmail.com>",