@mutineerjs/mutineer 0.7.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 (52) 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 +55 -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 +32 -0
  10. package/dist/runner/__tests__/cleanup.spec.js +7 -0
  11. package/dist/runner/__tests__/coverage-resolver.spec.js +3 -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 +5 -0
  17. package/dist/runner/args.js +10 -0
  18. package/dist/runner/cleanup.js +1 -1
  19. package/dist/runner/orchestrator.js +98 -17
  20. package/dist/runner/pool-executor.d.ts +2 -0
  21. package/dist/runner/pool-executor.js +15 -4
  22. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  23. package/dist/runner/shared/index.d.ts +1 -1
  24. package/dist/runner/shared/index.js +1 -1
  25. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  26. package/dist/runner/shared/mutant-paths.js +24 -0
  27. package/dist/runner/ts-checker-worker.d.ts +5 -0
  28. package/dist/runner/ts-checker-worker.js +66 -0
  29. package/dist/runner/ts-checker.d.ts +36 -0
  30. package/dist/runner/ts-checker.js +210 -0
  31. package/dist/runner/types.d.ts +2 -0
  32. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  33. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  34. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  35. package/dist/runner/vitest/adapter.js +1 -0
  36. package/dist/runner/vitest/plugin.d.ts +3 -0
  37. package/dist/runner/vitest/plugin.js +49 -11
  38. package/dist/runner/vitest/pool.d.ts +4 -1
  39. package/dist/runner/vitest/pool.js +25 -4
  40. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  41. package/dist/runner/vitest/worker-runtime.js +57 -18
  42. package/dist/runner/vitest/worker.mjs +10 -0
  43. package/dist/types/config.d.ts +14 -0
  44. package/dist/types/mutant.d.ts +5 -2
  45. package/dist/utils/CompileErrors.d.ts +7 -0
  46. package/dist/utils/CompileErrors.js +24 -0
  47. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  48. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  49. package/dist/utils/__tests__/summary.spec.js +83 -1
  50. package/dist/utils/summary.d.ts +5 -1
  51. package/dist/utils/summary.js +38 -3
  52. 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
+ });
@@ -25,6 +25,11 @@ export interface ParsedCliOptions {
25
25
  index: number;
26
26
  total: number;
27
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;
28
33
  }
29
34
  /**
30
35
  * Parse a numeric CLI flag value.
@@ -152,6 +152,13 @@ export function parseCliOptions(args, cfg) {
152
152
  const reportFlag = readStringFlag(args, '--report');
153
153
  const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
154
154
  const shard = parseShardOption(args);
155
+ const typescriptCheck = args.includes('--typescript')
156
+ ? true
157
+ : args.includes('--no-typescript')
158
+ ? false
159
+ : undefined;
160
+ const vitestProject = readStringFlag(args, '--vitest-project');
161
+ const skipBaseline = args.includes('--skip-baseline');
155
162
  return {
156
163
  configPath,
157
164
  wantsChanged,
@@ -166,5 +173,8 @@ export function parseCliOptions(args, cfg) {
166
173
  timeout,
167
174
  reportFormat,
168
175
  shard,
176
+ typescriptCheck,
177
+ vitestProject,
178
+ skipBaseline,
169
179
  };
170
180
  }
@@ -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,
@@ -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,25 @@ 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
+ log.info(`Running ${baselineTests.length} baseline tests${coverage.enableCoverageForBaseline ? ' (collecting coverage)' : ''}\u2026`);
128
+ const baselineOk = await adapter.runBaseline(baselineTests, {
129
+ collectCoverage: coverage.enableCoverageForBaseline,
130
+ perTestCoverage: coverage.wantsPerTestCoverage,
131
+ });
132
+ if (!baselineOk) {
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ log.info('\u2713 Baseline tests complete');
121
137
  }
122
- log.info('\u2713 Baseline tests complete');
123
138
  // 6. Load coverage from baseline if we generated it
124
139
  const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
125
140
  // 7. Enumerate mutation variants
126
- const variants = await enumerateAllVariants({
141
+ let variants = await enumerateAllVariants({
127
142
  cwd,
128
143
  targets,
129
144
  testMap,
@@ -138,18 +153,83 @@ export async function runOrchestrator(cliArgs, cwd) {
138
153
  log.info(msg);
139
154
  return;
140
155
  }
141
- // 8. Prepare tasks and execute via worker pool
142
- let tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
143
- // Apply shard filter if requested
156
+ // Apply shard filter before type-checking so each shard only processes its own mutants
144
157
  if (opts.shard) {
145
158
  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.');
159
+ variants = variants.filter((_, i) => i % total === index - 1);
160
+ log.info(`Shard ${index}/${total}: scoped to ${variants.length} variant(s)`);
161
+ if (!variants.length) {
162
+ log.info('No mutants in this shard. Exiting.');
150
163
  return;
151
164
  }
152
165
  }
166
+ // TypeScript pre-filtering (filter mutants that produce compile errors)
167
+ const tsEnabled = resolveTypescriptEnabled(opts.typescriptCheck, cfg, cwd);
168
+ let runnableVariants = variants;
169
+ if (tsEnabled) {
170
+ // Only return-value mutants change the expression type — operator mutants
171
+ // (equality, arithmetic, logical, etc.) always preserve the type.
172
+ const returnValueVariants = variants.filter((v) => v.name.startsWith('return'));
173
+ log.info(`Running TypeScript type checks on ${returnValueVariants.length} return-value mutant(s)...`);
174
+ const tsconfig = resolveTsconfigPath(cfg);
175
+ let tsSpinner = null;
176
+ if (process.stderr.isTTY) {
177
+ tsSpinner = render(createElement(PoolSpinner, {
178
+ message: `Type checking ${returnValueVariants.length} return-value mutant(s)...`,
179
+ }), { stdout: process.stderr, stderr: process.stderr });
180
+ }
181
+ let compileErrorIds;
182
+ try {
183
+ compileErrorIds = await checkTypes(returnValueVariants, tsconfig, cwd);
184
+ }
185
+ finally {
186
+ tsSpinner?.unmount();
187
+ }
188
+ if (compileErrorIds.size > 0) {
189
+ log.info(`\u2713 TypeScript: filtered ${compileErrorIds.size} mutant(s) with compile errors`);
190
+ runnableVariants = variants.filter((v) => !compileErrorIds.has(v.id));
191
+ // Pre-populate cache for compile-error mutants so they appear in summary
192
+ const compileErrorVariants = variants.filter((v) => compileErrorIds.has(v.id));
193
+ const compileErrorTasks = prepareTasks(compileErrorVariants, updatedCoverage.perTestCoverage, directTestMap);
194
+ for (const task of compileErrorTasks) {
195
+ cache[task.key] = {
196
+ status: 'compile-error',
197
+ file: task.v.file,
198
+ line: task.v.line,
199
+ col: task.v.col,
200
+ mutator: task.v.name,
201
+ };
202
+ }
203
+ }
204
+ }
205
+ // 9. Generate schema files for each source file
206
+ const fallbackIds = new Set();
207
+ const variantsByFile = new Map();
208
+ for (const v of runnableVariants) {
209
+ const arr = variantsByFile.get(v.file) ?? [];
210
+ arr.push(v);
211
+ variantsByFile.set(v.file, arr);
212
+ }
213
+ const results = await Promise.all([...variantsByFile.entries()].map(async ([file, fileVariants]) => {
214
+ try {
215
+ const originalCode = await fs.promises.readFile(file, 'utf8');
216
+ const schemaPath = getSchemaFilePath(file);
217
+ await fs.promises.mkdir(path.dirname(schemaPath), { recursive: true });
218
+ const { schemaCode, fallbackIds: fileFallbacks } = generateSchema(originalCode, fileVariants);
219
+ await fs.promises.writeFile(schemaPath, schemaCode, 'utf8');
220
+ return fileFallbacks;
221
+ }
222
+ catch {
223
+ return new Set(fileVariants.map((v) => v.id));
224
+ }
225
+ }));
226
+ for (const ids of results) {
227
+ for (const id of ids)
228
+ fallbackIds.add(id);
229
+ }
230
+ log.debug(`Schema: ${runnableVariants.length - fallbackIds.size} embedded, ${fallbackIds.size} fallback`);
231
+ // 10. Prepare tasks and execute via worker pool
232
+ let tasks = prepareTasks(runnableVariants, updatedCoverage.perTestCoverage, directTestMap);
153
233
  await executePool({
154
234
  tasks,
155
235
  adapter,
@@ -160,5 +240,6 @@ export async function runOrchestrator(cliArgs, cwd) {
160
240
  reportFormat: opts.reportFormat,
161
241
  cwd,
162
242
  shard: opts.shard,
243
+ fallbackIds,
163
244
  });
164
245
  }
@@ -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
  *
@@ -3,6 +3,30 @@
3
3
  */
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
+ /**
7
+ * Generate a file path for the schema file in the __mutineer__ directory.
8
+ * The schema file embeds all mutation variants for a source file.
9
+ *
10
+ * @param originalFile - Path to the original source file
11
+ * @returns Path where the schema file should be written (dir may not exist)
12
+ */
13
+ export function getSchemaFilePath(originalFile) {
14
+ const dir = path.dirname(originalFile);
15
+ const ext = path.extname(originalFile);
16
+ const basename = path.basename(originalFile, ext);
17
+ return path.join(dir, '__mutineer__', `${basename}_schema${ext}`);
18
+ }
19
+ /**
20
+ * Generate the path for a worker's active-mutant-ID file.
21
+ * Each worker writes the active mutant ID here so test forks can read it.
22
+ *
23
+ * @param workerId - Unique worker identifier
24
+ * @param cwd - Project working directory
25
+ * @returns Absolute path for the active ID file
26
+ */
27
+ export function getActiveIdFilePath(workerId, cwd) {
28
+ return path.join(cwd, '__mutineer__', `active_id_${workerId}.txt`);
29
+ }
6
30
  /**
7
31
  * Generate a file path for a mutant file in the __mutineer__ directory.
8
32
  *
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Worker thread entry point for parallel TypeScript type checking.
3
+ * Receives one file group, runs baseline + per-variant diagnose, posts results.
4
+ */
5
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Worker thread entry point for parallel TypeScript type checking.
3
+ * Receives one file group, runs baseline + per-variant diagnose, posts results.
4
+ */
5
+ import { workerData, parentPort } from 'worker_threads';
6
+ import ts from 'typescript';
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ /** Stable fingerprint for a diagnostic. */
10
+ function diagnosticKey(d) {
11
+ return `${d.code}:${d.start ?? -1}`;
12
+ }
13
+ /** Create a compiler host that serves `code` for `targetPath`. */
14
+ function makeHost(options, targetPath, code) {
15
+ const host = ts.createCompilerHost(options);
16
+ const orig = host.getSourceFile.bind(host);
17
+ host.getSourceFile = (fileName, langOrOpts) => {
18
+ if (path.resolve(fileName) === targetPath) {
19
+ return ts.createSourceFile(fileName, code, langOrOpts);
20
+ }
21
+ return orig(fileName, langOrOpts);
22
+ };
23
+ return host;
24
+ }
25
+ /** Run semantic diagnostics for `code` in `targetPath`. */
26
+ function diagnose(options, targetPath, code, oldProgram) {
27
+ const host = makeHost(options, targetPath, code);
28
+ const program = ts.createProgram({
29
+ rootNames: [targetPath],
30
+ options,
31
+ host,
32
+ oldProgram,
33
+ });
34
+ const sourceFile = program.getSourceFile(targetPath) ??
35
+ program.getSourceFile(path.relative(process.cwd(), targetPath));
36
+ if (!sourceFile) {
37
+ return { program, keys: new Set() };
38
+ }
39
+ const keys = new Set(program.getSemanticDiagnostics(sourceFile).map(diagnosticKey));
40
+ return { program, keys };
41
+ }
42
+ const { options, filePath, variants } = workerData;
43
+ const resolvedPath = path.resolve(filePath);
44
+ let originalCode = '';
45
+ try {
46
+ originalCode = fs.readFileSync(resolvedPath, 'utf8');
47
+ }
48
+ catch {
49
+ // empty baseline — all mutant errors count as new
50
+ }
51
+ const { program: baseProgram, keys: baselineKeys } = diagnose(options, resolvedPath, originalCode, undefined);
52
+ let prevProgram = baseProgram;
53
+ const compileErrorIds = [];
54
+ for (const variant of variants) {
55
+ const { program: mutProgram, keys: mutantKeys } = diagnose(options, resolvedPath, variant.code, prevProgram);
56
+ prevProgram = mutProgram;
57
+ let newErrors = 0;
58
+ for (const key of mutantKeys) {
59
+ if (!baselineKeys.has(key))
60
+ newErrors++;
61
+ }
62
+ if (newErrors > 0) {
63
+ compileErrorIds.push(variant.id);
64
+ }
65
+ }
66
+ parentPort.postMessage({ compileErrorIds });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TypeScript Type Checker for Mutants
3
+ *
4
+ * Pre-filters mutants that produce TypeScript compile errors before running
5
+ * them against tests. This avoids running mutations that would be trivially
6
+ * detected by the type system, saving significant test execution time.
7
+ *
8
+ * Strategy: noLib + noResolve (zero I/O) + baseline comparison
9
+ * - noLib: true → don't load lib.d.ts (huge, causes hangs)
10
+ * - noResolve: true → don't follow user imports (avoids loading project files)
11
+ * - Baseline check → type-check original file first; only flag mutants that
12
+ * introduce NEW errors not present in the original. This
13
+ * eliminates false positives from missing lib/import types.
14
+ */
15
+ import ts from 'typescript';
16
+ import type { MutineerConfig } from '../types/config.js';
17
+ import type { Variant } from '../types/mutant.js';
18
+ /**
19
+ * Run type checks for one file group synchronously. Used both by the sync
20
+ * fallback (test/dev environments) and by the worker thread.
21
+ */
22
+ export declare function checkFileSync(options: ts.CompilerOptions, filePath: string, fileVariants: readonly Variant[]): string[];
23
+ /**
24
+ * Check TypeScript types for mutated variants.
25
+ * Returns a Set of variant IDs that introduce NEW compile errors vs the original.
26
+ */
27
+ export declare function checkTypes(variants: readonly Variant[], tsconfig: string | undefined, cwd: string): Promise<Set<string>>;
28
+ /**
29
+ * Determine whether TypeScript type checking should run for this invocation.
30
+ * Precedence: CLI flag > config field > auto-detect (tsconfig.json presence).
31
+ */
32
+ export declare function resolveTypescriptEnabled(cliFlag: boolean | undefined, config: MutineerConfig, cwd: string): boolean;
33
+ /**
34
+ * Extract the tsconfig path from the MutineerConfig typescript option.
35
+ */
36
+ export declare function resolveTsconfigPath(config: MutineerConfig): string | undefined;