@mutineerjs/mutineer 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +58 -2
- 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/mutators/__tests__/operator.spec.js +97 -1
- package/dist/mutators/__tests__/registry.spec.js +8 -0
- package/dist/mutators/operator.d.ts +8 -0
- package/dist/mutators/operator.js +58 -1
- package/dist/mutators/registry.js +9 -1
- package/dist/mutators/utils.d.ts +2 -0
- package/dist/mutators/utils.js +58 -1
- package/dist/runner/__tests__/args.spec.js +89 -1
- package/dist/runner/__tests__/cache.spec.js +65 -8
- package/dist/runner/__tests__/cleanup.spec.js +37 -0
- package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
- package/dist/runner/__tests__/discover.spec.js +128 -0
- package/dist/runner/__tests__/orchestrator.spec.js +332 -2
- package/dist/runner/__tests__/pool-executor.spec.js +107 -1
- 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 +18 -0
- package/dist/runner/args.js +37 -0
- package/dist/runner/cache.d.ts +19 -3
- package/dist/runner/cache.js +14 -7
- package/dist/runner/cleanup.d.ts +3 -1
- package/dist/runner/cleanup.js +19 -2
- package/dist/runner/coverage-resolver.js +1 -1
- package/dist/runner/discover.d.ts +1 -1
- package/dist/runner/discover.js +30 -20
- package/dist/runner/orchestrator.d.ts +1 -0
- package/dist/runner/orchestrator.js +114 -19
- package/dist/runner/pool-executor.d.ts +7 -0
- package/dist/runner/pool-executor.js +29 -7
- 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__/adapter.spec.js +41 -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 +14 -9
- 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 +16 -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 +126 -2
- package/dist/utils/summary.d.ts +23 -1
- package/dist/utils/summary.js +63 -3
- package/package.json +2 -1
package/dist/runner/args.js
CHANGED
|
@@ -105,6 +105,28 @@ export function parseProgressMode(args) {
|
|
|
105
105
|
export function extractConfigPath(args) {
|
|
106
106
|
return readStringFlag(args, '--config', '-c');
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse the --shard <n>/<total> option.
|
|
110
|
+
* Throws on bad format or out-of-range values.
|
|
111
|
+
*/
|
|
112
|
+
export function parseShardOption(args) {
|
|
113
|
+
const raw = readStringFlag(args, '--shard');
|
|
114
|
+
if (raw === undefined)
|
|
115
|
+
return undefined;
|
|
116
|
+
const match = /^(\d+)\/(\d+)$/.exec(raw);
|
|
117
|
+
if (!match) {
|
|
118
|
+
throw new Error(`Invalid --shard format: expected <n>/<total>, got "${raw}"`);
|
|
119
|
+
}
|
|
120
|
+
const index = parseInt(match[1], 10);
|
|
121
|
+
const total = parseInt(match[2], 10);
|
|
122
|
+
if (total < 1) {
|
|
123
|
+
throw new Error(`Invalid --shard: total must be >= 1 (got ${total})`);
|
|
124
|
+
}
|
|
125
|
+
if (index < 1 || index > total) {
|
|
126
|
+
throw new Error(`Invalid --shard: index must be between 1 and ${total} (got ${index})`);
|
|
127
|
+
}
|
|
128
|
+
return { index, total };
|
|
129
|
+
}
|
|
108
130
|
/**
|
|
109
131
|
* Parse all CLI options.
|
|
110
132
|
*/
|
|
@@ -127,6 +149,16 @@ export function parseCliOptions(args, cfg) {
|
|
|
127
149
|
const configKillPercent = validatePercent(cfg.minKillPercent, 'mutineer.config minKillPercent');
|
|
128
150
|
const minKillPercent = cliKillPercent ?? configKillPercent;
|
|
129
151
|
const timeout = validatePositiveMs(readNumberFlag(args, '--timeout'), '--timeout');
|
|
152
|
+
const reportFlag = readStringFlag(args, '--report');
|
|
153
|
+
const reportFormat = reportFlag === 'json' || cfg.report === 'json' ? 'json' : 'text';
|
|
154
|
+
const shard = parseShardOption(args);
|
|
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');
|
|
130
162
|
return {
|
|
131
163
|
configPath,
|
|
132
164
|
wantsChanged,
|
|
@@ -139,5 +171,10 @@ export function parseCliOptions(args, cfg) {
|
|
|
139
171
|
minKillPercent,
|
|
140
172
|
runner,
|
|
141
173
|
timeout,
|
|
174
|
+
reportFormat,
|
|
175
|
+
shard,
|
|
176
|
+
typescriptCheck,
|
|
177
|
+
vitestProject,
|
|
178
|
+
skipBaseline,
|
|
142
179
|
};
|
|
143
180
|
}
|
package/dist/runner/cache.d.ts
CHANGED
|
@@ -5,14 +5,27 @@
|
|
|
5
5
|
* Handles reading, writing, and decoding cache entries.
|
|
6
6
|
*/
|
|
7
7
|
import type { MutantCacheEntry } from '../types/mutant.js';
|
|
8
|
+
/**
|
|
9
|
+
* Get the cache filename for a given shard (or the default if none).
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCacheFilename(shard?: {
|
|
12
|
+
index: number;
|
|
13
|
+
total: number;
|
|
14
|
+
}): string;
|
|
8
15
|
/**
|
|
9
16
|
* Clear the cache file at the start of a run.
|
|
10
17
|
*/
|
|
11
|
-
export declare function clearCacheOnStart(cwd: string
|
|
18
|
+
export declare function clearCacheOnStart(cwd: string, shard?: {
|
|
19
|
+
index: number;
|
|
20
|
+
total: number;
|
|
21
|
+
}): Promise<void>;
|
|
12
22
|
/**
|
|
13
23
|
* Save cache atomically using a temp file + rename.
|
|
14
24
|
*/
|
|
15
|
-
export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry
|
|
25
|
+
export declare function saveCacheAtomic(cwd: string, cache: Record<string, MutantCacheEntry>, shard?: {
|
|
26
|
+
index: number;
|
|
27
|
+
total: number;
|
|
28
|
+
}): Promise<void>;
|
|
16
29
|
/**
|
|
17
30
|
* Decode a cache key into its component parts.
|
|
18
31
|
* Cache keys have the format: testSig:codeSig:file:line,col:mutator
|
|
@@ -35,4 +48,7 @@ export declare function hash(s: string): string;
|
|
|
35
48
|
* Read the mutant cache from disk.
|
|
36
49
|
* Normalizes both old (string status) and new (object) formats.
|
|
37
50
|
*/
|
|
38
|
-
export declare function readMutantCache(cwd: string
|
|
51
|
+
export declare function readMutantCache(cwd: string, shard?: {
|
|
52
|
+
index: number;
|
|
53
|
+
total: number;
|
|
54
|
+
}): Promise<Record<string, MutantCacheEntry>>;
|
package/dist/runner/cache.js
CHANGED
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import crypto from 'node:crypto';
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Get the cache filename for a given shard (or the default if none).
|
|
12
|
+
*/
|
|
13
|
+
export function getCacheFilename(shard) {
|
|
14
|
+
if (!shard)
|
|
15
|
+
return '.mutineer-cache.json';
|
|
16
|
+
return `.mutineer-cache-shard-${shard.index}-of-${shard.total}.json`;
|
|
17
|
+
}
|
|
11
18
|
/**
|
|
12
19
|
* Clear the cache file at the start of a run.
|
|
13
20
|
*/
|
|
14
|
-
export async function clearCacheOnStart(cwd) {
|
|
15
|
-
const p = path.join(cwd,
|
|
21
|
+
export async function clearCacheOnStart(cwd, shard) {
|
|
22
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
16
23
|
try {
|
|
17
24
|
await fs.unlink(p);
|
|
18
25
|
}
|
|
@@ -23,8 +30,8 @@ export async function clearCacheOnStart(cwd) {
|
|
|
23
30
|
/**
|
|
24
31
|
* Save cache atomically using a temp file + rename.
|
|
25
32
|
*/
|
|
26
|
-
export async function saveCacheAtomic(cwd, cache) {
|
|
27
|
-
const p = path.join(cwd,
|
|
33
|
+
export async function saveCacheAtomic(cwd, cache, shard) {
|
|
34
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
28
35
|
const tmp = p + '.tmp';
|
|
29
36
|
const json = JSON.stringify(cache, null, 2);
|
|
30
37
|
await fs.writeFile(tmp, json, 'utf8');
|
|
@@ -87,8 +94,8 @@ export function hash(s) {
|
|
|
87
94
|
* Read the mutant cache from disk.
|
|
88
95
|
* Normalizes both old (string status) and new (object) formats.
|
|
89
96
|
*/
|
|
90
|
-
export async function readMutantCache(cwd) {
|
|
91
|
-
const p = path.join(cwd,
|
|
97
|
+
export async function readMutantCache(cwd, shard) {
|
|
98
|
+
const p = path.join(cwd, getCacheFilename(shard));
|
|
92
99
|
try {
|
|
93
100
|
const data = await fs.readFile(p, 'utf8');
|
|
94
101
|
const raw = JSON.parse(data);
|
package/dist/runner/cleanup.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Clean up all __mutineer__ temp directories created during mutation testing.
|
|
3
3
|
*/
|
|
4
|
-
export declare function cleanupMutineerDirs(cwd: string
|
|
4
|
+
export declare function cleanupMutineerDirs(cwd: string, opts?: {
|
|
5
|
+
includeCacheFiles?: boolean;
|
|
6
|
+
}): Promise<void>;
|
package/dist/runner/cleanup.js
CHANGED
|
@@ -2,9 +2,9 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
/**
|
|
3
3
|
* Clean up all __mutineer__ temp directories created during mutation testing.
|
|
4
4
|
*/
|
|
5
|
-
export async function cleanupMutineerDirs(cwd) {
|
|
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,
|
|
@@ -18,4 +18,21 @@ export async function cleanupMutineerDirs(cwd) {
|
|
|
18
18
|
// Ignore cleanup errors
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
if (opts.includeCacheFiles) {
|
|
22
|
+
// Remove cache files (new name + legacy .mutate-cache* for migration)
|
|
23
|
+
const cacheFiles = await glob.default([
|
|
24
|
+
'.mutineer-cache*.json',
|
|
25
|
+
'.mutineer-cache*.json.tmp',
|
|
26
|
+
'.mutate-cache*.json',
|
|
27
|
+
'.mutate-cache*.json.tmp',
|
|
28
|
+
], { cwd, absolute: true });
|
|
29
|
+
for (const f of cacheFiles) {
|
|
30
|
+
try {
|
|
31
|
+
await fs.unlink(f);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore cleanup errors
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
}
|
|
@@ -15,7 +15,7 @@ export async function resolveCoverageConfig(opts, cfg, adapter, cliArgs) {
|
|
|
15
15
|
? true
|
|
16
16
|
: coveragePreference === false
|
|
17
17
|
? false
|
|
18
|
-
: isCoverageRequestedInArgs([...cliArgs])
|
|
18
|
+
: isCoverageRequestedInArgs([...cliArgs]);
|
|
19
19
|
// Load pre-existing coverage data if provided
|
|
20
20
|
let coverageData = null;
|
|
21
21
|
if (opts.coverageFilePath) {
|
|
@@ -5,4 +5,4 @@ export interface DiscoveryResult {
|
|
|
5
5
|
readonly testMap: TestMap;
|
|
6
6
|
readonly directTestMap: TestMap;
|
|
7
7
|
}
|
|
8
|
-
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
|
|
8
|
+
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig, onProgress?: (msg: string) => void): Promise<DiscoveryResult>;
|
package/dist/runner/discover.js
CHANGED
|
@@ -200,7 +200,7 @@ async function createResolver(rootAbs, exts) {
|
|
|
200
200
|
return createNodeResolver();
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
203
|
+
export async function autoDiscoverTargetsAndTests(root, cfg, onProgress) {
|
|
204
204
|
const rootAbs = path.resolve(root);
|
|
205
205
|
const sourceRoots = toArray(cfg.source ?? 'src').map((s) => path.resolve(rootAbs, s));
|
|
206
206
|
const exts = new Set(toArray(cfg.extensions ?? EXT_DEFAULT));
|
|
@@ -219,6 +219,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
219
219
|
if (!tests.length)
|
|
220
220
|
return { targets: [], testMap: new Map(), directTestMap: new Map() };
|
|
221
221
|
const testSet = new Set(tests.map((t) => normalizePath(t)));
|
|
222
|
+
onProgress?.(`Found ${tests.length} test file(s), resolving imports...`);
|
|
222
223
|
// 2) Create resolver (Vite if available, otherwise Node-based fallback)
|
|
223
224
|
const { resolve, cleanup } = await createResolver(rootAbs, exts);
|
|
224
225
|
const targets = new Map();
|
|
@@ -226,6 +227,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
226
227
|
const directTestMap = new Map();
|
|
227
228
|
const contentCache = new Map();
|
|
228
229
|
const resolveCache = new Map(); // key: importer\0spec -> resolved id
|
|
230
|
+
const childrenCache = new Map(); // key: normalized file -> resolved child abs paths
|
|
229
231
|
async function crawl(absFile, depth, seen, currentTestAbs) {
|
|
230
232
|
if (depth > MAX_CRAWL_DEPTH)
|
|
231
233
|
return; // sane guard for huge graphs
|
|
@@ -266,27 +268,34 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
266
268
|
}
|
|
267
269
|
if (!code)
|
|
268
270
|
return;
|
|
269
|
-
// find import specs and resolve relative to absFile
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
resolveCache.
|
|
271
|
+
// find import specs and resolve relative to absFile, memoized per file
|
|
272
|
+
let children = childrenCache.get(key);
|
|
273
|
+
if (children === undefined) {
|
|
274
|
+
const resolved = [];
|
|
275
|
+
for (const spec of extractImportSpecs(code)) {
|
|
276
|
+
if (!spec)
|
|
277
|
+
continue;
|
|
278
|
+
const cacheKey = `${absFile}\0${spec}`;
|
|
279
|
+
let resolvedId = resolveCache.get(cacheKey);
|
|
280
|
+
if (!resolvedId) {
|
|
281
|
+
resolvedId = await resolve(spec, absFile);
|
|
282
|
+
resolveCache.set(cacheKey, resolvedId);
|
|
283
|
+
}
|
|
284
|
+
// vite ids could be URLs; ensure we turn into absolute disk path when possible
|
|
285
|
+
const next = path.isAbsolute(resolvedId)
|
|
286
|
+
? resolvedId
|
|
287
|
+
: normalizePath(path.resolve(rootAbs, resolvedId));
|
|
288
|
+
// skip node_modules and virtual ids
|
|
289
|
+
if (next.includes('/node_modules/'))
|
|
290
|
+
continue;
|
|
291
|
+
if (!path.isAbsolute(next))
|
|
292
|
+
continue;
|
|
293
|
+
resolved.push(next);
|
|
278
294
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
? resolved
|
|
282
|
-
: normalizePath(path.resolve(rootAbs, resolved));
|
|
283
|
-
// skip node_modules and virtual ids
|
|
284
|
-
if (next.includes('/node_modules/'))
|
|
285
|
-
continue;
|
|
286
|
-
if (!path.isAbsolute(next))
|
|
287
|
-
continue;
|
|
288
|
-
await crawl(next, depth + 1, seen, currentTestAbs);
|
|
295
|
+
childrenCache.set(key, resolved);
|
|
296
|
+
children = resolved;
|
|
289
297
|
}
|
|
298
|
+
await Promise.all(children.map((next) => crawl(next, depth + 1, seen, currentTestAbs)));
|
|
290
299
|
}
|
|
291
300
|
try {
|
|
292
301
|
await Promise.all(tests.map(async (testAbs) => {
|
|
@@ -312,6 +321,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
312
321
|
await crawl(abs, 0, seen, testAbs);
|
|
313
322
|
}
|
|
314
323
|
}));
|
|
324
|
+
onProgress?.(`Discovery complete: ${targets.size} source file(s), ${tests.length} test file(s)`);
|
|
315
325
|
return { targets: Array.from(targets.values()), testMap, directTestMap };
|
|
316
326
|
}
|
|
317
327
|
finally {
|
|
@@ -9,5 +9,6 @@
|
|
|
9
9
|
* 5. Execute mutants via worker pool
|
|
10
10
|
* 6. Report results
|
|
11
11
|
*/
|
|
12
|
+
export declare function parseMutantTimeoutMs(raw: string | undefined): number;
|
|
12
13
|
export { readMutantCache } from './cache.js';
|
|
13
14
|
export declare function runOrchestrator(cliArgs: string[], cwd: string): Promise<void>;
|
|
@@ -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,16 +25,19 @@ 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
|
-
|
|
30
|
-
const raw = process.env.MUTINEER_MUTANT_TIMEOUT_MS;
|
|
36
|
+
export function parseMutantTimeoutMs(raw) {
|
|
31
37
|
const n = raw ? Number(raw) : NaN;
|
|
32
38
|
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
33
|
-
}
|
|
39
|
+
}
|
|
40
|
+
const MUTANT_TIMEOUT_MS = parseMutantTimeoutMs(process.env.MUTINEER_MUTANT_TIMEOUT_MS);
|
|
34
41
|
// Re-export readMutantCache for external use
|
|
35
42
|
export { readMutantCache } from './cache.js';
|
|
36
43
|
export async function runOrchestrator(cliArgs, cwd) {
|
|
@@ -38,14 +45,17 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
38
45
|
const cfgPath = extractConfigPath(cliArgs);
|
|
39
46
|
const cfg = await loadMutineerConfig(cwd, cfgPath);
|
|
40
47
|
const opts = parseCliOptions(cliArgs, cfg);
|
|
41
|
-
await clearCacheOnStart(cwd);
|
|
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);
|
|
@@ -66,8 +76,9 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
66
76
|
}))
|
|
67
77
|
: null;
|
|
68
78
|
// 4. Discover targets and tests
|
|
69
|
-
const cache = await readMutantCache(cwd);
|
|
70
|
-
|
|
79
|
+
const cache = await readMutantCache(cwd, opts.shard);
|
|
80
|
+
log.info('Discovering tests...');
|
|
81
|
+
const discovered = await autoDiscoverTargetsAndTests(cwd, cfg, (msg) => log.info(msg));
|
|
71
82
|
const { testMap, directTestMap } = discovered;
|
|
72
83
|
const targets = cfg.targets?.length
|
|
73
84
|
? [...cfg.targets]
|
|
@@ -104,24 +115,30 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
if (!baselineTests.length) {
|
|
107
|
-
log.
|
|
118
|
+
log.error('No tests found for the selected targets. Ensure your source files are covered by at least one test file.');
|
|
119
|
+
process.exitCode = 1;
|
|
108
120
|
return;
|
|
109
121
|
}
|
|
110
122
|
// 5. Run baseline tests (with coverage if needed for filtering)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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');
|
|
119
137
|
}
|
|
120
|
-
log.info('\u2713 Baseline tests complete');
|
|
121
138
|
// 6. Load coverage from baseline if we generated it
|
|
122
139
|
const updatedCoverage = await loadCoverageAfterBaseline(coverage, cwd);
|
|
123
140
|
// 7. Enumerate mutation variants
|
|
124
|
-
|
|
141
|
+
let variants = await enumerateAllVariants({
|
|
125
142
|
cwd,
|
|
126
143
|
targets,
|
|
127
144
|
testMap,
|
|
@@ -136,8 +153,83 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
136
153
|
log.info(msg);
|
|
137
154
|
return;
|
|
138
155
|
}
|
|
139
|
-
//
|
|
140
|
-
|
|
156
|
+
// Apply shard filter before type-checking so each shard only processes its own mutants
|
|
157
|
+
if (opts.shard) {
|
|
158
|
+
const { index, total } = opts.shard;
|
|
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.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
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);
|
|
141
233
|
await executePool({
|
|
142
234
|
tasks,
|
|
143
235
|
adapter,
|
|
@@ -145,6 +237,9 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
145
237
|
concurrency: opts.concurrency,
|
|
146
238
|
progressMode: opts.progressMode,
|
|
147
239
|
minKillPercent: opts.minKillPercent,
|
|
240
|
+
reportFormat: opts.reportFormat,
|
|
148
241
|
cwd,
|
|
242
|
+
shard: opts.shard,
|
|
243
|
+
fallbackIds,
|
|
149
244
|
});
|
|
150
245
|
}
|
|
@@ -8,7 +8,14 @@ export interface PoolExecutionOptions {
|
|
|
8
8
|
concurrency: number;
|
|
9
9
|
progressMode: 'bar' | 'list' | 'quiet';
|
|
10
10
|
minKillPercent?: number;
|
|
11
|
+
reportFormat?: 'text' | 'json';
|
|
11
12
|
cwd: string;
|
|
13
|
+
shard?: {
|
|
14
|
+
index: number;
|
|
15
|
+
total: number;
|
|
16
|
+
};
|
|
17
|
+
/** IDs of variants that must use the legacy redirect path (overlapping diff ranges). */
|
|
18
|
+
fallbackIds?: Set<string>;
|
|
12
19
|
}
|
|
13
20
|
/**
|
|
14
21
|
* Execute all mutant tasks through the worker pool.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { render } from 'ink';
|
|
3
4
|
import { createElement } from 'react';
|
|
4
5
|
import { Progress } from '../utils/progress.js';
|
|
5
|
-
import { computeSummary, printSummary } from '../utils/summary.js';
|
|
6
|
+
import { computeSummary, printSummary, buildJsonReport, } from '../utils/summary.js';
|
|
6
7
|
import { saveCacheAtomic } from './cache.js';
|
|
7
8
|
import { cleanupMutineerDirs } from './cleanup.js';
|
|
8
9
|
import { PoolSpinner } from '../utils/PoolSpinner.js';
|
|
10
|
+
import { CompileErrors } from '../utils/CompileErrors.js';
|
|
9
11
|
import { createLogger } from '../utils/logger.js';
|
|
10
12
|
const log = createLogger('pool-executor');
|
|
11
13
|
/**
|
|
@@ -22,14 +24,33 @@ export async function executePool(opts) {
|
|
|
22
24
|
const mutationStartTime = Date.now();
|
|
23
25
|
// Ensure we only finish once
|
|
24
26
|
let finished = false;
|
|
25
|
-
const finishOnce = () => {
|
|
27
|
+
const finishOnce = async (interactive = true) => {
|
|
26
28
|
if (finished)
|
|
27
29
|
return;
|
|
28
30
|
finished = true;
|
|
29
31
|
const durationMs = Date.now() - mutationStartTime;
|
|
30
32
|
progress.finish();
|
|
31
33
|
const summary = computeSummary(cache);
|
|
32
|
-
|
|
34
|
+
if (opts.reportFormat === 'json') {
|
|
35
|
+
const report = buildJsonReport(summary, cache, durationMs);
|
|
36
|
+
const shardSuffix = opts.shard
|
|
37
|
+
? `-shard-${opts.shard.index}-of-${opts.shard.total}`
|
|
38
|
+
: '';
|
|
39
|
+
const outPath = path.join(opts.cwd, `mutineer-report${shardSuffix}.json`);
|
|
40
|
+
fs.writeFileSync(outPath, JSON.stringify(report, null, 2));
|
|
41
|
+
log.info(`JSON report written to ${path.relative(process.cwd(), outPath)}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
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
|
+
}
|
|
53
|
+
}
|
|
33
54
|
if (opts.minKillPercent !== undefined) {
|
|
34
55
|
const killRateString = summary.killRate.toFixed(2);
|
|
35
56
|
const thresholdString = opts.minKillPercent.toFixed(2);
|
|
@@ -68,7 +89,7 @@ export async function executePool(opts) {
|
|
|
68
89
|
let nextIdx = 0;
|
|
69
90
|
async function processTask(task) {
|
|
70
91
|
const { v, tests, key, directTests } = task;
|
|
71
|
-
|
|
92
|
+
const { fallbackIds } = opts;
|
|
72
93
|
const cached = cache[key];
|
|
73
94
|
if (cached) {
|
|
74
95
|
progress.update(cached.status);
|
|
@@ -93,6 +114,7 @@ export async function executePool(opts) {
|
|
|
93
114
|
code: v.code,
|
|
94
115
|
line: v.line,
|
|
95
116
|
col: v.col,
|
|
117
|
+
isFallback: !fallbackIds || fallbackIds.has(v.id),
|
|
96
118
|
}, tests);
|
|
97
119
|
const status = result.status;
|
|
98
120
|
let originalSnippet;
|
|
@@ -148,7 +170,7 @@ export async function executePool(opts) {
|
|
|
148
170
|
return;
|
|
149
171
|
signalCleanedUp = true;
|
|
150
172
|
log.info(`\nReceived ${signal}, cleaning up...`);
|
|
151
|
-
finishOnce();
|
|
173
|
+
await finishOnce(false);
|
|
152
174
|
await adapter.shutdown();
|
|
153
175
|
await cleanupMutineerDirs(cwd);
|
|
154
176
|
process.exit(1);
|
|
@@ -159,13 +181,13 @@ export async function executePool(opts) {
|
|
|
159
181
|
for (let i = 0; i < workerCount; i++)
|
|
160
182
|
workers.push(worker());
|
|
161
183
|
await Promise.all(workers);
|
|
162
|
-
await saveCacheAtomic(cwd, cache);
|
|
184
|
+
await saveCacheAtomic(cwd, cache, opts.shard);
|
|
163
185
|
}
|
|
164
186
|
finally {
|
|
165
187
|
process.removeAllListeners('SIGINT');
|
|
166
188
|
process.removeAllListeners('SIGTERM');
|
|
167
189
|
if (!signalCleanedUp) {
|
|
168
|
-
finishOnce();
|
|
190
|
+
await finishOnce();
|
|
169
191
|
await adapter.shutdown();
|
|
170
192
|
await cleanupMutineerDirs(cwd);
|
|
171
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';
|