@mutineerjs/mutineer 0.4.0 → 0.5.1
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/dist/core/__tests__/variant-utils.spec.js +24 -2
- package/dist/core/sfc.js +6 -1
- package/dist/core/variant-utils.js +6 -1
- package/dist/mutators/__tests__/operator.spec.js +16 -0
- package/dist/mutators/__tests__/return-value.spec.js +37 -0
- package/dist/mutators/__tests__/utils.spec.js +96 -2
- package/dist/mutators/operator.js +11 -5
- package/dist/mutators/return-value.js +36 -22
- package/dist/mutators/types.d.ts +2 -0
- package/dist/mutators/utils.d.ts +67 -0
- package/dist/mutators/utils.js +90 -15
- package/dist/runner/__tests__/discover.spec.js +80 -1
- package/dist/runner/__tests__/pool-executor.spec.js +124 -0
- package/dist/runner/__tests__/tasks.spec.js +14 -0
- package/dist/runner/discover.d.ts +1 -0
- package/dist/runner/discover.js +29 -14
- package/dist/runner/orchestrator.js +2 -2
- package/dist/runner/pool-executor.js +12 -2
- package/dist/runner/tasks.d.ts +3 -1
- package/dist/runner/tasks.js +10 -2
- package/dist/types/mutant.d.ts +1 -0
- package/dist/utils/__tests__/summary.spec.js +26 -0
- package/dist/utils/summary.js +9 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generateMutationVariants, getFilteredRegistry } from '../variant-utils.js';
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { generateMutationVariants, getFilteredRegistry, } from '../variant-utils.js';
|
|
3
3
|
function makeMutator(name, mutations) {
|
|
4
4
|
return {
|
|
5
5
|
name,
|
|
@@ -72,6 +72,28 @@ describe('generateMutationVariants', () => {
|
|
|
72
72
|
const result = generateMutationVariants([mutator], 'code', {});
|
|
73
73
|
expect(result).toHaveLength(2);
|
|
74
74
|
});
|
|
75
|
+
it('calls applyWithContext instead of apply when available', () => {
|
|
76
|
+
const applyWithContext = vi
|
|
77
|
+
.fn()
|
|
78
|
+
.mockReturnValue([{ code: 'ctx_result', line: 1, col: 0 }]);
|
|
79
|
+
const mutator = {
|
|
80
|
+
name: 'm',
|
|
81
|
+
description: 'm',
|
|
82
|
+
apply: () => [{ code: 'apply_result', line: 1, col: 0 }],
|
|
83
|
+
applyWithContext,
|
|
84
|
+
};
|
|
85
|
+
const result = generateMutationVariants([mutator], 'const x = 1');
|
|
86
|
+
expect(applyWithContext).toHaveBeenCalledOnce();
|
|
87
|
+
expect(result[0].code).toBe('ctx_result');
|
|
88
|
+
});
|
|
89
|
+
it('falls back to apply when applyWithContext is absent', () => {
|
|
90
|
+
const apply = vi
|
|
91
|
+
.fn()
|
|
92
|
+
.mockReturnValue([{ code: 'apply_result', line: 1, col: 0 }]);
|
|
93
|
+
const mutator = { name: 'm', description: 'm', apply };
|
|
94
|
+
generateMutationVariants([mutator], 'const x = 1');
|
|
95
|
+
expect(apply).toHaveBeenCalledOnce();
|
|
96
|
+
});
|
|
75
97
|
});
|
|
76
98
|
describe('getFilteredRegistry', () => {
|
|
77
99
|
it('returns the full registry when no filters', () => {
|
package/dist/core/sfc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import MagicString from 'magic-string';
|
|
2
2
|
import { getFilteredRegistry } from './variant-utils.js';
|
|
3
|
+
import { buildParseContext } from '../mutators/utils.js';
|
|
3
4
|
/**
|
|
4
5
|
* Generate all possible mutations for a Vue SFC `<script setup>` block.
|
|
5
6
|
* @param filename - The path to the Vue file (used by the parser for error reporting)
|
|
@@ -26,12 +27,16 @@ export async function mutateVueSfcScriptSetup(filename, code, include, exclude,
|
|
|
26
27
|
const registry = getFilteredRegistry(include, exclude);
|
|
27
28
|
const variants = [];
|
|
28
29
|
const seenOutputs = new Set();
|
|
30
|
+
const ctx = buildParseContext(originalBlock);
|
|
29
31
|
for (const mutator of registry) {
|
|
30
32
|
// Early termination if limit reached
|
|
31
33
|
if (max !== undefined && variants.length >= max) {
|
|
32
34
|
break;
|
|
33
35
|
}
|
|
34
|
-
|
|
36
|
+
const blockMutations = mutator.applyWithContext
|
|
37
|
+
? mutator.applyWithContext(originalBlock, ctx)
|
|
38
|
+
: mutator.apply(originalBlock);
|
|
39
|
+
for (const mutation of blockMutations) {
|
|
35
40
|
const ms = new MagicString(code);
|
|
36
41
|
ms.overwrite(startOffset, endOffset, mutation.code);
|
|
37
42
|
const mutatedOutput = ms.toString();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getRegistry } from '../mutators/registry.js';
|
|
2
|
+
import { buildParseContext } from '../mutators/utils.js';
|
|
2
3
|
/**
|
|
3
4
|
* Generate mutations from a registry of mutators, deduplicating the output.
|
|
4
5
|
* Supports early termination when max limit is reached.
|
|
@@ -17,12 +18,16 @@ export function generateMutationVariants(registry, code, opts = {}) {
|
|
|
17
18
|
}
|
|
18
19
|
const variants = [];
|
|
19
20
|
const seen = new Set();
|
|
21
|
+
const ctx = buildParseContext(code);
|
|
20
22
|
for (const mutator of registry) {
|
|
21
23
|
// Early termination if limit reached
|
|
22
24
|
if (max !== undefined && variants.length >= max) {
|
|
23
25
|
break;
|
|
24
26
|
}
|
|
25
|
-
|
|
27
|
+
const mutations = mutator.applyWithContext
|
|
28
|
+
? mutator.applyWithContext(code, ctx)
|
|
29
|
+
: mutator.apply(code);
|
|
30
|
+
for (const mutation of mutations) {
|
|
26
31
|
// Skip unchanged code and duplicates in a single check
|
|
27
32
|
if (!seen.has(mutation.code)) {
|
|
28
33
|
seen.add(mutation.code);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, } from '../operator.js';
|
|
3
|
+
import { buildParseContext } from '../utils.js';
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Shared behaviour (tested once; all mutators use the same factory)
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -37,6 +38,21 @@ describe('operator mutator shared behaviour', () => {
|
|
|
37
38
|
});
|
|
38
39
|
});
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
41
|
+
// applyWithContext (shared behaviour)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
describe('operator mutator applyWithContext', () => {
|
|
44
|
+
it('produces same results as apply', () => {
|
|
45
|
+
const src = `const ok = a && b && c`;
|
|
46
|
+
const ctx = buildParseContext(src);
|
|
47
|
+
expect(andToOr.applyWithContext(src, ctx)).toEqual(andToOr.apply(src));
|
|
48
|
+
});
|
|
49
|
+
it('respects disable comments via pre-built context', () => {
|
|
50
|
+
const src = `// mutineer-disable-next-line\nconst ok = a && b`;
|
|
51
|
+
const ctx = buildParseContext(src);
|
|
52
|
+
expect(andToOr.applyWithContext(src, ctx)).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
40
56
|
// Boundary mutators
|
|
41
57
|
// ---------------------------------------------------------------------------
|
|
42
58
|
describe('relaxLE', () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { returnToNull, returnToUndefined, returnFlipBool, returnZero, returnEmptyStr, returnEmptyArr, } from '../return-value.js';
|
|
3
|
+
import { buildParseContext } from '../utils.js';
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// returnToNull
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
@@ -237,3 +238,39 @@ describe('returnEmptyArr', () => {
|
|
|
237
238
|
expect(results).toHaveLength(0);
|
|
238
239
|
});
|
|
239
240
|
});
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// applyWithContext (shared behaviour)
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
describe('return-value mutator applyWithContext', () => {
|
|
245
|
+
it('produces same results as apply', () => {
|
|
246
|
+
const src = `function f() { return x }`;
|
|
247
|
+
const ctx = buildParseContext(src);
|
|
248
|
+
expect(returnToNull.applyWithContext(src, ctx)).toEqual(returnToNull.apply(src));
|
|
249
|
+
});
|
|
250
|
+
it('respects disable comments via pre-built context', () => {
|
|
251
|
+
const src = `function f() {\n // mutineer-disable-next-line\n return x\n}`;
|
|
252
|
+
const ctx = buildParseContext(src);
|
|
253
|
+
expect(returnToNull.applyWithContext(src, ctx)).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
it('all return mutators produce same results via applyWithContext as apply', () => {
|
|
256
|
+
const src = `
|
|
257
|
+
function f(x) {
|
|
258
|
+
if (x) return true
|
|
259
|
+
if (x > 0) return 42
|
|
260
|
+
if (x < 0) return 'hello'
|
|
261
|
+
return [1, 2]
|
|
262
|
+
}
|
|
263
|
+
`;
|
|
264
|
+
const ctx = buildParseContext(src);
|
|
265
|
+
for (const mutator of [
|
|
266
|
+
returnToNull,
|
|
267
|
+
returnToUndefined,
|
|
268
|
+
returnFlipBool,
|
|
269
|
+
returnZero,
|
|
270
|
+
returnEmptyStr,
|
|
271
|
+
returnEmptyArr,
|
|
272
|
+
]) {
|
|
273
|
+
expect(mutator.applyWithContext(src, ctx)).toEqual(mutator.apply(src));
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { collectOperatorTargets, buildIgnoreLines, parseSource, } from '../utils.js';
|
|
2
|
+
import { collectOperatorTargets, collectOperatorTargetsFromContext, collectAllTargets, buildIgnoreLines, buildParseContext, parseSource, } from '../utils.js';
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// buildIgnoreLines
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
@@ -65,7 +65,86 @@ describe('parseSource', () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
|
-
//
|
|
68
|
+
// buildParseContext
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
describe('buildParseContext', () => {
|
|
71
|
+
it('returns an object with ast, tokens, ignoreLines, and preCollected', () => {
|
|
72
|
+
const ctx = buildParseContext(`const x = a && b`);
|
|
73
|
+
expect(ctx.ast.type).toBe('File');
|
|
74
|
+
expect(Array.isArray(ctx.tokens)).toBe(true);
|
|
75
|
+
expect(ctx.ignoreLines).toBeInstanceOf(Set);
|
|
76
|
+
expect(ctx.preCollected).toBeDefined();
|
|
77
|
+
expect(ctx.preCollected.operatorTargets).toBeInstanceOf(Map);
|
|
78
|
+
expect(Array.isArray(ctx.preCollected.returnStatements)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
it('populates ignoreLines from disable comments', () => {
|
|
81
|
+
const src = `// mutineer-disable-next-line\nconst x = a && b`;
|
|
82
|
+
const ctx = buildParseContext(src);
|
|
83
|
+
expect(ctx.ignoreLines.has(2)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it('preCollected.operatorTargets groups targets by operator', () => {
|
|
86
|
+
const src = `const x = a && b || c && d`;
|
|
87
|
+
const ctx = buildParseContext(src);
|
|
88
|
+
expect(ctx.preCollected.operatorTargets.get('&&')).toHaveLength(2);
|
|
89
|
+
expect(ctx.preCollected.operatorTargets.get('||')).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
it('preCollected.returnStatements captures return arguments', () => {
|
|
92
|
+
const src = `function f() { return x }\nfunction g() { return y }`;
|
|
93
|
+
const ctx = buildParseContext(src);
|
|
94
|
+
expect(ctx.preCollected.returnStatements).toHaveLength(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// collectAllTargets
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
describe('collectAllTargets', () => {
|
|
101
|
+
it('collects operator targets grouped by operator', () => {
|
|
102
|
+
const src = `const x = a && b || c`;
|
|
103
|
+
const ctx = buildParseContext(src);
|
|
104
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
105
|
+
expect(result.operatorTargets.get('&&')).toHaveLength(1);
|
|
106
|
+
expect(result.operatorTargets.get('||')).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
it('collects return statement info', () => {
|
|
109
|
+
const src = `function f() { return 42 }`;
|
|
110
|
+
const ctx = buildParseContext(src);
|
|
111
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
112
|
+
expect(result.returnStatements).toHaveLength(1);
|
|
113
|
+
const info = result.returnStatements[0];
|
|
114
|
+
expect(info.line).toBe(1);
|
|
115
|
+
expect(typeof info.col).toBe('number');
|
|
116
|
+
expect(typeof info.argStart).toBe('number');
|
|
117
|
+
expect(typeof info.argEnd).toBe('number');
|
|
118
|
+
expect(info.argNode).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
it('skips operators on ignored lines', () => {
|
|
121
|
+
const src = `// mutineer-disable-next-line\nconst x = a && b`;
|
|
122
|
+
const ctx = buildParseContext(src);
|
|
123
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
124
|
+
expect(result.operatorTargets.get('&&') ?? []).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
it('skips return statements on ignored lines', () => {
|
|
127
|
+
const src = `function f() {\n // mutineer-disable-next-line\n return x\n}`;
|
|
128
|
+
const ctx = buildParseContext(src);
|
|
129
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
130
|
+
expect(result.returnStatements).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
it('skips bare return with no argument', () => {
|
|
133
|
+
const src = `function f() { return }`;
|
|
134
|
+
const ctx = buildParseContext(src);
|
|
135
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
136
|
+
expect(result.returnStatements).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
it('matches collectOperatorTargetsFromContext for &&', () => {
|
|
139
|
+
const src = `const x = a && b && c`;
|
|
140
|
+
const ctx = buildParseContext(src);
|
|
141
|
+
const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
|
|
142
|
+
const fromCtx = collectOperatorTargetsFromContext(src, ctx, '&&');
|
|
143
|
+
expect(result.operatorTargets.get('&&')).toEqual(fromCtx);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// collectOperatorTargets / collectOperatorTargetsFromContext
|
|
69
148
|
// ---------------------------------------------------------------------------
|
|
70
149
|
describe('collectOperatorTargets', () => {
|
|
71
150
|
it('honors mutineer disable comments', () => {
|
|
@@ -80,3 +159,18 @@ const d = e && f
|
|
|
80
159
|
expect(lines).toEqual([5]);
|
|
81
160
|
});
|
|
82
161
|
});
|
|
162
|
+
describe('collectOperatorTargetsFromContext', () => {
|
|
163
|
+
it('returns same results as collectOperatorTargets', () => {
|
|
164
|
+
const src = `const ok = a && b && c`;
|
|
165
|
+
const ctx = buildParseContext(src);
|
|
166
|
+
const fromCtx = collectOperatorTargetsFromContext(src, ctx, '&&');
|
|
167
|
+
const fromSrc = collectOperatorTargets(src, '&&');
|
|
168
|
+
expect(fromCtx).toEqual(fromSrc);
|
|
169
|
+
});
|
|
170
|
+
it('honors disable comments via pre-built context', () => {
|
|
171
|
+
const src = `// mutineer-disable-next-line\nconst x = a && b`;
|
|
172
|
+
const ctx = buildParseContext(src);
|
|
173
|
+
const targets = collectOperatorTargetsFromContext(src, ctx, '&&');
|
|
174
|
+
expect(targets).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -15,15 +15,21 @@ import { collectOperatorTargets } from './utils.js';
|
|
|
15
15
|
* @param toOp - The operator to replace it with (e.g., '||')
|
|
16
16
|
*/
|
|
17
17
|
function makeOperatorMutator(name, description, fromOp, toOp) {
|
|
18
|
+
function targetsToOutputs(src, targets) {
|
|
19
|
+
return targets.map((target) => ({
|
|
20
|
+
line: target.line,
|
|
21
|
+
col: target.col1,
|
|
22
|
+
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
18
25
|
return {
|
|
19
26
|
name,
|
|
20
27
|
description,
|
|
21
28
|
apply(src) {
|
|
22
|
-
return collectOperatorTargets(src, fromOp)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}));
|
|
29
|
+
return targetsToOutputs(src, collectOperatorTargets(src, fromOp));
|
|
30
|
+
},
|
|
31
|
+
applyWithContext(src, ctx) {
|
|
32
|
+
return targetsToOutputs(src, ctx.preCollected.operatorTargets.get(fromOp) ?? []);
|
|
27
33
|
},
|
|
28
34
|
};
|
|
29
35
|
}
|
|
@@ -20,6 +20,32 @@ import { traverse, getVisualColumn, parseSource, buildIgnoreLines, } from './uti
|
|
|
20
20
|
* @param description - Human-readable description shown in reports
|
|
21
21
|
* @param replacer - Returns the replacement source text, or null to skip
|
|
22
22
|
*/
|
|
23
|
+
function collectReturnMutations(src, ast, ignoreLines, replacer) {
|
|
24
|
+
const outputs = [];
|
|
25
|
+
traverse(ast, {
|
|
26
|
+
ReturnStatement(path) {
|
|
27
|
+
const node = path.node;
|
|
28
|
+
if (!node.argument)
|
|
29
|
+
return; // bare return; — nothing to replace
|
|
30
|
+
const line = node.loc?.start.line;
|
|
31
|
+
if (line === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (ignoreLines.has(line))
|
|
34
|
+
return;
|
|
35
|
+
const replacement = replacer(node.argument);
|
|
36
|
+
if (replacement === null)
|
|
37
|
+
return;
|
|
38
|
+
const argStart = node.argument.start;
|
|
39
|
+
const argEnd = node.argument.end;
|
|
40
|
+
if (argStart == null || argEnd == null)
|
|
41
|
+
return;
|
|
42
|
+
const col = getVisualColumn(src, node.start ?? 0);
|
|
43
|
+
const code = src.slice(0, argStart) + replacement + src.slice(argEnd);
|
|
44
|
+
outputs.push({ line, col, code });
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
return outputs;
|
|
48
|
+
}
|
|
23
49
|
function makeReturnMutator(name, description, replacer) {
|
|
24
50
|
return {
|
|
25
51
|
name,
|
|
@@ -28,29 +54,17 @@ function makeReturnMutator(name, description, replacer) {
|
|
|
28
54
|
const ast = parseSource(src);
|
|
29
55
|
const fileAst = ast;
|
|
30
56
|
const ignoreLines = buildIgnoreLines(fileAst.comments ?? []);
|
|
57
|
+
return collectReturnMutations(src, ast, ignoreLines, replacer);
|
|
58
|
+
},
|
|
59
|
+
applyWithContext(src, ctx) {
|
|
31
60
|
const outputs = [];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return;
|
|
40
|
-
if (ignoreLines.has(line))
|
|
41
|
-
return;
|
|
42
|
-
const replacement = replacer(node.argument);
|
|
43
|
-
if (replacement === null)
|
|
44
|
-
return;
|
|
45
|
-
const argStart = node.argument.start;
|
|
46
|
-
const argEnd = node.argument.end;
|
|
47
|
-
if (argStart == null || argEnd == null)
|
|
48
|
-
return;
|
|
49
|
-
const col = getVisualColumn(src, node.start ?? 0);
|
|
50
|
-
const code = src.slice(0, argStart) + replacement + src.slice(argEnd);
|
|
51
|
-
outputs.push({ line, col, code });
|
|
52
|
-
},
|
|
53
|
-
});
|
|
61
|
+
for (const info of ctx.preCollected.returnStatements) {
|
|
62
|
+
const replacement = replacer(info.argNode);
|
|
63
|
+
if (replacement === null)
|
|
64
|
+
continue;
|
|
65
|
+
const code = src.slice(0, info.argStart) + replacement + src.slice(info.argEnd);
|
|
66
|
+
outputs.push({ line: info.line, col: info.col, code });
|
|
67
|
+
}
|
|
54
68
|
return outputs;
|
|
55
69
|
},
|
|
56
70
|
};
|
package/dist/mutators/types.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Defines the interface that all mutators must implement and the output format
|
|
5
5
|
* for mutations. This allows for different mutator implementations to be plugged in.
|
|
6
6
|
*/
|
|
7
|
+
export type { ParseContext } from './utils.js';
|
|
7
8
|
/**
|
|
8
9
|
* Output of a single mutation, including location info and mutated code.
|
|
9
10
|
*/
|
|
@@ -20,6 +21,7 @@ export interface ASTMutator {
|
|
|
20
21
|
readonly name: string;
|
|
21
22
|
readonly description: string;
|
|
22
23
|
apply(src: string): readonly MutationOutput[];
|
|
24
|
+
applyWithContext?(src: string, ctx: import('./utils.js').ParseContext): readonly MutationOutput[];
|
|
23
25
|
}
|
|
24
26
|
/**
|
|
25
27
|
* Union type for different mutator kinds.
|
package/dist/mutators/utils.d.ts
CHANGED
|
@@ -46,6 +46,73 @@ export declare function parseSource(src: string): import("@babel/parser").ParseR
|
|
|
46
46
|
* Type guard to check if a node is a BinaryExpression or LogicalExpression.
|
|
47
47
|
*/
|
|
48
48
|
export declare function isBinaryOrLogical(node: t.Node): node is t.BinaryExpression | t.LogicalExpression;
|
|
49
|
+
/**
|
|
50
|
+
* Internal token-like interface for AST token analysis.
|
|
51
|
+
*/
|
|
52
|
+
export interface TokenLike {
|
|
53
|
+
readonly value?: string;
|
|
54
|
+
readonly start: number;
|
|
55
|
+
readonly end: number;
|
|
56
|
+
readonly loc: {
|
|
57
|
+
readonly start: {
|
|
58
|
+
readonly line: number;
|
|
59
|
+
readonly column: number;
|
|
60
|
+
};
|
|
61
|
+
readonly end: {
|
|
62
|
+
readonly line: number;
|
|
63
|
+
readonly column: number;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Location info for a single return statement argument, pre-collected
|
|
69
|
+
* during a single AST traversal so return-value mutators need no traversal.
|
|
70
|
+
*/
|
|
71
|
+
export interface ReturnStatementInfo {
|
|
72
|
+
readonly line: number;
|
|
73
|
+
readonly col: number;
|
|
74
|
+
readonly argStart: number;
|
|
75
|
+
readonly argEnd: number;
|
|
76
|
+
readonly argNode: t.Expression;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* All mutation targets pre-collected in a single traversal.
|
|
80
|
+
*/
|
|
81
|
+
export interface PreCollected {
|
|
82
|
+
readonly operatorTargets: Map<string, OperatorTarget[]>;
|
|
83
|
+
readonly returnStatements: ReturnStatementInfo[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pre-parsed context for a source file.
|
|
87
|
+
* Allows sharing a single Babel parse across all mutators.
|
|
88
|
+
*/
|
|
89
|
+
export interface ParseContext {
|
|
90
|
+
readonly ast: t.File & {
|
|
91
|
+
tokens?: TokenLike[];
|
|
92
|
+
comments?: t.Comment[];
|
|
93
|
+
};
|
|
94
|
+
readonly tokens: readonly TokenLike[];
|
|
95
|
+
readonly ignoreLines: Set<number>;
|
|
96
|
+
readonly preCollected: PreCollected;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Single traversal that collects all operator targets and return statements.
|
|
100
|
+
* Eliminates per-mutator traversals when using ParseContext.
|
|
101
|
+
*/
|
|
102
|
+
export declare function collectAllTargets(src: string, ast: t.File & {
|
|
103
|
+
tokens?: TokenLike[];
|
|
104
|
+
comments?: t.Comment[];
|
|
105
|
+
}, tokens: readonly TokenLike[], ignoreLines: Set<number>): PreCollected;
|
|
106
|
+
/**
|
|
107
|
+
* Parse a source file once and build a reusable ParseContext.
|
|
108
|
+
* Pass this to mutators' applyWithContext to avoid redundant parses.
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildParseContext(src: string): ParseContext;
|
|
111
|
+
/**
|
|
112
|
+
* Collect operator targets from a pre-built ParseContext.
|
|
113
|
+
* Avoids re-parsing; use when processing multiple operators on the same source.
|
|
114
|
+
*/
|
|
115
|
+
export declare function collectOperatorTargetsFromContext(src: string, ctx: ParseContext, opValue: string): OperatorTarget[];
|
|
49
116
|
/**
|
|
50
117
|
* Collect the operator tokens for a given operator and return their exact locations.
|
|
51
118
|
* Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
|
package/dist/mutators/utils.js
CHANGED
|
@@ -122,21 +122,86 @@ export function isBinaryOrLogical(node) {
|
|
|
122
122
|
return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* to token positions for accurate column reporting.
|
|
128
|
-
*
|
|
129
|
-
* @param src - The source code
|
|
130
|
-
* @param opValue - The operator to search for (e.g., '&&', '<=')
|
|
131
|
-
* @returns Array of target locations for the operator
|
|
125
|
+
* Single traversal that collects all operator targets and return statements.
|
|
126
|
+
* Eliminates per-mutator traversals when using ParseContext.
|
|
132
127
|
*/
|
|
133
|
-
export function
|
|
128
|
+
export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
129
|
+
const operatorTargets = new Map();
|
|
130
|
+
const returnStatements = [];
|
|
131
|
+
function handleBinaryOrLogical(n) {
|
|
132
|
+
const nodeStart = n.start ?? 0;
|
|
133
|
+
const nodeEnd = n.end ?? 0;
|
|
134
|
+
const opValue = n.operator;
|
|
135
|
+
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
136
|
+
if (tok) {
|
|
137
|
+
const line = tok.loc.start.line;
|
|
138
|
+
if (ignoreLines.has(line))
|
|
139
|
+
return;
|
|
140
|
+
const visualCol = getVisualColumn(src, tok.start);
|
|
141
|
+
let arr = operatorTargets.get(opValue);
|
|
142
|
+
if (!arr) {
|
|
143
|
+
arr = [];
|
|
144
|
+
operatorTargets.set(opValue, arr);
|
|
145
|
+
}
|
|
146
|
+
arr.push({
|
|
147
|
+
start: tok.start,
|
|
148
|
+
end: tok.end,
|
|
149
|
+
line,
|
|
150
|
+
col1: visualCol,
|
|
151
|
+
op: opValue,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
traverse(ast, {
|
|
156
|
+
BinaryExpression(p) {
|
|
157
|
+
handleBinaryOrLogical(p.node);
|
|
158
|
+
},
|
|
159
|
+
LogicalExpression(p) {
|
|
160
|
+
handleBinaryOrLogical(p.node);
|
|
161
|
+
},
|
|
162
|
+
ReturnStatement(p) {
|
|
163
|
+
const node = p.node;
|
|
164
|
+
if (!node.argument)
|
|
165
|
+
return;
|
|
166
|
+
const line = node.loc?.start.line;
|
|
167
|
+
if (line === undefined)
|
|
168
|
+
return;
|
|
169
|
+
if (ignoreLines.has(line))
|
|
170
|
+
return;
|
|
171
|
+
const argStart = node.argument.start;
|
|
172
|
+
const argEnd = node.argument.end;
|
|
173
|
+
if (argStart == null || argEnd == null)
|
|
174
|
+
return;
|
|
175
|
+
const col = getVisualColumn(src, node.start ?? 0);
|
|
176
|
+
returnStatements.push({
|
|
177
|
+
line,
|
|
178
|
+
col,
|
|
179
|
+
argStart,
|
|
180
|
+
argEnd,
|
|
181
|
+
argNode: node.argument,
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return { operatorTargets, returnStatements };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse a source file once and build a reusable ParseContext.
|
|
189
|
+
* Pass this to mutators' applyWithContext to avoid redundant parses.
|
|
190
|
+
*/
|
|
191
|
+
export function buildParseContext(src) {
|
|
134
192
|
const ast = parseSource(src);
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
193
|
+
const tokens = ast.tokens ?? [];
|
|
194
|
+
const ignoreLines = buildIgnoreLines(ast.comments ?? []);
|
|
195
|
+
const preCollected = collectAllTargets(src, ast, tokens, ignoreLines);
|
|
196
|
+
return { ast, tokens, ignoreLines, preCollected };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Collect operator targets from a pre-built ParseContext.
|
|
200
|
+
* Avoids re-parsing; use when processing multiple operators on the same source.
|
|
201
|
+
*/
|
|
202
|
+
export function collectOperatorTargetsFromContext(src, ctx, opValue) {
|
|
203
|
+
const { ast, tokens, ignoreLines } = ctx;
|
|
138
204
|
const out = [];
|
|
139
|
-
const ignoreLines = buildIgnoreLines(comments);
|
|
140
205
|
traverse(ast, {
|
|
141
206
|
enter(p) {
|
|
142
207
|
if (!isBinaryOrLogical(p.node))
|
|
@@ -144,12 +209,10 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
144
209
|
const n = p.node;
|
|
145
210
|
if (n.operator !== opValue)
|
|
146
211
|
return;
|
|
147
|
-
// Find the exact operator token inside the node span
|
|
148
212
|
const nodeStart = n.start ?? 0;
|
|
149
213
|
const nodeEnd = n.end ?? 0;
|
|
150
214
|
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
151
215
|
if (tok) {
|
|
152
|
-
// Convert Babel's character-based column to a visual column for accurate reporting
|
|
153
216
|
const line = tok.loc.start.line;
|
|
154
217
|
if (ignoreLines.has(line))
|
|
155
218
|
return;
|
|
@@ -158,7 +221,7 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
158
221
|
start: tok.start,
|
|
159
222
|
end: tok.end,
|
|
160
223
|
line,
|
|
161
|
-
col1: visualCol,
|
|
224
|
+
col1: visualCol,
|
|
162
225
|
op: opValue,
|
|
163
226
|
});
|
|
164
227
|
}
|
|
@@ -166,3 +229,15 @@ export function collectOperatorTargets(src, opValue) {
|
|
|
166
229
|
});
|
|
167
230
|
return out;
|
|
168
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Collect the operator tokens for a given operator and return their exact locations.
|
|
234
|
+
* Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
|
|
235
|
+
* to token positions for accurate column reporting.
|
|
236
|
+
*
|
|
237
|
+
* @param src - The source code
|
|
238
|
+
* @param opValue - The operator to search for (e.g., '&&', '<=')
|
|
239
|
+
* @returns Array of target locations for the operator
|
|
240
|
+
*/
|
|
241
|
+
export function collectOperatorTargets(src, opValue) {
|
|
242
|
+
return collectOperatorTargetsFromContext(src, buildParseContext(src), opValue);
|
|
243
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
@@ -34,7 +34,86 @@ vi.mock('vite', async () => {
|
|
|
34
34
|
}),
|
|
35
35
|
};
|
|
36
36
|
});
|
|
37
|
+
describe('createViteResolver Vue plugin gating', () => {
|
|
38
|
+
let warnSpy;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
});
|
|
45
|
+
it('does not warn about @vitejs/plugin-vue when no .vue files exist', async () => {
|
|
46
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-novue-'));
|
|
47
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
48
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
49
|
+
await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
|
|
50
|
+
const importLine = ['im', 'port { a } from "./a"'].join('');
|
|
51
|
+
await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
|
|
52
|
+
try {
|
|
53
|
+
await autoDiscoverTargetsAndTests(tmpDir, {
|
|
54
|
+
testPatterns: ['**/*.test.ts'],
|
|
55
|
+
});
|
|
56
|
+
const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
|
|
57
|
+
expect(pluginVueWarnings).toHaveLength(0);
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('warns when .vue files exist but @vitejs/plugin-vue fails to load', async () => {
|
|
64
|
+
vi.doMock('@vitejs/plugin-vue', () => {
|
|
65
|
+
throw new Error('Cannot find module @vitejs/plugin-vue');
|
|
66
|
+
});
|
|
67
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-vue-'));
|
|
68
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
69
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
70
|
+
await fs.writeFile(path.join(srcDir, 'Comp.vue'), '<template><div/></template>\n', 'utf8');
|
|
71
|
+
await fs.writeFile(path.join(srcDir, 'a.ts'), 'export const a = 1\n', 'utf8');
|
|
72
|
+
const importLine = ['im', 'port { a } from "./a"'].join('');
|
|
73
|
+
await fs.writeFile(path.join(srcDir, 'a.test.ts'), `${importLine}\n`, 'utf8');
|
|
74
|
+
try {
|
|
75
|
+
await autoDiscoverTargetsAndTests(tmpDir, {
|
|
76
|
+
testPatterns: ['**/*.test.ts'],
|
|
77
|
+
extensions: ['.vue', '.ts'],
|
|
78
|
+
});
|
|
79
|
+
const pluginVueWarnings = warnSpy.mock.calls.filter((args) => String(args[0]).includes('plugin-vue'));
|
|
80
|
+
expect(pluginVueWarnings.length).toBeGreaterThan(0);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
vi.doUnmock('@vitejs/plugin-vue');
|
|
84
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
37
88
|
describe('autoDiscoverTargetsAndTests', () => {
|
|
89
|
+
it('directTestMap only includes direct importers', async () => {
|
|
90
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
|
|
91
|
+
const srcDir = path.join(tmpDir, 'src');
|
|
92
|
+
const moduleX = path.join(srcDir, 'x.ts');
|
|
93
|
+
const moduleY = path.join(srcDir, 'y.ts');
|
|
94
|
+
const testFile = path.join(srcDir, 'a.test.ts');
|
|
95
|
+
await fs.mkdir(srcDir, { recursive: true });
|
|
96
|
+
await fs.writeFile(moduleY, 'export const y = 2\n', 'utf8');
|
|
97
|
+
const importY = ['im', 'port { y } from "./y"'].join('');
|
|
98
|
+
await fs.writeFile(moduleX, `${importY}\nexport const x = 1\n`, 'utf8');
|
|
99
|
+
const importX = ['im', 'port { x } from "./x"'].join('');
|
|
100
|
+
await fs.writeFile(testFile, `${importX}\nconsole.log(x)\n`, 'utf8');
|
|
101
|
+
try {
|
|
102
|
+
const { directTestMap } = await autoDiscoverTargetsAndTests(tmpDir, {
|
|
103
|
+
testPatterns: ['**/*.test.ts'],
|
|
104
|
+
});
|
|
105
|
+
const testAbs = normalizePath(testFile);
|
|
106
|
+
const xAbs = normalizePath(moduleX);
|
|
107
|
+
const yAbs = normalizePath(moduleY);
|
|
108
|
+
// x is directly imported by the test
|
|
109
|
+
expect(directTestMap.get(xAbs)?.has(testAbs)).toBe(true);
|
|
110
|
+
// y is only transitively imported (x -> y), not directly by the test
|
|
111
|
+
expect(directTestMap.get(yAbs)?.has(testAbs)).toBeFalsy();
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
38
117
|
it('ignores test files when collecting mutate targets', async () => {
|
|
39
118
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-'));
|
|
40
119
|
const srcDir = path.join(tmpDir, 'src');
|
|
@@ -286,6 +286,130 @@ describe('executePool', () => {
|
|
|
286
286
|
expect(cache['missing-file-key'].status).toBe('escaped');
|
|
287
287
|
expect(cache['missing-file-key'].originalSnippet).toBeUndefined();
|
|
288
288
|
});
|
|
289
|
+
it('escaped mutant stores coveringTests', async () => {
|
|
290
|
+
const tmpFile = path.join(tmpDir, 'covering.ts');
|
|
291
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
292
|
+
const adapter = makeAdapter({
|
|
293
|
+
runMutant: vi
|
|
294
|
+
.fn()
|
|
295
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
296
|
+
});
|
|
297
|
+
const cache = {};
|
|
298
|
+
const tests = ['/tests/foo.spec.ts', '/tests/bar.spec.ts'];
|
|
299
|
+
const task = makeTask({
|
|
300
|
+
key: 'covering-key',
|
|
301
|
+
tests,
|
|
302
|
+
v: {
|
|
303
|
+
id: 'covering.ts#0',
|
|
304
|
+
name: 'flipArith',
|
|
305
|
+
file: tmpFile,
|
|
306
|
+
code: 'const x = a - b\n',
|
|
307
|
+
line: 1,
|
|
308
|
+
col: 10,
|
|
309
|
+
tests,
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
await executePool({
|
|
313
|
+
tasks: [task],
|
|
314
|
+
adapter,
|
|
315
|
+
cache,
|
|
316
|
+
concurrency: 1,
|
|
317
|
+
progressMode: 'list',
|
|
318
|
+
cwd: tmpDir,
|
|
319
|
+
});
|
|
320
|
+
expect(cache['covering-key'].coveringTests).toEqual(tests);
|
|
321
|
+
});
|
|
322
|
+
it('escaped mutant uses directTests for coveringTests when available', async () => {
|
|
323
|
+
const tmpFile = path.join(tmpDir, 'direct-covering.ts');
|
|
324
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
325
|
+
const adapter = makeAdapter({
|
|
326
|
+
runMutant: vi
|
|
327
|
+
.fn()
|
|
328
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
329
|
+
});
|
|
330
|
+
const cache = {};
|
|
331
|
+
const directTests = ['/direct.spec.ts'];
|
|
332
|
+
const allTests = ['/direct.spec.ts', '/transitive.spec.ts'];
|
|
333
|
+
const task = makeTask({
|
|
334
|
+
key: 'direct-covering-key',
|
|
335
|
+
tests: allTests,
|
|
336
|
+
directTests,
|
|
337
|
+
v: {
|
|
338
|
+
id: 'direct-covering.ts#0',
|
|
339
|
+
name: 'flipArith',
|
|
340
|
+
file: tmpFile,
|
|
341
|
+
code: 'const x = a - b\n',
|
|
342
|
+
line: 1,
|
|
343
|
+
col: 10,
|
|
344
|
+
tests: allTests,
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
await executePool({
|
|
348
|
+
tasks: [task],
|
|
349
|
+
adapter,
|
|
350
|
+
cache,
|
|
351
|
+
concurrency: 1,
|
|
352
|
+
progressMode: 'list',
|
|
353
|
+
cwd: tmpDir,
|
|
354
|
+
});
|
|
355
|
+
expect(cache['direct-covering-key'].coveringTests).toEqual(directTests);
|
|
356
|
+
});
|
|
357
|
+
it('killed mutant does not store coveringTests', async () => {
|
|
358
|
+
const adapter = makeAdapter({
|
|
359
|
+
runMutant: vi.fn().mockResolvedValue({ status: 'killed', durationMs: 1 }),
|
|
360
|
+
});
|
|
361
|
+
const cache = {};
|
|
362
|
+
const task = makeTask({ key: 'killed-covering-key' });
|
|
363
|
+
await executePool({
|
|
364
|
+
tasks: [task],
|
|
365
|
+
adapter,
|
|
366
|
+
cache,
|
|
367
|
+
concurrency: 1,
|
|
368
|
+
progressMode: 'list',
|
|
369
|
+
cwd: tmpDir,
|
|
370
|
+
});
|
|
371
|
+
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
372
|
+
});
|
|
373
|
+
it('correctly stores snippets for multiple escaped mutants from the same file', async () => {
|
|
374
|
+
const tmpFile = path.join(tmpDir, 'shared.ts');
|
|
375
|
+
await fs.writeFile(tmpFile, 'const x = a + b\n');
|
|
376
|
+
const adapter = makeAdapter({
|
|
377
|
+
runMutant: vi
|
|
378
|
+
.fn()
|
|
379
|
+
.mockResolvedValue({ status: 'escaped', durationMs: 1 }),
|
|
380
|
+
});
|
|
381
|
+
const cache = {};
|
|
382
|
+
const makeFileTask = (key, mutated) => makeTask({
|
|
383
|
+
key,
|
|
384
|
+
v: {
|
|
385
|
+
id: `shared.ts#${key}`,
|
|
386
|
+
name: 'flipArith',
|
|
387
|
+
file: tmpFile,
|
|
388
|
+
code: mutated,
|
|
389
|
+
line: 1,
|
|
390
|
+
col: 10,
|
|
391
|
+
tests: ['/tests/file.test.ts'],
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
await executePool({
|
|
395
|
+
tasks: [
|
|
396
|
+
makeFileTask('cache-key-1', 'const x = a - b\n'),
|
|
397
|
+
makeFileTask('cache-key-2', 'const x = a * b\n'),
|
|
398
|
+
makeFileTask('cache-key-3', 'const x = a / b\n'),
|
|
399
|
+
],
|
|
400
|
+
adapter,
|
|
401
|
+
cache,
|
|
402
|
+
concurrency: 1,
|
|
403
|
+
progressMode: 'list',
|
|
404
|
+
cwd: tmpDir,
|
|
405
|
+
});
|
|
406
|
+
expect(cache['cache-key-1'].originalSnippet).toBe('const x = a + b');
|
|
407
|
+
expect(cache['cache-key-1'].mutatedSnippet).toBe('const x = a - b');
|
|
408
|
+
expect(cache['cache-key-2'].originalSnippet).toBe('const x = a + b');
|
|
409
|
+
expect(cache['cache-key-2'].mutatedSnippet).toBe('const x = a * b');
|
|
410
|
+
expect(cache['cache-key-3'].originalSnippet).toBe('const x = a + b');
|
|
411
|
+
expect(cache['cache-key-3'].mutatedSnippet).toBe('const x = a / b');
|
|
412
|
+
});
|
|
289
413
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
290
414
|
const adapter = makeAdapter({
|
|
291
415
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
|
@@ -80,6 +80,20 @@ describe('prepareTasks', () => {
|
|
|
80
80
|
const tasks = prepareTasks([v], null);
|
|
81
81
|
expect(tasks[0].tests).toHaveLength(2);
|
|
82
82
|
});
|
|
83
|
+
it('populates directTests from directTestMap', () => {
|
|
84
|
+
const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
|
|
85
|
+
const directTestMap = new Map([
|
|
86
|
+
['/src/file.ts', new Set(['/tests/direct.test.ts'])],
|
|
87
|
+
]);
|
|
88
|
+
const tasks = prepareTasks([v], null, directTestMap);
|
|
89
|
+
expect(tasks[0].directTests).toEqual(['/tests/direct.test.ts']);
|
|
90
|
+
});
|
|
91
|
+
it('directTests absent when not in directTestMap', () => {
|
|
92
|
+
const v = makeVariant({ file: '/src/file.ts', tests: ['/tests/a.test.ts'] });
|
|
93
|
+
const directTestMap = new Map();
|
|
94
|
+
const tasks = prepareTasks([v], null, directTestMap);
|
|
95
|
+
expect(tasks[0].directTests).toBeUndefined();
|
|
96
|
+
});
|
|
83
97
|
it('handles multiple variants', () => {
|
|
84
98
|
const variants = [
|
|
85
99
|
makeVariant({ id: 'file.ts#0', code: 'code1' }),
|
|
@@ -3,5 +3,6 @@ export type TestMap = Map<string, Set<string>>;
|
|
|
3
3
|
export interface DiscoveryResult {
|
|
4
4
|
readonly targets: MutateTarget[];
|
|
5
5
|
readonly testMap: TestMap;
|
|
6
|
+
readonly directTestMap: TestMap;
|
|
6
7
|
}
|
|
7
8
|
export declare function autoDiscoverTargetsAndTests(root: string, cfg: MutineerConfig): Promise<DiscoveryResult>;
|
package/dist/runner/discover.js
CHANGED
|
@@ -84,15 +84,24 @@ async function createViteResolver(rootAbs, exts) {
|
|
|
84
84
|
// Load Vue plugin if needed
|
|
85
85
|
let plugins = [];
|
|
86
86
|
if (exts.has('.vue')) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
87
|
+
const vueFiles = await fg(['**/*.vue'], {
|
|
88
|
+
cwd: rootAbs,
|
|
89
|
+
onlyFiles: true,
|
|
90
|
+
ignore: ['**/node_modules/**'],
|
|
91
|
+
deep: 5,
|
|
92
|
+
});
|
|
93
|
+
if (vueFiles.length > 0) {
|
|
94
|
+
try {
|
|
95
|
+
const mod = await import(
|
|
96
|
+
/* @vite-ignore */ '@vitejs/plugin-vue');
|
|
97
|
+
const vue = mod.default ?? mod;
|
|
98
|
+
plugins =
|
|
99
|
+
typeof vue === 'function' ? [vue()] : [];
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
103
|
+
log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
|
|
104
|
+
}
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
const quietLogger = {
|
|
@@ -208,12 +217,13 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
208
217
|
ignore,
|
|
209
218
|
});
|
|
210
219
|
if (!tests.length)
|
|
211
|
-
return { targets: [], testMap: new Map() };
|
|
220
|
+
return { targets: [], testMap: new Map(), directTestMap: new Map() };
|
|
212
221
|
const testSet = new Set(tests.map((t) => normalizePath(t)));
|
|
213
222
|
// 2) Create resolver (Vite if available, otherwise Node-based fallback)
|
|
214
223
|
const { resolve, cleanup } = await createResolver(rootAbs, exts);
|
|
215
224
|
const targets = new Map();
|
|
216
225
|
const testMap = new Map();
|
|
226
|
+
const directTestMap = new Map();
|
|
217
227
|
const contentCache = new Map();
|
|
218
228
|
const resolveCache = new Map(); // key: importer\0spec -> resolved id
|
|
219
229
|
async function crawl(absFile, depth, seen, currentTestAbs) {
|
|
@@ -242,6 +252,11 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
242
252
|
if (!testMap.has(key))
|
|
243
253
|
testMap.set(key, new Set());
|
|
244
254
|
testMap.get(key).add(currentTestAbs);
|
|
255
|
+
if (depth === 0) {
|
|
256
|
+
if (!directTestMap.has(key))
|
|
257
|
+
directTestMap.set(key, new Set());
|
|
258
|
+
directTestMap.get(key).add(currentTestAbs);
|
|
259
|
+
}
|
|
245
260
|
}
|
|
246
261
|
// read file content to find further imports (works for .vue too; imports are inside <script>)
|
|
247
262
|
let code = contentCache.get(absFile);
|
|
@@ -274,12 +289,12 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
274
289
|
}
|
|
275
290
|
}
|
|
276
291
|
try {
|
|
277
|
-
|
|
292
|
+
await Promise.all(tests.map(async (testAbs) => {
|
|
278
293
|
const seen = new Set();
|
|
279
294
|
// prime with the test's own direct imports
|
|
280
295
|
const code = safeRead(testAbs);
|
|
281
296
|
if (!code)
|
|
282
|
-
|
|
297
|
+
return;
|
|
283
298
|
const firstHop = [];
|
|
284
299
|
for (const spec of extractImportSpecs(code)) {
|
|
285
300
|
if (!spec)
|
|
@@ -296,8 +311,8 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
296
311
|
for (const abs of firstHop) {
|
|
297
312
|
await crawl(abs, 0, seen, testAbs);
|
|
298
313
|
}
|
|
299
|
-
}
|
|
300
|
-
return { targets: Array.from(targets.values()), testMap };
|
|
314
|
+
}));
|
|
315
|
+
return { targets: Array.from(targets.values()), testMap, directTestMap };
|
|
301
316
|
}
|
|
302
317
|
finally {
|
|
303
318
|
await cleanup();
|
|
@@ -68,7 +68,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
68
68
|
// 4. Discover targets and tests
|
|
69
69
|
const cache = await readMutantCache(cwd);
|
|
70
70
|
const discovered = await autoDiscoverTargetsAndTests(cwd, cfg);
|
|
71
|
-
const testMap = discovered
|
|
71
|
+
const { testMap, directTestMap } = discovered;
|
|
72
72
|
const targets = cfg.targets?.length
|
|
73
73
|
? [...cfg.targets]
|
|
74
74
|
: (cfg.autoDiscover ?? true)
|
|
@@ -122,7 +122,7 @@ export async function runOrchestrator(cliArgs, cwd) {
|
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
124
|
// 8. Prepare tasks and execute via worker pool
|
|
125
|
-
const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage);
|
|
125
|
+
const tasks = prepareTasks(variants, updatedCoverage.perTestCoverage, directTestMap);
|
|
126
126
|
await executePool({
|
|
127
127
|
tasks,
|
|
128
128
|
adapter,
|
|
@@ -64,9 +64,10 @@ export async function executePool(opts) {
|
|
|
64
64
|
const poolDurationMs = Date.now() - poolStart;
|
|
65
65
|
log.info(`\u2713 Worker pool ready (${poolDurationMs}ms)`);
|
|
66
66
|
progress.start();
|
|
67
|
+
const fileCache = new Map();
|
|
67
68
|
let nextIdx = 0;
|
|
68
69
|
async function processTask(task) {
|
|
69
|
-
const { v, tests, key } = task;
|
|
70
|
+
const { v, tests, key, directTests } = task;
|
|
70
71
|
log.debug('Cache ' + JSON.stringify(cache));
|
|
71
72
|
const cached = cache[key];
|
|
72
73
|
if (cached) {
|
|
@@ -98,7 +99,12 @@ export async function executePool(opts) {
|
|
|
98
99
|
let mutatedSnippet;
|
|
99
100
|
if (status === 'escaped') {
|
|
100
101
|
try {
|
|
101
|
-
|
|
102
|
+
let fileContent = fileCache.get(v.file);
|
|
103
|
+
if (fileContent === undefined) {
|
|
104
|
+
fileContent = fs.readFileSync(v.file, 'utf8');
|
|
105
|
+
fileCache.set(v.file, fileContent);
|
|
106
|
+
}
|
|
107
|
+
const originalLines = fileContent.split('\n');
|
|
102
108
|
const mutatedLines = v.code.split('\n');
|
|
103
109
|
const lineIdx = v.line - 1;
|
|
104
110
|
const orig = originalLines[lineIdx]?.trim();
|
|
@@ -119,6 +125,10 @@ export async function executePool(opts) {
|
|
|
119
125
|
col: v.col,
|
|
120
126
|
mutator: v.name,
|
|
121
127
|
...(originalSnippet !== undefined && { originalSnippet, mutatedSnippet }),
|
|
128
|
+
...(status === 'escaped' &&
|
|
129
|
+
(directTests ?? tests).length > 0 && {
|
|
130
|
+
coveringTests: directTests ?? tests,
|
|
131
|
+
}),
|
|
122
132
|
};
|
|
123
133
|
progress.update(status);
|
|
124
134
|
}
|
package/dist/runner/tasks.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { Variant } from '../types/mutant.js';
|
|
2
2
|
import type { PerTestCoverageMap } from '../utils/coverage.js';
|
|
3
|
+
import type { TestMap } from './discover.js';
|
|
3
4
|
export interface MutantTask {
|
|
4
5
|
v: Variant;
|
|
5
6
|
tests: string[];
|
|
6
7
|
key: string;
|
|
8
|
+
directTests?: readonly string[];
|
|
7
9
|
}
|
|
8
10
|
/**
|
|
9
11
|
* Prepare mutant tasks from variants by pruning tests via per-test coverage,
|
|
10
12
|
* sorting tests deterministically, and computing cache keys.
|
|
11
13
|
*/
|
|
12
|
-
export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null): MutantTask[];
|
|
14
|
+
export declare function prepareTasks(variants: readonly Variant[], perTestCoverage: PerTestCoverageMap | null, directTestMap?: TestMap): MutantTask[];
|
package/dist/runner/tasks.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { filterTestsByCoverage } from './variants.js';
|
|
2
2
|
import { hash, keyForTests } from './cache.js';
|
|
3
3
|
import { createLogger } from '../utils/logger.js';
|
|
4
|
+
import { normalizePath } from '../utils/normalizePath.js';
|
|
4
5
|
const log = createLogger('tasks');
|
|
5
6
|
/**
|
|
6
7
|
* Prepare mutant tasks from variants by pruning tests via per-test coverage,
|
|
7
8
|
* sorting tests deterministically, and computing cache keys.
|
|
8
9
|
*/
|
|
9
|
-
export function prepareTasks(variants, perTestCoverage) {
|
|
10
|
+
export function prepareTasks(variants, perTestCoverage, directTestMap) {
|
|
10
11
|
return variants.map((v) => {
|
|
11
12
|
let tests = Array.from(v.tests);
|
|
12
13
|
if (perTestCoverage && tests.length) {
|
|
@@ -20,6 +21,13 @@ export function prepareTasks(variants, perTestCoverage) {
|
|
|
20
21
|
const testSig = hash(keyForTests(tests));
|
|
21
22
|
const codeSig = hash(v.code);
|
|
22
23
|
const key = `${testSig}:${codeSig}`;
|
|
23
|
-
|
|
24
|
+
const direct = directTestMap?.get(normalizePath(v.file));
|
|
25
|
+
return {
|
|
26
|
+
v,
|
|
27
|
+
tests,
|
|
28
|
+
key,
|
|
29
|
+
...(direct &&
|
|
30
|
+
direct.size > 0 && { directTests: Array.from(direct).sort() }),
|
|
31
|
+
};
|
|
24
32
|
});
|
|
25
33
|
}
|
package/dist/types/mutant.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface MutantCacheEntry extends MutantLocation {
|
|
|
27
27
|
readonly mutator: string;
|
|
28
28
|
readonly originalSnippet?: string;
|
|
29
29
|
readonly mutatedSnippet?: string;
|
|
30
|
+
readonly coveringTests?: readonly string[];
|
|
30
31
|
}
|
|
31
32
|
export interface MutantResult extends MutantCacheEntry {
|
|
32
33
|
readonly id: string;
|
|
@@ -71,6 +71,32 @@ describe('summary', () => {
|
|
|
71
71
|
expect(lines.some((l) => l.trimStart().startsWith('+ '))).toBe(false);
|
|
72
72
|
logSpy.mockRestore();
|
|
73
73
|
});
|
|
74
|
+
it('prints covering test path for escaped mutant', () => {
|
|
75
|
+
const cache = {
|
|
76
|
+
a: makeEntry({
|
|
77
|
+
status: 'escaped',
|
|
78
|
+
coveringTests: ['/abs/foo.spec.ts'],
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
const summary = computeSummary(cache);
|
|
82
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
83
|
+
printSummary(summary, cache);
|
|
84
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
85
|
+
expect(lines.some((l) => l.includes('↳'))).toBe(true);
|
|
86
|
+
expect(lines.some((l) => l.includes('foo.spec.ts'))).toBe(true);
|
|
87
|
+
logSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
it('does not print covering tests when array is absent', () => {
|
|
90
|
+
const cache = {
|
|
91
|
+
a: makeEntry({ status: 'escaped' }),
|
|
92
|
+
};
|
|
93
|
+
const summary = computeSummary(cache);
|
|
94
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
95
|
+
printSummary(summary, cache);
|
|
96
|
+
const lines = logSpy.mock.calls.map((c) => stripAnsi(c.join(' ')));
|
|
97
|
+
expect(lines.some((l) => l.includes('↳'))).toBe(false);
|
|
98
|
+
logSpy.mockRestore();
|
|
99
|
+
});
|
|
74
100
|
it('summarise returns summary and prints', () => {
|
|
75
101
|
const cache = { a: makeEntry({ status: 'killed' }) };
|
|
76
102
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
package/dist/utils/summary.js
CHANGED
|
@@ -81,6 +81,15 @@ export function printSummary(summary, cache, durationMs) {
|
|
|
81
81
|
console.log(' ' + chalk.red('- ' + entry.originalSnippet));
|
|
82
82
|
console.log(' ' + chalk.green('+ ' + entry.mutatedSnippet));
|
|
83
83
|
}
|
|
84
|
+
if (entry.coveringTests?.length) {
|
|
85
|
+
const shown = entry.coveringTests.slice(0, 2);
|
|
86
|
+
for (const t of shown) {
|
|
87
|
+
console.log(' ' + chalk.dim('↳ ' + path.relative(cwd, t)));
|
|
88
|
+
}
|
|
89
|
+
if (entry.coveringTests.length > 2) {
|
|
90
|
+
console.log(' ' + chalk.dim(` +${entry.coveringTests.length - 2} more`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
84
93
|
}
|
|
85
94
|
}
|
|
86
95
|
if (entriesByStatus.skipped.length) {
|