@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
@@ -1,4 +1,14 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ const { mockLogDebug } = vi.hoisted(() => ({ mockLogDebug: vi.fn() }));
3
+ vi.mock('../../utils/logger.js', () => ({
4
+ createLogger: () => ({
5
+ debug: mockLogDebug,
6
+ info: (...args) => console.log(...args),
7
+ warn: (...args) => console.warn(...args),
8
+ error: (...args) => console.error(...args),
9
+ }),
10
+ DEBUG: true,
11
+ }));
2
12
  // Mock all heavy dependencies before importing orchestrator
3
13
  vi.mock('../config.js', () => ({
4
14
  loadMutineerConfig: vi.fn(),
@@ -34,11 +44,43 @@ vi.mock('../discover.js', () => ({
34
44
  vi.mock('../changed.js', () => ({
35
45
  listChangedFiles: vi.fn().mockReturnValue([]),
36
46
  }));
37
- import { runOrchestrator } from '../orchestrator.js';
47
+ vi.mock('../pool-executor.js', () => ({
48
+ executePool: vi.fn().mockResolvedValue(undefined),
49
+ }));
50
+ vi.mock('../variants.js', () => ({
51
+ enumerateAllVariants: vi.fn().mockResolvedValue([]),
52
+ getTargetFile: vi
53
+ .fn()
54
+ .mockImplementation((t) => typeof t === 'string' ? t : t.file),
55
+ }));
56
+ vi.mock('../tasks.js', () => ({
57
+ prepareTasks: vi.fn().mockReturnValue([]),
58
+ }));
59
+ vi.mock('../ts-checker.js', () => ({
60
+ checkTypes: vi.fn().mockResolvedValue(new Set()),
61
+ resolveTypescriptEnabled: vi.fn().mockReturnValue(false),
62
+ resolveTsconfigPath: vi.fn().mockReturnValue(undefined),
63
+ }));
64
+ vi.mock('../../core/schemata.js', () => ({
65
+ generateSchema: vi
66
+ .fn()
67
+ .mockReturnValue({
68
+ schemaCode: '// @ts-nocheck\n',
69
+ fallbackIds: new Set(),
70
+ }),
71
+ }));
72
+ import { runOrchestrator, parseMutantTimeoutMs } from '../orchestrator.js';
38
73
  import { loadMutineerConfig } from '../config.js';
39
74
  import { createVitestAdapter } from '../vitest/index.js';
40
75
  import { autoDiscoverTargetsAndTests } from '../discover.js';
41
76
  import { listChangedFiles } from '../changed.js';
77
+ import { executePool } from '../pool-executor.js';
78
+ import { prepareTasks } from '../tasks.js';
79
+ import { enumerateAllVariants } from '../variants.js';
80
+ import { generateSchema } from '../../core/schemata.js';
81
+ import os from 'node:os';
82
+ import fssync from 'node:fs';
83
+ import path from 'node:path';
42
84
  const mockAdapter = {
43
85
  name: 'vitest',
44
86
  init: vi.fn().mockResolvedValue(undefined),
@@ -54,6 +96,95 @@ beforeEach(() => {
54
96
  vi.clearAllMocks();
55
97
  vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
56
98
  });
99
+ describe('parseMutantTimeoutMs', () => {
100
+ it('returns the parsed value for a valid positive number', () => {
101
+ expect(parseMutantTimeoutMs('5000')).toBe(5000);
102
+ });
103
+ it('returns 30_000 for undefined', () => {
104
+ expect(parseMutantTimeoutMs(undefined)).toBe(30_000);
105
+ });
106
+ it('returns 30_000 for zero (kills tightenGT: n>=0 would return 0)', () => {
107
+ expect(parseMutantTimeoutMs('0')).toBe(30_000);
108
+ });
109
+ it('returns 30_000 for Infinity (kills andToOr: || would return Infinity)', () => {
110
+ expect(parseMutantTimeoutMs('Infinity')).toBe(30_000);
111
+ });
112
+ it('returns 30_000 for negative values', () => {
113
+ expect(parseMutantTimeoutMs('-1')).toBe(30_000);
114
+ });
115
+ it('returns 30_000 for non-numeric strings', () => {
116
+ expect(parseMutantTimeoutMs('abc')).toBe(30_000);
117
+ });
118
+ });
119
+ describe('runOrchestrator no tests found', () => {
120
+ afterEach(() => {
121
+ process.exitCode = undefined;
122
+ });
123
+ it('sets exitCode=1 when no tests are found for targets', async () => {
124
+ process.exitCode = undefined;
125
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
126
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
127
+ targets: [],
128
+ testMap: new Map(),
129
+ directTestMap: new Map(),
130
+ });
131
+ await runOrchestrator([], '/cwd');
132
+ expect(process.exitCode).toBe(1);
133
+ });
134
+ it('logs error message when no tests are found for targets', async () => {
135
+ process.exitCode = undefined;
136
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
137
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
138
+ targets: [],
139
+ testMap: new Map(),
140
+ directTestMap: new Map(),
141
+ });
142
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
143
+ await runOrchestrator([], '/cwd');
144
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('No tests found for the selected targets'));
145
+ });
146
+ it('does not run baseline when no tests are found', async () => {
147
+ process.exitCode = undefined;
148
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
149
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
150
+ targets: [],
151
+ testMap: new Map(),
152
+ directTestMap: new Map(),
153
+ });
154
+ await runOrchestrator([], '/cwd');
155
+ expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
156
+ });
157
+ });
158
+ describe('runOrchestrator discovery logging', () => {
159
+ afterEach(() => {
160
+ process.exitCode = undefined;
161
+ });
162
+ it('logs "Discovering tests..." before calling autoDiscoverTargetsAndTests', async () => {
163
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
164
+ const consoleSpy = vi.spyOn(console, 'log');
165
+ await runOrchestrator([], '/cwd');
166
+ const calls = consoleSpy.mock.calls.map((c) => c[0]);
167
+ const discoveringIdx = calls.findIndex((m) => m === 'Discovering tests...');
168
+ expect(discoveringIdx).toBeGreaterThanOrEqual(0);
169
+ });
170
+ it('passes an onProgress callback to autoDiscoverTargetsAndTests', async () => {
171
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
172
+ await runOrchestrator([], '/cwd');
173
+ const [, , onProgress] = vi.mocked(autoDiscoverTargetsAndTests).mock
174
+ .calls[0];
175
+ expect(typeof onProgress).toBe('function');
176
+ });
177
+ it('logs progress messages emitted by autoDiscoverTargetsAndTests', async () => {
178
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
179
+ vi.mocked(autoDiscoverTargetsAndTests).mockImplementationOnce(async (_root, _cfg, onProgress) => {
180
+ onProgress?.('Discovery complete: 3 source file(s), 2 test file(s)');
181
+ return { targets: [], testMap: new Map(), directTestMap: new Map() };
182
+ });
183
+ const consoleSpy = vi.spyOn(console, 'log');
184
+ await runOrchestrator([], '/cwd');
185
+ expect(consoleSpy).toHaveBeenCalledWith('Discovery complete: 3 source file(s), 2 test file(s)');
186
+ });
187
+ });
57
188
  describe('runOrchestrator --changed-with-deps diagnostic', () => {
58
189
  it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
59
190
  const cfg = {};
@@ -139,3 +270,202 @@ describe('runOrchestrator timeout precedence', () => {
139
270
  expect(Number.isFinite(call.timeoutMs)).toBe(true);
140
271
  });
141
272
  });
273
+ describe('runOrchestrator shard filtering', () => {
274
+ const targetFile = '/cwd/src/foo.ts';
275
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
276
+ function makeVariant(id) {
277
+ return {
278
+ id,
279
+ name: 'flipEQ',
280
+ file: targetFile,
281
+ code: '',
282
+ line: 1,
283
+ col: 0,
284
+ tests: [testFile],
285
+ };
286
+ }
287
+ function makeTask(key) {
288
+ return {
289
+ key,
290
+ v: makeVariant(key),
291
+ tests: [testFile],
292
+ };
293
+ }
294
+ beforeEach(() => {
295
+ vi.clearAllMocks();
296
+ process.exitCode = undefined;
297
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
298
+ vi.mocked(loadMutineerConfig).mockResolvedValue({
299
+ targets: [targetFile],
300
+ });
301
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
302
+ targets: [targetFile],
303
+ testMap: new Map([[targetFile, new Set([testFile])]]),
304
+ directTestMap: new Map(),
305
+ });
306
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
307
+ // Return 4 variants so shard filtering can split them across shards
308
+ vi.mocked(enumerateAllVariants).mockResolvedValue(['k0', 'k1', 'k2', 'k3'].map(makeVariant));
309
+ // Map each variant to a task by its id
310
+ vi.mocked(prepareTasks).mockImplementation((variants) => variants.map((v) => makeTask(v.id)));
311
+ });
312
+ afterEach(() => {
313
+ process.exitCode = undefined;
314
+ });
315
+ it('shard 1/2 assigns even-indexed variants', async () => {
316
+ await runOrchestrator(['--shard', '1/2'], '/cwd');
317
+ const call = vi.mocked(executePool).mock.calls[0][0];
318
+ expect(call.tasks.map((t) => t.key)).toEqual(['k0', 'k2']);
319
+ });
320
+ it('shard 2/2 assigns odd-indexed variants', async () => {
321
+ await runOrchestrator(['--shard', '2/2'], '/cwd');
322
+ const call = vi.mocked(executePool).mock.calls[0][0];
323
+ expect(call.tasks.map((t) => t.key)).toEqual(['k1', 'k3']);
324
+ });
325
+ it('does not call executePool when shard has no variants', async () => {
326
+ // Only 1 variant total; shard 2/2 gets nothing
327
+ vi.mocked(enumerateAllVariants).mockResolvedValue([makeVariant('only')]);
328
+ await runOrchestrator(['--shard', '2/2'], '/cwd');
329
+ expect(executePool).not.toHaveBeenCalled();
330
+ });
331
+ it('propagates shard to executePool', async () => {
332
+ await runOrchestrator(['--shard', '1/4'], '/cwd');
333
+ const call = vi.mocked(executePool).mock.calls[0][0];
334
+ expect(call.shard).toEqual({ index: 1, total: 4 });
335
+ });
336
+ });
337
+ describe('runOrchestrator --skip-baseline', () => {
338
+ const targetFile = '/cwd/src/foo.ts';
339
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
340
+ beforeEach(() => {
341
+ vi.clearAllMocks();
342
+ process.exitCode = undefined;
343
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
344
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
345
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
346
+ targets: [targetFile],
347
+ testMap: new Map([[targetFile, new Set([testFile])]]),
348
+ directTestMap: new Map(),
349
+ });
350
+ });
351
+ afterEach(() => {
352
+ process.exitCode = undefined;
353
+ });
354
+ it('does not call adapter.runBaseline when --skip-baseline is passed', async () => {
355
+ await runOrchestrator(['--skip-baseline'], '/cwd');
356
+ expect(mockAdapter.runBaseline).not.toHaveBeenCalled();
357
+ });
358
+ it('logs skip message when --skip-baseline is passed', async () => {
359
+ const consoleSpy = vi.spyOn(console, 'log');
360
+ await runOrchestrator(['--skip-baseline'], '/cwd');
361
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping baseline tests (--skip-baseline)'));
362
+ });
363
+ it('still calls adapter.runBaseline when --skip-baseline is absent', async () => {
364
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
365
+ await runOrchestrator([], '/cwd');
366
+ expect(mockAdapter.runBaseline).toHaveBeenCalledOnce();
367
+ });
368
+ });
369
+ describe('runOrchestrator schema generation', () => {
370
+ let tmpDir;
371
+ const testFile = '/cwd/src/__tests__/foo.spec.ts';
372
+ beforeEach(() => {
373
+ tmpDir = fssync.mkdtempSync(path.join(os.tmpdir(), 'mutineer-orch-schema-'));
374
+ vi.clearAllMocks();
375
+ process.exitCode = undefined;
376
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
377
+ vi.mocked(loadMutineerConfig).mockResolvedValue({});
378
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
379
+ });
380
+ afterEach(() => {
381
+ process.exitCode = undefined;
382
+ fssync.rmSync(tmpDir, { recursive: true, force: true });
383
+ });
384
+ it('calls generateSchema and passes fallbackIds to executePool', async () => {
385
+ const sourceFile = path.join(tmpDir, 'source.ts');
386
+ fssync.writeFileSync(sourceFile, 'const x = 1 + 2', 'utf8');
387
+ const variant = {
388
+ id: 'source.ts#0',
389
+ name: 'flipArith',
390
+ file: sourceFile,
391
+ code: 'const x = 1 - 2',
392
+ line: 1,
393
+ col: 10,
394
+ tests: [testFile],
395
+ };
396
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
397
+ targets: [sourceFile],
398
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
399
+ directTestMap: new Map(),
400
+ });
401
+ vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
402
+ vi.mocked(prepareTasks).mockReturnValue([
403
+ {
404
+ key: 'schema-test-key',
405
+ v: variant,
406
+ tests: [testFile],
407
+ },
408
+ ]);
409
+ const mockFallbacks = new Set(['source.ts#0']);
410
+ vi.mocked(generateSchema).mockReturnValue({
411
+ schemaCode: '// @ts-nocheck\nconst x = 1',
412
+ fallbackIds: mockFallbacks,
413
+ });
414
+ await runOrchestrator([], tmpDir);
415
+ expect(generateSchema).toHaveBeenCalledWith(expect.any(String), [variant]);
416
+ const call = vi.mocked(executePool).mock.calls[0][0];
417
+ expect(call.fallbackIds).toStrictEqual(mockFallbacks);
418
+ });
419
+ it('treats all variants as fallback when source file read fails', async () => {
420
+ const missingFile = path.join(tmpDir, 'nonexistent.ts');
421
+ const variant = {
422
+ id: 'nonexistent.ts#0',
423
+ name: 'test',
424
+ file: missingFile,
425
+ code: 'x',
426
+ line: 1,
427
+ col: 0,
428
+ tests: [testFile],
429
+ };
430
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
431
+ targets: [missingFile],
432
+ testMap: new Map([[missingFile, new Set([testFile])]]),
433
+ directTestMap: new Map(),
434
+ });
435
+ vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
436
+ vi.mocked(prepareTasks).mockReturnValue([
437
+ { key: 'fallback-key', v: variant, tests: [testFile] },
438
+ ]);
439
+ await runOrchestrator([], tmpDir);
440
+ const call = vi.mocked(executePool).mock.calls[0][0];
441
+ expect(call.fallbackIds?.has('nonexistent.ts#0')).toBe(true);
442
+ });
443
+ it('logs embedded and fallback schema counts', async () => {
444
+ const sourceFile = path.join(tmpDir, 'source.ts');
445
+ fssync.writeFileSync(sourceFile, 'const x = 1 + 2', 'utf8');
446
+ const variant = {
447
+ id: 'source.ts#0',
448
+ name: 'flipArith',
449
+ file: sourceFile,
450
+ code: 'const x = 1 - 2',
451
+ line: 1,
452
+ col: 10,
453
+ tests: [testFile],
454
+ };
455
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
456
+ targets: [sourceFile],
457
+ testMap: new Map([[sourceFile, new Set([testFile])]]),
458
+ directTestMap: new Map(),
459
+ });
460
+ vi.mocked(enumerateAllVariants).mockResolvedValue([variant]);
461
+ vi.mocked(prepareTasks).mockReturnValue([
462
+ { key: 'log-test-key', v: variant, tests: [testFile] },
463
+ ]);
464
+ vi.mocked(generateSchema).mockReturnValue({
465
+ schemaCode: '// @ts-nocheck\n',
466
+ fallbackIds: new Set(),
467
+ });
468
+ await runOrchestrator([], tmpDir);
469
+ expect(mockLogDebug).toHaveBeenCalledWith(expect.stringMatching(/Schema: 1 embedded, 0 fallback/));
470
+ });
471
+ });
@@ -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');
@@ -370,6 +407,53 @@ describe('executePool', () => {
370
407
  });
371
408
  expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
372
409
  });
410
+ it('sets isFallback=true when mutant id is in fallbackIds', async () => {
411
+ const adapter = makeAdapter();
412
+ const cache = {};
413
+ const task = makeTask({ key: 'fb-key' });
414
+ const fallbackIds = new Set(['file.ts#0']);
415
+ await executePool({
416
+ tasks: [task],
417
+ adapter,
418
+ cache,
419
+ concurrency: 1,
420
+ progressMode: 'list',
421
+ cwd: tmpDir,
422
+ fallbackIds,
423
+ });
424
+ expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: true }), expect.any(Array));
425
+ });
426
+ it('sets isFallback=false when mutant id is not in fallbackIds', async () => {
427
+ const adapter = makeAdapter();
428
+ const cache = {};
429
+ const task = makeTask({ key: 'schema-key' });
430
+ const fallbackIds = new Set(); // empty — no fallbacks
431
+ await executePool({
432
+ tasks: [task],
433
+ adapter,
434
+ cache,
435
+ concurrency: 1,
436
+ progressMode: 'list',
437
+ cwd: tmpDir,
438
+ fallbackIds,
439
+ });
440
+ expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: false }), expect.any(Array));
441
+ });
442
+ it('defaults isFallback=true when fallbackIds is not provided', async () => {
443
+ const adapter = makeAdapter();
444
+ const cache = {};
445
+ const task = makeTask({ key: 'no-fallback-ids-key' });
446
+ await executePool({
447
+ tasks: [task],
448
+ adapter,
449
+ cache,
450
+ concurrency: 1,
451
+ progressMode: 'list',
452
+ cwd: tmpDir,
453
+ // no fallbackIds
454
+ });
455
+ expect(adapter.runMutant).toHaveBeenCalledWith(expect.objectContaining({ isFallback: true }), expect.any(Array));
456
+ });
373
457
  it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
374
458
  const tmpFile = path.join(tmpDir, 'shared.ts');
375
459
  await fs.writeFile(tmpFile, 'const x = a + b\n');
@@ -410,6 +494,28 @@ describe('executePool', () => {
410
494
  expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
411
495
  expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
412
496
  });
497
+ it('does not eagerly JSON.stringify cache on every task (only once for save)', async () => {
498
+ const adapter = makeAdapter();
499
+ const cache = {};
500
+ const stringifySpy = vi.spyOn(JSON, 'stringify');
501
+ // 3 tasks — if stringify(cache) ran per-task it would be called 3 times with cache
502
+ await executePool({
503
+ tasks: [
504
+ makeTask({ key: 'k1' }),
505
+ makeTask({ key: 'k2' }),
506
+ makeTask({ key: 'k3' }),
507
+ ],
508
+ adapter,
509
+ cache,
510
+ concurrency: 1,
511
+ progressMode: 'list',
512
+ cwd: tmpDir,
513
+ });
514
+ // saveCacheAtomic calls stringify(cache) exactly once; per-task eager call was removed
515
+ const cacheStringifyCalls = stringifySpy.mock.calls.filter((args) => args[0] === cache);
516
+ expect(cacheStringifyCalls.length).toBe(1);
517
+ stringifySpy.mockRestore();
518
+ });
413
519
  it('handles adapter errors gracefully and still shuts down', async () => {
414
520
  const adapter = makeAdapter({
415
521
  runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import path from 'node:path';
3
+ import { resolveTypescriptEnabled, resolveTsconfigPath, checkTypes, } from '../ts-checker.js';
4
+ // Directory with a real tsconfig.json (the project root, 3 levels up from __tests__)
5
+ const CWD_WITH_TSCONFIG = path.resolve(import.meta.dirname, '../../../');
6
+ // A temp-like directory unlikely to have tsconfig.json
7
+ const CWD_WITHOUT_TSCONFIG = '/tmp';
8
+ function makeVariant(overrides = {}) {
9
+ return {
10
+ id: 'foo.ts#0',
11
+ name: 'flipEQ',
12
+ file: '/nonexistent/foo.ts',
13
+ code: 'const x: number = 1',
14
+ line: 1,
15
+ col: 0,
16
+ tests: [],
17
+ ...overrides,
18
+ };
19
+ }
20
+ describe('resolveTsconfigPath', () => {
21
+ it('returns undefined for boolean true', () => {
22
+ expect(resolveTsconfigPath({ typescript: true })).toBeUndefined();
23
+ });
24
+ it('returns undefined for boolean false', () => {
25
+ expect(resolveTsconfigPath({ typescript: false })).toBeUndefined();
26
+ });
27
+ it('returns tsconfig path from object config', () => {
28
+ expect(resolveTsconfigPath({ typescript: { tsconfig: './tsconfig.app.json' } })).toBe('./tsconfig.app.json');
29
+ });
30
+ it('returns undefined when object config has no tsconfig property', () => {
31
+ expect(resolveTsconfigPath({ typescript: {} })).toBeUndefined();
32
+ });
33
+ it('returns undefined when no typescript config key', () => {
34
+ expect(resolveTsconfigPath({})).toBeUndefined();
35
+ });
36
+ });
37
+ describe('resolveTypescriptEnabled', () => {
38
+ it('CLI false overrides everything', () => {
39
+ expect(resolveTypescriptEnabled(false, { typescript: true }, CWD_WITH_TSCONFIG)).toBe(false);
40
+ });
41
+ it('CLI true overrides everything', () => {
42
+ expect(resolveTypescriptEnabled(true, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(true);
43
+ });
44
+ it('config false disables checking', () => {
45
+ expect(resolveTypescriptEnabled(undefined, { typescript: false }, CWD_WITH_TSCONFIG)).toBe(false);
46
+ });
47
+ it('config true enables checking', () => {
48
+ expect(resolveTypescriptEnabled(undefined, { typescript: true }, CWD_WITHOUT_TSCONFIG)).toBe(true);
49
+ });
50
+ it('config object enables checking', () => {
51
+ expect(resolveTypescriptEnabled(undefined, { typescript: { tsconfig: './tsconfig.json' } }, CWD_WITHOUT_TSCONFIG)).toBe(true);
52
+ });
53
+ it('auto-detects: enabled when tsconfig.json is present in cwd', () => {
54
+ // The project root has a tsconfig.json
55
+ expect(resolveTypescriptEnabled(undefined, {}, CWD_WITH_TSCONFIG)).toBe(true);
56
+ });
57
+ it('auto-detects: disabled when no tsconfig.json in cwd', () => {
58
+ // /tmp should not have a tsconfig.json
59
+ expect(resolveTypescriptEnabled(undefined, {}, CWD_WITHOUT_TSCONFIG)).toBe(false);
60
+ });
61
+ });
62
+ describe('checkTypes', () => {
63
+ it('returns empty set for empty variants array', async () => {
64
+ const result = await checkTypes([], undefined, CWD_WITHOUT_TSCONFIG);
65
+ expect(result.size).toBe(0);
66
+ });
67
+ it('does not flag valid TypeScript code', async () => {
68
+ const variant = makeVariant({
69
+ id: 'valid.ts#0',
70
+ file: '/nonexistent/valid.ts',
71
+ code: 'const x: number = 42; export default x',
72
+ });
73
+ const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
74
+ expect(result.has('valid.ts#0')).toBe(false);
75
+ }, 15000);
76
+ it('flags TypeScript type mismatch as compile error', async () => {
77
+ const variant = makeVariant({
78
+ id: 'bad.ts#0',
79
+ file: '/nonexistent/bad.ts',
80
+ code: 'const x: number = "this is not a number"',
81
+ });
82
+ const result = await checkTypes([variant], undefined, CWD_WITHOUT_TSCONFIG);
83
+ expect(result.has('bad.ts#0')).toBe(true);
84
+ }, 15000);
85
+ it('checks multiple variants for same file independently', async () => {
86
+ const valid = makeVariant({
87
+ id: 'multi.ts#0',
88
+ file: '/nonexistent/multi.ts',
89
+ code: 'const x: number = 1',
90
+ });
91
+ const invalid = makeVariant({
92
+ id: 'multi.ts#1',
93
+ file: '/nonexistent/multi.ts',
94
+ code: 'const x: number = "bad"',
95
+ });
96
+ const result = await checkTypes([valid, invalid], undefined, CWD_WITHOUT_TSCONFIG);
97
+ expect(result.has('multi.ts#0')).toBe(false);
98
+ expect(result.has('multi.ts#1')).toBe(true);
99
+ }, 15000);
100
+ it('checks variants from different files independently', async () => {
101
+ const validA = makeVariant({
102
+ id: 'a.ts#0',
103
+ file: '/nonexistent/a.ts',
104
+ code: 'const x: number = 1',
105
+ });
106
+ const invalidB = makeVariant({
107
+ id: 'b.ts#0',
108
+ file: '/nonexistent/b.ts',
109
+ code: 'const y: string = 999',
110
+ });
111
+ const result = await checkTypes([validA, invalidB], undefined, CWD_WITHOUT_TSCONFIG);
112
+ expect(result.has('a.ts#0')).toBe(false);
113
+ expect(result.has('b.ts#0')).toBe(true);
114
+ }, 15000);
115
+ });
@@ -20,6 +20,16 @@ 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;
28
+ /** undefined = use config/auto-detect, true = force enable, false = force disable */
29
+ readonly typescriptCheck: boolean | undefined;
30
+ /** Vitest workspace project name(s) to filter mutations to */
31
+ readonly vitestProject: string | undefined;
32
+ readonly skipBaseline: boolean;
23
33
  }
24
34
  /**
25
35
  * Parse a numeric CLI flag value.
@@ -54,6 +64,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
54
64
  * Handles --config=path, -c=path, --config path, and -c path.
55
65
  */
56
66
  export declare function extractConfigPath(args: readonly string[]): string | undefined;
67
+ /**
68
+ * Parse the --shard <n>/<total> option.
69
+ * Throws on bad format or out-of-range values.
70
+ */
71
+ export declare function parseShardOption(args: readonly string[]): {
72
+ index: number;
73
+ total: number;
74
+ } | undefined;
57
75
  /**
58
76
  * Parse all CLI options.
59
77
  */