oxmutate 1.4.0 → 1.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.
@@ -50,6 +50,9 @@ export interface RunStats {
50
50
  idleStalls: number;
51
51
  crashes: number;
52
52
  canaryFiles: number;
53
+ /** Of the verdicts the incremental cache would have reused, how many were run afresh to check it, and how many disagreed on detected or not. */
54
+ auditSampled: number;
55
+ auditDisagreed: number;
53
56
  /** Contexts restarted because a mutant left state behind (the per-execution canary fired). */
54
57
  pollutionRestarts: number;
55
58
  /** 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. */
@@ -12,6 +12,7 @@ import { globToRegExp, matchFiles } from './files.js';
12
12
  import { changedLines, parseMutateSpec, shardOf, touches, withinRange } from './lanes.js';
13
13
  import { native } from './native.js';
14
14
  import { startProgress } from './progress.js';
15
+ import { VERSION } from './reports.js';
15
16
  import { FILE_KEY, env, stderr } from './runtime.js';
16
17
  import { createPool } from './scheduler.js';
17
18
  import { summarizeSpec, tracesFor } from './spec-oracle.js';
@@ -27,7 +28,26 @@ function readRecords(file) {
27
28
  .map((l) => JSON.parse(l));
28
29
  }
29
30
  const sha = (s) => createHash('sha256').update(s).digest('hex').slice(0, 16);
31
+ const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'];
32
+ function lockfileHash(root) {
33
+ const found = LOCKFILES.map((f) => path.join(root, f)).find((f) => existsSync(f));
34
+ if (found)
35
+ return sha(readFileSync(found, 'utf8'));
36
+ const above = path.dirname(root);
37
+ return above === root ? 'none' : lockfileHash(above);
38
+ }
39
+ const verdictOptions = (o) => ({
40
+ timeoutMS: o.timeoutMS,
41
+ timeoutFactor: o.timeoutFactor,
42
+ excludedMutations: [...o.excludedMutations].sort(),
43
+ ignoreStatic: o.ignoreStatic,
44
+ strykerCompat: o.strykerCompat,
45
+ testOverrides: o.testOverrides,
46
+ vitestConfig: o.vitestConfig && existsSync(o.vitestConfig) ? sha(readFileSync(o.vitestConfig, 'utf8')) : null,
47
+ });
30
48
  const UNIT_PAIRS = 400;
49
+ const detected = (s) => s === 'Killed' || s === 'Timeout';
50
+ const AUDIT_SHARE = 0.05;
31
51
  const DEADLOCK_REASON = 'its test deadlocked: nothing left in the worker could resume it (a hang, called in under a second)';
32
52
  function timeoutReason(v) {
33
53
  if (v.deadlock)
@@ -54,7 +74,7 @@ export async function run(options) {
54
74
  const work = mkdtempSync(path.join(tmpdir(), 'oxmutate-'));
55
75
  const stats = {
56
76
  files: 0, mutants: 0, instrumentMs: 0, dryRunMs: 0, mutationMs: 0, staticMs: 0, staticRecollections: 0, isolatedMs: 0, wallMs: 0, testExecutions: 0,
57
- watchdogKills: 0, idleStalls: 0, deadlocks: 0, crashes: 0, canaryFiles: 0, pollutionRestarts: 0, diskWrites: 0, survivorsOverturned: 0, timeoutsOverturned: 0, compileErrors: 0, isolatedRuns: 0, reused: 0, flakyTests: 0, flakyMutants: 0, units: 0, serversSpawned: 0,
77
+ 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,
58
78
  };
59
79
  let unitSeq = 0;
60
80
  const fileFor = (kind, ext) => path.join(work, `${kind}-${String(unitSeq++)}.${ext}`);
@@ -169,7 +189,7 @@ export async function run(options) {
169
189
  if (fileErrors.length) {
170
190
  throw new InstrumentFault(`${String(fileErrors.length)} test file(s) failed to load during the dry run, so their tests would be missing from every verdict: ${fileErrors.slice(0, 3).map((e) => `${path.relative(root, e.file)}: ${e.error}`).join('; ')}`);
171
191
  }
172
- return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []) };
192
+ return { tests: recs.flatMap((r) => r.tests ?? []), perTest, static: stat, runOnce: recs.flatMap((r) => r.runOnce ?? []), modules: Object.assign({}, ...recs.map((r) => r.modules ?? {})) };
173
193
  };
174
194
  progress.phase('dry run');
175
195
  const d0 = performance.now();
@@ -263,13 +283,23 @@ export async function run(options) {
263
283
  }
264
284
  return h;
265
285
  };
266
- const fingerprint = (m) => sha([sha(sources[m.file] ?? ''), ...[...new Set(m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''))].sort().map(fileHash)].join(':'));
286
+ const envKey = sha(JSON.stringify({ oxmutate: VERSION, node: process.version, lock: lockfileHash(root), options: verdictOptions(options) }));
287
+ const fingerprint = (m) => {
288
+ const testFiles = new Set([...m.coveredBy.map((t) => testInfo.get(t)?.file ?? ''), ...(staticFiles.get(m.id) ?? [])]);
289
+ const graph = new Set([...testFiles].flatMap((f) => dry.modules[f] ?? []).filter((f) => !testFiles.has(f) && f !== path.join(root, m.file)));
290
+ return sha([envKey, sha(sources[m.file] ?? ''), ...[...testFiles].sort().map(fileHash), '|', ...[...graph].sort().map((f) => `${f}=${fileHash(f)}`)].join(':'));
291
+ };
292
+ const audited = new Map();
267
293
  const cache = options.incremental && existsSync(options.incrementalFile) ? JSON.parse(readFileSync(options.incrementalFile, 'utf8')).mutants : {};
268
294
  for (const m of mutants) {
269
295
  if (m.status === 'Ignored' || (!m.static && m.coveredBy.length === 0))
270
296
  continue;
271
297
  const hit = cache[m.id];
272
298
  if (hit && hit.fp === fingerprint(m)) {
299
+ if (Math.random() < AUDIT_SHARE) {
300
+ audited.set(m.id, hit.status);
301
+ continue;
302
+ }
273
303
  m.status = hit.status;
274
304
  m.killedBy = hit.killedBy;
275
305
  m.testsCompleted = hit.testsCompleted;
@@ -729,7 +759,11 @@ export async function run(options) {
729
759
  stats.crashes = pool.stats.crashed;
730
760
  if (options.incremental) {
731
761
  mkdirSync(path.dirname(options.incrementalFile), { recursive: true });
732
- const next = { ...cache };
762
+ stats.auditSampled = audited.size;
763
+ stats.auditDisagreed = [...audited].filter(([id, was]) => detected(byId.get(id)?.status) !== detected(was)).length;
764
+ if (stats.auditDisagreed > 0)
765
+ stderr.write(`oxmutate: ${String(stats.auditDisagreed)} of ${String(audited.size)} audited cached verdicts disagree with a fresh run; the incremental cache is rebuilt from this run\n`);
766
+ const next = stats.auditDisagreed > 0 ? {} : { ...cache };
733
767
  for (const m of mutants) {
734
768
  if (['Killed', 'Survived', 'Timeout'].includes(m.status) && !m.flaky)
735
769
  next[m.id] = { fp: fingerprint(m), status: m.status, killedBy: m.killedBy, testsCompleted: m.testsCompleted };
package/dist/reports.js CHANGED
@@ -102,7 +102,7 @@ function corrections(s) {
102
102
  s.survivorsOverturned && `${String(s.survivorsOverturned)} survivor(s) killed by a whole-file re-run`,
103
103
  s.timeoutsOverturned && `${String(s.timeoutsOverturned)} timeout(s) that finished given 3x the time`,
104
104
  s.compileErrors && `${String(s.compileErrors)} survivor(s) that do not type-check, out of the score`,
105
- s.reused && `${String(s.reused)} verdict(s) reused`,
105
+ s.reused && `${String(s.reused)} verdict(s) reused${s.auditSampled ? ` (${String(s.auditSampled)} audited, ${String(s.auditDisagreed)} disagreeing)` : ''}`,
106
106
  s.crashes && `${String(s.crashes)} worker crash(es)`,
107
107
  ].filter((c) => Boolean(c));
108
108
  }
package/dist/runner.d.ts CHANGED
@@ -35,6 +35,7 @@ declare const OxmutateRunner_base: new (config: unknown) => {
35
35
  evaluatedModules?: {
36
36
  getModuleById: (id: string) => unknown;
37
37
  invalidateModule: (node: unknown) => void;
38
+ fileToModulesMap?: Map<string, unknown>;
38
39
  };
39
40
  };
40
41
  _currentSpecification?: unknown;
package/dist/runner.js CHANGED
@@ -120,6 +120,7 @@ function takeCopy(test) {
120
120
  const file = collections.get(test.file?.filepath ?? '')?.[n];
121
121
  return file && find(file, test.id);
122
122
  }
123
+ const dryModules = new Map();
123
124
  const PROBE_ID = '__oxmutate_probe__';
124
125
  async function probe(test, fn) {
125
126
  const file = test.file?.filepath;
@@ -425,6 +426,10 @@ export default class OxmutateRunner extends Base {
425
426
  return super.importFile?.(filepath, source);
426
427
  }
427
428
  async onAfterRunSuite(suite) {
429
+ if (mode === 'dry' && suite.filepath) {
430
+ const files = [...(this.workerState?.evaluatedModules?.fileToModulesMap?.keys() ?? [])];
431
+ dryModules.set(suite.filepath, files.filter((f) => path.isAbsolute(f) && !f.split(path.sep).includes('node_modules')));
432
+ }
428
433
  const copies = suite.filepath ? collections.get(suite.filepath) : undefined;
429
434
  if (suite.filepath && copies) {
430
435
  collections.delete(suite.filepath);
@@ -574,7 +579,7 @@ export default class OxmutateRunner extends Base {
574
579
  if (result?.state === 'fail' && !f.tasks?.length)
575
580
  fileErrors.push({ file: filepath, error: (result.errors?.[0]?.message ?? 'failed to collect').slice(0, FILE_ERROR_CHARS) });
576
581
  }
577
- emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce] });
582
+ emit({ type: 'dry', tests, coverage: ns.mutantCoverage, fileErrors, runOnce: [...runOnce], modules: Object.fromEntries(dryModules) });
578
583
  }
579
584
  else if (mode === 'isolated') {
580
585
  const failed = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxmutate",
3
- "version": "1.4.0",
3
+ "version": "1.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
  "repository": {