@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.
@@ -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
- for (const mutation of mutator.apply(originalBlock)) {
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
- for (const mutation of mutator.apply(code)) {
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
- // collectOperatorTargets
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).map((target) => ({
23
- line: target.line,
24
- col: target.col1,
25
- code: src.slice(0, target.start) + toOp + src.slice(target.end),
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
- traverse(ast, {
33
- ReturnStatement(path) {
34
- const node = path.node;
35
- if (!node.argument)
36
- return; // bare return; nothing to replace
37
- const line = node.loc?.start.line;
38
- if (line === undefined)
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
  };
@@ -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.
@@ -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
@@ -122,21 +122,86 @@ export function isBinaryOrLogical(node) {
122
122
  return node.type === 'BinaryExpression' || node.type === 'LogicalExpression';
123
123
  }
124
124
  /**
125
- * Collect the operator tokens for a given operator and return their exact locations.
126
- * Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
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 collectOperatorTargets(src, opValue) {
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 fileAst = ast;
136
- const tokens = fileAst.tokens ?? [];
137
- const comments = fileAst.comments ?? [];
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, // convert to 1-based
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')),
@@ -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
- try {
88
- const mod = await import(
89
- /* @vite-ignore */ '@vitejs/plugin-vue');
90
- const vue = mod.default ?? mod;
91
- plugins = typeof vue === 'function' ? [vue()] : [];
92
- }
93
- catch (err) {
94
- const detail = err instanceof Error ? err.message : String(err);
95
- log.warn(`Unable to load @vitejs/plugin-vue; Vue SFC imports may fail to resolve (${detail})`);
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
- for (const testAbs of tests) {
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
- continue;
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
- const originalLines = fs.readFileSync(v.file, 'utf8').split('\n');
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutineerjs/mutineer",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A fast, targeted mutation testing framework for JavaScript and TypeScript",
5
5
  "type": "module",
6
6
  "private": false,