@mutineerjs/mutineer 0.6.0 → 0.7.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 (36) hide show
  1. package/dist/bin/mutineer.d.ts +1 -1
  2. package/dist/bin/mutineer.js +3 -1
  3. package/dist/mutators/__tests__/operator.spec.js +97 -1
  4. package/dist/mutators/__tests__/registry.spec.js +8 -0
  5. package/dist/mutators/operator.d.ts +8 -0
  6. package/dist/mutators/operator.js +58 -1
  7. package/dist/mutators/registry.js +9 -1
  8. package/dist/mutators/utils.d.ts +2 -0
  9. package/dist/mutators/utils.js +58 -1
  10. package/dist/runner/__tests__/args.spec.js +57 -1
  11. package/dist/runner/__tests__/cache.spec.js +65 -8
  12. package/dist/runner/__tests__/cleanup.spec.js +30 -0
  13. package/dist/runner/__tests__/coverage-resolver.spec.js +2 -0
  14. package/dist/runner/__tests__/discover.spec.js +128 -0
  15. package/dist/runner/__tests__/orchestrator.spec.js +167 -2
  16. package/dist/runner/__tests__/pool-executor.spec.js +60 -1
  17. package/dist/runner/args.d.ts +13 -0
  18. package/dist/runner/args.js +27 -0
  19. package/dist/runner/cache.d.ts +19 -3
  20. package/dist/runner/cache.js +14 -7
  21. package/dist/runner/cleanup.d.ts +3 -1
  22. package/dist/runner/cleanup.js +18 -1
  23. package/dist/runner/coverage-resolver.js +1 -1
  24. package/dist/runner/discover.d.ts +1 -1
  25. package/dist/runner/discover.js +30 -20
  26. package/dist/runner/orchestrator.d.ts +1 -0
  27. package/dist/runner/orchestrator.js +22 -8
  28. package/dist/runner/pool-executor.d.ts +5 -0
  29. package/dist/runner/pool-executor.js +15 -4
  30. package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
  31. package/dist/runner/vitest/adapter.js +13 -9
  32. package/dist/types/config.d.ts +2 -0
  33. package/dist/utils/__tests__/summary.spec.js +43 -1
  34. package/dist/utils/summary.d.ts +18 -0
  35. package/dist/utils/summary.js +25 -0
  36. package/package.json +2 -1
@@ -114,6 +114,134 @@ describe('autoDiscoverTargetsAndTests', () => {
114
114
  await fs.rm(tmpDir, { recursive: true, force: true });
115
115
  }
116
116
  });
117
+ it('calls onProgress at least twice with informational messages', async () => {
118
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-progress-'));
119
+ const srcDir = path.join(tmpDir, 'src');
120
+ const moduleFile = path.join(srcDir, 'foo.ts');
121
+ const testFile = path.join(srcDir, 'foo.test.ts');
122
+ await fs.mkdir(srcDir, { recursive: true });
123
+ await fs.writeFile(moduleFile, 'export const foo = 1\n', 'utf8');
124
+ const importLine = ['im', 'port { foo } from "./foo"'].join('');
125
+ await fs.writeFile(testFile, `${importLine}\nconsole.log(foo)\n`, 'utf8');
126
+ const messages = [];
127
+ try {
128
+ await autoDiscoverTargetsAndTests(tmpDir, { testPatterns: ['**/*.test.ts'] }, (msg) => messages.push(msg));
129
+ expect(messages.length).toBeGreaterThanOrEqual(2);
130
+ expect(messages.some((m) => m.includes('test file'))).toBe(true);
131
+ expect(messages.some((m) => m.includes('Discovery complete'))).toBe(true);
132
+ }
133
+ finally {
134
+ await fs.rm(tmpDir, { recursive: true, force: true });
135
+ }
136
+ });
137
+ it('shared dep imported by 2 tests appears in testMap for both', async () => {
138
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-shared-'));
139
+ const srcDir = path.join(tmpDir, 'src');
140
+ const sharedDep = path.join(srcDir, 'shared.ts');
141
+ const test1 = path.join(srcDir, 'a.test.ts');
142
+ const test2 = path.join(srcDir, 'b.test.ts');
143
+ await fs.mkdir(srcDir, { recursive: true });
144
+ await fs.writeFile(sharedDep, 'export const shared = 1\n', 'utf8');
145
+ const importShared = ['im', 'port { shared } from "./shared"'].join('');
146
+ await fs.writeFile(test1, `${importShared}\n`, 'utf8');
147
+ await fs.writeFile(test2, `${importShared}\n`, 'utf8');
148
+ try {
149
+ const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
150
+ testPatterns: ['**/*.test.ts'],
151
+ });
152
+ const sharedAbs = normalizePath(sharedDep);
153
+ const test1Abs = normalizePath(test1);
154
+ const test2Abs = normalizePath(test2);
155
+ expect(testMap.get(sharedAbs)?.has(test1Abs)).toBe(true);
156
+ expect(testMap.get(sharedAbs)?.has(test2Abs)).toBe(true);
157
+ }
158
+ finally {
159
+ await fs.rm(tmpDir, { recursive: true, force: true });
160
+ }
161
+ });
162
+ it('diamond graph: shared grandchild discovered with no duplicates', async () => {
163
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-diamond-'));
164
+ const srcDir = path.join(tmpDir, 'src');
165
+ const fileA = path.join(srcDir, 'A.ts');
166
+ const fileB = path.join(srcDir, 'B.ts');
167
+ const fileC = path.join(srcDir, 'C.ts');
168
+ const fileD = path.join(srcDir, 'D.ts');
169
+ const testFile = path.join(srcDir, 'test.test.ts');
170
+ await fs.mkdir(srcDir, { recursive: true });
171
+ await fs.writeFile(fileD, 'export const d = 4\n', 'utf8');
172
+ const importD = ['im', 'port { d } from "./D"'].join('');
173
+ await fs.writeFile(fileB, `${importD}\nexport const b = 2\n`, 'utf8');
174
+ await fs.writeFile(fileC, `${importD}\nexport const c = 3\n`, 'utf8');
175
+ const importB = ['im', 'port { b } from "./B"'].join('');
176
+ const importC = ['im', 'port { c } from "./C"'].join('');
177
+ await fs.writeFile(fileA, `${importB}\n${importC}\nexport const a = 1\n`, 'utf8');
178
+ const importA = ['im', 'port { a } from "./A"'].join('');
179
+ await fs.writeFile(testFile, `${importA}\n`, 'utf8');
180
+ try {
181
+ const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
182
+ testPatterns: ['**/*.test.ts'],
183
+ });
184
+ const dAbs = normalizePath(fileD);
185
+ const testAbs = normalizePath(testFile);
186
+ expect(testMap.get(dAbs)?.has(testAbs)).toBe(true);
187
+ // D should only be in testMap once (Set ensures no duplicates)
188
+ expect(testMap.get(dAbs)?.size).toBe(1);
189
+ }
190
+ finally {
191
+ await fs.rm(tmpDir, { recursive: true, force: true });
192
+ }
193
+ });
194
+ it('deep chain: deepest file is discovered correctly', async () => {
195
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-deep-'));
196
+ const srcDir = path.join(tmpDir, 'src');
197
+ await fs.mkdir(srcDir, { recursive: true });
198
+ // chain: test -> f1 -> f2 -> f3 -> f4 -> f5
199
+ const files = Array.from({ length: 5 }, (_, i) => path.join(srcDir, `f${i + 1}.ts`));
200
+ const testFile = path.join(srcDir, 'chain.test.ts');
201
+ await fs.writeFile(files[4], 'export const f5 = 5\n', 'utf8');
202
+ for (let i = 3; i >= 0; i--) {
203
+ const importNext = ['im', `port { f${i + 2} } from "./f${i + 2}"`].join('');
204
+ await fs.writeFile(files[i], `${importNext}\nexport const f${i + 1} = ${i + 1}\n`, 'utf8');
205
+ }
206
+ const importF1 = ['im', 'port { f1 } from "./f1"'].join('');
207
+ await fs.writeFile(testFile, `${importF1}\n`, 'utf8');
208
+ try {
209
+ const { testMap } = await autoDiscoverTargetsAndTests(tmpDir, {
210
+ testPatterns: ['**/*.test.ts'],
211
+ });
212
+ const f5Abs = normalizePath(files[4]);
213
+ const testAbs = normalizePath(testFile);
214
+ expect(testMap.get(f5Abs)?.has(testAbs)).toBe(true);
215
+ }
216
+ finally {
217
+ await fs.rm(tmpDir, { recursive: true, force: true });
218
+ }
219
+ });
220
+ it('2 tests directly importing same file both appear in directTestMap', async () => {
221
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct2-'));
222
+ const srcDir = path.join(tmpDir, 'src');
223
+ const sharedDep = path.join(srcDir, 'shared.ts');
224
+ const test1 = path.join(srcDir, 'a.test.ts');
225
+ const test2 = path.join(srcDir, 'b.test.ts');
226
+ await fs.mkdir(srcDir, { recursive: true });
227
+ await fs.writeFile(sharedDep, 'export const shared = 1\n', 'utf8');
228
+ const importShared = ['im', 'port { shared } from "./shared"'].join('');
229
+ await fs.writeFile(test1, `${importShared}\n`, 'utf8');
230
+ await fs.writeFile(test2, `${importShared}\n`, 'utf8');
231
+ try {
232
+ const { directTestMap } = await autoDiscoverTargetsAndTests(tmpDir, {
233
+ testPatterns: ['**/*.test.ts'],
234
+ });
235
+ const sharedAbs = normalizePath(sharedDep);
236
+ const test1Abs = normalizePath(test1);
237
+ const test2Abs = normalizePath(test2);
238
+ expect(directTestMap.get(sharedAbs)?.has(test1Abs)).toBe(true);
239
+ expect(directTestMap.get(sharedAbs)?.has(test2Abs)).toBe(true);
240
+ }
241
+ finally {
242
+ await fs.rm(tmpDir, { recursive: true, force: true });
243
+ }
244
+ });
117
245
  it('ignores test files when collecting mutate targets', async () => {
118
246
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
119
247
  const srcDir = path.join(tmpDir, 'src');
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  // Mock all heavy dependencies before importing orchestrator
3
3
  vi.mock('../config.js', () => ({
4
4
  loadMutineerConfig: vi.fn(),
@@ -34,11 +34,26 @@ vi.mock('../discover.js', () => ({
34
34
  vi.mock('../changed.js', () => ({
35
35
  listChangedFiles: vi.fn().mockReturnValue([]),
36
36
  }));
37
- import { runOrchestrator } from '../orchestrator.js';
37
+ vi.mock('../pool-executor.js', () => ({
38
+ executePool: vi.fn().mockResolvedValue(undefined),
39
+ }));
40
+ vi.mock('../variants.js', () => ({
41
+ enumerateAllVariants: vi.fn().mockResolvedValue([]),
42
+ getTargetFile: vi
43
+ .fn()
44
+ .mockImplementation((t) => typeof t === 'string' ? t : t.file),
45
+ }));
46
+ vi.mock('../tasks.js', () => ({
47
+ prepareTasks: vi.fn().mockReturnValue([]),
48
+ }));
49
+ import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
38
50
  import { loadMutineerConfig } from '../config.js';
39
51
  import { createVitestAdapter } from '../vitest/index.js';
40
52
  import { autoDiscoverTargetsAndTests } from '../discover.js';
41
53
  import { listChangedFiles } from '../changed.js';
54
+ import { executePool } from '../pool-executor.js';
55
+ import { prepareTasks } from '../tasks.js';
56
+ import { enumerateAllVariants } from '../variants.js';
42
57
  const mockAdapter = {
43
58
  name: 'vitest',
44
59
  init: vi.fn().mockResolvedValue(undefined),
@@ -54,6 +69,95 @@ beforeEach(() => {
54
69
  vi.clearAllMocks();
55
70
  vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
56
71
  });
72
+ describe('parseMutantTimeoutMs', () => {
73
+ it('returns the parsed value for a valid positive number', () => {
74
+ expect(parseMutantTimeoutMs('5000')).toBe(5000);
75
+ });
76
+ it('returns 30_000 for undefined', () => {
77
+ expect(parseMutantTimeoutMs(undefined)).toBe(30_000);
78
+ });
79
+ it('returns 30_000 for zero (kills tightenGT: n>=0 would return 0)', () => {
80
+ expect(parseMutantTimeoutMs('0')).toBe(30_000);
81
+ });
82
+ it('returns 30_000 for Infinity (kills andToOr: || would return Infinity)', () => {
83
+ expect(parseMutantTimeoutMs('Infinity')).toBe(30_000);
84
+ });
85
+ it('returns 30_000 for negative values', () => {
86
+ expect(parseMutantTimeoutMs('-1')).toBe(30_000);
87
+ });
88
+ it('returns 30_000 for non-numeric strings', () => {
89
+ expect(parseMutantTimeoutMs('abc')).toBe(30_000);
90
+ });
91
+ });
92
+ describe('runOrchestrator no tests found', () => {
93
+ afterEach(() => {
94
+ process.exitCode = undefined;
95
+ });
96
+ it('sets exitCode=1 when no tests are found for targets', async () => {
97
+ process.exitCode = undefined;
98
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
99
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
100
+ targets: [],
101
+ testMap: new Map(),
102
+ directTestMap: new Map(),
103
+ });
104
+ await runOrchestrator([], '/cwd');
105
+ expect(process.exitCode).toBe(1);
106
+ });
107
+ it('logs error message when no tests are found for targets', async () => {
108
+ process.exitCode = undefined;
109
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
110
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
111
+ targets: [],
112
+ testMap: new Map(),
113
+ directTestMap: new Map(),
114
+ });
115
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
116
+ await runOrchestrator([], '/cwd');
117
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No tests found for the selected targets'));
118
+ });
119
+ it('does not run baseline when no tests are found', async () => {
120
+ process.exitCode = undefined;
121
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
122
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
123
+ targets: [],
124
+ testMap: new Map(),
125
+ directTestMap: new Map(),
126
+ });
127
+ await runOrchestrator([], '/cwd');
128
+ expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
129
+ });
130
+ });
131
+ describe('runOrchestrator discovery logging', () => {
132
+ afterEach(() => {
133
+ process.exitCode = undefined;
134
+ });
135
+ it('logs "Discovering tests..." before calling autoDiscoverTargetsAndTests', async () => {
136
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
137
+ const consoleSpy = vi.spyOn(console, 'log');
138
+ await runOrchestrator([], '/cwd');
139
+ const calls = consoleSpy.mock.calls.map((c) => c[0]);
140
+ const discoveringIdx = calls.findIndex((m) => m === 'Discovering tests...');
141
+ expect(discoveringIdx).toBeGreaterThanOrEqual(0);
142
+ });
143
+ it('passes an onProgress callback to autoDiscoverTargetsAndTests', async () => {
144
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
145
+ await runOrchestrator([], '/cwd');
146
+ const [, , onProgress] = vi.mocked(autoDiscoverTargetsAndTests).mock
147
+ .calls[0];
148
+ expect(typeof onProgress).toBe('function');
149
+ });
150
+ it('logs progress messages emitted by autoDiscoverTargetsAndTests', async () => {
151
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
152
+ vi.mocked(autoDiscoverTargetsAndTests).mockImplementationOnce(async (_root, _cfg, onProgress) => {
153
+ onProgress?.('Discovery complete: 3 source file(s), 2 test file(s)');
154
+ return { targets: [], testMap: new Map(), directTestMap: new Map() };
155
+ });
156
+ const consoleSpy = vi.spyOn(console, 'log');
157
+ await runOrchestrator([], '/cwd');
158
+ expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
159
+ });
160
+ });
57
161
  describe('runOrchestrator --changed-with-deps diagnostic', () => {
58
162
  it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
59
163
  const cfg = {};
@@ -139,3 +243,64 @@ describe('runOrchestrator timeout precedence', () => {
139
243
  expect(Number.isFinite(call.timeoutMs)).toBe(true);
140
244
  });
141
245
  });
246
+ describe('runOrchestrator shard filtering', () => {
247
+ const targetFile = '/cwd/src/foo.ts';
248
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
249
+ function makeTask(key) {
250
+ return {
251
+ key,
252
+ v: {
253
+ id: `${key}`,
254
+ name: 'flipEQ',
255
+ file: targetFile,
256
+ code: '',
257
+ line: 1,
258
+ col: 0,
259
+ tests: [testFile],
260
+ },
261
+ tests: [testFile],
262
+ };
263
+ }
264
+ beforeEach(() => {
265
+ vi.clearAllMocks();
266
+ process.exitCode = undefined;
267
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
268
+ vi.mocked(loadMutineerConfig).mockResolvedValue({
269
+ targets: [targetFile],
270
+ });
271
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
272
+ targets: [targetFile],
273
+ testMap: new Map([[targetFile, new Set([testFile])]]),
274
+ directTestMap: new Map(),
275
+ });
276
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
277
+ // Return a non-empty variants array so orchestrator doesn't exit early
278
+ vi.mocked(enumerateAllVariants).mockResolvedValue([{}]);
279
+ const tasks = ['k0', 'k1', 'k2', 'k3'].map(makeTask);
280
+ vi.mocked(prepareTasks).mockReturnValue(tasks);
281
+ });
282
+ afterEach(() => {
283
+ process.exitCode = undefined;
284
+ });
285
+ it('shard 1/2 assigns even-indexed tasks', async () => {
286
+ await runOrchestrator(['--shard', '1/2'], '/cwd');
287
+ const call = vi.mocked(executePool).mock.calls[0][0];
288
+ expect(call.tasks.map((t) => t.key)).toEqual(['k0', 'k2']);
289
+ });
290
+ it('shard 2/2 assigns odd-indexed tasks', async () => {
291
+ await runOrchestrator(['--shard', '2/2'], '/cwd');
292
+ const call = vi.mocked(executePool).mock.calls[0][0];
293
+ expect(call.tasks.map((t) => t.key)).toEqual(['k1', 'k3']);
294
+ });
295
+ it('does not call executePool when shard has no tasks', async () => {
296
+ // Only 1 task total; shard 2/2 gets nothing
297
+ vi.mocked(prepareTasks).mockReturnValue([makeTask('only')]);
298
+ await runOrchestrator(['--shard', '2/2'], '/cwd');
299
+ expect(executePool).not.toHaveBeenCalled();
300
+ });
301
+ it('propagates shard to executePool', async () => {
302
+ await runOrchestrator(['--shard', '1/4'], '/cwd');
303
+ const call = vi.mocked(executePool).mock.calls[0][0];
304
+ expect(call.shard).toEqual({ index: 1, total: 4 });
305
+ });
306
+ });
@@ -189,10 +189,47 @@ describe('executePool', () => {
189
189
  progressMode: 'list',
190
190
  cwd: tmpDir,
191
191
  });
192
- const cacheFile = path.join(tmpDir, '.mutate-cache.json');
192
+ const cacheFile = path.join(tmpDir, '.mutineer-cache.json');
193
193
  const content = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
194
194
  expect(content['persist-key']).toBeDefined();
195
195
  });
196
+ it('saves to shard-named cache file when shard is provided', async () => {
197
+ const adapter = makeAdapter();
198
+ const cache = {};
199
+ await executePool({
200
+ tasks: [makeTask({ key: 'shard-key' })],
201
+ adapter,
202
+ cache,
203
+ concurrency: 1,
204
+ progressMode: 'list',
205
+ cwd: tmpDir,
206
+ shard: { index: 2, total: 4 },
207
+ });
208
+ const shardFile = path.join(tmpDir, '.mutineer-cache-shard-2-of-4.json');
209
+ const content = JSON.parse(await fs.readFile(shardFile, 'utf8'));
210
+ expect(content['shard-key']).toBeDefined();
211
+ // default file should NOT exist
212
+ await expect(fs.access(path.join(tmpDir, '.mutineer-cache.json'))).rejects.toThrow();
213
+ });
214
+ it('writes shard-suffixed JSON report when shard and reportFormat=json are set', async () => {
215
+ const adapter = makeAdapter();
216
+ const cache = {};
217
+ await executePool({
218
+ tasks: [makeTask({ key: 'shard-report-key' })],
219
+ adapter,
220
+ cache,
221
+ concurrency: 1,
222
+ progressMode: 'list',
223
+ cwd: tmpDir,
224
+ reportFormat: 'json',
225
+ shard: { index: 1, total: 2 },
226
+ });
227
+ const reportFile = path.join(tmpDir, 'mutineer-report-shard-1-of-2.json');
228
+ const content = JSON.parse(await fs.readFile(reportFile, 'utf8'));
229
+ expect(content.schemaVersion).toBe(1);
230
+ // default report should NOT exist
231
+ await expect(fs.access(path.join(tmpDir, 'mutineer-report.json'))).rejects.toThrow();
232
+ });
196
233
  it('escaped mutant stores snippets when lines differ', async () => {
197
234
  const tmpFile = path.join(tmpDir, 'source.ts');
198
235
  await fs.writeFile(tmpFile, 'const x = a + b\n');
@@ -410,6 +447,28 @@ describe('executePool', () => {
410
447
  expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
411
448
  expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
412
449
  });
450
+ it('does not eagerly JSON.stringify cache on every task (only once for save)', async () => {
451
+ const adapter = makeAdapter();
452
+ const cache = {};
453
+ const stringifySpy = vi.spyOn(JSON, 'stringify');
454
+ // 3 tasks — if stringify(cache) ran per-task it would be called 3 times with cache
455
+ await executePool({
456
+ tasks: [
457
+ makeTask({ key: 'k1' }),
458
+ makeTask({ key: 'k2' }),
459
+ makeTask({ key: 'k3' }),
460
+ ],
461
+ adapter,
462
+ cache,
463
+ concurrency: 1,
464
+ progressMode: 'list',
465
+ cwd: tmpDir,
466
+ });
467
+ // saveCacheAtomic calls stringify(cache) exactly once; per-task eager call was removed
468
+ const cacheStringifyCalls = stringifySpy.mock.calls.filter((args) => args[0] === cache);
469
+ expect(cacheStringifyCalls.length).toBe(1);
470
+ stringifySpy.mockRestore();
471
+ });
413
472
  it('handles adapter errors gracefully and still shuts down', async () => {
414
473
  const adapter = makeAdapter({
415
474
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -20,6 +20,11 @@ export interface ParsedCliOptions {
20
20
  readonly minKillPercent: number | undefined;
21
21
  readonly runner: 'vitest' | 'jest';
22
22
  readonly timeout: number | undefined;
23
+ readonly reportFormat: 'text' | 'json';
24
+ readonly shard: {
25
+ index: number;
26
+ total: number;
27
+ } | undefined;
23
28
  }
24
29
  /**
25
30
  * Parse a numeric CLI flag value.
@@ -54,6 +59,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
54
59
  * Handles --config=path, -c=path, --config path, and -c path.
55
60
  */
56
61
  export declare function extractConfigPath(args: readonly string[]): string | undefined;
62
+ /**
63
+ * Parse the --shard <n>/<total> option.
64
+ * Throws on bad format or out-of-range values.
65
+ */
66
+ export declare function parseShardOption(args: readonly string[]): {
67
+ index: number;
68
+ total: number;
69
+ } | undefined;
57
70
  /**
58
71
  * Parse all CLI options.
59
72
  */
@@ -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,9 @@ 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);
130
155
  return {
131
156
  configPath,
132
157
  wantsChanged,
@@ -139,5 +164,7 @@ export function parseCliOptions(args, cfg) {
139
164
  minKillPercent,
140
165
  runner,
141
166
  timeout,
167
+ reportFormat,
168
+ shard,
142
169
  };
143
170
  }
@@ -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,7 +2,7 @@ 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
7
  const dirs = await glob.default('**/__mutineer__', {
8
8
  cwd,
@@ -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>;