oxmutate 2.3.0 → 2.4.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
@@ -64,6 +64,8 @@ function overridesFrom(o, root) {
64
64
  overrides.staticGroup = Math.max(1, Math.floor(o['staticGroup']));
65
65
  if (typeof o['staticBudget'] === 'number' && o['staticBudget'] >= 1)
66
66
  overrides.staticBudgetMs = Math.floor(o['staticBudget']);
67
+ if (o['reportStaticReaders'] === true)
68
+ overrides.staticReaders = 'report';
67
69
  if (o['incremental'] === true)
68
70
  overrides.incremental = true;
69
71
  if (typeof o['flakyReruns'] === 'number')
@@ -96,6 +98,7 @@ const runCommand = defineCommand({
96
98
  ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
97
99
  staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
98
100
  staticBudget: { type: 'number', description: 'the most the static phase may take, ms; static mutants still undecided then are left out of the score' },
101
+ 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
102
  incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
100
103
  flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
101
104
  reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
package/dist/config.d.ts CHANGED
@@ -33,6 +33,12 @@ 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
+ */
41
+ staticReaders: 'off' | 'report';
36
42
  strykerCompat: boolean;
37
43
  reporters: string[];
38
44
  jsonReport: string;
package/dist/config.js CHANGED
@@ -111,6 +111,7 @@ export async function loadOptions(root, overrides = {}, configFile) {
111
111
  ignoreStatic: raw['ignoreStatic'] === true,
112
112
  staticGroup: staticGroupOf(raw['static']?.group),
113
113
  staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
114
+ staticReaders: raw['static']?.readers === 'report' ? 'report' : 'off',
114
115
  strykerCompat: raw['strykerCompat'] === true,
115
116
  reporters: raw['reporters'] ?? ['clear-text', 'json'],
116
117
  jsonReport: path.resolve(root, json.fileName ?? 'reports/mutation/mutation.json'),
@@ -76,6 +76,18 @@ 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
+ } | undefined;
79
91
  /** 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
92
  diskWritePaths: string[];
81
93
  /** Survivors of the warm run that a whole-file re-run killed (cross-test effects). */
@@ -121,6 +121,7 @@ export async function run(options) {
121
121
  throw new InstrumentFault(`cannot parse ${String(parseErrors.length)} file(s): ${parseErrors.slice(0, 3).join('; ')}`);
122
122
  const changed = options.since ? await changedLines(root, options.since) : undefined;
123
123
  const mutants = [];
124
+ const trackedIds = new Set();
124
125
  const codeMap = {};
125
126
  const directiveErrors = [];
126
127
  for (const f of instrumented) {
@@ -139,8 +140,11 @@ export async function run(options) {
139
140
  const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
140
141
  const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
141
142
  const excluded = options.excludedMutations.includes(m.mutatorName);
143
+ if (m.tracked)
144
+ trackedIds.add(m.id);
145
+ const { tracked: _tracked, ...plain } = m;
142
146
  mutants.push({
143
- ...m,
147
+ ...plain,
144
148
  file: f.path,
145
149
  coveredBy: [],
146
150
  status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
@@ -184,8 +188,8 @@ export async function run(options) {
184
188
  env[SPEC_TRACES_ENV] = JSON.stringify(traceDirs);
185
189
  const dryPool = createPool({ root, vitestConfig: options.vitestConfig, workers: options.concurrency, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, 1, work, 'dry');
186
190
  pools.push(dryPool);
187
- const dryRun = async () => {
188
- const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress') };
191
+ const dryRun = async (trackReads = false) => {
192
+ const unit = { mode: 'dry', files: [], results: fileFor('dry', 'jsonl'), progress: fileFor('dry', 'progress'), ...(trackReads && { trackReads: true }) };
189
193
  let failure;
190
194
  let overBudget = false;
191
195
  await dryPool.drain([unit], () => options.dryRunTimeoutMinutes * MS_PER_MINUTE, (_, o) => {
@@ -202,10 +206,13 @@ export async function run(options) {
202
206
  throw new InstrumentFault(`the dry run did not complete: ${failure.stderr.slice(-1500)}`);
203
207
  const perTest = {};
204
208
  const stat = {};
209
+ const reads = { static: {}, perTest: {} };
205
210
  for (const r of recs) {
206
211
  Object.assign(perTest, r.coverage?.perTest ?? {});
207
212
  for (const [k, v] of Object.entries(r.coverage?.static ?? {}))
208
213
  stat[k] = (stat[k] ?? 0) + v;
214
+ Object.assign(reads.perTest, r.reads?.perTest ?? {});
215
+ Object.assign(reads.static, r.reads?.static ?? {});
209
216
  }
210
217
  const fileErrors = recs.flatMap((r) => r.fileErrors ?? []);
211
218
  if (fileErrors.length) {
@@ -214,12 +221,17 @@ export async function run(options) {
214
221
  const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
215
222
  if (unhandled > 0)
216
223
  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 ?? {})) };
224
+ 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
225
  };
219
226
  progress.phase('dry run');
220
227
  const d0 = performance.now();
221
228
  const dry = await dryRun();
222
229
  stats.dryRunMs = performance.now() - d0;
230
+ const r0 = performance.now();
231
+ if (options.staticReaders === 'report' && trackedIds.size > 0)
232
+ progress.phase('dry run with reads tracked (static.readers)');
233
+ const tracking = options.staticReaders === 'report' && trackedIds.size > 0 ? await dryRun(true) : undefined;
234
+ const readersDryRunMs = performance.now() - r0;
223
235
  const failing = dry.tests.filter((t) => t.state === 'fail');
224
236
  if (failing.length)
225
237
  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 +588,36 @@ export async function run(options) {
576
588
  const covering = byCost([...new Set(tests)]);
577
589
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
578
590
  };
591
+ const readersOf = (items) => {
592
+ const testsIn = new Map();
593
+ for (const t of dry.tests)
594
+ testsIn.set(t.file, (testsIn.get(t.file) ?? 0) + 1);
595
+ 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
+ let loaderTests = 0;
607
+ let readerTests = 0;
608
+ let fellBack = 0;
609
+ for (const m of items) {
610
+ const loaders = filesFor(m);
611
+ loaderTests += sum(loaders);
612
+ if (broken || !trackedIds.has(m.id) || m.id in reads.static) {
613
+ fellBack++;
614
+ readerTests += sum(loaders);
615
+ }
616
+ else
617
+ readerTests += sum(loaders.filter((f) => readerFiles.get(m.id)?.has(f)));
618
+ }
619
+ return { mutants: items.length, loaderTests, readerTests, fellBack };
620
+ };
579
621
  const testsRun = (id) => {
580
622
  const m = byId.get(id);
581
623
  const fs = m ? filesFor(m) : allTestFiles;
@@ -727,6 +769,8 @@ export async function run(options) {
727
769
  verdicts.set(m.id, { status: 'RuntimeError' });
728
770
  return verdicts;
729
771
  };
772
+ if (tracking)
773
+ stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
730
774
  const s0 = performance.now();
731
775
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
732
776
  stats.staticMs = performance.now() - s0;
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,11 @@ 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
+ 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)`);
147
+ }
143
148
  const c = s.isolatedByCause;
144
149
  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
150
  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.4.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>",