oxmutate 2.1.0 → 2.3.0

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