@mutineerjs/mutineer 0.7.0 → 0.9.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 (55) hide show
  1. package/README.md +33 -15
  2. package/dist/bin/__tests__/mutineer.spec.js +75 -2
  3. package/dist/bin/mutineer.d.ts +6 -1
  4. package/dist/bin/mutineer.js +56 -1
  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/runner/__tests__/args.spec.js +40 -0
  10. package/dist/runner/__tests__/cleanup.spec.js +7 -0
  11. package/dist/runner/__tests__/coverage-resolver.spec.js +4 -0
  12. package/dist/runner/__tests__/orchestrator.spec.js +183 -18
  13. package/dist/runner/__tests__/pool-executor.spec.js +47 -0
  14. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  15. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  16. package/dist/runner/args.d.ts +6 -0
  17. package/dist/runner/args.js +12 -0
  18. package/dist/runner/cleanup.js +1 -1
  19. package/dist/runner/jest/__tests__/adapter.spec.js +49 -0
  20. package/dist/runner/jest/adapter.js +30 -2
  21. package/dist/runner/orchestrator.js +111 -17
  22. package/dist/runner/pool-executor.d.ts +2 -0
  23. package/dist/runner/pool-executor.js +15 -4
  24. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  25. package/dist/runner/shared/index.d.ts +1 -1
  26. package/dist/runner/shared/index.js +1 -1
  27. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  28. package/dist/runner/shared/mutant-paths.js +24 -0
  29. package/dist/runner/ts-checker-worker.d.ts +5 -0
  30. package/dist/runner/ts-checker-worker.js +66 -0
  31. package/dist/runner/ts-checker.d.ts +36 -0
  32. package/dist/runner/ts-checker.js +210 -0
  33. package/dist/runner/types.d.ts +2 -0
  34. package/dist/runner/vitest/__tests__/adapter.spec.js +70 -0
  35. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  36. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  37. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  38. package/dist/runner/vitest/adapter.js +13 -1
  39. package/dist/runner/vitest/plugin.d.ts +3 -0
  40. package/dist/runner/vitest/plugin.js +49 -11
  41. package/dist/runner/vitest/pool.d.ts +4 -1
  42. package/dist/runner/vitest/pool.js +25 -4
  43. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  44. package/dist/runner/vitest/worker-runtime.js +57 -18
  45. package/dist/runner/vitest/worker.mjs +10 -0
  46. package/dist/types/config.d.ts +14 -0
  47. package/dist/types/mutant.d.ts +5 -2
  48. package/dist/utils/CompileErrors.d.ts +7 -0
  49. package/dist/utils/CompileErrors.js +24 -0
  50. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  51. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  52. package/dist/utils/__tests__/summary.spec.js +83 -1
  53. package/dist/utils/summary.d.ts +5 -1
  54. package/dist/utils/summary.js +38 -3
  55. package/package.json +2 -2
@@ -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
+ });
@@ -12,6 +12,7 @@ export interface ParsedCliOptions {
12
12
  readonly configPath: string | undefined;
13
13
  readonly wantsChanged: boolean;
14
14
  readonly wantsChangedWithDeps: boolean;
15
+ readonly wantsFull: boolean;
15
16
  readonly wantsOnlyCoveredLines: boolean;
16
17
  readonly wantsPerTestCoverage: boolean;
17
18
  readonly coverageFilePath: string | undefined;
@@ -25,6 +26,11 @@ export interface ParsedCliOptions {
25
26
  index: number;
26
27
  total: number;
27
28
  } | undefined;
29
+ /** undefined = use config/auto-detect, true = force enable, false = force disable */
30
+ readonly typescriptCheck: boolean | undefined;
31
+ /** Vitest workspace project name(s) to filter mutations to */
32
+ readonly vitestProject: string | undefined;
33
+ readonly skipBaseline: boolean;
28
34
  }
29
35
  /**
30
36
  * Parse a numeric CLI flag value.
@@ -134,6 +134,7 @@ export function parseCliOptions(args, cfg) {
134
134
  const configPath = readStringFlag(args, '--config', '-c');
135
135
  const wantsChanged = args.includes('--changed');
136
136
  const wantsChangedWithDeps = args.includes('--changed-with-deps');
137
+ const wantsFull = args.includes('--full');
137
138
  const wantsOnlyCoveredLines = args.includes('--only-covered-lines') || cfg.onlyCoveredLines === true;
138
139
  const wantsPerTestCoverage = args.includes('--per-test-coverage') || cfg.perTestCoverage === true;
139
140
  const coverageFilePath = readStringFlag(args, '--coverage-file') ?? cfg.coverageFile;
@@ -152,10 +153,18 @@ export function parseCliOptions(args, cfg) {
152
153
  const reportFlag = readStringFlag(args, '--report');
153
154
  const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
154
155
  const shard = parseShardOption(args);
156
+ const typescriptCheck = args.includes('--typescript')
157
+ ? true
158
+ : args.includes('--no-typescript')
159
+ ? false
160
+ : undefined;
161
+ const vitestProject = readStringFlag(args, '--vitest-project');
162
+ const skipBaseline = args.includes('--skip-baseline');
155
163
  return {
156
164
  configPath,
157
165
  wantsChanged,
158
166
  wantsChangedWithDeps,
167
+ wantsFull,
159
168
  wantsOnlyCoveredLines,
160
169
  wantsPerTestCoverage,
161
170
  coverageFilePath,
@@ -166,5 +175,8 @@ export function parseCliOptions(args, cfg) {
166
175
  timeout,
167
176
  reportFormat,
168
177
  shard,
178
+ typescriptCheck,
179
+ vitestProject,
180
+ skipBaseline,
169
181
  };
170
182
  }
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises';
4
4
  */
5
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,
@@ -61,6 +61,55 @@ describe('Jest adapter', () => {
61
61
  expect(args.coverageProvider).toBe('v8');
62
62
  expect(args.config).toBe('jest.config.ts');
63
63
  });
64
+ it('does not write captured output to stdout/stderr on success', async () => {
65
+ const adapter = makeAdapter();
66
+ const stdoutWrite = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
67
+ const stderrWrite = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
68
+ runCLIMock.mockResolvedValueOnce({ results: { success: true } });
69
+ const ok = await adapter.runBaseline(['test-a'], {
70
+ collectCoverage: false,
71
+ perTestCoverage: false,
72
+ });
73
+ expect(ok).toBe(true);
74
+ // No output should be replayed on success
75
+ expect(stdoutWrite).not.toHaveBeenCalled();
76
+ expect(stderrWrite).not.toHaveBeenCalled();
77
+ stdoutWrite.mockRestore();
78
+ stderrWrite.mockRestore();
79
+ });
80
+ it('replays captured output to stdout/stderr on failure', async () => {
81
+ const adapter = makeAdapter();
82
+ // Spy before runBaseline so adapter's origStdoutWrite/origStderrWrite bind to the spy.
83
+ // runCLI writes during capture are suppressed; the spy sees only the replay after restore.
84
+ const writtenStdout = [];
85
+ const writtenStderr = [];
86
+ const stdoutSpy = vi
87
+ .spyOn(process.stdout, 'write')
88
+ .mockImplementation((chunk) => {
89
+ writtenStdout.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
90
+ return true;
91
+ });
92
+ const stderrSpy = vi
93
+ .spyOn(process.stderr, 'write')
94
+ .mockImplementation((chunk) => {
95
+ writtenStderr.push(Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk));
96
+ return true;
97
+ });
98
+ runCLIMock.mockImplementationOnce(async () => {
99
+ process.stdout.write('jest stdout output');
100
+ process.stderr.write('jest stderr output');
101
+ return { results: { success: false } };
102
+ });
103
+ const ok = await adapter.runBaseline(['test-a'], {
104
+ collectCoverage: false,
105
+ perTestCoverage: false,
106
+ });
107
+ expect(ok).toBe(false);
108
+ expect(writtenStdout.join('')).toContain('jest stdout output');
109
+ expect(writtenStderr.join('')).toContain('jest stderr output');
110
+ stdoutSpy.mockRestore();
111
+ stderrSpy.mockRestore();
112
+ });
64
113
  it('maps pool result to mutant status', async () => {
65
114
  const adapter = makeAdapter();
66
115
  await adapter.init();
@@ -33,6 +33,7 @@ function stripMutineerArgs(args) {
33
33
  '--mutate',
34
34
  '--changed',
35
35
  '--changed-with-deps',
36
+ '--full',
36
37
  '--only-covered-lines',
37
38
  '--per-test-coverage',
38
39
  '--perTestCoverage',
@@ -108,16 +109,43 @@ export class JestAdapter {
108
109
  ? 'baseline-with-coverage'
109
110
  : 'baseline';
110
111
  const cliOptions = buildJestCliOptions(tests, mode, this.jestConfigPath);
112
+ const stdoutChunks = [];
113
+ const stderrChunks = [];
114
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
115
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
116
+ // Temporarily capture stdout/stderr so Jest output is suppressed during baseline;
117
+ // captured output is replayed below on failure.
118
+ const makeCapture = (chunks) => function (chunk, encodingOrCb, cb) {
119
+ chunks.push(Buffer.isBuffer(chunk)
120
+ ? Buffer.from(chunk)
121
+ : Buffer.from(chunk));
122
+ const callback = (typeof encodingOrCb === 'function' ? encodingOrCb : cb);
123
+ callback?.();
124
+ return true;
125
+ };
126
+ process.stdout.write = makeCapture(stdoutChunks);
127
+ process.stderr.write = makeCapture(stderrChunks);
128
+ let success = false;
111
129
  try {
112
130
  const { runCLI } = await loadRunCLI(this.requireFromCwd);
113
131
  const { results } = await runCLI(cliOptions, [this.options.cwd]);
114
- return results.success;
132
+ success = results.success;
115
133
  }
116
134
  catch (err) {
117
135
  log.debug('Failed to run Jest baseline: ' +
118
136
  (err instanceof Error ? err.message : String(err)));
119
- return false;
120
137
  }
138
+ finally {
139
+ process.stdout.write = origStdoutWrite;
140
+ process.stderr.write = origStderrWrite;
141
+ }
142
+ if (!success) {
143
+ if (stdoutChunks.length)
144
+ process.stdout.write(Buffer.concat(stdoutChunks));
145
+ if (stderrChunks.length)
146
+ process.stderr.write(Buffer.concat(stderrChunks));
147
+ }
148
+ return success;
121
149
  }
122
150
  async runMutant(mutant, tests) {
123
151
  if (!this.pool) {
@@ -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,9 +25,12 @@ 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
36
  export function parseMutantTimeoutMs(raw) {
@@ -40,12 +47,15 @@ export async function runOrchestrator(cliArgs, cwd) {
40
47
  const opts = parseCliOptions(cliArgs, cfg);
41
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);
@@ -110,20 +120,38 @@ export async function runOrchestrator(cliArgs, cwd) {
110
120
  return;
111
121
  }
112
122
  // 5. Run baseline tests (with coverage if needed for filtering)
113
- log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
114
- const baselineOk = await adapter.runBaseline(baselineTests, {
115
- collectCoverage: coverage.enableCoverageForBaseline,
116
- perTestCoverage: coverage.wantsPerTestCoverage,
117
- });
118
- if (!baselineOk) {
119
- process.exitCode = 1;
120
- return;
123
+ if (opts.skipBaseline) {
124
+ log.info('Skipping baseline tests (--skip-baseline)');
125
+ }
126
+ else {
127
+ const baselineMsg = `Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`;
128
+ let baselineSpinner = null;
129
+ if (process.stderr.isTTY) {
130
+ baselineSpinner = render(createElement(PoolSpinner, { message: baselineMsg }), { stdout: process.stderr, stderr: process.stderr });
131
+ }
132
+ else {
133
+ log.info(baselineMsg);
134
+ }
135
+ let baselineOk;
136
+ try {
137
+ baselineOk = await adapter.runBaseline(baselineTests, {
138
+ collectCoverage: coverage.enableCoverageForBaseline,
139
+ perTestCoverage: coverage.wantsPerTestCoverage,
140
+ });
141
+ }
142
+ finally {
143
+ baselineSpinner?.unmount();
144
+ }
145
+ if (!baselineOk) {
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ log.info('\u2713 Baseline tests complete');
121
150
  }
122
- log.info('\u2713 Baseline tests complete');
123
151
  // 6. Load coverage from baseline if we generated it
124
152
  const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
125
153
  // 7. Enumerate mutation variants
126
- const variants = await enumerateAllVariants({
154
+ let variants = await enumerateAllVariants({
127
155
  cwd,
128
156
  targets,
129
157
  testMap,
@@ -138,18 +166,83 @@ export async function runOrchestrator(cliArgs, cwd) {
138
166
  log.info(msg);
139
167
  return;
140
168
  }
141
- // 8. Prepare tasks and execute via worker pool
142
- let tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
143
- // Apply shard filter if requested
169
+ // Apply shard filter before type-checking so each shard only processes its own mutants
144
170
  if (opts.shard) {
145
171
  const { index, total } = opts.shard;
146
- tasks = tasks.filter((_, i) => i % total === index - 1);
147
- log.info(`Shard ${index}/${total}: running ${tasks.length} mutant(s)`);
148
- if (tasks.length === 0) {
149
- log.info('No mutants assigned to this shard. Exiting.');
172
+ variants = variants.filter((_, i) => i % total === index - 1);
173
+ log.info(`Shard ${index}/${total}: scoped to ${variants.length} variant(s)`);
174
+ if (!variants.length) {
175
+ log.info('No mutants in this shard. Exiting.');
150
176
  return;
151
177
  }
152
178
  }
179
+ // TypeScript pre-filtering (filter mutants that produce compile errors)
180
+ const tsEnabled = resolveTypescriptEnabled(opts.typescriptCheck, cfg, cwd);
181
+ let runnableVariants = variants;
182
+ if (tsEnabled) {
183
+ // Only return-value mutants change the expression type — operator mutants
184
+ // (equality, arithmetic, logical, etc.) always preserve the type.
185
+ const returnValueVariants = variants.filter((v) => v.name.startsWith('return'));
186
+ log.info(`Running TypeScript type checks on ${returnValueVariants.length} return-value mutant(s)...`);
187
+ const tsconfig = resolveTsconfigPath(cfg);
188
+ let tsSpinner = null;
189
+ if (process.stderr.isTTY) {
190
+ tsSpinner = render(createElement(PoolSpinner, {
191
+ message: `Type checking ${returnValueVariants.length} return-value mutant(s)...`,
192
+ }), { stdout: process.stderr, stderr: process.stderr });
193
+ }
194
+ let compileErrorIds;
195
+ try {
196
+ compileErrorIds = await checkTypes(returnValueVariants, tsconfig, cwd);
197
+ }
198
+ finally {
199
+ tsSpinner?.unmount();
200
+ }
201
+ if (compileErrorIds.size > 0) {
202
+ log.info(`\u2713 TypeScript: filtered ${compileErrorIds.size} mutant(s) with compile errors`);
203
+ runnableVariants = variants.filter((v) => !compileErrorIds.has(v.id));
204
+ // Pre-populate cache for compile-error mutants so they appear in summary
205
+ const compileErrorVariants = variants.filter((v) => compileErrorIds.has(v.id));
206
+ const compileErrorTasks = prepareTasks(compileErrorVariants, updatedCoverage.perTestCoverage, directTestMap);
207
+ for (const task of compileErrorTasks) {
208
+ cache[task.key] = {
209
+ status: 'compile-error',
210
+ file: task.v.file,
211
+ line: task.v.line,
212
+ col: task.v.col,
213
+ mutator: task.v.name,
214
+ };
215
+ }
216
+ }
217
+ }
218
+ // 9. Generate schema files for each source file
219
+ const fallbackIds = new Set();
220
+ const variantsByFile = new Map();
221
+ for (const v of runnableVariants) {
222
+ const arr = variantsByFile.get(v.file) ?? [];
223
+ arr.push(v);
224
+ variantsByFile.set(v.file, arr);
225
+ }
226
+ const results = await Promise.all([...variantsByFile.entries()].map(async ([file, fileVariants]) => {
227
+ try {
228
+ const originalCode = await fs.promises.readFile(file, 'utf8');
229
+ const schemaPath = getSchemaFilePath(file);
230
+ await fs.promises.mkdir(path.dirname(schemaPath), { recursive: true });
231
+ const { schemaCode, fallbackIds: fileFallbacks } = generateSchema(originalCode, fileVariants);
232
+ await fs.promises.writeFile(schemaPath, schemaCode, 'utf8');
233
+ return fileFallbacks;
234
+ }
235
+ catch {
236
+ return new Set(fileVariants.map((v) => v.id));
237
+ }
238
+ }));
239
+ for (const ids of results) {
240
+ for (const id of ids)
241
+ fallbackIds.add(id);
242
+ }
243
+ log.debug(`Schema: ${runnableVariants.length - fallbackIds.size} embedded, ${fallbackIds.size} fallback`);
244
+ // 10. Prepare tasks and execute via worker pool
245
+ let tasks = prepareTasks(runnableVariants, updatedCoverage.perTestCoverage, directTestMap);
153
246
  await executePool({
154
247
  tasks,
155
248
  adapter,
@@ -160,5 +253,6 @@ export async function runOrchestrator(cliArgs, cwd) {
160
253
  reportFormat: opts.reportFormat,
161
254
  cwd,
162
255
  shard: opts.shard,
256
+ fallbackIds,
163
257
  });
164
258
  }
@@ -14,6 +14,8 @@ export interface PoolExecutionOptions {
14
14
  index: number;
15
15
  total: number;
16
16
  };
17
+ /** IDs of variants that must use the legacy redirect path (overlapping diff ranges). */
18
+ fallbackIds?: Set<string>;
17
19
  }
18
20
  /**
19
21
  * Execute all mutant tasks through the worker pool.
@@ -7,6 +7,7 @@ import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary
7
7
  import { saveCacheAtomic } from './cache.js';
8
8
  import { cleanupMutineerDirs } from './cleanup.js';
9
9
  import { PoolSpinner } from '../utils/PoolSpinner.js';
10
+ import { CompileErrors } from '../utils/CompileErrors.js';
10
11
  import { createLogger } from '../utils/logger.js';
11
12
  const log = createLogger('pool-executor');
12
13
  /**
@@ -23,7 +24,7 @@ export async function executePool(opts) {
23
24
  const mutationStartTime = Date.now();
24
25
  // Ensure we only finish once
25
26
  let finished = false;
26
- const finishOnce = () => {
27
+ const finishOnce = async (interactive = true) => {
27
28
  if (finished)
28
29
  return;
29
30
  finished = true;
@@ -40,7 +41,15 @@ export async function executePool(opts) {
40
41
  log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
41
42
  }
42
43
  else {
43
- printSummary(summary, cache, durationMs);
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
+ }
44
53
  }
45
54
  if (opts.minKillPercent !== undefined) {
46
55
  const killRateString = summary.killRate.toFixed(2);
@@ -80,6 +89,7 @@ export async function executePool(opts) {
80
89
  let nextIdx = 0;
81
90
  async function processTask(task) {
82
91
  const { v, tests, key, directTests } = task;
92
+ const { fallbackIds } = opts;
83
93
  const cached = cache[key];
84
94
  if (cached) {
85
95
  progress.update(cached.status);
@@ -104,6 +114,7 @@ export async function executePool(opts) {
104
114
  code: v.code,
105
115
  line: v.line,
106
116
  col: v.col,
117
+ isFallback: !fallbackIds || fallbackIds.has(v.id),
107
118
  }, tests);
108
119
  const status = result.status;
109
120
  let originalSnippet;
@@ -159,7 +170,7 @@ export async function executePool(opts) {
159
170
  return;
160
171
  signalCleanedUp = true;
161
172
  log.info(`\nReceived ${signal}, cleaning up...`);
162
- finishOnce();
173
+ await finishOnce(false);
163
174
  await adapter.shutdown();
164
175
  await cleanupMutineerDirs(cwd);
165
176
  process.exit(1);
@@ -176,7 +187,7 @@ export async function executePool(opts) {
176
187
  process.removeAllListeners('SIGINT');
177
188
  process.removeAllListeners('SIGTERM');
178
189
  if (!signalCleanedUp) {
179
- finishOnce();
190
+ await finishOnce();
180
191
  await adapter.shutdown();
181
192
  await cleanupMutineerDirs(cwd);
182
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';
@@ -1,6 +1,23 @@
1
1
  /**
2
2
  * Shared utilities for managing mutant file paths.
3
3
  */
4
+ /**
5
+ * Generate a file path for the schema file in the __mutineer__ directory.
6
+ * The schema file embeds all mutation variants for a source file.
7
+ *
8
+ * @param originalFile - Path to the original source file
9
+ * @returns Path where the schema file should be written (dir may not exist)
10
+ */
11
+ export declare function getSchemaFilePath(originalFile: string): string;
12
+ /**
13
+ * Generate the path for a worker's active-mutant-ID file.
14
+ * Each worker writes the active mutant ID here so test forks can read it.
15
+ *
16
+ * @param workerId - Unique worker identifier
17
+ * @param cwd - Project working directory
18
+ * @returns Absolute path for the active ID file
19
+ */
20
+ export declare function getActiveIdFilePath(workerId: string, cwd: string): string;
4
21
  /**
5
22
  * Generate a file path for a mutant file in the __mutineer__ directory.
6
23
  *