@mutineerjs/mutineer 0.5.0 → 0.6.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.
Files changed (39) hide show
  1. package/README.md +42 -2
  2. package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +43 -0
  4. package/dist/bin/mutineer.d.ts +2 -1
  5. package/dist/bin/mutineer.js +41 -0
  6. package/dist/core/__tests__/variant-utils.spec.js +24 -2
  7. package/dist/core/sfc.js +6 -1
  8. package/dist/core/variant-utils.js +6 -1
  9. package/dist/mutators/__tests__/operator.spec.js +16 -0
  10. package/dist/mutators/__tests__/return-value.spec.js +37 -0
  11. package/dist/mutators/__tests__/utils.spec.js +96 -2
  12. package/dist/mutators/operator.js +11 -5
  13. package/dist/mutators/return-value.js +36 -22
  14. package/dist/mutators/types.d.ts +2 -0
  15. package/dist/mutators/utils.d.ts +67 -0
  16. package/dist/mutators/utils.js +90 -15
  17. package/dist/runner/__tests__/args.spec.js +45 -1
  18. package/dist/runner/__tests__/changed.spec.js +85 -2
  19. package/dist/runner/__tests__/config.spec.js +2 -13
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +7 -2
  21. package/dist/runner/__tests__/discover.spec.js +52 -1
  22. package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
  23. package/dist/runner/__tests__/orchestrator.spec.js +141 -0
  24. package/dist/runner/__tests__/pool-executor.spec.js +40 -0
  25. package/dist/runner/args.d.ts +5 -0
  26. package/dist/runner/args.js +13 -0
  27. package/dist/runner/changed.js +15 -43
  28. package/dist/runner/config.js +1 -1
  29. package/dist/runner/discover.js +21 -12
  30. package/dist/runner/jest/__tests__/pool.spec.js +41 -0
  31. package/dist/runner/jest/pool.js +3 -3
  32. package/dist/runner/orchestrator.js +16 -1
  33. package/dist/runner/pool-executor.js +7 -1
  34. package/dist/runner/vitest/__tests__/adapter.spec.js +19 -0
  35. package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
  36. package/dist/runner/vitest/adapter.js +3 -0
  37. package/dist/runner/vitest/pool.js +3 -3
  38. package/dist/types/config.d.ts +2 -0
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -19,10 +19,10 @@ Built for **Vitest** with first-class **Jest** support. Other test runners can b
19
19
 
20
20
  1. **Baseline** -- runs your test suite to make sure everything passes before mutating
21
21
  2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
22
- 3. **Test** -- re-runs only the tests that import the mutated file, using a fast file-swap mechanism
22
+ 3. **Test** -- re-runs only the tests that import the mutated file, using temp files in `__mutineer__` dirs loaded via Vite plugin / Jest resolver
23
23
  4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
24
24
 
25
- Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is injected at runtime via Vite plugins (Vitest) or custom resolvers (Jest) -- no files on disk are modified.
25
+ Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is written to a temporary `__mutineer__` directory next to each source file, then loaded at runtime via Vite plugins (Vitest) or custom resolvers (Jest).
26
26
 
27
27
  ## Supported Mutations (WIP)
28
28
 
@@ -164,6 +164,46 @@ export default defineMutineerConfig({
164
164
  | `testPatterns` | `string[]` | Globs for test file discovery |
165
165
  | `extensions` | `string[]` | File extensions to consider |
166
166
 
167
+ ## Recommended Workflow
168
+
169
+ Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
170
+
171
+ ### 1. PR-scoped runs (CI) — `--changed-with-deps`
172
+
173
+ Run only on files changed in the branch plus their direct dependents:
174
+
175
+ ```bash
176
+ mutineer run --changed-with-deps
177
+ ```
178
+
179
+ - Tune the dependency graph depth with `dependencyDepth` in config (default: `1`)
180
+ - Add `--per-test-coverage` to only run tests that cover the mutated line
181
+ - Recommended `package.json` script:
182
+
183
+ ```json
184
+ "mutineer:ci": "mutineer run --changed-with-deps --per-test-coverage"
185
+ ```
186
+
187
+ ### 2. Split configs by domain
188
+
189
+ Create a `mutineer.config.ts` per domain and run selectively:
190
+
191
+ ```bash
192
+ mutineer run -c src/api/mutineer.config.ts
193
+ ```
194
+
195
+ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos or large modular projects — domains can also be parallelized in CI.
196
+
197
+ ### 3. Combine filters to reduce scope
198
+
199
+ - `--only-covered-lines` — skips lines not covered by any test (requires a coverage file)
200
+ - `maxMutantsPerFile` — caps mutations per file as a safety valve
201
+ - Combine for maximum focus:
202
+
203
+ ```bash
204
+ mutineer run --changed-with-deps --only-covered-lines --per-test-coverage
205
+ ```
206
+
167
207
  ## File Support
168
208
 
169
209
  - TypeScript and JavaScript modules (`.ts`, `.js`, `.tsx`, `.jsx`)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { createRequire } from 'node:module';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { HELP_TEXT, getVersion } from '../mutineer.js';
4
+ describe('HELP_TEXT', () => {
5
+ const flags = [
6
+ '--config',
7
+ '-c',
8
+ '--concurrency',
9
+ '--runner',
10
+ '--progress',
11
+ '--changed',
12
+ '--changed-with-deps',
13
+ '--only-covered-lines',
14
+ '--per-test-coverage',
15
+ '--coverage-file',
16
+ '--min-kill-percent',
17
+ '--help',
18
+ '-h',
19
+ '--version',
20
+ '-V',
21
+ ];
22
+ it.each(flags)('includes %s', (flag) => {
23
+ expect(HELP_TEXT).toContain(flag);
24
+ });
25
+ it('includes all three commands', () => {
26
+ expect(HELP_TEXT).toContain('init');
27
+ expect(HELP_TEXT).toContain('run');
28
+ expect(HELP_TEXT).toContain('clean');
29
+ });
30
+ it('--changed-with-deps description mentions local dependencies', () => {
31
+ expect(HELP_TEXT).toContain('local dependencies');
32
+ });
33
+ });
34
+ describe('getVersion()', () => {
35
+ it('returns a semver string', () => {
36
+ expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
37
+ });
38
+ it('matches package.json version', () => {
39
+ const require = createRequire(import.meta.url);
40
+ const pkg = require('../../../package.json');
41
+ expect(getVersion()).toBe(pkg.version);
42
+ });
43
+ });
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare const HELP_TEXT = "Usage: mutineer <command> [options]\n\nCommands:\n init Create a mutineer.config.ts template\n run Run mutation testing\n clean Remove __mutineer__ temp directories\n\nOptions (run):\n --config, -c <path> Config file path\n --concurrency <n> Worker count (default: CPU count - 1)\n --runner <vitest|jest> Test runner (default: vitest)\n --progress <bar|list|quiet> Progress display (default: bar)\n --changed Mutate only git-changed files\n --changed-with-deps Mutate changed files + their local dependencies\n --only-covered-lines Mutate only lines covered by tests\n --per-test-coverage Collect per-test coverage data\n --coverage-file <path> Path to coverage JSON\n --min-kill-percent <n> Minimum kill % threshold (0\u2013100)\n --timeout <ms> Per-mutant test timeout in ms (default: 30000)\n\n --help, -h Show this help\n --version, -V Show version\n";
3
+ export declare function getVersion(): string;
@@ -1,12 +1,41 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createRequire } from 'node:module';
4
5
  import { runOrchestrator } from '../runner/orchestrator.js';
5
6
  import { cleanupMutineerDirs } from '../runner/cleanup.js';
6
7
  // Constants
7
8
  const RUN_COMMAND = 'run';
8
9
  const CLEAN_COMMAND = 'clean';
9
10
  const INIT_COMMAND = 'init';
11
+ export const HELP_TEXT = `\
12
+ Usage: mutineer <command> [options]
13
+
14
+ Commands:
15
+ init Create a mutineer.config.ts template
16
+ run Run mutation testing
17
+ clean Remove __mutineer__ temp directories
18
+
19
+ Options (run):
20
+ --config, -c <path> Config file path
21
+ --concurrency <n> Worker count (default: CPU count - 1)
22
+ --runner <vitest|jest> Test runner (default: vitest)
23
+ --progress <bar|list|quiet> Progress display (default: bar)
24
+ --changed Mutate only git-changed files
25
+ --changed-with-deps Mutate changed files + their local dependencies
26
+ --only-covered-lines Mutate only lines covered by tests
27
+ --per-test-coverage Collect per-test coverage data
28
+ --coverage-file <path> Path to coverage JSON
29
+ --min-kill-percent <n> Minimum kill % threshold (0–100)
30
+ --timeout <ms> Per-mutant test timeout in ms (default: 30000)
31
+
32
+ --help, -h Show this help
33
+ --version, -V Show version
34
+ `;
35
+ export function getVersion() {
36
+ const require = createRequire(import.meta.url);
37
+ return require('../../package.json').version;
38
+ }
10
39
  const CONFIG_TEMPLATE = `\
11
40
  import { defineMutineerConfig } from 'mutineer'
12
41
 
@@ -19,7 +48,19 @@ export default defineMutineerConfig({
19
48
  */
20
49
  async function main() {
21
50
  const args = process.argv.slice(2);
51
+ if (args[0] === '--help' || args[0] === '-h') {
52
+ process.stdout.write(HELP_TEXT);
53
+ process.exit(0);
54
+ }
55
+ if (args[0] === '--version' || args[0] === '-V') {
56
+ console.log(getVersion());
57
+ process.exit(0);
58
+ }
22
59
  if (args[0] === RUN_COMMAND) {
60
+ if (args.includes('--help') || args.includes('-h')) {
61
+ process.stdout.write(HELP_TEXT);
62
+ process.exit(0);
63
+ }
23
64
  await runOrchestrator(args.slice(1), process.cwd());
24
65
  }
25
66
  else if (args[0] === INIT_COMMAND) {
@@ -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