oxmutate 2.4.0 → 2.5.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/dist/cli.js CHANGED
@@ -44,8 +44,20 @@ function summaryData(r, written) {
44
44
  return data;
45
45
  }
46
46
  const WEAKENED_SHOWN = 3;
47
- function overridesFrom(o, root) {
47
+ function staticOverridesFrom(o) {
48
48
  const overrides = {};
49
+ if (typeof o['staticGroup'] === 'number')
50
+ overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
51
+ if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
52
+ overrides.staticBudgetMs = Math.floor(o['staticBudget']);
53
+ if (o['reportStaticReaders'] === true)
54
+ overrides.staticReaders = 'report';
55
+ if (o['staticReaders'] === true)
56
+ overrides.staticReaders = 'on';
57
+ return overrides;
58
+ }
59
+ function overridesFrom(o, root) {
60
+ const overrides = { ...staticOverridesFrom(o) };
49
61
  if (typeof o['since'] === 'string')
50
62
  overrides.since = o['since'];
51
63
  if (typeof o['frozenOracle'] === 'string')
@@ -60,12 +72,6 @@ function overridesFrom(o, root) {
60
72
  overrides.strykerCompat = true;
61
73
  if (o['ignoreStatic'] === true)
62
74
  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']);
67
- if (o['reportStaticReaders'] === true)
68
- overrides.staticReaders = 'report';
69
75
  if (o['incremental'] === true)
70
76
  overrides.incremental = true;
71
77
  if (typeof o['flakyReruns'] === 'number')
@@ -98,6 +104,7 @@ const runCommand = defineCommand({
98
104
  ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
99
105
  staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
100
106
  staticBudget: { type: 'number', description: 'the most the static phase may take, ms; static mutants still undecided then are left out of the score' },
107
+ staticReaders: { type: 'boolean', description: 'run each static mutant only in the test files that read its value (a heuristic, audited)' },
101
108
  reportStaticReaders: { type: 'boolean', description: 'one more dry run that reports how many tests static mutants would need if each ran only where its value is read; changes no verdict' },
102
109
  incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
103
110
  flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
@@ -132,6 +139,10 @@ const runCommand = defineCommand({
132
139
  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`);
133
140
  data['exitCode'] = ExitCode.RUNTIME;
134
141
  }
142
+ if ((result.stats.staticReaders?.auditDisagreed ?? 0) > 0) {
143
+ stderr.write(`oxmutate: ${String(result.stats.staticReaders?.auditDisagreed ?? 0)} static mutant(s) that survived the files reading their value were killed in a file that loads it; they are reported Killed. fix: run without --static-readers; a test reads the value through a path the tracking does not see\n`);
144
+ data['exitCode'] = ExitCode.RUNTIME;
145
+ }
135
146
  if (result.stats.staticAuditDisagreed > 0) {
136
147
  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`);
137
148
  data['exitCode'] = ExitCode.RUNTIME;
package/dist/config.d.ts CHANGED
@@ -37,8 +37,11 @@ export interface Options {
37
37
  * `static.readers: 'report'`: one more dry run, with every module-level literal reporting which
38
38
  * tests read each of its properties, and a summary line of how many tests the static phase would
39
39
  * run if each static mutant ran only where its value is read (roadmap 2.4). Changes no verdict.
40
+ * `'on'`: the same dry run, and then each static mutant runs only in the files that read its
41
+ * value. A heuristic: one survivor in twenty is run again in every file that loads it, and one
42
+ * that fails there is reported Killed and fails the run.
40
43
  */
41
- staticReaders: 'off' | 'report';
44
+ staticReaders: 'off' | 'report' | 'on';
42
45
  strykerCompat: boolean;
43
46
  reporters: string[];
44
47
  jsonReport: string;
package/dist/config.js CHANGED
@@ -12,6 +12,9 @@ function staticBudgetOf(ms) {
12
12
  return asked >= 1 ? asked : undefined;
13
13
  }
14
14
  export const STATIC_BUDGET_REASON = 'static mutant (static budget spent)';
15
+ function staticReadersOf(readers) {
16
+ return readers === 'report' || readers === 'on' ? readers : 'off';
17
+ }
15
18
  export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
16
19
  const NOT_NEEDED = {
17
20
  inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
@@ -111,7 +114,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
111
114
  ignoreStatic: raw['ignoreStatic'] === true,
112
115
  staticGroup: staticGroupOf(raw['static']?.group),
113
116
  staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
114
- staticReaders: raw['static']?.readers === 'report' ? 'report' : 'off',
117
+ staticReaders: staticReadersOf(raw['static']?.readers),
115
118
  strykerCompat: raw['strykerCompat'] === true,
116
119
  reporters: raw['reporters'] ?? ['clear-text', 'json'],
117
120
  jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
@@ -87,6 +87,8 @@ export interface RunStats {
87
87
  readerTests: number;
88
88
  fellBack: number;
89
89
  dryRunMs: number;
90
+ auditSampled?: number;
91
+ auditDisagreed?: number;
90
92
  } | undefined;
91
93
  /** 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. */
92
94
  diskWritePaths: string[];
@@ -49,6 +49,8 @@ const verdictOptions = (o) => ({
49
49
  vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
50
50
  });
51
51
  const UNIT_PAIRS = 400;
52
+ const READERS_AUDIT_EVERY = 20;
53
+ const auditedReader = (id) => env['OXMUTATE_STATIC_AUDIT'] === 'all' || [...id].reduce((sum, c) => sum + (c.codePointAt(0) ?? 0), 0) % READERS_AUDIT_EVERY === 0;
52
54
  const detected = (s) => s === 'Killed' || s === 'Timeout';
53
55
  const AUDIT_SHARE = 0.05;
54
56
  const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
@@ -228,9 +230,9 @@ export async function run(options) {
228
230
  const dry = await dryRun();
229
231
  stats.dryRunMs = performance.now() - d0;
230
232
  const r0 = performance.now();
231
- if (options.staticReaders === 'report' && trackedIds.size > 0)
233
+ if (options.staticReaders !== 'off' && trackedIds.size > 0)
232
234
  progress.phase('dry run with reads tracked (static.readers)');
233
- const tracking = options.staticReaders === 'report' && trackedIds.size > 0 ? await dryRun(true) : undefined;
235
+ const tracking = options.staticReaders !== 'off' && trackedIds.size > 0 ? await dryRun(true) : undefined;
234
236
  const readersDryRunMs = performance.now() - r0;
235
237
  const failing = dry.tests.filter((t) => t.state === 'fail');
236
238
  if (failing.length)
@@ -588,33 +590,34 @@ export async function run(options) {
588
590
  const covering = byCost([...new Set(tests)]);
589
591
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
590
592
  };
593
+ const readerFiles = new Map();
594
+ const trackingTests = tracking?.tests ?? [];
595
+ const readsTracked = tracking !== undefined && !trackingTests.some((t) => t.state === 'fail');
596
+ const trackedReads = tracking?.reads ?? { static: {}, perTest: {} };
597
+ for (const [key, ids] of Object.entries(trackedReads.perTest)) {
598
+ const file = key.startsWith(FILE_KEY) ? key.slice(FILE_KEY.length) : testInfo.get(key)?.file;
599
+ if (!file)
600
+ continue;
601
+ for (const id of Object.keys(ids))
602
+ (readerFiles.get(id) ?? readerFiles.set(id, new Set()).get(id))?.add(file);
603
+ }
604
+ const readersFor = (m) => readsTracked && trackedIds.has(m.id) && !(m.id in trackedReads.static) ? filesFor(m).filter((f) => readerFiles.get(m.id)?.has(f)) : undefined;
605
+ const staticFilesFor = (m) => (options.staticReaders === 'on' ? readersFor(m) ?? filesFor(m) : filesFor(m));
591
606
  const readersOf = (items) => {
592
607
  const testsIn = new Map();
593
608
  for (const t of dry.tests)
594
609
  testsIn.set(t.file, (testsIn.get(t.file) ?? 0) + 1);
595
610
  const sum = (files) => [...files].reduce((n, f) => n + (testsIn.get(f) ?? 0), 0);
596
- const reads = tracking?.reads ?? { static: {}, perTest: {} };
597
- const broken = tracking?.tests.some((t) => t.state === 'fail') ?? true;
598
- const readerFiles = new Map();
599
- for (const [key, ids] of Object.entries(reads.perTest)) {
600
- const file = key.startsWith(FILE_KEY) ? key.slice(FILE_KEY.length) : testInfo.get(key)?.file;
601
- if (!file)
602
- continue;
603
- for (const id of Object.keys(ids))
604
- (readerFiles.get(id) ?? readerFiles.set(id, new Set()).get(id))?.add(file);
605
- }
606
611
  let loaderTests = 0;
607
612
  let readerTests = 0;
608
613
  let fellBack = 0;
609
614
  for (const m of items) {
610
615
  const loaders = filesFor(m);
616
+ const readers = readersFor(m);
611
617
  loaderTests += sum(loaders);
612
- if (broken || !trackedIds.has(m.id) || m.id in reads.static) {
618
+ if (!readers)
613
619
  fellBack++;
614
- readerTests += sum(loaders);
615
- }
616
- else
617
- readerTests += sum(loaders.filter((f) => readerFiles.get(m.id)?.has(f)));
620
+ readerTests += sum(readers ?? loaders);
618
621
  }
619
622
  return { mutants: items.length, loaderTests, readerTests, fellBack };
620
623
  };
@@ -632,7 +635,7 @@ export async function run(options) {
632
635
  return Math.min(MAX_TEST_MS * testsRun(id), 1500 + options.timeoutMS + options.timeoutFactor * ms * 2);
633
636
  };
634
637
  const isolatedCapped = (id, slack) => Math.min(slack * isolatedBudget(id), MAX_TEST_MS * testsRun(id));
635
- const decideStaticLive = async (items) => {
638
+ const decideStaticLive = async (items, filesOfMutant = staticFilesFor) => {
636
639
  if (items.length === 0)
637
640
  return [];
638
641
  const STATIC_BATCH = 25;
@@ -703,7 +706,7 @@ export async function run(options) {
703
706
  return unit.staticIds.length > 0;
704
707
  };
705
708
  const open = (m) => !killed.has(m.id) && !back.has(m.id);
706
- const rounds = [(m) => filesFor(m).slice(0, 1), (m) => (open(m) ? filesFor(m).slice(1) : [])];
709
+ const rounds = [(m) => filesOfMutant(m).slice(0, 1), (m) => (open(m) ? filesOfMutant(m).slice(1) : [])];
707
710
  for (const [i, round] of rounds.entries())
708
711
  await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
709
712
  const undecided = [];
@@ -714,7 +717,7 @@ export async function run(options) {
714
717
  m.killedBy = [...new Set(k.killedBy)];
715
718
  m.testsCompleted = ran.get(m.id) ?? 0;
716
719
  }
717
- else if (!back.has(m.id) && filesFor(m).every((f) => passed.get(m.id)?.has(f))) {
720
+ else if (!back.has(m.id) && filesOfMutant(m).every((f) => passed.get(m.id)?.has(f))) {
718
721
  m.status = 'Survived';
719
722
  m.testsCompleted = ran.get(m.id) ?? 0;
720
723
  }
@@ -773,6 +776,17 @@ export async function run(options) {
773
776
  stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
774
777
  const s0 = performance.now();
775
778
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
779
+ if (options.staticReaders === 'on' && stats.staticReaders) {
780
+ const sampled = staticMutants.filter((m) => m.status === 'Survived' && staticFilesFor(m).length < filesFor(m).length && auditedReader(m.id));
781
+ for (const m of sampled)
782
+ m.status = 'NoCoverage';
783
+ await decideStaticLive(sampled, filesFor);
784
+ stats.staticReaders.auditSampled = sampled.length;
785
+ stats.staticReaders.auditDisagreed = sampled.filter((m) => m.status === 'Killed' || m.status === 'Timeout').length;
786
+ for (const m of sampled)
787
+ if (m.status === 'NoCoverage')
788
+ m.status = 'Survived';
789
+ }
776
790
  stats.staticMs = performance.now() - s0;
777
791
  stats.isolatedByCause.static = staticBack.length;
778
792
  isolatedQueue.push(...staticBack);
package/dist/reports.js CHANGED
@@ -143,7 +143,9 @@ function whereItWent(r) {
143
143
  const sr = s.staticReaders;
144
144
  if (sr && sr.mutants > 0) {
145
145
  const ratio = sr.readerTests > 0 ? `, ${(sr.loaderTests / sr.readerTests).toFixed(1)}x fewer` : '';
146
- lines.push(` static readers: ${String(sr.mutants)} static mutants would run ${String(sr.readerTests)} tests where their value is read, against ${String(sr.loaderTests)} in every file that loads them${ratio}; ${String(sr.fellBack)} kept every loader (report only, ${(sr.dryRunMs / MS_PER_S).toFixed(1)} s)`);
146
+ const inUse = sr.auditSampled !== undefined;
147
+ const how = inUse ? `in use, ${String(sr.auditSampled)} survivor(s) audited in every loader, ${String(sr.auditDisagreed ?? 0)} disagreed` : 'report only';
148
+ lines.push(` static readers: ${String(sr.mutants)} static mutants ${inUse ? 'ran' : 'would run'} ${String(sr.readerTests)} tests where their value is read, against ${String(sr.loaderTests)} in every file that loads them${ratio}; ${String(sr.fellBack)} kept every loader (${how}, ${(sr.dryRunMs / MS_PER_S).toFixed(1)} s)`);
147
149
  }
148
150
  const c = s.isolatedByCause;
149
151
  const causes = [`static ${String(c.static)}`, `stopped ${String(c.stopped)}`, `unfinished ${String(c.unfinished)}`, `re-run ${String(c.rerun)}`, `survivors confirmed ${String(c.confirm)}`, `flaky ${String(c.flaky)}`];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "2.4.0",
3
+ "version": "2.5.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>",