@mutineerjs/mutineer 0.9.0 → 0.11.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 (83) hide show
  1. package/README.md +52 -47
  2. package/dist/__tests__/index.spec.js +8 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +7 -7
  4. package/dist/bin/mutineer.d.ts +1 -1
  5. package/dist/bin/mutineer.js +7 -4
  6. package/dist/core/__tests__/schemata.spec.js +62 -0
  7. package/dist/core/__tests__/sfc.spec.js +41 -1
  8. package/dist/core/schemata.js +15 -21
  9. package/dist/core/sfc.js +0 -4
  10. package/dist/core/variant-utils.js +0 -4
  11. package/dist/mutators/__tests__/utils.spec.js +65 -1
  12. package/dist/mutators/operator.js +13 -27
  13. package/dist/mutators/return-value.js +3 -7
  14. package/dist/mutators/utils.d.ts +2 -2
  15. package/dist/mutators/utils.js +59 -96
  16. package/dist/runner/__tests__/args.spec.js +8 -4
  17. package/dist/runner/__tests__/cache.spec.js +24 -0
  18. package/dist/runner/__tests__/changed.spec.js +75 -0
  19. package/dist/runner/__tests__/config.spec.js +50 -1
  20. package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
  21. package/dist/runner/__tests__/discover.spec.js +179 -0
  22. package/dist/runner/__tests__/orchestrator.spec.js +336 -11
  23. package/dist/runner/__tests__/pool-executor.spec.js +77 -0
  24. package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
  25. package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
  26. package/dist/runner/__tests__/ts-checker.spec.js +89 -2
  27. package/dist/runner/args.d.ts +1 -1
  28. package/dist/runner/args.js +2 -2
  29. package/dist/runner/config.js +3 -4
  30. package/dist/runner/coverage-resolver.js +2 -1
  31. package/dist/runner/discover.js +2 -2
  32. package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
  33. package/dist/runner/jest/__tests__/pool.spec.js +223 -1
  34. package/dist/runner/jest/adapter.js +3 -45
  35. package/dist/runner/jest/pool.js +4 -10
  36. package/dist/runner/jest/worker-runtime.js +2 -1
  37. package/dist/runner/orchestrator.js +8 -7
  38. package/dist/runner/pool-executor.js +7 -12
  39. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
  40. package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
  41. package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
  42. package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
  43. package/dist/runner/shared/index.d.ts +4 -0
  44. package/dist/runner/shared/index.js +2 -0
  45. package/dist/runner/shared/pending-task.d.ts +9 -0
  46. package/dist/runner/shared/pending-task.js +1 -0
  47. package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
  48. package/dist/runner/shared/strip-mutineer-args.js +47 -0
  49. package/dist/runner/shared/worker-script.d.ts +5 -0
  50. package/dist/runner/shared/worker-script.js +12 -0
  51. package/dist/runner/ts-checker-worker.d.ts +10 -1
  52. package/dist/runner/ts-checker-worker.js +27 -25
  53. package/dist/runner/ts-checker.d.ts +6 -0
  54. package/dist/runner/ts-checker.js +1 -1
  55. package/dist/runner/vitest/__tests__/adapter.spec.js +294 -0
  56. package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
  57. package/dist/runner/vitest/__tests__/pool.spec.js +711 -0
  58. package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
  59. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +81 -0
  60. package/dist/runner/vitest/adapter.js +14 -46
  61. package/dist/runner/vitest/plugin.js +1 -7
  62. package/dist/runner/vitest/pool.js +6 -19
  63. package/dist/runner/vitest/redirect-loader.js +3 -1
  64. package/dist/runner/vitest/worker-runtime.js +16 -1
  65. package/dist/runner/vitest/worker.mjs +1 -0
  66. package/dist/types/config.d.ts +2 -2
  67. package/dist/types/mutant.d.ts +3 -0
  68. package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
  69. package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
  70. package/dist/utils/__tests__/coverage.spec.js +89 -0
  71. package/dist/utils/__tests__/logger.spec.js +9 -0
  72. package/dist/utils/__tests__/progress.spec.js +38 -0
  73. package/dist/utils/__tests__/summary.spec.js +70 -31
  74. package/dist/utils/coverage.js +3 -4
  75. package/dist/utils/errors.d.ts +4 -0
  76. package/dist/utils/errors.js +6 -0
  77. package/dist/utils/summary.d.ts +2 -3
  78. package/dist/utils/summary.js +5 -6
  79. package/package.json +1 -1
  80. package/dist/utils/CompileErrors.d.ts +0 -7
  81. package/dist/utils/CompileErrors.js +0 -24
  82. package/dist/utils/__tests__/CompileErrors.spec.js +0 -96
  83. /package/dist/{utils/__tests__/CompileErrors.spec.d.ts → __tests__/index.spec.d.ts} +0 -0
package/README.md CHANGED
@@ -9,16 +9,16 @@
9
9
  === ~> !== · && ~> || · + ~> -
10
10
  ```
11
11
 
12
- Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeScript. Mutineer introduces small code changes (mutations) into your source files and runs your existing tests to see if they catch the defect. If a test fails, the mutant is "killed" -- meaning your tests are doing their job. If all tests pass, the mutant "escaped" -- revealing a gap in your test coverage.
12
+ Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeScript. Mutineer introduces small code changes (mutations) into your source files and runs your existing tests to see if they catch the defect. If a test fails, the mutant is "killed" - meaning your tests are doing their job. If all tests pass, the mutant "escaped" - revealing a gap in your test coverage.
13
13
 
14
14
  Built for **Vitest** with first-class **Jest** support. Other test runners can be added via the adapter interface.
15
15
 
16
16
  ## How It Works
17
17
 
18
- 1. **Baseline** -- runs your test suite to make sure everything passes before mutating
19
- 2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
20
- 3. **Test** -- re-runs only the tests that import the mutated file; compile errors are detected via parallel TypeScript workers and surfaced in an interactive UI
21
- 4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
18
+ 1. **Baseline** - runs your test suite to make sure everything passes before mutating
19
+ 2. **Mutate** - applies AST-safe operator replacements to your source files (not your tests)
20
+ 3. **Test** - re-runs only the tests that import the mutated file; compile errors are detected via parallel TypeScript workers
21
+ 4. **Report** - prints a summary with kill rate, escaped mutants, and per-file breakdowns
22
22
 
23
23
  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).
24
24
 
@@ -93,23 +93,26 @@ npm run mutineer
93
93
 
94
94
  ### CLI Options (for `mutineer run`)
95
95
 
96
- | Flag | Description | Default |
97
- | ------------------------ | --------------------------------------------------------------- | ------------- |
98
- | `--runner <type>` | Test runner: `vitest` or `jest` | `vitest` |
99
- | `--config`, `-c` | Path to config file | auto-detected |
100
- | `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
101
- | `--changed` | Only mutate files changed vs base branch | -- |
102
- | `--changed-with-deps` | Include dependents of changed files | -- |
103
- | `--full` | Mutate full codebase, skipping confirmation prompt | -- |
104
- | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
105
- | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
106
- | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
107
- | `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
108
- | `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
109
- | `--timeout <ms>` | Per-mutant test timeout | `30000` |
110
- | `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
111
- | `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
112
- | `--skip-baseline` | Skip the baseline test run | -- |
96
+ | Flag | Description | Default |
97
+ | ------------------------- | ------------------------------------------------------------------------------------ | ------------- |
98
+ | `--runner <type>` | Test runner: `vitest` or `jest` | `vitest` |
99
+ | `--config`, `-c` | Path to config file | auto-detected |
100
+ | `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
101
+ | `--changed` | Only mutate files changed vs base branch | -- |
102
+ | `--changed-with-imports` | Include local imports of changed files | -- |
103
+ | `--full` | Mutate full codebase, skipping confirmation prompt | -- |
104
+ | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
105
+ | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
106
+ | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
107
+ | `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
108
+ | `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
109
+ | `--timeout <ms>` | Per-mutant test timeout | `30000` |
110
+ | `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
111
+ | `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
112
+ | `--skip-baseline` | Skip the baseline test run | -- |
113
+ | `--vitest-project <name>` | Filter mutations to a specific Vitest workspace project (requires `test.projects`) | -- |
114
+ | `--typescript` | Enable TypeScript type-check pre-filtering (skips mutants that cause compile errors) | auto |
115
+ | `--no-typescript` | Disable TypeScript type-check pre-filtering | -- |
113
116
 
114
117
  ### Examples
115
118
 
@@ -149,53 +152,55 @@ export default defineMutineerConfig({
149
152
 
150
153
  ### Config Options
151
154
 
152
- | Option | Type | Description |
153
- | ------------------- | -------------------- | ------------------------------------------------ |
154
- | `source` | `string \| string[]` | Glob patterns for source files to mutate |
155
- | `targets` | `MutateTarget[]` | Explicit list of files to mutate |
156
- | `runner` | `'vitest' \| 'jest'` | Test runner to use |
157
- | `vitestConfig` | `string` | Path to vitest config |
158
- | `jestConfig` | `string` | Path to jest config |
159
- | `include` | `string[]` | Only run these mutators |
160
- | `exclude` | `string[]` | Skip these mutators |
161
- | `excludePaths` | `string[]` | Glob patterns for paths to skip |
162
- | `maxMutantsPerFile` | `number` | Cap mutations per file |
163
- | `minKillPercent` | `number` | Fail if kill rate is below this |
164
- | `onlyCoveredLines` | `boolean` | Only mutate lines covered by tests |
165
- | `perTestCoverage` | `boolean` | Use per-test coverage to select tests |
166
- | `baseRef` | `string` | Git ref for `--changed` (default: `origin/main`) |
167
- | `testPatterns` | `string[]` | Globs for test file discovery |
168
- | `extensions` | `string[]` | File extensions to consider |
155
+ | Option | Type | Description |
156
+ | ------------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
157
+ | `source` | `string \| string[]` | Glob patterns for source files to mutate |
158
+ | `targets` | `MutateTarget[]` | Explicit list of files to mutate |
159
+ | `runner` | `'vitest' \| 'jest'` | Test runner to use |
160
+ | `vitestConfig` | `string` | Path to vitest config |
161
+ | `jestConfig` | `string` | Path to jest config |
162
+ | `include` | `string[]` | Only run these mutators |
163
+ | `exclude` | `string[]` | Skip these mutators |
164
+ | `excludePaths` | `string[]` | Glob patterns for paths to skip |
165
+ | `maxMutantsPerFile` | `number` | Cap mutations per file |
166
+ | `minKillPercent` | `number` | Fail if kill rate is below this |
167
+ | `onlyCoveredLines` | `boolean` | Only mutate lines covered by tests |
168
+ | `perTestCoverage` | `boolean` | Use per-test coverage to select tests |
169
+ | `baseRef` | `string` | Git ref for `--changed` (default: `origin/main`) |
170
+ | `testPatterns` | `string[]` | Globs for test file discovery |
171
+ | `extensions` | `string[]` | File extensions to consider |
172
+ | `vitestProject` | `string \| string[]` | Filter to a specific Vitest workspace project |
173
+ | `typescript` | `boolean \| { tsconfig?: string }` | Enable TS type-check pre-filtering; auto-detected if `tsconfig.json` present |
169
174
 
170
175
  ## Recommended Workflow
171
176
 
172
177
  Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
173
178
 
174
- When you run `mutineer run` without `--changed`, `--changed-with-deps`, or `--full` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
179
+ When you run `mutineer run` without `--changed`, `--changed-with-imports`, or `--full` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
175
180
 
176
181
  ```
177
182
  Warning: Running on the full codebase may take a while.
178
183
 
179
184
  [1] Continue (full codebase)
180
185
  [2] --changed (git-changed files only)
181
- [3] --changed-with-deps (changed + their local deps)
186
+ [3] --changed-with-imports (changed + their local imports)
182
187
  [4] Abort
183
188
  ```
184
189
 
185
- ### 1. PR-scoped runs (CI) — `--changed-with-deps`
190
+ ### 1. PR-scoped runs (CI) — `--changed-with-imports`
186
191
 
187
- Run only on files changed in the branch plus their direct dependents:
192
+ Run only on files changed in the branch plus their local imports:
188
193
 
189
194
  ```bash
190
- mutineer run --changed-with-deps
195
+ mutineer run --changed-with-imports
191
196
  ```
192
197
 
193
- - Tune the dependency graph depth with `dependencyDepth` in config (default: `1`)
198
+ - Tune the import resolution depth with `importDepth` in config (default: `1`)
194
199
  - Add `--per-test-coverage` to only run tests that cover the mutated line
195
200
  - Recommended `package.json` script:
196
201
 
197
202
  ```json
198
- "mutineer:ci": "mutineer run --changed-with-deps --per-test-coverage"
203
+ "mutineer:ci": "mutineer run --changed-with-imports --per-test-coverage"
199
204
  ```
200
205
 
201
206
  ### 2. Split configs by domain
@@ -215,7 +220,7 @@ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos
215
220
  - Combine for maximum focus:
216
221
 
217
222
  ```bash
218
- mutineer run --changed-with-deps --only-covered-lines --per-test-coverage
223
+ mutineer run --changed-with-imports --only-covered-lines --per-test-coverage
219
224
  ```
220
225
 
221
226
  ## File Support
@@ -0,0 +1,8 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { defineMutineerConfig } from '../index.js';
3
+ describe('defineMutineerConfig', () => {
4
+ it('returns the config object unchanged', () => {
5
+ const cfg = { runner: 'vitest' };
6
+ expect(defineMutineerConfig(cfg)).toBe(cfg);
7
+ });
8
+ });
@@ -10,7 +10,7 @@ describe('HELP_TEXT', () => {
10
10
  '--runner',
11
11
  '--progress',
12
12
  '--changed',
13
- '--changed-with-deps',
13
+ '--changed-with-imports',
14
14
  '--full',
15
15
  '--only-covered-lines',
16
16
  '--per-test-coverage',
@@ -29,8 +29,8 @@ describe('HELP_TEXT', () => {
29
29
  expect(HELP_TEXT).toContain('run');
30
30
  expect(HELP_TEXT).toContain('clean');
31
31
  });
32
- it('--changed-with-deps description mentions local dependencies', () => {
33
- expect(HELP_TEXT).toContain('local dependencies');
32
+ it('--changed-with-imports description mentions local imports', () => {
33
+ expect(HELP_TEXT).toContain('local imports');
34
34
  });
35
35
  });
36
36
  describe('confirmFullRun()', () => {
@@ -57,9 +57,9 @@ describe('confirmFullRun()', () => {
57
57
  const args = ['--changed', '--concurrency', '4'];
58
58
  expect(await confirmFullRun(args)).toBe(args);
59
59
  });
60
- it('returns args unchanged when --changed-with-deps is present', async () => {
60
+ it('returns args unchanged when --changed-with-imports is present', async () => {
61
61
  mockTTY(true);
62
- const args = ['--changed-with-deps'];
62
+ const args = ['--changed-with-imports'];
63
63
  expect(await confirmFullRun(args)).toBe(args);
64
64
  });
65
65
  it('returns args unchanged when --full is present, skipping prompt', async () => {
@@ -93,10 +93,10 @@ describe('confirmFullRun()', () => {
93
93
  mockReadline(['2']);
94
94
  expect(await confirmFullRun([])).toEqual(['--changed']);
95
95
  });
96
- it('choice 3 appends --changed-with-deps', async () => {
96
+ it('choice 3 appends --changed-with-imports', async () => {
97
97
  mockTTY(true);
98
98
  mockReadline(['3']);
99
- expect(await confirmFullRun([])).toEqual(['--changed-with-deps']);
99
+ expect(await confirmFullRun([])).toEqual(['--changed-with-imports']);
100
100
  });
101
101
  it('invalid input re-prompts, then accepts valid choice', async () => {
102
102
  mockTTY(true);
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
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 --full Mutate full codebase, skipping confirmation prompt\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 --report <text|json> Output format: text (default) or json (writes mutineer-report.json)\n --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)\n --skip-baseline Skip the baseline test run\n\n --help, -h Show this help\n --version, -V Show version\n";
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-imports Mutate changed files + their local imports\n --full Mutate full codebase, skipping confirmation prompt\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 --report <text|json> Output format: text (default) or json (writes mutineer-report.json)\n --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)\n --skip-baseline Skip the baseline test run\n --vitest-project <name> Filter to a specific Vitest workspace project\n --typescript Enable TS type-check pre-filtering\n --no-typescript Disable TS type-check pre-filtering\n\n --help, -h Show this help\n --version, -V Show version\n";
3
3
  export declare function getVersion(): string;
4
4
  /**
5
5
  * When running in full-codebase mode on an interactive TTY, warn the user and
@@ -23,7 +23,7 @@ Options (run):
23
23
  --runner <vitest|jest> Test runner (default: vitest)
24
24
  --progress <bar|list|quiet> Progress display (default: bar)
25
25
  --changed Mutate only git-changed files
26
- --changed-with-deps Mutate changed files + their local dependencies
26
+ --changed-with-imports Mutate changed files + their local imports
27
27
  --full Mutate full codebase, skipping confirmation prompt
28
28
  --only-covered-lines Mutate only lines covered by tests
29
29
  --per-test-coverage Collect per-test coverage data
@@ -33,6 +33,9 @@ Options (run):
33
33
  --report <text|json> Output format: text (default) or json (writes mutineer-report.json)
34
34
  --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
35
35
  --skip-baseline Skip the baseline test run
36
+ --vitest-project <name> Filter to a specific Vitest workspace project
37
+ --typescript Enable TS type-check pre-filtering
38
+ --no-typescript Disable TS type-check pre-filtering
36
39
 
37
40
  --help, -h Show this help
38
41
  --version, -V Show version
@@ -53,7 +56,7 @@ Warning: Running on the full codebase may take a while.
53
56
 
54
57
  [1] Continue (full codebase)
55
58
  [2] --changed (git-changed files only)
56
- [3] --changed-with-deps (changed + their local deps)
59
+ [3] --changed-with-imports (changed + their local imports)
57
60
  [4] Abort
58
61
 
59
62
  `;
@@ -62,7 +65,7 @@ Warning: Running on the full codebase may take a while.
62
65
  * let them narrow scope or abort. Returns args (possibly with a flag appended).
63
66
  */
64
67
  export async function confirmFullRun(args) {
65
- const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-deps');
68
+ const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-imports');
66
69
  if (!isFullRun || !process.stdin.isTTY || args.includes('--full'))
67
70
  return args;
68
71
  process.stdout.write(FULL_RUN_WARNING);
@@ -84,7 +87,7 @@ export async function confirmFullRun(args) {
84
87
  }
85
88
  else if (choice === '3') {
86
89
  rl.close();
87
- resolve([...args, '--changed-with-deps']);
90
+ resolve([...args, '--changed-with-imports']);
88
91
  }
89
92
  else if (choice === '4') {
90
93
  rl.close();
@@ -162,4 +162,66 @@ describe('generateSchema', () => {
162
162
  expect(schemaCode).toContain("'f#0'");
163
163
  expect(schemaCode).toContain("'f#1'");
164
164
  });
165
+ it('finds the smallest enclosing expression when operator is nested inside a call', () => {
166
+ // CallExpression 'foo(x + y)' and BinaryExpression 'x + y' both contain the '+'
167
+ // offset. Walk visits outer (CallExpression) first — !best true — then inner
168
+ // (BinaryExpression) — !best false, span < best span — so best is updated to inner.
169
+ const original = 'foo(x + y)';
170
+ const { schemaCode, fallbackIds } = generateSchema(original, [
171
+ makeVariant('f#0', 'foo(x - y)'),
172
+ ]);
173
+ expect(fallbackIds.size).toBe(0);
174
+ expect(schemaCode).toContain('x - y');
175
+ expect(schemaCode).toContain('x + y');
176
+ });
177
+ it('handles sparse array elements (null entries in Babel AST arrays) without crashing', () => {
178
+ // '[1,,a+b]' produces an ArrayExpression with a null element for the elision.
179
+ // The walk must skip the null entry and still find the BinaryExpression 'a+b'.
180
+ const original = '[1,,a+b]';
181
+ const { schemaCode, fallbackIds } = generateSchema(original, [
182
+ makeVariant('f#0', '[1,,a-b]'),
183
+ ]);
184
+ expect(fallbackIds.size).toBe(0);
185
+ expect(schemaCode).toContain('a-b');
186
+ expect(schemaCode).toContain('a+b');
187
+ });
188
+ it('marks operator variant as fallback when original code cannot be parsed', () => {
189
+ // Unterminated brace causes Babel parse error → parseForSchema returns null
190
+ const original = 'const x = {';
191
+ const v = makeVariant('f#0', 'const x = |');
192
+ const { fallbackIds } = generateSchema(original, [v]);
193
+ expect(fallbackIds.has('f#0')).toBe(true);
194
+ });
195
+ it('marks operator variant as fallback when operator is inside a comment (no enclosing expression)', () => {
196
+ // parseForSchema succeeds but findSmallestEnclosingExpression returns null
197
+ // because position 3 is inside a comment, not any AST expression node
198
+ const original = '// +\nconst x = 1';
199
+ const v = makeVariant('f#0', '// -\nconst x = 1');
200
+ const { fallbackIds } = generateSchema(original, [v]);
201
+ expect(fallbackIds.has('f#0')).toBe(true);
202
+ });
203
+ it('marks outer site as fallback when inner site is fully contained within it', () => {
204
+ // '&&' maps to outer BinaryExpression [0,12]; '+' maps to inner 'b + c' [6,11]
205
+ // b.origEnd=11 <= a.origEnd=12 → outer marked fallback, inner kept
206
+ const original = 'a && (b + c)';
207
+ const vOuter = makeVariant('f#0', 'a || (b + c)'); // && → ||
208
+ const vInner = makeVariant('f#1', 'a && (b - c)'); // + → -
209
+ const { schemaCode, fallbackIds } = generateSchema(original, [
210
+ vOuter,
211
+ vInner,
212
+ ]);
213
+ expect(fallbackIds.has('f#0')).toBe(true);
214
+ expect(fallbackIds.has('f#1')).toBe(false);
215
+ expect(schemaCode).not.toContain("'f#0'");
216
+ expect(schemaCode).toContain("'f#1'");
217
+ });
218
+ it('marks both sites as fallback when they partially overlap', () => {
219
+ // Sites [0:3] and [2:5] partially overlap (neither fully contains the other)
220
+ const original = 'a>b>c';
221
+ const v0 = makeVariant('f#0', 'X>Y>c'); // changes [0:3] (a>b → X>Y)
222
+ const v1 = makeVariant('f#1', 'a>B>C'); // changes [2:5] (b>c → B>C)
223
+ const { fallbackIds } = generateSchema(original, [v0, v1]);
224
+ expect(fallbackIds.has('f#0')).toBe(true);
225
+ expect(fallbackIds.has('f#1')).toBe(true);
226
+ });
165
227
  });
@@ -1,5 +1,13 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { mutateVueSfcScriptSetup } from '../sfc.js';
3
+ // Allow individual tests to override getFilteredRegistry
4
+ vi.mock('../variant-utils.js', async (importOriginal) => {
5
+ const actual = await importOriginal();
6
+ return {
7
+ ...actual,
8
+ getFilteredRegistry: vi.fn(actual.getFilteredRegistry),
9
+ };
10
+ });
3
11
  // Mock @vue/compiler-sfc
4
12
  vi.mock('@vue/compiler-sfc', () => ({
5
13
  parse: (code, _opts) => {
@@ -66,9 +74,41 @@ describe('mutateVueSfcScriptSetup', () => {
66
74
  expect(v.name).toBe('andToOr');
67
75
  }
68
76
  });
77
+ it('uses apply() fallback when mutator has no applyWithContext', async () => {
78
+ const { getFilteredRegistry } = await import('../variant-utils.js');
79
+ const applyFn = vi.fn(() => [{ code: 'FALLBACK', line: 1, col: 0 }]);
80
+ vi.mocked(getFilteredRegistry).mockReturnValueOnce([
81
+ { name: 'noCtx', description: 'noCtx', apply: applyFn },
82
+ ]);
83
+ const code = '<script setup>\nconst x = 1\n</script>';
84
+ const result = await mutateVueSfcScriptSetup('test.vue', code);
85
+ expect(applyFn).toHaveBeenCalled();
86
+ expect(result.length).toBe(1);
87
+ });
88
+ it('deduplicates when two mutators produce the same full SFC output', async () => {
89
+ const { getFilteredRegistry } = await import('../variant-utils.js');
90
+ vi.mocked(getFilteredRegistry).mockReturnValueOnce([
91
+ {
92
+ name: 'A',
93
+ description: 'A',
94
+ apply: () => [{ code: 'SAME_OUTPUT', line: 1, col: 0 }],
95
+ },
96
+ {
97
+ name: 'B',
98
+ description: 'B',
99
+ apply: () => [{ code: 'SAME_OUTPUT', line: 1, col: 0 }],
100
+ },
101
+ ]);
102
+ const code = '<script setup>\nconst x = 1\n</script>';
103
+ const result = await mutateVueSfcScriptSetup('test.vue', code);
104
+ // Both mutators produce the same output; only one variant should exist
105
+ expect(result.length).toBe(1);
106
+ });
69
107
  it('filters mutators with exclude', async () => {
70
108
  const code = '<script setup>\nconst x = a && b\n</script>';
71
- const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, ['andToOr']);
109
+ const result = await mutateVueSfcScriptSetup('test.vue', code, undefined, [
110
+ 'andToOr',
111
+ ]);
72
112
  for (const v of result) {
73
113
  expect(v.name).not.toBe('andToOr');
74
114
  }
@@ -31,6 +31,15 @@ function findSingleDiff(original, mutated) {
31
31
  }
32
32
  return { origStart: start, origEnd, mutEnd };
33
33
  }
34
+ const AST_SKIP_KEYS = new Set([
35
+ 'type',
36
+ 'start',
37
+ 'end',
38
+ 'loc',
39
+ 'range',
40
+ 'errors',
41
+ 'operator', // skip operator — forces expansion to the full enclosing expression
42
+ ]);
34
43
  function escapeId(id) {
35
44
  return id.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
36
45
  }
@@ -62,13 +71,10 @@ function parseForSchema(code) {
62
71
  function findSmallestEnclosingExpression(root, offset) {
63
72
  let best = null;
64
73
  function walk(node) {
65
- if (!node ||
66
- typeof node.start !== 'number' ||
67
- typeof node.end !== 'number') {
68
- return;
69
- }
74
+ // prune: offset is outside this node's span
70
75
  if (offset < node.start || offset >= node.end)
71
76
  return;
77
+ // track this node if it's an expression and smaller than current best
72
78
  if (t.isExpression(node)) {
73
79
  const span = node.end - node.start;
74
80
  if (!best || span < best.end - best.start) {
@@ -76,26 +82,19 @@ function findSmallestEnclosingExpression(root, offset) {
76
82
  }
77
83
  }
78
84
  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') {
85
+ if (AST_SKIP_KEYS.has(key))
86
86
  continue;
87
- }
88
87
  const child = node[key];
89
88
  if (!child || typeof child !== 'object')
90
89
  continue;
90
+ // recurse into child node arrays (e.g. function arguments)
91
91
  if (Array.isArray(child)) {
92
92
  for (const item of child) {
93
- if (item &&
94
- typeof item === 'object' &&
95
- typeof item.start === 'number') {
93
+ if (item && typeof item.start === 'number') {
96
94
  walk(item);
97
95
  }
98
96
  }
97
+ // recurse into single child nodes (e.g. callee, left, right)
99
98
  }
100
99
  else if (typeof child.start === 'number') {
101
100
  walk(child);
@@ -171,11 +170,6 @@ export function generateSchema(originalCode, variants) {
171
170
  // shorter by delta = mutEnd - origEnd. Adjust the slice end accordingly.
172
171
  const delta = mutEnd - origEnd;
173
172
  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
173
  }
180
174
  const key = `${siteStart}:${siteEnd}`;
181
175
  if (!siteMap.has(key)) {
package/dist/core/sfc.js CHANGED
@@ -29,10 +29,6 @@ export async function mutateVueSfcScriptSetup(filename, code, include, exclude,
29
29
  const seenOutputs = new Set();
30
30
  const ctx = buildParseContext(originalBlock);
31
31
  for (const mutator of registry) {
32
- // Early termination if limit reached
33
- if (max !== undefined && variants.length >= max) {
34
- break;
35
- }
36
32
  const blockMutations = mutator.applyWithContext
37
33
  ? mutator.applyWithContext(originalBlock, ctx)
38
34
  : mutator.apply(originalBlock);
@@ -20,10 +20,6 @@ export function generateMutationVariants(registry, code, opts = {}) {
20
20
  const seen = new Set();
21
21
  const ctx = buildParseContext(code);
22
22
  for (const mutator of registry) {
23
- // Early termination if limit reached
24
- if (max !== undefined && variants.length >= max) {
25
- break;
26
- }
27
23
  const mutations = mutator.applyWithContext
28
24
  ? mutator.applyWithContext(code, ctx)
29
25
  : mutator.apply(code);
@@ -1,8 +1,25 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { collectOperatorTargets, collectOperatorTargetsFromContext, collectAllTargets, buildIgnoreLines, buildParseContext, parseSource, } from '../utils.js';
2
+ import { collectOperatorTargets, collectOperatorTargetsFromContext, collectAllTargets, buildIgnoreLines, buildParseContext, parseSource, getVisualColumn, } from '../utils.js';
3
3
  // ---------------------------------------------------------------------------
4
4
  // buildIgnoreLines
5
5
  // ---------------------------------------------------------------------------
6
+ // ---------------------------------------------------------------------------
7
+ // getVisualColumn
8
+ // ---------------------------------------------------------------------------
9
+ describe('getVisualColumn', () => {
10
+ it('returns 1 for offset at start of line', () => {
11
+ expect(getVisualColumn('abc', 0)).toBe(1);
12
+ });
13
+ it('advances to next tab stop for a tab character', () => {
14
+ // '\t' at column 0 (visualCol=1) advances to next stop: (floor(0/4)+1)*4+1 = 5
15
+ expect(getVisualColumn('\tx', 1)).toBe(5);
16
+ });
17
+ it('handles tab mid-line correctly', () => {
18
+ // 'ab\tc': charOffset=3 ('c'), linePrefix='ab\t'
19
+ // 'a' → visualCol=2, 'b' → 3, '\t' → nextStop=(floor(2/4)+1)*4+1=5 → visualCol=5
20
+ expect(getVisualColumn('ab\tc', 3)).toBe(5);
21
+ });
22
+ });
6
23
  describe('buildIgnoreLines', () => {
7
24
  function comment(text, startLine, endLine = startLine) {
8
25
  return {
@@ -48,6 +65,19 @@ describe('buildIgnoreLines', () => {
48
65
  const lines = buildIgnoreLines([comment('just a normal comment', 1)]);
49
66
  expect(lines.size).toBe(0);
50
67
  });
68
+ it('skips comment with empty or whitespace-only text', () => {
69
+ const c = {
70
+ type: 'CommentLine',
71
+ value: ' ',
72
+ start: 0,
73
+ end: 0,
74
+ loc: {
75
+ start: { line: 1, column: 0, index: 0 },
76
+ end: { line: 1, column: 0, index: 0 },
77
+ },
78
+ };
79
+ expect(buildIgnoreLines([c]).size).toBe(0);
80
+ });
51
81
  });
52
82
  // ---------------------------------------------------------------------------
53
83
  // parseSource
@@ -63,6 +93,10 @@ describe('parseSource', () => {
63
93
  const ast = parseSource(`const x = 1`);
64
94
  expect(ast.type).toBe('File');
65
95
  });
96
+ it('falls back to Flow parser for Flow-specific nullable type syntax', () => {
97
+ // ?string is Flow syntax; Babel TS parser throws, falls back to Flow plugin
98
+ expect(() => parseSource('const x: ?string = null')).not.toThrow();
99
+ });
66
100
  });
67
101
  // ---------------------------------------------------------------------------
68
102
  // buildParseContext
@@ -142,6 +176,36 @@ describe('collectAllTargets', () => {
142
176
  const fromCtx = collectOperatorTargetsFromContext(src, ctx, '&&');
143
177
  expect(result.operatorTargets.get('&&')).toEqual(fromCtx);
144
178
  });
179
+ it('collects update expression targets', () => {
180
+ const src = `i++`;
181
+ const ctx = buildParseContext(src);
182
+ const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
183
+ expect(result.updateTargets.size).toBeGreaterThan(0);
184
+ });
185
+ it('skips update expressions on ignored lines', () => {
186
+ const src = `// mutineer-disable-next-line\ni++`;
187
+ const ctx = buildParseContext(src);
188
+ const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
189
+ expect(result.updateTargets.size).toBe(0);
190
+ });
191
+ it('collects assignment expression targets', () => {
192
+ const src = `i += 1`;
193
+ const ctx = buildParseContext(src);
194
+ const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
195
+ expect(result.assignmentTargets.size).toBeGreaterThan(0);
196
+ });
197
+ it('skips assignment expressions on ignored lines', () => {
198
+ const src = `// mutineer-disable-next-line\ni += 1`;
199
+ const ctx = buildParseContext(src);
200
+ const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
201
+ expect(result.assignmentTargets.size).toBe(0);
202
+ });
203
+ it('groups multiple assignment expressions of same operator together', () => {
204
+ const src = `i += 1; j += 2`;
205
+ const ctx = buildParseContext(src);
206
+ const result = collectAllTargets(src, ctx.ast, ctx.tokens, ctx.ignoreLines);
207
+ expect(result.assignmentTargets.get('+=')?.length).toBe(2);
208
+ });
145
209
  });
146
210
  // ---------------------------------------------------------------------------
147
211
  // collectOperatorTargets / collectOperatorTargetsFromContext