@mutineerjs/mutineer 0.5.1 → 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 (48) hide show
  1. package/README.md +42 -2
  2. package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +43 -0
  4. package/dist/bin/mutineer.d.ts +2 -1
  5. package/dist/bin/mutineer.js +44 -1
  6. package/dist/mutators/__tests__/operator.spec.js +97 -1
  7. package/dist/mutators/__tests__/registry.spec.js +8 -0
  8. package/dist/mutators/operator.d.ts +8 -0
  9. package/dist/mutators/operator.js +58 -1
  10. package/dist/mutators/registry.js +9 -1
  11. package/dist/mutators/utils.d.ts +2 -0
  12. package/dist/mutators/utils.js +58 -1
  13. package/dist/runner/__tests__/args.spec.js +101 -1
  14. package/dist/runner/__tests__/cache.spec.js +65 -8
  15. package/dist/runner/__tests__/changed.spec.js +85 -2
  16. package/dist/runner/__tests__/cleanup.spec.js +30 -0
  17. package/dist/runner/__tests__/config.spec.js +2 -13
  18. package/dist/runner/__tests__/coverage-resolver.spec.js +9 -2
  19. package/dist/runner/__tests__/discover.spec.js +128 -0
  20. package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +306 -0
  22. package/dist/runner/__tests__/pool-executor.spec.js +60 -1
  23. package/dist/runner/args.d.ts +18 -0
  24. package/dist/runner/args.js +40 -0
  25. package/dist/runner/cache.d.ts +19 -3
  26. package/dist/runner/cache.js +14 -7
  27. package/dist/runner/changed.js +15 -43
  28. package/dist/runner/cleanup.d.ts +3 -1
  29. package/dist/runner/cleanup.js +18 -1
  30. package/dist/runner/config.js +1 -1
  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/jest/__tests__/pool.spec.js +41 -0
  35. package/dist/runner/jest/pool.js +3 -3
  36. package/dist/runner/orchestrator.d.ts +1 -0
  37. package/dist/runner/orchestrator.js +38 -9
  38. package/dist/runner/pool-executor.d.ts +5 -0
  39. package/dist/runner/pool-executor.js +15 -4
  40. package/dist/runner/vitest/__tests__/adapter.spec.js +60 -0
  41. package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
  42. package/dist/runner/vitest/adapter.js +16 -9
  43. package/dist/runner/vitest/pool.js +3 -3
  44. package/dist/types/config.d.ts +4 -0
  45. package/dist/utils/__tests__/summary.spec.js +43 -1
  46. package/dist/utils/summary.d.ts +18 -0
  47. package/dist/utils/summary.js +25 -0
  48. package/package.json +2 -1
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ // Mock all heavy dependencies before importing orchestrator
3
+ vi.mock('../config.js', () => ({
4
+ loadMutineerConfig: vi.fn(),
5
+ }));
6
+ vi.mock('../cache.js', () => ({
7
+ clearCacheOnStart: vi.fn().mockResolvedValue(undefined),
8
+ readMutantCache: vi.fn().mockResolvedValue({}),
9
+ }));
10
+ vi.mock('../vitest/index.js', () => ({
11
+ createVitestAdapter: vi.fn(),
12
+ }));
13
+ vi.mock('../jest/index.js', () => ({
14
+ createJestAdapter: vi.fn(),
15
+ }));
16
+ vi.mock('../coverage-resolver.js', () => ({
17
+ resolveCoverageConfig: vi.fn().mockResolvedValue({
18
+ enableCoverageForBaseline: false,
19
+ wantsPerTestCoverage: false,
20
+ coverageData: null,
21
+ }),
22
+ loadCoverageAfterBaseline: vi.fn().mockResolvedValue({
23
+ coverageData: null,
24
+ perTestCoverage: null,
25
+ }),
26
+ }));
27
+ vi.mock('../discover.js', () => ({
28
+ autoDiscoverTargetsAndTests: vi.fn().mockResolvedValue({
29
+ targets: [],
30
+ testMap: new Map(),
31
+ directTestMap: new Map(),
32
+ }),
33
+ }));
34
+ vi.mock('../changed.js', () => ({
35
+ listChangedFiles: vi.fn().mockReturnValue([]),
36
+ }));
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';
50
+ import { loadMutineerConfig } from '../config.js';
51
+ import { createVitestAdapter } from '../vitest/index.js';
52
+ import { autoDiscoverTargetsAndTests } from '../discover.js';
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';
57
+ const mockAdapter = {
58
+ name: 'vitest',
59
+ init: vi.fn().mockResolvedValue(undefined),
60
+ runBaseline: vi.fn().mockResolvedValue(true),
61
+ runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 10 }),
62
+ shutdown: vi.fn().mockResolvedValue(undefined),
63
+ hasCoverageProvider: vi.fn().mockReturnValue(false),
64
+ detectCoverageConfig: vi
65
+ .fn()
66
+ .mockResolvedValue({ perTestEnabled: false, coverageEnabled: false }),
67
+ };
68
+ beforeEach(() => {
69
+ vi.clearAllMocks();
70
+ vi.mocked(createVitestAdapter).mockReturnValue(mockAdapter);
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
+ });
161
+ describe('runOrchestrator --changed-with-deps diagnostic', () => {
162
+ it('logs uncovered targets when wantsChangedWithDeps is true', async () => {
163
+ const cfg = {};
164
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
165
+ const depFile = '/cwd/src/dep.ts';
166
+ vi.mocked(listChangedFiles).mockReturnValue([depFile]);
167
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
168
+ targets: [depFile],
169
+ testMap: new Map(), // dep has no covering tests
170
+ directTestMap: new Map(),
171
+ });
172
+ const consoleSpy = vi.spyOn(console, 'log');
173
+ await runOrchestrator(['--changed-with-deps'], '/cwd');
174
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1 target(s) from --changed-with-deps have no covering tests and will be skipped'));
175
+ });
176
+ it('does not log when all changed-with-deps targets have covering tests', async () => {
177
+ const cfg = {};
178
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
179
+ const depFile = '/cwd/src/dep.ts';
180
+ const testFile = '/cwd/src/__tests__/dep.spec.ts';
181
+ vi.mocked(listChangedFiles).mockReturnValue([depFile]);
182
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
183
+ targets: [depFile],
184
+ testMap: new Map([[depFile, new Set([testFile])]]),
185
+ directTestMap: new Map(),
186
+ });
187
+ vi.mocked(mockAdapter.runBaseline).mockResolvedValue(true);
188
+ const consoleSpy = vi.spyOn(console, 'log');
189
+ await runOrchestrator(['--changed-with-deps'], '/cwd');
190
+ const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
191
+ expect(diagnosticCalls).toHaveLength(0);
192
+ });
193
+ it('does not log diagnostic when wantsChangedWithDeps is false', async () => {
194
+ const cfg = {};
195
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
196
+ const depFile = '/cwd/src/dep.ts';
197
+ vi.mocked(autoDiscoverTargetsAndTests).mockResolvedValue({
198
+ targets: [depFile],
199
+ testMap: new Map(),
200
+ directTestMap: new Map(),
201
+ });
202
+ const consoleSpy = vi.spyOn(console, 'log');
203
+ await runOrchestrator([], '/cwd');
204
+ const diagnosticCalls = consoleSpy.mock.calls.filter(([msg]) => typeof msg === 'string' && msg.includes('have no covering tests'));
205
+ expect(diagnosticCalls).toHaveLength(0);
206
+ });
207
+ });
208
+ describe('runOrchestrator timeout precedence', () => {
209
+ it('uses CLI --timeout when provided', async () => {
210
+ const cfg = {};
211
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
212
+ await runOrchestrator(['--timeout', '5000'], '/cwd');
213
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 5000 }));
214
+ });
215
+ it('uses config timeout when CLI flag is absent', async () => {
216
+ const cfg = { timeout: 10000 };
217
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
218
+ await runOrchestrator([], '/cwd');
219
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 10000 }));
220
+ });
221
+ it('CLI --timeout takes precedence over config timeout', async () => {
222
+ const cfg = { timeout: 10000 };
223
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
224
+ await runOrchestrator(['--timeout', '2000'], '/cwd');
225
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 2000 }));
226
+ });
227
+ it('falls back to MUTANT_TIMEOUT_MS default when neither CLI nor config timeout set', async () => {
228
+ const cfg = {};
229
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
230
+ await runOrchestrator([], '/cwd');
231
+ expect(createVitestAdapter).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 30_000 }));
232
+ });
233
+ it('env var MUTINEER_MUTANT_TIMEOUT_MS affects default when no CLI/config timeout', async () => {
234
+ const cfg = {};
235
+ vi.mocked(loadMutineerConfig).mockResolvedValue(cfg);
236
+ const orig = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
237
+ // env var is read at module load time, so we can only verify the default
238
+ // is used when no cli/config override is present
239
+ process.env.MUTINEER_MUTANT_TIMEOUT_MS = orig;
240
+ await runOrchestrator([], '/cwd');
241
+ const call = vi.mocked(createVitestAdapter).mock.calls[0][0];
242
+ expect(call.timeoutMs).toBeGreaterThan(0);
243
+ expect(Number.isFinite(call.timeoutMs)).toBe(true);
244
+ });
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')),
@@ -19,6 +19,12 @@ export interface ParsedCliOptions {
19
19
  readonly progressMode: 'bar' | 'list' | 'quiet';
20
20
  readonly minKillPercent: number | undefined;
21
21
  readonly runner: 'vitest' | 'jest';
22
+ readonly timeout: number | undefined;
23
+ readonly reportFormat: 'text' | 'json';
24
+ readonly shard: {
25
+ index: number;
26
+ total: number;
27
+ } | undefined;
22
28
  }
23
29
  /**
24
30
  * Parse a numeric CLI flag value.
@@ -36,6 +42,10 @@ export declare function readStringFlag(args: readonly string[], flag: string, al
36
42
  * Validate a percentage value (0-100).
37
43
  */
38
44
  export declare function validatePercent(value: number | undefined, source: string): number | undefined;
45
+ /**
46
+ * Validate a timeout value (must be a positive finite number).
47
+ */
48
+ export declare function validatePositiveMs(value: number | undefined, source: string): number | undefined;
39
49
  /**
40
50
  * Parse concurrency from CLI args or use default.
41
51
  */
@@ -49,6 +59,14 @@ export declare function parseProgressMode(args: readonly string[]): 'bar' | 'lis
49
59
  * Handles --config=path, -c=path, --config path, and -c path.
50
60
  */
51
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;
52
70
  /**
53
71
  * Parse all CLI options.
54
72
  */
@@ -70,6 +70,17 @@ export function validatePercent(value, source) {
70
70
  }
71
71
  return value;
72
72
  }
73
+ /**
74
+ * Validate a timeout value (must be a positive finite number).
75
+ */
76
+ export function validatePositiveMs(value, source) {
77
+ if (value === undefined)
78
+ return undefined;
79
+ if (!Number.isFinite(value) || value <= 0) {
80
+ throw new Error(`Invalid ${source}: expected a positive number (received ${value})`);
81
+ }
82
+ return value;
83
+ }
73
84
  /**
74
85
  * Parse concurrency from CLI args or use default.
75
86
  */
@@ -94,6 +105,28 @@ export function parseProgressMode(args) {
94
105
  export function extractConfigPath(args) {
95
106
  return readStringFlag(args, '--config', '-c');
96
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
+ }
97
130
  /**
98
131
  * Parse all CLI options.
99
132
  */
@@ -115,6 +148,10 @@ export function parseCliOptions(args, cfg) {
115
148
  const cliKillPercent = validatePercent(readNumberFlag(args, '--min-kill-percent'), '--min-kill-percent');
116
149
  const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
117
150
  const minKillPercent = cliKillPercent ?? configKillPercent;
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);
118
155
  return {
119
156
  configPath,
120
157
  wantsChanged,
@@ -126,5 +163,8 @@ export function parseCliOptions(args, cfg) {
126
163
  progressMode,
127
164
  minKillPercent,
128
165
  runner,
166
+ timeout,
167
+ reportFormat,
168
+ shard,
129
169
  };
130
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,7 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import fs from 'node:fs';
4
- import { createRequire } from 'node:module';
5
4
  import { normalizePath } from '../utils/normalizePath.js';
6
5
  import { createLogger } from '../utils/logger.js';
7
6
  const log = createLogger('changed');
@@ -50,52 +49,25 @@ function resolveLocalDependencies(file, cwd, seen = new Set(), maxDepth = 1, cur
50
49
  const dep = match[1];
51
50
  if (!dep.startsWith('.'))
52
51
  continue;
53
- try {
54
- const require = createRequire(file);
55
- let resolvedPath;
56
- // Try direct resolution first
57
- try {
58
- resolvedPath = require.resolve(dep);
59
- }
60
- catch {
61
- // Try different extensions if direct resolution fails
62
- for (const ext of SUPPORTED_EXTENSIONS) {
63
- try {
64
- // Try replacing existing extension
65
- resolvedPath = require.resolve(dep.replace(/\.(js|ts|vue)$/, ext));
66
- break;
67
- }
68
- catch {
69
- try {
70
- // Try adding extension
71
- resolvedPath = require.resolve(dep + ext);
72
- break;
73
- }
74
- catch {
75
- continue;
76
- }
77
- }
78
- }
79
- }
80
- if (!resolvedPath) {
81
- log.warn(`Could not resolve path for dependency ${dep} in ${file}`);
52
+ const dir = path.dirname(file);
53
+ const base = dep.replace(/\.(js|ts|mjs|cjs|vue)$/, '');
54
+ const candidates = [dep, ...SUPPORTED_EXTENSIONS.map((ext) => base + ext)];
55
+ let resolvedPath;
56
+ for (const candidate of candidates) {
57
+ const abs = normalizePath(path.resolve(dir, candidate));
58
+ if (abs.includes('node_modules') || !abs.startsWith(cwd))
82
59
  continue;
60
+ if (fs.existsSync(abs)) {
61
+ resolvedPath = abs;
62
+ break;
83
63
  }
84
- // Skip anything outside the repo/cwd, node_modules, tests, or missing
85
- if (!resolvedPath.startsWith(cwd) ||
86
- resolvedPath.includes('node_modules'))
87
- continue;
88
- if (TEST_FILE_PATTERN.test(resolvedPath))
89
- continue;
90
- if (!fs.existsSync(resolvedPath))
91
- continue;
92
- deps.push(normalizePath(resolvedPath));
93
- deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
94
64
  }
95
- catch (err) {
96
- log.warn(`Failed to resolve dependency ${dep} in ${file}: ${err}`);
65
+ if (!resolvedPath)
97
66
  continue;
98
- }
67
+ if (TEST_FILE_PATTERN.test(resolvedPath))
68
+ continue;
69
+ deps.push(resolvedPath);
70
+ deps.push(...resolveLocalDependencies(resolvedPath, cwd, seen, maxDepth, currentDepth + 1));
99
71
  }
100
72
  }
101
73
  return [...new Set(deps)];
@@ -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>;