@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.
- package/README.md +32 -15
- package/dist/bin/__tests__/mutineer.spec.js +67 -2
- package/dist/bin/mutineer.d.ts +6 -1
- package/dist/bin/mutineer.js +55 -1
- package/dist/core/__tests__/schemata.spec.d.ts +1 -0
- package/dist/core/__tests__/schemata.spec.js +165 -0
- package/dist/core/schemata.d.ts +22 -0
- package/dist/core/schemata.js +236 -0
- package/dist/runner/__tests__/args.spec.js +32 -0
- package/dist/runner/__tests__/cleanup.spec.js +7 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +3 -0
- package/dist/runner/__tests__/orchestrator.spec.js +183 -18
- package/dist/runner/__tests__/pool-executor.spec.js +47 -0
- package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker.spec.js +115 -0
- package/dist/runner/args.d.ts +5 -0
- package/dist/runner/args.js +10 -0
- package/dist/runner/cleanup.js +1 -1
- package/dist/runner/orchestrator.js +98 -17
- package/dist/runner/pool-executor.d.ts +2 -0
- package/dist/runner/pool-executor.js +15 -4
- package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
- package/dist/runner/shared/index.d.ts +1 -1
- package/dist/runner/shared/index.js +1 -1
- package/dist/runner/shared/mutant-paths.d.ts +17 -0
- package/dist/runner/shared/mutant-paths.js +24 -0
- package/dist/runner/ts-checker-worker.d.ts +5 -0
- package/dist/runner/ts-checker-worker.js +66 -0
- package/dist/runner/ts-checker.d.ts +36 -0
- package/dist/runner/ts-checker.js +210 -0
- package/dist/runner/types.d.ts +2 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
- package/dist/runner/vitest/adapter.js +1 -0
- package/dist/runner/vitest/plugin.d.ts +3 -0
- package/dist/runner/vitest/plugin.js +49 -11
- package/dist/runner/vitest/pool.d.ts +4 -1
- package/dist/runner/vitest/pool.js +25 -4
- package/dist/runner/vitest/worker-runtime.d.ts +1 -0
- package/dist/runner/vitest/worker-runtime.js +57 -18
- package/dist/runner/vitest/worker.mjs +10 -0
- package/dist/types/config.d.ts +14 -0
- package/dist/types/mutant.d.ts +5 -2
- package/dist/utils/CompileErrors.d.ts +7 -0
- package/dist/utils/CompileErrors.js +24 -0
- package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
- package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
- package/dist/utils/__tests__/summary.spec.js +83 -1
- package/dist/utils/summary.d.ts +5 -1
- package/dist/utils/summary.js +38 -3
- 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
|
+
});
|
package/dist/runner/args.d.ts
CHANGED
|
@@ -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.
|
package/dist/runner/args.js
CHANGED
|
@@ -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
|
}
|
package/dist/runner/cleanup.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
147
|
-
log.info(`Shard ${index}/${total}:
|
|
148
|
-
if (
|
|
149
|
-
log.info('No mutants
|
|
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
|
-
|
|
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,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;
|