@mutineerjs/mutineer 0.5.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 +52 -1
- package/dist/runner/__tests__/pool-executor.spec.js +40 -0
- package/dist/runner/discover.js +21 -12
- package/dist/runner/pool-executor.js +7 -1
- 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,6 +34,57 @@ 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', () => {
|
|
38
89
|
it('directTestMap only includes direct importers', async () => {
|
|
39
90
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mutineer-discover-direct-'));
|
|
@@ -370,6 +370,46 @@ describe('executePool', () => {
|
|
|
370
370
|
});
|
|
371
371
|
expect(cache['killed-covering-key'].coveringTests).toBeUndefined();
|
|
372
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
|
+
});
|
|
373
413
|
it('handles adapter errors gracefully and still shuts down', async () => {
|
|
374
414
|
const adapter = makeAdapter({
|
|
375
415
|
runMutant: vi.fn().mockRejectedValue(new Error('adapter failure')),
|
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 = {
|
|
@@ -280,12 +289,12 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
280
289
|
}
|
|
281
290
|
}
|
|
282
291
|
try {
|
|
283
|
-
|
|
292
|
+
await Promise.all(tests.map(async (testAbs) => {
|
|
284
293
|
const seen = new Set();
|
|
285
294
|
// prime with the test's own direct imports
|
|
286
295
|
const code = safeRead(testAbs);
|
|
287
296
|
if (!code)
|
|
288
|
-
|
|
297
|
+
return;
|
|
289
298
|
const firstHop = [];
|
|
290
299
|
for (const spec of extractImportSpecs(code)) {
|
|
291
300
|
if (!spec)
|
|
@@ -302,7 +311,7 @@ export async function autoDiscoverTargetsAndTests(root, cfg) {
|
|
|
302
311
|
for (const abs of firstHop) {
|
|
303
312
|
await crawl(abs, 0, seen, testAbs);
|
|
304
313
|
}
|
|
305
|
-
}
|
|
314
|
+
}));
|
|
306
315
|
return { targets: Array.from(targets.values()), testMap, directTestMap };
|
|
307
316
|
}
|
|
308
317
|
finally {
|
|
@@ -64,6 +64,7 @@ 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
70
|
const { v, tests, key, directTests } = task;
|
|
@@ -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();
|