@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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import { parse } from '@babel/parser';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import { parserOptsTs } from '../mutators/utils.js';
|
|
5
|
+
const isWordChar = (s, i) => i >= 0 && i < s.length && /[a-zA-Z0-9_]/.test(s[i]);
|
|
6
|
+
function findSingleDiff(original, mutated) {
|
|
7
|
+
let start = 0;
|
|
8
|
+
const minLen = Math.min(original.length, mutated.length);
|
|
9
|
+
while (start < minLen && original[start] === mutated[start]) {
|
|
10
|
+
start++;
|
|
11
|
+
}
|
|
12
|
+
let origEnd = original.length;
|
|
13
|
+
let mutEnd = mutated.length;
|
|
14
|
+
while (origEnd > start &&
|
|
15
|
+
mutEnd > start &&
|
|
16
|
+
original[origEnd - 1] === mutated[mutEnd - 1]) {
|
|
17
|
+
origEnd--;
|
|
18
|
+
mutEnd--;
|
|
19
|
+
}
|
|
20
|
+
// Extend to word boundaries so we never produce partial-token ternary branches.
|
|
21
|
+
// e.g. 'true'→'false' has minimal diff 'tru'→'fals' (shared 'e' suffix), but
|
|
22
|
+
// 'tru' alone is not a valid expression — extend forward to include the 'e'.
|
|
23
|
+
while (start > 0 &&
|
|
24
|
+
isWordChar(original, start - 1) &&
|
|
25
|
+
isWordChar(original, start)) {
|
|
26
|
+
start--;
|
|
27
|
+
}
|
|
28
|
+
while (isWordChar(original, origEnd - 1) && isWordChar(original, origEnd)) {
|
|
29
|
+
origEnd++;
|
|
30
|
+
mutEnd++;
|
|
31
|
+
}
|
|
32
|
+
return { origStart: start, origEnd, mutEnd };
|
|
33
|
+
}
|
|
34
|
+
function escapeId(id) {
|
|
35
|
+
return id.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns true if the string is usable as a standalone JS expression fragment.
|
|
39
|
+
* Operator-only strings (e.g. '+', '!', '===') are not valid standalone expressions
|
|
40
|
+
* and cannot appear as ternary branches in generated schema code.
|
|
41
|
+
*/
|
|
42
|
+
function isExpressionFragment(s) {
|
|
43
|
+
return /[a-zA-Z0-9_]/.test(s);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parse source code for AST-based span detection.
|
|
47
|
+
* Returns null if parsing fails (e.g. syntax errors in the file).
|
|
48
|
+
*/
|
|
49
|
+
function parseForSchema(code) {
|
|
50
|
+
try {
|
|
51
|
+
return parse(code, { ...parserOptsTs, tokens: false });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Find the smallest AST Expression node whose span contains `offset`.
|
|
59
|
+
* Used to expand operator-only char diffs (e.g. '+') to the full enclosing
|
|
60
|
+
* expression (e.g. 'x + y') so the ternary branch is a valid JS expression.
|
|
61
|
+
*/
|
|
62
|
+
function findSmallestEnclosingExpression(root, offset) {
|
|
63
|
+
let best = null;
|
|
64
|
+
function walk(node) {
|
|
65
|
+
if (!node ||
|
|
66
|
+
typeof node.start !== 'number' ||
|
|
67
|
+
typeof node.end !== 'number') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (offset < node.start || offset >= node.end)
|
|
71
|
+
return;
|
|
72
|
+
if (t.isExpression(node)) {
|
|
73
|
+
const span = node.end - node.start;
|
|
74
|
+
if (!best || span < best.end - best.start) {
|
|
75
|
+
best = { start: node.start, end: node.end };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const key of Object.keys(node)) {
|
|
79
|
+
if (key === 'type' ||
|
|
80
|
+
key === 'start' ||
|
|
81
|
+
key === 'end' ||
|
|
82
|
+
key === 'loc' ||
|
|
83
|
+
key === 'range' ||
|
|
84
|
+
key === 'errors' ||
|
|
85
|
+
key === 'operator') {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const child = node[key];
|
|
89
|
+
if (!child || typeof child !== 'object')
|
|
90
|
+
continue;
|
|
91
|
+
if (Array.isArray(child)) {
|
|
92
|
+
for (const item of child) {
|
|
93
|
+
if (item &&
|
|
94
|
+
typeof item === 'object' &&
|
|
95
|
+
typeof item.start === 'number') {
|
|
96
|
+
walk(item);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (typeof child.start === 'number') {
|
|
101
|
+
walk(child);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(root);
|
|
106
|
+
return best;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Generate a schema file that embeds all mutation variants as a ternary chain.
|
|
110
|
+
*
|
|
111
|
+
* The schema uses `globalThis.__mutineer_active_id__` at call time to select
|
|
112
|
+
* which mutant is active, avoiding per-mutant module reloads.
|
|
113
|
+
*
|
|
114
|
+
* For value mutations (true→false, null→undefined), a character-level diff with
|
|
115
|
+
* word-boundary extension finds the span. For operator mutations (+→-, ===→!==),
|
|
116
|
+
* the Babel AST is used to find the enclosing expression (x + y, x === y) so
|
|
117
|
+
* the ternary branch is a valid JS expression.
|
|
118
|
+
*
|
|
119
|
+
* @param originalCode - The original source file contents
|
|
120
|
+
* @param variants - All variants to embed (must all be from the same source file)
|
|
121
|
+
* @returns schemaCode (the embedded schema) and fallbackIds (variants that
|
|
122
|
+
* couldn't be embedded due to overlapping diff ranges or parse errors)
|
|
123
|
+
*/
|
|
124
|
+
export function generateSchema(originalCode, variants) {
|
|
125
|
+
const fallbackIds = new Set();
|
|
126
|
+
const siteMap = new Map();
|
|
127
|
+
// Lazily parsed AST — only needed for operator mutations
|
|
128
|
+
let ast = undefined;
|
|
129
|
+
function getAst() {
|
|
130
|
+
if (ast === undefined)
|
|
131
|
+
ast = parseForSchema(originalCode);
|
|
132
|
+
return ast;
|
|
133
|
+
}
|
|
134
|
+
for (const variant of variants) {
|
|
135
|
+
const { origStart, origEnd, mutEnd } = findSingleDiff(originalCode, variant.code);
|
|
136
|
+
// Skip variants with empty diffs (identical to original)
|
|
137
|
+
if (origStart >= origEnd && origStart >= mutEnd) {
|
|
138
|
+
fallbackIds.add(variant.id);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const origSpan = originalCode.slice(origStart, origEnd);
|
|
142
|
+
const repSpan = variant.code.slice(origStart, mutEnd);
|
|
143
|
+
let siteStart;
|
|
144
|
+
let siteEnd;
|
|
145
|
+
let replacement;
|
|
146
|
+
if (isExpressionFragment(origSpan) && isExpressionFragment(repSpan)) {
|
|
147
|
+
// Value mutation (true→false, null→undefined, 0→1 etc.): char diff is usable
|
|
148
|
+
siteStart = origStart;
|
|
149
|
+
siteEnd = origEnd;
|
|
150
|
+
replacement = repSpan;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// Operator mutation (+→-, ===→!==, &&→|| etc.): char diff produces an
|
|
154
|
+
// operator-only span that isn't a valid standalone expression.
|
|
155
|
+
// Use the Babel AST to find the smallest enclosing Expression node
|
|
156
|
+
// (e.g. the BinaryExpression x + y) so the ternary branches are valid JS.
|
|
157
|
+
const parsedAst = getAst();
|
|
158
|
+
if (!parsedAst) {
|
|
159
|
+
fallbackIds.add(variant.id);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const enclosing = findSmallestEnclosingExpression(parsedAst, origStart);
|
|
163
|
+
if (!enclosing) {
|
|
164
|
+
fallbackIds.add(variant.id);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
siteStart = enclosing.start;
|
|
168
|
+
siteEnd = enclosing.end;
|
|
169
|
+
// The enclosing node end is from the original AST. For variable-length
|
|
170
|
+
// operators (e.g. '>' → '>=', '+=' → '-='), the variant code is longer or
|
|
171
|
+
// shorter by delta = mutEnd - origEnd. Adjust the slice end accordingly.
|
|
172
|
+
const delta = mutEnd - origEnd;
|
|
173
|
+
replacement = variant.code.slice(siteStart, siteEnd + delta);
|
|
174
|
+
// Sanity: if replacement equals original span, the mutation had no effect
|
|
175
|
+
if (replacement === originalCode.slice(siteStart, siteEnd)) {
|
|
176
|
+
fallbackIds.add(variant.id);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const key = `${siteStart}:${siteEnd}`;
|
|
181
|
+
if (!siteMap.has(key)) {
|
|
182
|
+
siteMap.set(key, { origStart: siteStart, origEnd: siteEnd, variants: [] });
|
|
183
|
+
}
|
|
184
|
+
siteMap.get(key).variants.push({ id: variant.id, replacement });
|
|
185
|
+
}
|
|
186
|
+
const sites = Array.from(siteMap.values()).sort((a, b) => a.origStart - b.origStart);
|
|
187
|
+
// Detect overlapping sites.
|
|
188
|
+
// When one site is fully contained within another (nested expressions), keep
|
|
189
|
+
// the inner (smaller) site and mark the outer one as fallback. For partial
|
|
190
|
+
// overlaps, mark both as fallback.
|
|
191
|
+
const overlappingSiteKeys = new Set();
|
|
192
|
+
for (let i = 0; i < sites.length; i++) {
|
|
193
|
+
for (let j = i + 1; j < sites.length; j++) {
|
|
194
|
+
const a = sites[i];
|
|
195
|
+
const b = sites[j];
|
|
196
|
+
if (b.origStart >= a.origEnd)
|
|
197
|
+
break; // sorted by start, no further overlap
|
|
198
|
+
const keyA = `${a.origStart}:${a.origEnd}`;
|
|
199
|
+
const keyB = `${b.origStart}:${b.origEnd}`;
|
|
200
|
+
if (b.origEnd <= a.origEnd) {
|
|
201
|
+
// b fully contained within a → keep b (inner), mark a (outer) as fallback
|
|
202
|
+
overlappingSiteKeys.add(keyA);
|
|
203
|
+
}
|
|
204
|
+
else if (a.origStart === b.origStart) {
|
|
205
|
+
// Same start, b is larger → b contains a → mark b (outer) as fallback
|
|
206
|
+
overlappingSiteKeys.add(keyB);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
// Partial overlap → mark both as fallback
|
|
210
|
+
overlappingSiteKeys.add(keyA);
|
|
211
|
+
overlappingSiteKeys.add(keyB);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const s = new MagicString(originalCode);
|
|
216
|
+
// Apply sites in descending order to preserve character positions
|
|
217
|
+
const sortedDesc = [...sites].sort((a, b) => b.origStart - a.origStart);
|
|
218
|
+
for (const site of sortedDesc) {
|
|
219
|
+
const key = `${site.origStart}:${site.origEnd}`;
|
|
220
|
+
if (overlappingSiteKeys.has(key)) {
|
|
221
|
+
for (const v of site.variants)
|
|
222
|
+
fallbackIds.add(v.id);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const originalSpan = originalCode.slice(site.origStart, site.origEnd);
|
|
226
|
+
// Build ternary chain: iterate from last variant inward, wrapping originalSpan
|
|
227
|
+
let chain = originalSpan;
|
|
228
|
+
for (let i = site.variants.length - 1; i >= 0; i--) {
|
|
229
|
+
const v = site.variants[i];
|
|
230
|
+
chain = `(globalThis.__mutineer_active_id__ === '${escapeId(v.id)}' ? (${v.replacement}) : ${chain})`;
|
|
231
|
+
}
|
|
232
|
+
s.overwrite(site.origStart, site.origEnd, chain);
|
|
233
|
+
}
|
|
234
|
+
const schemaCode = `// @ts-nocheck\n` + s.toString();
|
|
235
|
+
return { schemaCode, fallbackIds };
|
|
236
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, } from '../operator.js';
|
|
2
|
+
import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, preInc, preDec, postInc, postDec, addAssignToSub, subAssignToAdd, mulAssignToDiv, divAssignToMul, } from '../operator.js';
|
|
3
3
|
import { buildParseContext } from '../utils.js';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Shared behaviour (tested once; all mutators use the same factory)
|
|
@@ -183,3 +183,99 @@ describe('powerToMul', () => {
|
|
|
183
183
|
expect(result.code).toBe(`const n = a * b`);
|
|
184
184
|
});
|
|
185
185
|
});
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Increment/decrement mutators
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
describe('preInc', () => {
|
|
190
|
+
it("replaces '++x' to '--x'", () => {
|
|
191
|
+
const src = `const n = ++i`;
|
|
192
|
+
const [result] = preInc.apply(src);
|
|
193
|
+
expect(result.code).toBe(`const n = --i`);
|
|
194
|
+
});
|
|
195
|
+
it('does not match postfix x++', () => {
|
|
196
|
+
const src = `i++`;
|
|
197
|
+
expect(preInc.apply(src)).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
it('applyWithContext matches apply', () => {
|
|
200
|
+
const src = `const n = ++i`;
|
|
201
|
+
const ctx = buildParseContext(src);
|
|
202
|
+
expect(preInc.applyWithContext(src, ctx)).toEqual(preInc.apply(src));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('preDec', () => {
|
|
206
|
+
it("replaces '--x' to '++x'", () => {
|
|
207
|
+
const src = `const n = --i`;
|
|
208
|
+
const [result] = preDec.apply(src);
|
|
209
|
+
expect(result.code).toBe(`const n = ++i`);
|
|
210
|
+
});
|
|
211
|
+
it('does not match postfix x--', () => {
|
|
212
|
+
const src = `i--`;
|
|
213
|
+
expect(preDec.apply(src)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
describe('postInc', () => {
|
|
217
|
+
it("replaces 'x++' to 'x--'", () => {
|
|
218
|
+
const src = `i++`;
|
|
219
|
+
const [result] = postInc.apply(src);
|
|
220
|
+
expect(result.code).toBe(`i--`);
|
|
221
|
+
});
|
|
222
|
+
it('does not match prefix ++x', () => {
|
|
223
|
+
const src = `const n = ++i`;
|
|
224
|
+
expect(postInc.apply(src)).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
it('applyWithContext matches apply', () => {
|
|
227
|
+
const src = `i++`;
|
|
228
|
+
const ctx = buildParseContext(src);
|
|
229
|
+
expect(postInc.applyWithContext(src, ctx)).toEqual(postInc.apply(src));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe('postDec', () => {
|
|
233
|
+
it("replaces 'x--' to 'x++'", () => {
|
|
234
|
+
const src = `i--`;
|
|
235
|
+
const [result] = postDec.apply(src);
|
|
236
|
+
expect(result.code).toBe(`i++`);
|
|
237
|
+
});
|
|
238
|
+
it('does not match prefix --x', () => {
|
|
239
|
+
const src = `const n = --i`;
|
|
240
|
+
expect(postDec.apply(src)).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Compound assignment mutators
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
describe('addAssignToSub', () => {
|
|
247
|
+
it("replaces '+=' with '-='", () => {
|
|
248
|
+
const src = `x += 1`;
|
|
249
|
+
const [result] = addAssignToSub.apply(src);
|
|
250
|
+
expect(result.code).toBe(`x -= 1`);
|
|
251
|
+
});
|
|
252
|
+
it('returns no results when operator absent', () => {
|
|
253
|
+
expect(addAssignToSub.apply(`x -= 1`)).toHaveLength(0);
|
|
254
|
+
});
|
|
255
|
+
it('applyWithContext matches apply', () => {
|
|
256
|
+
const src = `x += 1`;
|
|
257
|
+
const ctx = buildParseContext(src);
|
|
258
|
+
expect(addAssignToSub.applyWithContext(src, ctx)).toEqual(addAssignToSub.apply(src));
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe('subAssignToAdd', () => {
|
|
262
|
+
it("replaces '-=' with '+='", () => {
|
|
263
|
+
const src = `x -= 1`;
|
|
264
|
+
const [result] = subAssignToAdd.apply(src);
|
|
265
|
+
expect(result.code).toBe(`x += 1`);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
describe('mulAssignToDiv', () => {
|
|
269
|
+
it("replaces '*=' with '/='", () => {
|
|
270
|
+
const src = `x *= 2`;
|
|
271
|
+
const [result] = mulAssignToDiv.apply(src);
|
|
272
|
+
expect(result.code).toBe(`x /= 2`);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('divAssignToMul', () => {
|
|
276
|
+
it("replaces '/=' with '*='", () => {
|
|
277
|
+
const src = `x /= 2`;
|
|
278
|
+
const [result] = divAssignToMul.apply(src);
|
|
279
|
+
expect(result.code).toBe(`x *= 2`);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -18,6 +18,14 @@ const ALL_NAMES = [
|
|
|
18
18
|
'divToMul',
|
|
19
19
|
'modToMul',
|
|
20
20
|
'powerToMul',
|
|
21
|
+
'preInc',
|
|
22
|
+
'preDec',
|
|
23
|
+
'postInc',
|
|
24
|
+
'postDec',
|
|
25
|
+
'addAssignToSub',
|
|
26
|
+
'subAssignToAdd',
|
|
27
|
+
'mulAssignToDiv',
|
|
28
|
+
'divAssignToMul',
|
|
21
29
|
'returnToNull',
|
|
22
30
|
'returnToUndefined',
|
|
23
31
|
'returnFlipBool',
|
|
@@ -23,3 +23,11 @@ export declare const mulToDiv: ASTMutator;
|
|
|
23
23
|
export declare const divToMul: ASTMutator;
|
|
24
24
|
export declare const modToMul: ASTMutator;
|
|
25
25
|
export declare const powerToMul: ASTMutator;
|
|
26
|
+
export declare const preInc: ASTMutator;
|
|
27
|
+
export declare const preDec: ASTMutator;
|
|
28
|
+
export declare const postInc: ASTMutator;
|
|
29
|
+
export declare const postDec: ASTMutator;
|
|
30
|
+
export declare const addAssignToSub: ASTMutator;
|
|
31
|
+
export declare const subAssignToAdd: ASTMutator;
|
|
32
|
+
export declare const mulAssignToDiv: ASTMutator;
|
|
33
|
+
export declare const divAssignToMul: ASTMutator;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* using AST traversal and produces a mutated source string with that operator
|
|
6
6
|
* replaced by its counterpart.
|
|
7
7
|
*/
|
|
8
|
-
import { collectOperatorTargets } from './utils.js';
|
|
8
|
+
import { collectOperatorTargets, buildParseContext } from './utils.js';
|
|
9
9
|
/**
|
|
10
10
|
* Factory to build an operator mutator using AST traversal and token analysis.
|
|
11
11
|
*
|
|
@@ -33,6 +33,53 @@ function makeOperatorMutator(name, description, fromOp, toOp) {
|
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Factory for UpdateExpression mutators (++/--).
|
|
38
|
+
* mapKey distinguishes prefix vs postfix: 'pre++', 'post++', 'pre--', 'post--'
|
|
39
|
+
*/
|
|
40
|
+
function makeUpdateMutator(name, description, mapKey, toOp) {
|
|
41
|
+
function targetsToOutputs(src, targets) {
|
|
42
|
+
return targets.map((target) => ({
|
|
43
|
+
line: target.line,
|
|
44
|
+
col: target.col1,
|
|
45
|
+
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
description,
|
|
51
|
+
apply(src) {
|
|
52
|
+
const ctx = buildParseContext(src);
|
|
53
|
+
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
|
|
54
|
+
},
|
|
55
|
+
applyWithContext(src, ctx) {
|
|
56
|
+
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Factory for AssignmentExpression mutators (+=, -=, *=, /=).
|
|
62
|
+
*/
|
|
63
|
+
function makeAssignmentMutator(name, description, fromOp, toOp) {
|
|
64
|
+
function targetsToOutputs(src, targets) {
|
|
65
|
+
return targets.map((target) => ({
|
|
66
|
+
line: target.line,
|
|
67
|
+
col: target.col1,
|
|
68
|
+
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
description,
|
|
74
|
+
apply(src) {
|
|
75
|
+
const ctx = buildParseContext(src);
|
|
76
|
+
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
|
|
77
|
+
},
|
|
78
|
+
applyWithContext(src, ctx) {
|
|
79
|
+
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
36
83
|
/* === Boundary mutators === */
|
|
37
84
|
export const relaxLE = makeOperatorMutator('relaxLE', "Change '<=' to '<' (relax boundary)", '<=', '<');
|
|
38
85
|
export const relaxGE = makeOperatorMutator('relaxGE', "Change '>=' to '>' (relax boundary)", '>=', '>');
|
|
@@ -54,3 +101,13 @@ export const mulToDiv = makeOperatorMutator('mulToDiv', "Change '*' to '/'", '*'
|
|
|
54
101
|
export const divToMul = makeOperatorMutator('divToMul', "Change '/' to '*'", '/', '*');
|
|
55
102
|
export const modToMul = makeOperatorMutator('modToMul', "Change '%' to '*'", '%', '*');
|
|
56
103
|
export const powerToMul = makeOperatorMutator('powerToMul', "Change '**' to '*'", '**', '*');
|
|
104
|
+
/* === Increment/decrement mutators === */
|
|
105
|
+
export const preInc = makeUpdateMutator('preInc', "Change '++x' to '--x'", 'pre++', '--');
|
|
106
|
+
export const preDec = makeUpdateMutator('preDec', "Change '--x' to '++x'", 'pre--', '++');
|
|
107
|
+
export const postInc = makeUpdateMutator('postInc', "Change 'x++' to 'x--'", 'post++', '--');
|
|
108
|
+
export const postDec = makeUpdateMutator('postDec', "Change 'x--' to 'x++'", 'post--', '++');
|
|
109
|
+
/* === Compound assignment mutators === */
|
|
110
|
+
export const addAssignToSub = makeAssignmentMutator('addAssignToSub', "Change '+=' to '-='", '+=', '-=');
|
|
111
|
+
export const subAssignToAdd = makeAssignmentMutator('subAssignToAdd', "Change '-=' to '+='", '-=', '+=');
|
|
112
|
+
export const mulAssignToDiv = makeAssignmentMutator('mulAssignToDiv', "Change '*=' to '/='", '*=', '/=');
|
|
113
|
+
export const divAssignToMul = makeAssignmentMutator('divAssignToMul', "Change '/=' to '*='", '/=', '*=');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Aggregates all mutators and exposes `getRegistry` for filtered access.
|
|
5
5
|
*/
|
|
6
|
-
import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, } from './operator.js';
|
|
6
|
+
import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, preInc, preDec, postInc, postDec, addAssignToSub, subAssignToAdd, mulAssignToDiv, divAssignToMul, } from './operator.js';
|
|
7
7
|
import { returnToNull, returnToUndefined, returnFlipBool, returnZero, returnEmptyStr, returnEmptyArr, } from './return-value.js';
|
|
8
8
|
const ALL = [
|
|
9
9
|
relaxLE,
|
|
@@ -23,6 +23,14 @@ const ALL = [
|
|
|
23
23
|
divToMul,
|
|
24
24
|
modToMul,
|
|
25
25
|
powerToMul,
|
|
26
|
+
preInc,
|
|
27
|
+
preDec,
|
|
28
|
+
postInc,
|
|
29
|
+
postDec,
|
|
30
|
+
addAssignToSub,
|
|
31
|
+
subAssignToAdd,
|
|
32
|
+
mulAssignToDiv,
|
|
33
|
+
divAssignToMul,
|
|
26
34
|
returnToNull,
|
|
27
35
|
returnToUndefined,
|
|
28
36
|
returnFlipBool,
|
package/dist/mutators/utils.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface ReturnStatementInfo {
|
|
|
81
81
|
export interface PreCollected {
|
|
82
82
|
readonly operatorTargets: Map<string, OperatorTarget[]>;
|
|
83
83
|
readonly returnStatements: ReturnStatementInfo[];
|
|
84
|
+
readonly updateTargets: Map<string, OperatorTarget[]>;
|
|
85
|
+
readonly assignmentTargets: Map<string, OperatorTarget[]>;
|
|
84
86
|
}
|
|
85
87
|
/**
|
|
86
88
|
* Pre-parsed context for a source file.
|
package/dist/mutators/utils.js
CHANGED
|
@@ -128,6 +128,8 @@ export function isBinaryOrLogical(node) {
|
|
|
128
128
|
export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
129
129
|
const operatorTargets = new Map();
|
|
130
130
|
const returnStatements = [];
|
|
131
|
+
const updateTargets = new Map();
|
|
132
|
+
const assignmentTargets = new Map();
|
|
131
133
|
function handleBinaryOrLogical(n) {
|
|
132
134
|
const nodeStart = n.start ?? 0;
|
|
133
135
|
const nodeEnd = n.end ?? 0;
|
|
@@ -152,6 +154,55 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
|
152
154
|
});
|
|
153
155
|
}
|
|
154
156
|
}
|
|
157
|
+
function handleUpdate(n) {
|
|
158
|
+
const nodeStart = n.start ?? 0;
|
|
159
|
+
const nodeEnd = n.end ?? 0;
|
|
160
|
+
const opValue = n.operator;
|
|
161
|
+
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
162
|
+
if (tok) {
|
|
163
|
+
const line = tok.loc.start.line;
|
|
164
|
+
if (ignoreLines.has(line))
|
|
165
|
+
return;
|
|
166
|
+
const visualCol = getVisualColumn(src, tok.start);
|
|
167
|
+
const mapKey = (n.prefix ? 'pre' : 'post') + opValue;
|
|
168
|
+
let arr = updateTargets.get(mapKey);
|
|
169
|
+
if (!arr) {
|
|
170
|
+
arr = [];
|
|
171
|
+
updateTargets.set(mapKey, arr);
|
|
172
|
+
}
|
|
173
|
+
arr.push({
|
|
174
|
+
start: tok.start,
|
|
175
|
+
end: tok.end,
|
|
176
|
+
line,
|
|
177
|
+
col1: visualCol,
|
|
178
|
+
op: opValue,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function handleAssignment(n) {
|
|
183
|
+
const nodeStart = n.start ?? 0;
|
|
184
|
+
const nodeEnd = n.end ?? 0;
|
|
185
|
+
const opValue = n.operator;
|
|
186
|
+
const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
|
|
187
|
+
if (tok) {
|
|
188
|
+
const line = tok.loc.start.line;
|
|
189
|
+
if (ignoreLines.has(line))
|
|
190
|
+
return;
|
|
191
|
+
const visualCol = getVisualColumn(src, tok.start);
|
|
192
|
+
let arr = assignmentTargets.get(opValue);
|
|
193
|
+
if (!arr) {
|
|
194
|
+
arr = [];
|
|
195
|
+
assignmentTargets.set(opValue, arr);
|
|
196
|
+
}
|
|
197
|
+
arr.push({
|
|
198
|
+
start: tok.start,
|
|
199
|
+
end: tok.end,
|
|
200
|
+
line,
|
|
201
|
+
col1: visualCol,
|
|
202
|
+
op: opValue,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
155
206
|
traverse(ast, {
|
|
156
207
|
BinaryExpression(p) {
|
|
157
208
|
handleBinaryOrLogical(p.node);
|
|
@@ -159,6 +210,12 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
|
159
210
|
LogicalExpression(p) {
|
|
160
211
|
handleBinaryOrLogical(p.node);
|
|
161
212
|
},
|
|
213
|
+
UpdateExpression(p) {
|
|
214
|
+
handleUpdate(p.node);
|
|
215
|
+
},
|
|
216
|
+
AssignmentExpression(p) {
|
|
217
|
+
handleAssignment(p.node);
|
|
218
|
+
},
|
|
162
219
|
ReturnStatement(p) {
|
|
163
220
|
const node = p.node;
|
|
164
221
|
if (!node.argument)
|
|
@@ -182,7 +239,7 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
|
|
|
182
239
|
});
|
|
183
240
|
},
|
|
184
241
|
});
|
|
185
|
-
return { operatorTargets, returnStatements };
|
|
242
|
+
return { operatorTargets, returnStatements, updateTargets, assignmentTargets };
|
|
186
243
|
}
|
|
187
244
|
/**
|
|
188
245
|
* Parse a source file once and build a reusable ParseContext.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, validatePositiveMs, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
|
|
2
|
+
import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, validatePositiveMs, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, parseShardOption, } from '../args.js';
|
|
3
3
|
describe('parseFlagNumber', () => {
|
|
4
4
|
it('parses valid integers', () => {
|
|
5
5
|
expect(parseFlagNumber('42', '--flag')).toBe(42);
|
|
@@ -227,6 +227,56 @@ describe('parseCliOptions', () => {
|
|
|
227
227
|
it('rejects --timeout abc', () => {
|
|
228
228
|
expect(() => parseCliOptions(['--timeout', 'abc'], emptyCfg)).toThrow('Invalid value for --timeout: abc');
|
|
229
229
|
});
|
|
230
|
+
it('defaults reportFormat to text', () => {
|
|
231
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
232
|
+
expect(opts.reportFormat).toBe('text');
|
|
233
|
+
});
|
|
234
|
+
it('parses --report json', () => {
|
|
235
|
+
const opts = parseCliOptions(['--report', 'json'], emptyCfg);
|
|
236
|
+
expect(opts.reportFormat).toBe('json');
|
|
237
|
+
});
|
|
238
|
+
it('reads report from config', () => {
|
|
239
|
+
const opts = parseCliOptions([], { report: 'json' });
|
|
240
|
+
expect(opts.reportFormat).toBe('json');
|
|
241
|
+
});
|
|
242
|
+
it('CLI --report takes precedence over config', () => {
|
|
243
|
+
const opts = parseCliOptions(['--report', 'json'], {
|
|
244
|
+
report: 'text',
|
|
245
|
+
});
|
|
246
|
+
expect(opts.reportFormat).toBe('json');
|
|
247
|
+
});
|
|
248
|
+
it('parses --typescript flag', () => {
|
|
249
|
+
const opts = parseCliOptions(['--typescript'], emptyCfg);
|
|
250
|
+
expect(opts.typescriptCheck).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
it('parses --no-typescript flag', () => {
|
|
253
|
+
const opts = parseCliOptions(['--no-typescript'], emptyCfg);
|
|
254
|
+
expect(opts.typescriptCheck).toBe(false);
|
|
255
|
+
});
|
|
256
|
+
it('defaults typescriptCheck to undefined', () => {
|
|
257
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
258
|
+
expect(opts.typescriptCheck).toBeUndefined();
|
|
259
|
+
});
|
|
260
|
+
it('--typescript takes precedence over --no-typescript (first one wins)', () => {
|
|
261
|
+
const opts = parseCliOptions(['--typescript', '--no-typescript'], emptyCfg);
|
|
262
|
+
expect(opts.typescriptCheck).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
it('parses --vitest-project flag', () => {
|
|
265
|
+
const opts = parseCliOptions(['--vitest-project', 'my-pkg'], emptyCfg);
|
|
266
|
+
expect(opts.vitestProject).toBe('my-pkg');
|
|
267
|
+
});
|
|
268
|
+
it('defaults vitestProject to undefined', () => {
|
|
269
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
270
|
+
expect(opts.vitestProject).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
it('parses --skip-baseline flag', () => {
|
|
273
|
+
const opts = parseCliOptions(['--skip-baseline'], emptyCfg);
|
|
274
|
+
expect(opts.skipBaseline).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
it('defaults skipBaseline to false', () => {
|
|
277
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
278
|
+
expect(opts.skipBaseline).toBe(false);
|
|
279
|
+
});
|
|
230
280
|
});
|
|
231
281
|
describe('validatePositiveMs', () => {
|
|
232
282
|
it('returns undefined for undefined', () => {
|
|
@@ -247,6 +297,44 @@ describe('validatePositiveMs', () => {
|
|
|
247
297
|
expect(() => validatePositiveMs(NaN, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
|
|
248
298
|
});
|
|
249
299
|
});
|
|
300
|
+
describe('parseShardOption', () => {
|
|
301
|
+
it('returns undefined when --shard is absent', () => {
|
|
302
|
+
expect(parseShardOption([])).toBeUndefined();
|
|
303
|
+
expect(parseShardOption(['--runner', 'vitest'])).toBeUndefined();
|
|
304
|
+
});
|
|
305
|
+
it('parses valid shard with space syntax', () => {
|
|
306
|
+
expect(parseShardOption(['--shard', '1/2'])).toEqual({ index: 1, total: 2 });
|
|
307
|
+
expect(parseShardOption(['--shard', '2/2'])).toEqual({ index: 2, total: 2 });
|
|
308
|
+
expect(parseShardOption(['--shard', '3/4'])).toEqual({ index: 3, total: 4 });
|
|
309
|
+
});
|
|
310
|
+
it('parses valid shard with = syntax', () => {
|
|
311
|
+
expect(parseShardOption(['--shard=1/2'])).toEqual({ index: 1, total: 2 });
|
|
312
|
+
});
|
|
313
|
+
it('throws on 5/4 (index > total)', () => {
|
|
314
|
+
expect(() => parseShardOption(['--shard', '5/4'])).toThrow('Invalid --shard');
|
|
315
|
+
});
|
|
316
|
+
it('throws on 0/4 (index < 1)', () => {
|
|
317
|
+
expect(() => parseShardOption(['--shard', '0/4'])).toThrow('Invalid --shard');
|
|
318
|
+
});
|
|
319
|
+
it('throws on 1/0 (total < 1)', () => {
|
|
320
|
+
expect(() => parseShardOption(['--shard', '1/0'])).toThrow('Invalid --shard');
|
|
321
|
+
});
|
|
322
|
+
it('throws on bad format', () => {
|
|
323
|
+
expect(() => parseShardOption(['--shard', 'bad'])).toThrow('Invalid --shard format');
|
|
324
|
+
expect(() => parseShardOption(['--shard', '1-2'])).toThrow('Invalid --shard format');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
describe('parseCliOptions shard', () => {
|
|
328
|
+
const emptyCfg = {};
|
|
329
|
+
it('parses --shard into opts.shard', () => {
|
|
330
|
+
const opts = parseCliOptions(['--shard', '2/4'], emptyCfg);
|
|
331
|
+
expect(opts.shard).toEqual({ index: 2, total: 4 });
|
|
332
|
+
});
|
|
333
|
+
it('opts.shard is undefined when flag absent', () => {
|
|
334
|
+
const opts = parseCliOptions([], emptyCfg);
|
|
335
|
+
expect(opts.shard).toBeUndefined();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
250
338
|
describe('extractConfigPath', () => {
|
|
251
339
|
it('extracts --config with separate value', () => {
|
|
252
340
|
expect(extractConfigPath(['--config', 'my.config.ts'])).toBe('my.config.ts');
|