oxmutate 2.2.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
@@ -62,6 +62,10 @@ function overridesFrom(o, root) {
62
62
  overrides.ignoreStatic = true;
63
63
  if (typeof o['staticGroup'] === 'number')
64
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';
65
69
  if (o['incremental'] === true)
66
70
  overrides.incremental = true;
67
71
  if (typeof o['flakyReruns'] === 'number')
@@ -93,6 +97,8 @@ const runCommand = defineCommand({
93
97
  strykerCompat: { type: 'boolean', description: 'reproduce Stryker 10.0.0 skips (no mutants inside `as` casts or enums)' },
94
98
  ignoreStatic: { type: 'boolean', description: 'skip static mutants (module-level code): faster, and the score leaves them out' },
95
99
  staticGroup: { type: 'number', description: 'arm this many static mutants together in a file that is not their first (default 1; a heuristic, audited)' },
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' },
96
102
  incremental: { type: 'boolean', description: 'reuse verdicts whose source and covering tests did not change' },
97
103
  flakyReruns: { type: 'number', description: 're-runs for verdicts with nondeterministic tests (default 2; 0 disables)' },
98
104
  reporter: { type: 'string', description: 'comma-separated: clear-text, json, sarif, html (default clear-text,json)' },
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 {
@@ -25,6 +27,18 @@ export interface Options {
25
27
  * heuristic: two mutants can hide each other, so one in twenty group survivors is run again alone.
26
28
  */
27
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;
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';
28
42
  strykerCompat: boolean;
29
43
  reporters: string[];
30
44
  jsonReport: string;
package/dist/config.js CHANGED
@@ -7,6 +7,11 @@ function staticGroupOf(group) {
7
7
  const asked = Math.floor(Number(group));
8
8
  return asked >= 2 ? asked : 1;
9
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)';
10
15
  export const IGNORE_STATIC_REASON = 'static mutant (ignoreStatic)';
11
16
  const NOT_NEEDED = {
12
17
  inPlace: 'oxmutate never copies or rewrites the project; instrumented sources are served from memory',
@@ -105,6 +110,8 @@ export async function loadOptions(root, overrides = {}, configFile) {
105
110
  excludedMutations: mutator.excludedMutations ?? [],
106
111
  ignoreStatic: raw['ignoreStatic'] === true,
107
112
  staticGroup: staticGroupOf(raw['static']?.group),
113
+ staticBudgetMs: staticBudgetOf(raw['static']?.budgetMs),
114
+ staticReaders: raw['static']?.readers === 'report' ? 'report' : 'off',
108
115
  strykerCompat: raw['strykerCompat'] === true,
109
116
  reporters: raw['reporters'] ?? ['clear-text', 'json'],
110
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). */
@@ -5,7 +5,7 @@ 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';
@@ -43,6 +43,7 @@ const verdictOptions = (o) => ({
43
43
  excludedMutations: [...o.excludedMutations].sort(),
44
44
  ignoreStatic: o.ignoreStatic,
45
45
  staticGroup: o.staticGroup,
46
+ staticBudgetMs: o.staticBudgetMs,
46
47
  strykerCompat: o.strykerCompat,
47
48
  testOverrides: o.testOverrides,
48
49
  vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
@@ -120,6 +121,7 @@ export async function run(options) {
120
121
  throw new InstrumentFault(`cannot parse ${String(parseErrors.length)} file(s): ${parseErrors.slice(0, 3).join('; ')}`);
121
122
  const changed = options.since ? await changedLines(root, options.since) : undefined;
122
123
  const mutants = [];
124
+ const trackedIds = new Set();
123
125
  const codeMap = {};
124
126
  const directiveErrors = [];
125
127
  for (const f of instrumented) {
@@ -138,8 +140,11 @@ export async function run(options) {
138
140
  const off = offs.find((d) => d.line === m.start.line && (d.mutators === 'all' || d.mutators.includes(m.mutatorName)));
139
141
  const inRange = ranged.reasonFor(m.start.line, m.mutatorName);
140
142
  const excluded = options.excludedMutations.includes(m.mutatorName);
143
+ if (m.tracked)
144
+ trackedIds.add(m.id);
145
+ const { tracked: _tracked, ...plain } = m;
141
146
  mutants.push({
142
- ...m,
147
+ ...plain,
143
148
  file: f.path,
144
149
  coveredBy: [],
145
150
  status: off || inRange !== undefined || excluded || m.ignoredReason ? 'Ignored' : 'NoCoverage',
@@ -183,8 +188,8 @@ export async function run(options) {
183
188
  env[SPEC_TRACES_ENV] = JSON.stringify(traceDirs);
184
189
  const dryPool = createPool({ root, vitestConfig: options.vitestConfig, workers: options.concurrency, instrumentedMap: mapFile, nodeArgs: options.testRunnerNodeArgs }, 1, work, 'dry');
185
190
  pools.push(dryPool);
186
- const dryRun = async () => {
187
- 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 }) };
188
193
  let failure;
189
194
  let overBudget = false;
190
195
  await dryPool.drain([unit], () => options.dryRunTimeoutMinutes * MS_PER_MINUTE, (_, o) => {
@@ -201,10 +206,13 @@ export async function run(options) {
201
206
  throw new InstrumentFault(`the dry run did not complete: ${failure.stderr.slice(-1500)}`);
202
207
  const perTest = {};
203
208
  const stat = {};
209
+ const reads = { static: {}, perTest: {} };
204
210
  for (const r of recs) {
205
211
  Object.assign(perTest, r.coverage?.perTest ?? {});
206
212
  for (const [k, v] of Object.entries(r.coverage?.static ?? {}))
207
213
  stat[k] = (stat[k] ?? 0) + v;
214
+ Object.assign(reads.perTest, r.reads?.perTest ?? {});
215
+ Object.assign(reads.static, r.reads?.static ?? {});
208
216
  }
209
217
  const fileErrors = recs.flatMap((r) => r.fileErrors ?? []);
210
218
  if (fileErrors.length) {
@@ -213,12 +221,17 @@ export async function run(options) {
213
221
  const unhandled = recs.reduce((n, r) => n + (r.unhandled ?? 0), 0);
214
222
  if (unhandled > 0)
215
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`);
216
- 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 };
217
225
  };
218
226
  progress.phase('dry run');
219
227
  const d0 = performance.now();
220
228
  const dry = await dryRun();
221
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;
222
235
  const failing = dry.tests.filter((t) => t.state === 'fail');
223
236
  if (failing.length)
224
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`);
@@ -575,6 +588,36 @@ export async function run(options) {
575
588
  const covering = byCost([...new Set(tests)]);
576
589
  return [...covering, ...byAffinity(m.file, [...loaders].filter((f) => !covering.includes(f)))];
577
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
+ };
578
621
  const testsRun = (id) => {
579
622
  const m = byId.get(id);
580
623
  const fs = m ? filesFor(m) : allTestFiles;
@@ -649,7 +692,11 @@ export async function run(options) {
649
692
  back.add(id);
650
693
  return [];
651
694
  };
695
+ const deadline = options.staticBudgetMs === undefined ? Infinity : performance.now() + options.staticBudgetMs;
696
+ const spent = () => performance.now() >= deadline;
652
697
  const keepUndecided = (unit) => {
698
+ if (spent())
699
+ return false;
653
700
  unit.staticIds = (unit.staticIds ?? []).filter((id) => !killed.has(id) && !back.has(id));
654
701
  if (unit.staticGroups)
655
702
  unit.staticGroups = unit.staticGroups.map((g) => g.filter((id) => !killed.has(id) && !back.has(id))).filter((g) => g.length > 0);
@@ -671,6 +718,10 @@ export async function run(options) {
671
718
  m.status = 'Survived';
672
719
  m.testsCompleted = ran.get(m.id) ?? 0;
673
720
  }
721
+ else if (spent()) {
722
+ m.status = 'Ignored';
723
+ m.statusReason = STATIC_BUDGET_REASON;
724
+ }
674
725
  else
675
726
  undecided.push(m);
676
727
  }
@@ -718,6 +769,8 @@ export async function run(options) {
718
769
  verdicts.set(m.id, { status: 'RuntimeError' });
719
770
  return verdicts;
720
771
  };
772
+ if (tracking)
773
+ stats.staticReaders = { ...readersOf(staticMutants), dryRunMs: readersDryRunMs };
721
774
  const s0 = performance.now();
722
775
  const staticBack = env['OXMUTATE_ISOLATE_STATIC'] ? staticMutants : await decideStaticLive(staticMutants);
723
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
@@ -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 = '...';
@@ -128,6 +128,9 @@ function staticLine(r) {
128
128
  if (ignored > 0)
129
129
  return [` static mutants: ${String(ignored)} ignored (ignoreStatic), not scored`];
130
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`];
131
134
  const hint = share >= STATIC_SHARE_WORTH_SAYING ? '; --ignore-static skips them, and the score then leaves them out' : '';
132
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}`];
133
136
  }
@@ -137,6 +140,11 @@ function whereItWent(r) {
137
140
  if (s.warmUnitMs > 0)
138
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`);
139
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
+ }
140
148
  const c = s.isolatedByCause;
141
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)}`];
142
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.2.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>",