oxmutate 2.3.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.
Binary file
Binary file
Binary file
package/dist/child.js CHANGED
@@ -75,6 +75,10 @@ async function runIsolated(unit) {
75
75
  }
76
76
  async function runUnit(unit) {
77
77
  env['OXMUTATE_MODE'] = unit.mode;
78
+ if (unit.trackReads)
79
+ env['__OXMUTATE_TRACK_READS__'] = '1';
80
+ else
81
+ delete env['__OXMUTATE_TRACK_READS__'];
78
82
  env['OXMUTATE_RESULTS'] = unit.results;
79
83
  env['OXMUTATE_PROGRESS'] = unit.progress;
80
84
  if (unit.plan)
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,10 +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
75
  if (o['incremental'] === true)
68
76
  overrides.incremental = true;
69
77
  if (typeof o['flakyReruns'] === 'number')
@@ -96,6 +104,8 @@ const runCommand = defineCommand({
96
104
  ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
97
105
  staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
98
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)' },
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' },
99
109
  incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
100
110
  flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
101
111
  reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
@@ -129,6 +139,10 @@ const runCommand = defineCommand({
129
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`);
130
140
  data['exitCode'] = ExitCode.RUNTIME;
131
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
+ }
132
146
  if (result.stats.staticAuditDisagreed > 0) {
133
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`);
134
148
  data['exitCode'] = ExitCode.RUNTIME;
package/dist/config.d.ts CHANGED
@@ -33,6 +33,15 @@ export interface Options {
33
33
  * left out of the score. A kill found before that stands. Unset: no limit.
34
34
  */
35
35
  staticBudgetMs?: number | undefined;
36
+ /**
37
+ * `static.readers: 'report'`: one more dry run, with every module-level literal reporting which
38
+ * tests read each of its properties, and a summary line of how many tests the static phase would
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.
43
+ */
44
+ staticReaders: 'off' | 'report' | 'on';
36
45
  strykerCompat: boolean;
37
46
  reporters: string[];
38
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,6 +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),
117
+ staticReaders: staticReadersOf(raw['static']?.readers),
114
118
  strykerCompat: raw['strykerCompat'] === true,
115
119
  reporters: raw['reporters'] ?? ['clear-text', 'json'],
116
120
  jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
@@ -76,6 +76,20 @@ export interface RunStats {
76
76
  pollutionRestarts: number;
77
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. */
78
78
  diskWrites: number;
79
+ /**
80
+ * `static.readers: 'report'` (roadmap 2.4): over the static mutants, the tests of every file that
81
+ * loads each one's module (what the static phase runs), and the tests of only the files that read
82
+ * its value. A mutant whose value is untracked, or read while a module loaded, counts its loaders.
83
+ */
84
+ staticReaders?: {
85
+ mutants: number;
86
+ loaderTests: number;
87
+ readerTests: number;
88
+ fellBack: number;
89
+ dryRunMs: number;
90
+ auditSampled?: number;
91
+ auditDisagreed?: number;
92
+ } | undefined;
79
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. */
80
94
  diskWritePaths: string[];
81
95
  /** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
@@ -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)';
@@ -121,6 +123,7 @@ export async function run(options) {
121
123
  throw new InstrumentFault(`cannot parse ${String(parseErrors.length)} file(s): ${parseErrors.slice(0, 3).join('; ')}`);
122
124
  const changed = options.since ? await changedLines(root, options.since) : undefined;
123
125
  const mutants = [];
126
+ const trackedIds = new Set();
124
127
  const codeMap = {};
125
128
  const directiveErrors = [];
126
129
  for (const f of instrumented) {
@@ -139,8 +142,11 @@ export async function run(options) {
139
142
  const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
140
143
  const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
141
144
  const excluded = options.excludedMutations.includes(m.mutatorName);
145
+ if (m.tracked)
146
+ trackedIds.add(m.id);
147
+ const { tracked: _tracked, ...plain } = m;
142
148
  mutants.push({
143
- ...m,
149
+ ...plain,
144
150
  file: f.path,
145
151
  coveredBy: [],
146
152
  status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
@@ -184,8 +190,8 @@ export async function run(options) {
184
190
  env[SPEC_TRACES_ENV] = JSON.stringify(traceDirs);
185
191
  const dryPool = createPool({ root, vitestConfig: options.vitestConfig, workers: options.concurrency, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, 1, work, 'dry');
186
192
  pools.push(dryPool);
187
- const dryRun = async () => {
188
- const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress') };
193
+ const dryRun = async (trackReads = false) => {
194
+ const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress'), ...(trackReads && { trackReads: true }) };
189
195
  let failure;
190
196
  let overBudget = false;
191
197
  await dryPool.drain([unit], () => options.dryRunTimeoutMinutes * MS_PER_MINUTE, (_, o) => {
@@ -202,10 +208,13 @@ export async function run(options) {
202
208
  throw new InstrumentFault(`the dry run did not complete: ${failure.stderr.slice(-1500)}`);
203
209
  const perTest = {};
204
210
  const stat = {};
211
+ const reads = { static: {}, perTest: {} };
205
212
  for (const r of recs) {
206
213
  Object.assign(perTest, r.coverage?.perTest ?? {});
207
214
  for (const [k, v] of Object.entries(r.coverage?.static ?? {}))
208
215
  stat[k] = (stat[k] ?? 0) + v;
216
+ Object.assign(reads.perTest, r.reads?.perTest ?? {});
217
+ Object.assign(reads.static, r.reads?.static ?? {});
209
218
  }
210
219
  const fileErrors = recs.flatMap((r) => r.fileErrors ?? []);
211
220
  if (fileErrors.length) {
@@ -214,12 +223,17 @@ export async function run(options) {
214
223
  const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
215
224
  if (unhandled > 0)
216
225
  throw new InstrumentFault(`the suite raised ${String(unhandled)} unhandled error(s) with no mutant active, so no verdict can be trusted. fix: make \`vitest run\` pass with no "Unhandled Errors" first`);
217
- return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
226
+ return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})), reads };
218
227
  };
219
228
  progress.phase('dry run');
220
229
  const d0 = performance.now();
221
230
  const dry = await dryRun();
222
231
  stats.dryRunMs = performance.now() - d0;
232
+ const r0 = performance.now();
233
+ if (options.staticReaders !== 'off' && trackedIds.size > 0)
234
+ progress.phase('dry run with reads tracked (static.readers)');
235
+ const tracking = options.staticReaders !== 'off' && trackedIds.size > 0 ? await dryRun(true) : undefined;
236
+ const readersDryRunMs = performance.now() - r0;
223
237
  const failing = dry.tests.filter((t) => t.state === 'fail');
224
238
  if (failing.length)
225
239
  throw new InstrumentFault(`${String(failing.length)} test(s) fail with no mutant active, so no verdict can be trusted: ${failing.slice(0, 5).map((t) => t.name).join('; ')}. fix: make the suite pass first`);
@@ -576,6 +590,37 @@ export async function run(options) {
576
590
  const covering = byCost([...new Set(tests)]);
577
591
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
578
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));
606
+ const readersOf = (items) => {
607
+ const testsIn = new Map();
608
+ for (const t of dry.tests)
609
+ testsIn.set(t.file, (testsIn.get(t.file) ?? 0) + 1);
610
+ const sum = (files) => [...files].reduce((n, f) => n + (testsIn.get(f) ?? 0), 0);
611
+ let loaderTests = 0;
612
+ let readerTests = 0;
613
+ let fellBack = 0;
614
+ for (const m of items) {
615
+ const loaders = filesFor(m);
616
+ const readers = readersFor(m);
617
+ loaderTests += sum(loaders);
618
+ if (!readers)
619
+ fellBack++;
620
+ readerTests += sum(readers ?? loaders);
621
+ }
622
+ return { mutants: items.length, loaderTests, readerTests, fellBack };
623
+ };
579
624
  const testsRun = (id) => {
580
625
  const m = byId.get(id);
581
626
  const fs = m ? filesFor(m) : allTestFiles;
@@ -590,7 +635,7 @@ export async function run(options) {
590
635
  return Math.min(MAX_TEST_MS * testsRun(id), 1500 + options.timeoutMS + options.timeoutFactor * ms * 2);
591
636
  };
592
637
  const isolatedCapped = (id, slack) => Math.min(slack * isolatedBudget(id), MAX_TEST_MS * testsRun(id));
593
- const decideStaticLive = async (items) => {
638
+ const decideStaticLive = async (items, filesOfMutant = staticFilesFor) => {
594
639
  if (items.length === 0)
595
640
  return [];
596
641
  const STATIC_BATCH = 25;
@@ -661,7 +706,7 @@ export async function run(options) {
661
706
  return unit.staticIds.length > 0;
662
707
  };
663
708
  const open = (m) => !killed.has(m.id) && !back.has(m.id);
664
- 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) : [])];
665
710
  for (const [i, round] of rounds.entries())
666
711
  await staticPool.drain(unitsFor(round, i > 0), (_, id) => isolatedCapped(id, 2), afterStatic, keepUndecided);
667
712
  const undecided = [];
@@ -672,7 +717,7 @@ export async function run(options) {
672
717
  m.killedBy = [...new Set(k.killedBy)];
673
718
  m.testsCompleted = ran.get(m.id) ?? 0;
674
719
  }
675
- 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))) {
676
721
  m.status = 'Survived';
677
722
  m.testsCompleted = ran.get(m.id) ?? 0;
678
723
  }
@@ -727,8 +772,21 @@ export async function run(options) {
727
772
  verdicts.set(m.id, { status: 'RuntimeError' });
728
773
  return verdicts;
729
774
  };
775
+ if (tracking)
776
+ stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
730
777
  const s0 = performance.now();
731
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
+ }
732
790
  stats.staticMs = performance.now() - s0;
733
791
  stats.isolatedByCause.static = staticBack.length;
734
792
  isolatedQueue.push(...staticBack);
package/dist/native.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface NativeMutant {
10
10
  end: Location;
11
11
  /** Set when an ignore rule covers the mutant: reported as Ignored, never placed in the code. */
12
12
  ignoredReason?: string;
13
+ /** Reads of the property holding it are reported by a tracking dry run (`oxmTrack`, roadmap 2.4). */
14
+ tracked?: boolean;
13
15
  }
14
16
  export interface InstrumentedFile {
15
17
  path: string;
package/dist/reports.js CHANGED
@@ -140,6 +140,13 @@ function whereItWent(r) {
140
140
  if (s.warmUnitMs > 0)
141
141
  lines.push(` warm servers ran ${(s.warmUnitMs / MS_PER_S).toFixed(1)} s, ${(s.warmArmedMs / MS_PER_S).toFixed(1)} s of it inside test runs`);
142
142
  lines.push(...staticLine(r));
143
+ const sr = s.staticReaders;
144
+ if (sr && sr.mutants > 0) {
145
+ const ratio = sr.readerTests > 0 ? `, ${(sr.loaderTests / sr.readerTests).toFixed(1)}x fewer` : '';
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)`);
149
+ }
143
150
  const c = s.isolatedByCause;
144
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)}`];
145
152
  if (s.isolatedRuns > 0)
package/dist/runner.js CHANGED
@@ -655,7 +655,7 @@ export default class OxmutateRunner extends Base {
655
655
  if (result?.state === 'fail' && !f.tasks?.length)
656
656
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
657
657
  }
658
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
658
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, reads: ns.readCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules), unhandled });
659
659
  }
660
660
  else if (mode === 'isolated') {
661
661
  const failed = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "2.3.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>",