@mutineerjs/mutineer 0.6.0 → 0.8.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.
Files changed (69) hide show
  1. package/README.md +32 -15
  2. package/dist/bin/__tests__/mutineer.spec.js +67 -2
  3. package/dist/bin/mutineer.d.ts +6 -1
  4. package/dist/bin/mutineer.js +58 -2
  5. package/dist/core/__tests__/schemata.spec.d.ts +1 -0
  6. package/dist/core/__tests__/schemata.spec.js +165 -0
  7. package/dist/core/schemata.d.ts +22 -0
  8. package/dist/core/schemata.js +236 -0
  9. package/dist/mutators/__tests__/operator.spec.js +97 -1
  10. package/dist/mutators/__tests__/registry.spec.js +8 -0
  11. package/dist/mutators/operator.d.ts +8 -0
  12. package/dist/mutators/operator.js +58 -1
  13. package/dist/mutators/registry.js +9 -1
  14. package/dist/mutators/utils.d.ts +2 -0
  15. package/dist/mutators/utils.js +58 -1
  16. package/dist/runner/__tests__/args.spec.js +89 -1
  17. package/dist/runner/__tests__/cache.spec.js +65 -8
  18. package/dist/runner/__tests__/cleanup.spec.js +37 -0
  19. package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
  20. package/dist/runner/__tests__/discover.spec.js +128 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +332 -2
  22. package/dist/runner/__tests__/pool-executor.spec.js +107 -1
  23. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  24. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  25. package/dist/runner/args.d.ts +18 -0
  26. package/dist/runner/args.js +37 -0
  27. package/dist/runner/cache.d.ts +19 -3
  28. package/dist/runner/cache.js +14 -7
  29. package/dist/runner/cleanup.d.ts +3 -1
  30. package/dist/runner/cleanup.js +19 -2
  31. package/dist/runner/coverage-resolver.js +1 -1
  32. package/dist/runner/discover.d.ts +1 -1
  33. package/dist/runner/discover.js +30 -20
  34. package/dist/runner/orchestrator.d.ts +1 -0
  35. package/dist/runner/orchestrator.js +114 -19
  36. package/dist/runner/pool-executor.d.ts +7 -0
  37. package/dist/runner/pool-executor.js +29 -7
  38. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  39. package/dist/runner/shared/index.d.ts +1 -1
  40. package/dist/runner/shared/index.js +1 -1
  41. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  42. package/dist/runner/shared/mutant-paths.js +24 -0
  43. package/dist/runner/ts-checker-worker.d.ts +5 -0
  44. package/dist/runner/ts-checker-worker.js +66 -0
  45. package/dist/runner/ts-checker.d.ts +36 -0
  46. package/dist/runner/ts-checker.js +210 -0
  47. package/dist/runner/types.d.ts +2 -0
  48. package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
  49. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  50. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  51. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  52. package/dist/runner/vitest/adapter.js +14 -9
  53. package/dist/runner/vitest/plugin.d.ts +3 -0
  54. package/dist/runner/vitest/plugin.js +49 -11
  55. package/dist/runner/vitest/pool.d.ts +4 -1
  56. package/dist/runner/vitest/pool.js +25 -4
  57. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  58. package/dist/runner/vitest/worker-runtime.js +57 -18
  59. package/dist/runner/vitest/worker.mjs +10 -0
  60. package/dist/types/config.d.ts +16 -0
  61. package/dist/types/mutant.d.ts +5 -2
  62. package/dist/utils/CompileErrors.d.ts +7 -0
  63. package/dist/utils/CompileErrors.js +24 -0
  64. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  65. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  66. package/dist/utils/__tests__/summary.spec.js +126 -2
  67. package/dist/utils/summary.d.ts +23 -1
  68. package/dist/utils/summary.js +63 -3
  69. package/package.json +2 -1
@@ -105,6 +105,28 @@ export function parseProgressMode(args) {
105
105
  export function extractConfigPath(args) {
106
106
  return readStringFlag(args, '--config', '-c');
107
107
  }
108
+ /**
109
+ * Parse the --shard <n>/<total> option.
110
+ * Throws on bad format or out-of-range values.
111
+ */
112
+ export function parseShardOption(args) {
113
+ const raw = readStringFlag(args, '--shard');
114
+ if (raw === undefined)
115
+ return undefined;
116
+ const match = /^(\d+)\/(\d+)$/.exec(raw);
117
+ if (!match) {
118
+ throw new Error(`Invalid --shard format: expected <n>/<total>, got "${raw}"`);
119
+ }
120
+ const index = parseInt(match[1], 10);
121
+ const total = parseInt(match[2], 10);
122
+ if (total < 1) {
123
+ throw new Error(`Invalid --shard: total must be >= 1 (got ${total})`);
124
+ }
125
+ if (index < 1 || index > total) {
126
+ throw new Error(`Invalid --shard: index must be between 1 and ${total} (got ${index})`);
127
+ }
128
+ return { index, total };
129
+ }
108
130
  /**
109
131
  * Parse all CLI options.
110
132
  */
@@ -127,6 +149,16 @@ export function parseCliOptions(args, cfg) {
127
149
  const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
128
150
  const minKillPercent = cliKillPercent ?? configKillPercent;
129
151
  const timeout = validatePositiveMs(readNumberFlag(args, '--timeout'), '--timeout');
152
+ const reportFlag = readStringFlag(args, '--report');
153
+ const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
154
+ const shard = parseShardOption(args);
155
+ const typescriptCheck = args.includes('--typescript')
156
+ ? true
157
+ : args.includes('--no-typescript')
158
+ ? false
159
+ : undefined;
160
+ const vitestProject = readStringFlag(args, '--vitest-project');
161
+ const skipBaseline = args.includes('--skip-baseline');
130
162
  return {
131
163
  configPath,
132
164
  wantsChanged,
@@ -139,5 +171,10 @@ export function parseCliOptions(args, cfg) {
139
171
  minKillPercent,
140
172
  runner,
141
173
  timeout,
174
+ reportFormat,
175
+ shard,
176
+ typescriptCheck,
177
+ vitestProject,
178
+ skipBaseline,
142
179
  };
143
180
  }
@@ -5,14 +5,27 @@
5
5
  * Handles reading, writing, and decoding cache entries.
6
6
  */
7
7
  import type { MutantCacheEntry } from '../types/mutant.js';
8
+ /**
9
+ * Get the cache filename for a given shard (or the default if none).
10
+ */
11
+ export declare function getCacheFilename(shard?: {
12
+ index: number;
13
+ total: number;
14
+ }): string;
8
15
  /**
9
16
  * Clear the cache file at the start of a run.
10
17
  */
11
- export declare function clearCacheOnStart(cwd: string): Promise<void>;
18
+ export declare function clearCacheOnStart(cwd: string, shard?: {
19
+ index: number;
20
+ total: number;
21
+ }): Promise<void>;
12
22
  /**
13
23
  * Save cache atomically using a temp file + rename.
14
24
  */
15
- export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry>): Promise<void>;
25
+ export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry>, shard?: {
26
+ index: number;
27
+ total: number;
28
+ }): Promise<void>;
16
29
  /**
17
30
  * Decode a cache key into its component parts.
18
31
  * Cache keys have the format: testSig:codeSig:file:line,col:mutator
@@ -35,4 +48,7 @@ export declare function hash(s: string): string;
35
48
  * Read the mutant cache from disk.
36
49
  * Normalizes both old (string status) and new (object) formats.
37
50
  */
38
- export declare function readMutantCache(cwd: string): Promise<Record<string, MutantCacheEntry>>;
51
+ export declare function readMutantCache(cwd: string, shard?: {
52
+ index: number;
53
+ total: number;
54
+ }): Promise<Record<string, MutantCacheEntry>>;
@@ -7,12 +7,19 @@
7
7
  import fs from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import crypto from 'node:crypto';
10
- const CACHE_FILENAME = '.mutate-cache.json';
10
+ /**
11
+ * Get the cache filename for a given shard (or the default if none).
12
+ */
13
+ export function getCacheFilename(shard) {
14
+ if (!shard)
15
+ return '.mutineer-cache.json';
16
+ return `.mutineer-cache-shard-${shard.index}-of-${shard.total}.json`;
17
+ }
11
18
  /**
12
19
  * Clear the cache file at the start of a run.
13
20
  */
14
- export async function clearCacheOnStart(cwd) {
15
- const p = path.join(cwd, CACHE_FILENAME);
21
+ export async function clearCacheOnStart(cwd, shard) {
22
+ const p = path.join(cwd, getCacheFilename(shard));
16
23
  try {
17
24
  await fs.unlink(p);
18
25
  }
@@ -23,8 +30,8 @@ export async function clearCacheOnStart(cwd) {
23
30
  /**
24
31
  * Save cache atomically using a temp file + rename.
25
32
  */
26
- export async function saveCacheAtomic(cwd, cache) {
27
- const p = path.join(cwd, CACHE_FILENAME);
33
+ export async function saveCacheAtomic(cwd, cache, shard) {
34
+ const p = path.join(cwd, getCacheFilename(shard));
28
35
  const tmp = p + '.tmp';
29
36
  const json = JSON.stringify(cache, null, 2);
30
37
  await fs.writeFile(tmp, json, 'utf8');
@@ -87,8 +94,8 @@ export function hash(s) {
87
94
  * Read the mutant cache from disk.
88
95
  * Normalizes both old (string status) and new (object) formats.
89
96
  */
90
- export async function readMutantCache(cwd) {
91
- const p = path.join(cwd, CACHE_FILENAME);
97
+ export async function readMutantCache(cwd, shard) {
98
+ const p = path.join(cwd, getCacheFilename(shard));
92
99
  try {
93
100
  const data = await fs.readFile(p, 'utf8');
94
101
  const raw = JSON.parse(data);
@@ -1,4 +1,6 @@
1
1
  /**
2
2
  * Clean up all __mutineer__ temp directories created during mutation testing.
3
3
  */
4
- export declare function cleanupMutineerDirs(cwd: string): Promise<void>;
4
+ export declare function cleanupMutineerDirs(cwd: string, opts?: {
5
+ includeCacheFiles?: boolean;
6
+ }): Promise<void>;
@@ -2,9 +2,9 @@ import fs from 'node:fs/promises';
2
2
  /**
3
3
  * Clean up all __mutineer__ temp directories created during mutation testing.
4
4
  */
5
- export async function cleanupMutineerDirs(cwd) {
5
+ export async function cleanupMutineerDirs(cwd, opts = {}) {
6
6
  const glob = await import('fast-glob');
7
- const dirs = await glob.default('**/__mutineer__', {
7
+ const dirs = await glob.default(['__mutineer__', '**/__mutineer__'], {
8
8
  cwd,
9
9
  onlyDirectories: true,
10
10
  absolute: true,
@@ -18,4 +18,21 @@ export async function cleanupMutineerDirs(cwd) {
18
18
  // Ignore cleanup errors
19
19
  }
20
20
  }
21
+ if (opts.includeCacheFiles) {
22
+ // Remove cache files (new name + legacy .mutate-cache* for migration)
23
+ const cacheFiles = await glob.default([
24
+ '.mutineer-cache*.json',
25
+ '.mutineer-cache*.json.tmp',
26
+ '.mutate-cache*.json',
27
+ '.mutate-cache*.json.tmp',
28
+ ], { cwd, absolute: true });
29
+ for (const f of cacheFiles) {
30
+ try {
31
+ await fs.unlink(f);
32
+ }
33
+ catch {
34
+ // Ignore cleanup errors
35
+ }
36
+ }
37
+ }
21
38
  }
@@ -15,7 +15,7 @@ export async function resolveCoverageConfig(opts, cfg, adapter, cliArgs) {
15
15
  ? true
16
16
  : coveragePreference === false
17
17
  ? false
18
- : isCoverageRequestedInArgs([...cliArgs]) || coverageConfig.coverageEnabled;
18
+ : isCoverageRequestedInArgs([...cliArgs]);
19
19
  // Load pre-existing coverage data if provided
20
20
  let coverageData = null;
21
21
  if (opts.coverageFilePath) {
@@ -5,4 +5,4 @@ export interface DiscoveryResult {
5
5
  readonly testMap: TestMap;
6
6
  readonly directTestMap: TestMap;
7
7
  }
8
- export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
8
+ export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig, onProgress?: (msg: string) => void): Promise<DiscoveryResult>;
@@ -200,7 +200,7 @@ async function createResolver(rootAbs, exts) {
200
200
  return createNodeResolver();
201
201
  }
202
202
  }
203
- export async function autoDiscoverTargetsAndTests(root, cfg) {
203
+ export async function autoDiscoverTargetsAndTests(root, cfg, onProgress) {
204
204
  const rootAbs = path.resolve(root);
205
205
  const sourceRoots = toArray(cfg.source ?? 'src').map((s) => path.resolve(rootAbs, s));
206
206
  const exts = new Set(toArray(cfg.extensions ?? EXT_DEFAULT));
@@ -219,6 +219,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
219
219
  if (!tests.length)
220
220
  return { targets: [], testMap: new Map(), directTestMap: new Map() };
221
221
  const testSet = new Set(tests.map((t) => normalizePath(t)));
222
+ onProgress?.(`Found ${tests.length} test file(s), resolving imports...`);
222
223
  // 2) Create resolver (Vite if available, otherwise Node-based fallback)
223
224
  const { resolve, cleanup } = await createResolver(rootAbs, exts);
224
225
  const targets = new Map();
@@ -226,6 +227,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
226
227
  const directTestMap = new Map();
227
228
  const contentCache = new Map();
228
229
  const resolveCache = new Map(); // key: importer\0spec -> resolved id
230
+ const childrenCache = new Map(); // key: normalized file -> resolved child abs paths
229
231
  async function crawl(absFile, depth, seen, currentTestAbs) {
230
232
  if (depth > MAX_CRAWL_DEPTH)
231
233
  return; // sane guard for huge graphs
@@ -266,27 +268,34 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
266
268
  }
267
269
  if (!code)
268
270
  return;
269
- // find import specs and resolve relative to absFile
270
- for (const spec of extractImportSpecs(code)) {
271
- if (!spec)
272
- continue;
273
- const cacheKey = `${absFile}\0${spec}`;
274
- let resolved = resolveCache.get(cacheKey);
275
- if (!resolved) {
276
- resolved = await resolve(spec, absFile);
277
- resolveCache.set(cacheKey, resolved);
271
+ // find import specs and resolve relative to absFile, memoized per file
272
+ let children = childrenCache.get(key);
273
+ if (children === undefined) {
274
+ const resolved = [];
275
+ for (const spec of extractImportSpecs(code)) {
276
+ if (!spec)
277
+ continue;
278
+ const cacheKey = `${absFile}\0${spec}`;
279
+ let resolvedId = resolveCache.get(cacheKey);
280
+ if (!resolvedId) {
281
+ resolvedId = await resolve(spec, absFile);
282
+ resolveCache.set(cacheKey, resolvedId);
283
+ }
284
+ // vite ids could be URLs; ensure we turn into absolute disk path when possible
285
+ const next = path.isAbsolute(resolvedId)
286
+ ? resolvedId
287
+ : normalizePath(path.resolve(rootAbs, resolvedId));
288
+ // skip node_modules and virtual ids
289
+ if (next.includes('/node_modules/'))
290
+ continue;
291
+ if (!path.isAbsolute(next))
292
+ continue;
293
+ resolved.push(next);
278
294
  }
279
- // vite ids could be URLs; ensure we turn into absolute disk path when possible
280
- const next = path.isAbsolute(resolved)
281
- ? resolved
282
- : normalizePath(path.resolve(rootAbs, resolved));
283
- // skip node_modules and virtual ids
284
- if (next.includes('/node_modules/'))
285
- continue;
286
- if (!path.isAbsolute(next))
287
- continue;
288
- await crawl(next, depth + 1, seen, currentTestAbs);
295
+ childrenCache.set(key, resolved);
296
+ children = resolved;
289
297
  }
298
+ await Promise.all(children.map((next) => crawl(next, depth + 1, seen, currentTestAbs)));
290
299
  }
291
300
  try {
292
301
  await Promise.all(tests.map(async (testAbs) => {
@@ -312,6 +321,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
312
321
  await crawl(abs, 0, seen, testAbs);
313
322
  }
314
323
  }));
324
+ onProgress?.(`Discovery complete: ${targets.size} source file(s), ${tests.length} test file(s)`);
315
325
  return { targets: Array.from(targets.values()), testMap, directTestMap };
316
326
  }
317
327
  finally {
@@ -9,5 +9,6 @@
9
9
  * 5. Execute mutants via worker pool
10
10
  * 6. Report results
11
11
  */
12
+ export declare function parseMutantTimeoutMs(raw: string | undefined): number;
12
13
  export { readMutantCache } from './cache.js';
13
14
  export declare function runOrchestrator(cliArgs: string[], cwd: string): Promise<void>;
@@ -10,8 +10,12 @@
10
10
  * 6. Report results
11
11
  */
12
12
  import path from 'node:path';
13
+ import fs from 'node:fs';
13
14
  import os from 'node:os';
15
+ import { render } from 'ink';
16
+ import { createElement } from 'react';
14
17
  import { normalizePath } from '../utils/normalizePath.js';
18
+ import { PoolSpinner } from '../utils/PoolSpinner.js';
15
19
  import { autoDiscoverTargetsAndTests } from './discover.js';
16
20
  import { listChangedFiles } from './changed.js';
17
21
  import { loadMutineerConfig } from './config.js';
@@ -21,16 +25,19 @@ import { createLogger } from '../utils/logger.js';
21
25
  import { extractConfigPath, parseCliOptions } from './args.js';
22
26
  import { clearCacheOnStart, readMutantCache } from './cache.js';
23
27
  import { getTargetFile, enumerateAllVariants } from './variants.js';
28
+ import { generateSchema } from '../core/schemata.js';
29
+ import { getSchemaFilePath } from './shared/mutant-paths.js';
24
30
  import { resolveCoverageConfig, loadCoverageAfterBaseline, } from './coverage-resolver.js';
25
31
  import { prepareTasks } from './tasks.js';
26
32
  import { executePool } from './pool-executor.js';
33
+ import { checkTypes, resolveTypescriptEnabled, resolveTsconfigPath, } from './ts-checker.js';
27
34
  const log = createLogger('orchestrator');
28
35
  // Per-mutant test timeout (ms). Can be overridden with env MUTINEER_MUTANT_TIMEOUT_MS
29
- const MUTANT_TIMEOUT_MS = (() => {
30
- const raw = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
36
+ export function parseMutantTimeoutMs(raw) {
31
37
  const n = raw ? Number(raw) : NaN;
32
38
  return Number.isFinite(n) && n > 0 ? n : 30_000;
33
- })();
39
+ }
40
+ const MUTANT_TIMEOUT_MS = parseMutantTimeoutMs(process.env.MUTINEER_MUTANT_TIMEOUT_MS);
34
41
  // Re-export readMutantCache for external use
35
42
  export { readMutantCache } from './cache.js';
36
43
  export async function runOrchestrator(cliArgs, cwd) {
@@ -38,14 +45,17 @@ export async function runOrchestrator(cliArgs, cwd) {
38
45
  const cfgPath = extractConfigPath(cliArgs);
39
46
  const cfg = await loadMutineerConfig(cwd, cfgPath);
40
47
  const opts = parseCliOptions(cliArgs, cfg);
41
- await clearCacheOnStart(cwd);
48
+ await clearCacheOnStart(cwd, opts.shard);
42
49
  // Create test runner adapter
50
+ const vitestProject = opts.vitestProject ??
51
+ (typeof cfg.vitestProject === 'string' ? cfg.vitestProject : undefined);
43
52
  const adapter = (opts.runner === 'jest' ? createJestAdapter : createVitestAdapter)({
44
53
  cwd,
45
54
  concurrency: opts.concurrency,
46
55
  timeoutMs: opts.timeout ?? cfg.timeout ?? MUTANT_TIMEOUT_MS,
47
56
  config: cfg,
48
57
  cliArgs,
58
+ vitestProject,
49
59
  });
50
60
  // 2. Resolve coverage configuration
51
61
  const coverage = await resolveCoverageConfig(opts, cfg, adapter, cliArgs);
@@ -66,8 +76,9 @@ export async function runOrchestrator(cliArgs, cwd) {
66
76
  }))
67
77
  : null;
68
78
  // 4. Discover targets and tests
69
- const cache = await readMutantCache(cwd);
70
- const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
79
+ const cache = await readMutantCache(cwd, opts.shard);
80
+ log.info('Discovering tests...');
81
+ const discovered = await autoDiscoverTargetsAndTests(cwd, cfg, (msg) => log.info(msg));
71
82
  const { testMap, directTestMap } = discovered;
72
83
  const targets = cfg.targets?.length
73
84
  ? [...cfg.targets]
@@ -104,24 +115,30 @@ export async function runOrchestrator(cliArgs, cwd) {
104
115
  }
105
116
  }
106
117
  if (!baselineTests.length) {
107
- log.info('No tests found for targets. Exiting.');
118
+ log.error('No tests found for the selected targets. Ensure your source files are covered by at least one test file.');
119
+ process.exitCode = 1;
108
120
  return;
109
121
  }
110
122
  // 5. Run baseline tests (with coverage if needed for filtering)
111
- log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
112
- const baselineOk = await adapter.runBaseline(baselineTests, {
113
- collectCoverage: coverage.enableCoverageForBaseline,
114
- perTestCoverage: coverage.wantsPerTestCoverage,
115
- });
116
- if (!baselineOk) {
117
- process.exitCode = 1;
118
- return;
123
+ if (opts.skipBaseline) {
124
+ log.info('Skipping baseline tests (--skip-baseline)');
125
+ }
126
+ else {
127
+ log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
128
+ const baselineOk = await adapter.runBaseline(baselineTests, {
129
+ collectCoverage: coverage.enableCoverageForBaseline,
130
+ perTestCoverage: coverage.wantsPerTestCoverage,
131
+ });
132
+ if (!baselineOk) {
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ log.info('\u2713 Baseline tests complete');
119
137
  }
120
- log.info('\u2713 Baseline tests complete');
121
138
  // 6. Load coverage from baseline if we generated it
122
139
  const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
123
140
  // 7. Enumerate mutation variants
124
- const variants = await enumerateAllVariants({
141
+ let variants = await enumerateAllVariants({
125
142
  cwd,
126
143
  targets,
127
144
  testMap,
@@ -136,8 +153,83 @@ export async function runOrchestrator(cliArgs, cwd) {
136
153
  log.info(msg);
137
154
  return;
138
155
  }
139
- // 8. Prepare tasks and execute via worker pool
140
- const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
156
+ // Apply shard filter before type-checking so each shard only processes its own mutants
157
+ if (opts.shard) {
158
+ const { index, total } = opts.shard;
159
+ variants = variants.filter((_, i) => i % total === index - 1);
160
+ log.info(`Shard ${index}/${total}: scoped to ${variants.length} variant(s)`);
161
+ if (!variants.length) {
162
+ log.info('No mutants in this shard. Exiting.');
163
+ return;
164
+ }
165
+ }
166
+ // TypeScript pre-filtering (filter mutants that produce compile errors)
167
+ const tsEnabled = resolveTypescriptEnabled(opts.typescriptCheck, cfg, cwd);
168
+ let runnableVariants = variants;
169
+ if (tsEnabled) {
170
+ // Only return-value mutants change the expression type — operator mutants
171
+ // (equality, arithmetic, logical, etc.) always preserve the type.
172
+ const returnValueVariants = variants.filter((v) => v.name.startsWith('return'));
173
+ log.info(`Running TypeScript type checks on ${returnValueVariants.length} return-value mutant(s)...`);
174
+ const tsconfig = resolveTsconfigPath(cfg);
175
+ let tsSpinner = null;
176
+ if (process.stderr.isTTY) {
177
+ tsSpinner = render(createElement(PoolSpinner, {
178
+ message: `Type checking ${returnValueVariants.length} return-value mutant(s)...`,
179
+ }), { stdout: process.stderr, stderr: process.stderr });
180
+ }
181
+ let compileErrorIds;
182
+ try {
183
+ compileErrorIds = await checkTypes(returnValueVariants, tsconfig, cwd);
184
+ }
185
+ finally {
186
+ tsSpinner?.unmount();
187
+ }
188
+ if (compileErrorIds.size > 0) {
189
+ log.info(`\u2713 TypeScript: filtered ${compileErrorIds.size} mutant(s) with compile errors`);
190
+ runnableVariants = variants.filter((v) => !compileErrorIds.has(v.id));
191
+ // Pre-populate cache for compile-error mutants so they appear in summary
192
+ const compileErrorVariants = variants.filter((v) => compileErrorIds.has(v.id));
193
+ const compileErrorTasks = prepareTasks(compileErrorVariants, updatedCoverage.perTestCoverage, directTestMap);
194
+ for (const task of compileErrorTasks) {
195
+ cache[task.key] = {
196
+ status: 'compile-error',
197
+ file: task.v.file,
198
+ line: task.v.line,
199
+ col: task.v.col,
200
+ mutator: task.v.name,
201
+ };
202
+ }
203
+ }
204
+ }
205
+ // 9. Generate schema files for each source file
206
+ const fallbackIds = new Set();
207
+ const variantsByFile = new Map();
208
+ for (const v of runnableVariants) {
209
+ const arr = variantsByFile.get(v.file) ?? [];
210
+ arr.push(v);
211
+ variantsByFile.set(v.file, arr);
212
+ }
213
+ const results = await Promise.all([...variantsByFile.entries()].map(async ([file, fileVariants]) => {
214
+ try {
215
+ const originalCode = await fs.promises.readFile(file, 'utf8');
216
+ const schemaPath = getSchemaFilePath(file);
217
+ await fs.promises.mkdir(path.dirname(schemaPath), { recursive: true });
218
+ const { schemaCode, fallbackIds: fileFallbacks } = generateSchema(originalCode, fileVariants);
219
+ await fs.promises.writeFile(schemaPath, schemaCode, 'utf8');
220
+ return fileFallbacks;
221
+ }
222
+ catch {
223
+ return new Set(fileVariants.map((v) => v.id));
224
+ }
225
+ }));
226
+ for (const ids of results) {
227
+ for (const id of ids)
228
+ fallbackIds.add(id);
229
+ }
230
+ log.debug(`Schema: ${runnableVariants.length - fallbackIds.size} embedded, ${fallbackIds.size} fallback`);
231
+ // 10. Prepare tasks and execute via worker pool
232
+ let tasks = prepareTasks(runnableVariants, updatedCoverage.perTestCoverage, directTestMap);
141
233
  await executePool({
142
234
  tasks,
143
235
  adapter,
@@ -145,6 +237,9 @@ export async function runOrchestrator(cliArgs, cwd) {
145
237
  concurrency: opts.concurrency,
146
238
  progressMode: opts.progressMode,
147
239
  minKillPercent: opts.minKillPercent,
240
+ reportFormat: opts.reportFormat,
148
241
  cwd,
242
+ shard: opts.shard,
243
+ fallbackIds,
149
244
  });
150
245
  }
@@ -8,7 +8,14 @@ export interface PoolExecutionOptions {
8
8
  concurrency: number;
9
9
  progressMode: 'bar' | 'list' | 'quiet';
10
10
  minKillPercent?: number;
11
+ reportFormat?: 'text' | 'json';
11
12
  cwd: string;
13
+ shard?: {
14
+ index: number;
15
+ total: number;
16
+ };
17
+ /** IDs of variants that must use the legacy redirect path (overlapping diff ranges). */
18
+ fallbackIds?: Set<string>;
12
19
  }
13
20
  /**
14
21
  * Execute all mutant tasks through the worker pool.
@@ -1,11 +1,13 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { render } from 'ink';
3
4
  import { createElement } from 'react';
4
5
  import { Progress } from '../utils/progress.js';
5
- import { computeSummary, printSummary } from '../utils/summary.js';
6
+ import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary.js';
6
7
  import { saveCacheAtomic } from './cache.js';
7
8
  import { cleanupMutineerDirs } from './cleanup.js';
8
9
  import { PoolSpinner } from '../utils/PoolSpinner.js';
10
+ import { CompileErrors } from '../utils/CompileErrors.js';
9
11
  import { createLogger } from '../utils/logger.js';
10
12
  const log = createLogger('pool-executor');
11
13
  /**
@@ -22,14 +24,33 @@ export async function executePool(opts) {
22
24
  const mutationStartTime = Date.now();
23
25
  // Ensure we only finish once
24
26
  let finished = false;
25
- const finishOnce = () => {
27
+ const finishOnce = async (interactive = true) => {
26
28
  if (finished)
27
29
  return;
28
30
  finished = true;
29
31
  const durationMs = Date.now() - mutationStartTime;
30
32
  progress.finish();
31
33
  const summary = computeSummary(cache);
32
- printSummary(summary, cache, durationMs);
34
+ if (opts.reportFormat === 'json') {
35
+ const report = buildJsonReport(summary, cache, durationMs);
36
+ const shardSuffix = opts.shard
37
+ ? `-shard-${opts.shard.index}-of-${opts.shard.total}`
38
+ : '';
39
+ const outPath = path.join(opts.cwd, `mutineer-report${shardSuffix}.json`);
40
+ fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
41
+ log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
42
+ }
43
+ else {
44
+ const compileErrorEntries = Object.values(cache).filter((e) => e.status === 'compile-error');
45
+ const useInteractive = interactive && process.stdout.isTTY && compileErrorEntries.length > 0;
46
+ printSummary(summary, cache, durationMs, {
47
+ skipCompileErrors: useInteractive,
48
+ });
49
+ if (useInteractive) {
50
+ const { waitUntilExit } = render(createElement(CompileErrors, { entries: compileErrorEntries, cwd }));
51
+ await waitUntilExit();
52
+ }
53
+ }
33
54
  if (opts.minKillPercent !== undefined) {
34
55
  const killRateString = summary.killRate.toFixed(2);
35
56
  const thresholdString = opts.minKillPercent.toFixed(2);
@@ -68,7 +89,7 @@ export async function executePool(opts) {
68
89
  let nextIdx = 0;
69
90
  async function processTask(task) {
70
91
  const { v, tests, key, directTests } = task;
71
- log.debug('Cache ' + JSON.stringify(cache));
92
+ const { fallbackIds } = opts;
72
93
  const cached = cache[key];
73
94
  if (cached) {
74
95
  progress.update(cached.status);
@@ -93,6 +114,7 @@ export async function executePool(opts) {
93
114
  code: v.code,
94
115
  line: v.line,
95
116
  col: v.col,
117
+ isFallback: !fallbackIds || fallbackIds.has(v.id),
96
118
  }, tests);
97
119
  const status = result.status;
98
120
  let originalSnippet;
@@ -148,7 +170,7 @@ export async function executePool(opts) {
148
170
  return;
149
171
  signalCleanedUp = true;
150
172
  log.info(`\nReceived ${signal}, cleaning up...`);
151
- finishOnce();
173
+ await finishOnce(false);
152
174
  await adapter.shutdown();
153
175
  await cleanupMutineerDirs(cwd);
154
176
  process.exit(1);
@@ -159,13 +181,13 @@ export async function executePool(opts) {
159
181
  for (let i = 0; i < workerCount; i++)
160
182
  workers.push(worker());
161
183
  await Promise.all(workers);
162
- await saveCacheAtomic(cwd, cache);
184
+ await saveCacheAtomic(cwd, cache, opts.shard);
163
185
  }
164
186
  finally {
165
187
  process.removeAllListeners('SIGINT');
166
188
  process.removeAllListeners('SIGTERM');
167
189
  if (!signalCleanedUp) {
168
- finishOnce();
190
+ await finishOnce();
169
191
  await adapter.shutdown();
170
192
  await cleanupMutineerDirs(cwd);
171
193
  }
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
- import { getMutantFilePath } from '../mutant-paths.js';
5
+ import { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from '../mutant-paths.js';
6
6
  let createdDirs = [];
7
7
  afterEach(() => {
8
8
  // Clean up any __mutineer__ directories we created
@@ -64,3 +64,32 @@ describe('getMutantFilePath', () => {
64
64
  expect(result).toBe(path.join(mutineerDir, 'foo_1.ts'));
65
65
  });
66
66
  });
67
+ describe('getSchemaFilePath', () => {
68
+ it('returns path with _schema suffix and correct extension', () => {
69
+ const result = getSchemaFilePath('/src/foo.ts');
70
+ expect(result).toBe('/src/__mutineer__/foo_schema.ts');
71
+ });
72
+ it('preserves the original extension', () => {
73
+ const result = getSchemaFilePath('/src/component.vue');
74
+ expect(result).toBe('/src/__mutineer__/component_schema.vue');
75
+ });
76
+ it('does not create the __mutineer__ directory', () => {
77
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mutineer-schema-'));
78
+ createdDirs.push(tmpDir);
79
+ const srcFile = path.join(tmpDir, 'bar.ts');
80
+ const mutineerDir = path.join(tmpDir, '__mutineer__');
81
+ getSchemaFilePath(srcFile);
82
+ expect(fs.existsSync(mutineerDir)).toBe(false);
83
+ });
84
+ });
85
+ describe('getActiveIdFilePath', () => {
86
+ it('returns path under cwd/__mutineer__ keyed by workerId', () => {
87
+ const result = getActiveIdFilePath('w0', '/project');
88
+ expect(result).toBe('/project/__mutineer__/active_id_w0.txt');
89
+ });
90
+ it('produces distinct paths for different worker IDs', () => {
91
+ const a = getActiveIdFilePath('w0', '/cwd');
92
+ const b = getActiveIdFilePath('w1', '/cwd');
93
+ expect(a).not.toBe(b);
94
+ });
95
+ });
@@ -4,6 +4,6 @@
4
4
  * This module provides common functionality used by both Jest and Vitest adapters,
5
5
  * including mutant file path generation and redirect state management.
6
6
  */
7
- export { getMutantFilePath } from './mutant-paths.js';
7
+ export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
8
8
  export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';
9
9
  export type { RedirectConfig } from './redirect-state.js';
@@ -4,5 +4,5 @@
4
4
  * This module provides common functionality used by both Jest and Vitest adapters,
5
5
  * including mutant file path generation and redirect state management.
6
6
  */
7
- export { getMutantFilePath } from './mutant-paths.js';
7
+ export { getMutantFilePath, getSchemaFilePath, getActiveIdFilePath, } from './mutant-paths.js';
8
8
  export { setRedirect, getRedirect, clearRedirect, initialiseRedirectState, } from './redirect-state.js';