@mutineerjs/mutineer 0.10.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.
- package/README.md +10 -10
- package/dist/__tests__/index.spec.d.ts +1 -0
- package/dist/__tests__/index.spec.js +8 -0
- package/dist/bin/__tests__/mutineer.spec.js +7 -7
- package/dist/bin/mutineer.d.ts +1 -1
- package/dist/bin/mutineer.js +4 -4
- package/dist/core/__tests__/schemata.spec.js +62 -0
- package/dist/core/__tests__/sfc.spec.js +41 -1
- package/dist/core/schemata.js +15 -21
- package/dist/core/sfc.js +0 -4
- package/dist/core/variant-utils.js +0 -4
- package/dist/mutators/__tests__/utils.spec.js +65 -1
- package/dist/mutators/operator.js +13 -27
- package/dist/mutators/return-value.js +3 -7
- package/dist/mutators/utils.d.ts +2 -2
- package/dist/mutators/utils.js +59 -96
- package/dist/runner/__tests__/args.spec.js +8 -4
- package/dist/runner/__tests__/cache.spec.js +24 -0
- package/dist/runner/__tests__/changed.spec.js +75 -0
- package/dist/runner/__tests__/config.spec.js +50 -1
- package/dist/runner/__tests__/coverage-resolver.spec.js +88 -1
- package/dist/runner/__tests__/discover.spec.js +179 -0
- package/dist/runner/__tests__/orchestrator.spec.js +301 -8
- package/dist/runner/__tests__/pool-executor.spec.js +77 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.d.ts +1 -0
- package/dist/runner/__tests__/ts-checker-worker.spec.js +66 -0
- package/dist/runner/__tests__/ts-checker.spec.js +89 -2
- package/dist/runner/args.d.ts +1 -1
- package/dist/runner/args.js +2 -2
- package/dist/runner/config.js +3 -4
- package/dist/runner/coverage-resolver.js +2 -1
- package/dist/runner/discover.js +2 -2
- package/dist/runner/jest/__tests__/adapter.spec.js +169 -0
- package/dist/runner/jest/__tests__/pool.spec.js +223 -1
- package/dist/runner/jest/adapter.js +3 -46
- package/dist/runner/jest/pool.js +4 -10
- package/dist/runner/jest/worker-runtime.js +2 -1
- package/dist/runner/orchestrator.js +7 -7
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/strip-mutineer-args.spec.js +104 -0
- package/dist/runner/shared/__tests__/worker-script.spec.d.ts +1 -0
- package/dist/runner/shared/__tests__/worker-script.spec.js +32 -0
- package/dist/runner/shared/index.d.ts +4 -0
- package/dist/runner/shared/index.js +2 -0
- package/dist/runner/shared/pending-task.d.ts +9 -0
- package/dist/runner/shared/pending-task.js +1 -0
- package/dist/runner/shared/strip-mutineer-args.d.ts +11 -0
- package/dist/runner/shared/strip-mutineer-args.js +47 -0
- package/dist/runner/shared/worker-script.d.ts +5 -0
- package/dist/runner/shared/worker-script.js +12 -0
- package/dist/runner/ts-checker-worker.d.ts +10 -1
- package/dist/runner/ts-checker-worker.js +27 -25
- package/dist/runner/ts-checker.d.ts +6 -0
- package/dist/runner/ts-checker.js +1 -1
- package/dist/runner/vitest/__tests__/adapter.spec.js +254 -0
- package/dist/runner/vitest/__tests__/plugin.spec.js +28 -1
- package/dist/runner/vitest/__tests__/pool.spec.js +674 -0
- package/dist/runner/vitest/__tests__/redirect-loader.spec.js +116 -1
- package/dist/runner/vitest/__tests__/worker-runtime.spec.js +21 -0
- package/dist/runner/vitest/adapter.js +8 -46
- package/dist/runner/vitest/plugin.js +1 -7
- package/dist/runner/vitest/pool.js +5 -19
- package/dist/runner/vitest/redirect-loader.js +3 -1
- package/dist/runner/vitest/worker-runtime.js +2 -1
- package/dist/types/config.d.ts +2 -2
- package/dist/utils/__tests__/PoolSpinner.spec.d.ts +1 -0
- package/dist/utils/__tests__/PoolSpinner.spec.js +15 -0
- package/dist/utils/__tests__/coverage.spec.js +89 -0
- package/dist/utils/__tests__/logger.spec.js +9 -0
- package/dist/utils/__tests__/progress.spec.js +38 -0
- package/dist/utils/__tests__/summary.spec.js +56 -0
- package/dist/utils/coverage.js +3 -4
- package/dist/utils/errors.d.ts +4 -0
- package/dist/utils/errors.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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"
|
|
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
|
|
|
@@ -99,7 +99,7 @@ npm run mutineer
|
|
|
99
99
|
| `--config`, `-c` | Path to config file | auto-detected |
|
|
100
100
|
| `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
|
|
101
101
|
| `--changed` | Only mutate files changed vs base branch | -- |
|
|
102
|
-
| `--changed-with-
|
|
102
|
+
| `--changed-with-imports` | Include local imports of changed files | -- |
|
|
103
103
|
| `--full` | Mutate full codebase, skipping confirmation prompt | -- |
|
|
104
104
|
| `--only-covered-lines` | Skip mutations on uncovered lines | -- |
|
|
105
105
|
| `--per-test-coverage` | Run only tests that cover the mutated line | -- |
|
|
@@ -176,31 +176,31 @@ export default defineMutineerConfig({
|
|
|
176
176
|
|
|
177
177
|
Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
|
|
178
178
|
|
|
179
|
-
When you run `mutineer run` without `--changed`, `--changed-with-
|
|
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:
|
|
180
180
|
|
|
181
181
|
```
|
|
182
182
|
Warning: Running on the full codebase may take a while.
|
|
183
183
|
|
|
184
184
|
[1] Continue (full codebase)
|
|
185
185
|
[2] --changed (git-changed files only)
|
|
186
|
-
[3] --changed-with-
|
|
186
|
+
[3] --changed-with-imports (changed + their local imports)
|
|
187
187
|
[4] Abort
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
### 1. PR-scoped runs (CI) — `--changed-with-
|
|
190
|
+
### 1. PR-scoped runs (CI) — `--changed-with-imports`
|
|
191
191
|
|
|
192
|
-
Run only on files changed in the branch plus their
|
|
192
|
+
Run only on files changed in the branch plus their local imports:
|
|
193
193
|
|
|
194
194
|
```bash
|
|
195
|
-
mutineer run --changed-with-
|
|
195
|
+
mutineer run --changed-with-imports
|
|
196
196
|
```
|
|
197
197
|
|
|
198
|
-
- Tune the
|
|
198
|
+
- Tune the import resolution depth with `importDepth` in config (default: `1`)
|
|
199
199
|
- Add `--per-test-coverage` to only run tests that cover the mutated line
|
|
200
200
|
- Recommended `package.json` script:
|
|
201
201
|
|
|
202
202
|
```json
|
|
203
|
-
"mutineer:ci": "mutineer run --changed-with-
|
|
203
|
+
"mutineer:ci": "mutineer run --changed-with-imports --per-test-coverage"
|
|
204
204
|
```
|
|
205
205
|
|
|
206
206
|
### 2. Split configs by domain
|
|
@@ -220,7 +220,7 @@ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos
|
|
|
220
220
|
- Combine for maximum focus:
|
|
221
221
|
|
|
222
222
|
```bash
|
|
223
|
-
mutineer run --changed-with-
|
|
223
|
+
mutineer run --changed-with-imports --only-covered-lines --per-test-coverage
|
|
224
224
|
```
|
|
225
225
|
|
|
226
226
|
## File Support
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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-
|
|
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-
|
|
33
|
-
expect(HELP_TEXT).toContain('local
|
|
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-
|
|
60
|
+
it('returns args unchanged when --changed-with-imports is present', async () => {
|
|
61
61
|
mockTTY(true);
|
|
62
|
-
const args = ['--changed-with-
|
|
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-
|
|
96
|
+
it('choice 3 appends --changed-with-imports', async () => {
|
|
97
97
|
mockTTY(true);
|
|
98
98
|
mockReadline(['3']);
|
|
99
|
-
expect(await confirmFullRun([])).toEqual(['--changed-with-
|
|
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);
|
package/dist/bin/mutineer.d.ts
CHANGED
|
@@ -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-
|
|
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
|
package/dist/bin/mutineer.js
CHANGED
|
@@ -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-
|
|
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
|
|
@@ -56,7 +56,7 @@ Warning: Running on the full codebase may take a while.
|
|
|
56
56
|
|
|
57
57
|
[1] Continue (full codebase)
|
|
58
58
|
[2] --changed (git-changed files only)
|
|
59
|
-
[3] --changed-with-
|
|
59
|
+
[3] --changed-with-imports (changed + their local imports)
|
|
60
60
|
[4] Abort
|
|
61
61
|
|
|
62
62
|
`;
|
|
@@ -65,7 +65,7 @@ Warning: Running on the full codebase may take a while.
|
|
|
65
65
|
* let them narrow scope or abort. Returns args (possibly with a flag appended).
|
|
66
66
|
*/
|
|
67
67
|
export async function confirmFullRun(args) {
|
|
68
|
-
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-
|
|
68
|
+
const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-imports');
|
|
69
69
|
if (!isFullRun || !process.stdin.isTTY || args.includes('--full'))
|
|
70
70
|
return args;
|
|
71
71
|
process.stdout.write(FULL_RUN_WARNING);
|
|
@@ -87,7 +87,7 @@ export async function confirmFullRun(args) {
|
|
|
87
87
|
}
|
|
88
88
|
else if (choice === '3') {
|
|
89
89
|
rl.close();
|
|
90
|
-
resolve([...args, '--changed-with-
|
|
90
|
+
resolve([...args, '--changed-with-imports']);
|
|
91
91
|
}
|
|
92
92
|
else if (choice === '4') {
|
|
93
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, [
|
|
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
|
}
|
package/dist/core/schemata.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* replaced by its counterpart.
|
|
7
7
|
*/
|
|
8
8
|
import { collectOperatorTargets, buildParseContext } from './utils.js';
|
|
9
|
+
function targetsToOutputs(src, targets, toOp) {
|
|
10
|
+
return targets.map((target) => ({
|
|
11
|
+
line: target.line,
|
|
12
|
+
col: target.col1,
|
|
13
|
+
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
9
16
|
/**
|
|
10
17
|
* Factory to build an operator mutator using AST traversal and token analysis.
|
|
11
18
|
*
|
|
@@ -15,21 +22,14 @@ import { collectOperatorTargets, buildParseContext } from './utils.js';
|
|
|
15
22
|
* @param toOp - The operator to replace it with (e.g., '||')
|
|
16
23
|
*/
|
|
17
24
|
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
|
-
}
|
|
25
25
|
return {
|
|
26
26
|
name,
|
|
27
27
|
description,
|
|
28
28
|
apply(src) {
|
|
29
|
-
return targetsToOutputs(src, collectOperatorTargets(src, fromOp));
|
|
29
|
+
return targetsToOutputs(src, collectOperatorTargets(src, fromOp), toOp);
|
|
30
30
|
},
|
|
31
31
|
applyWithContext(src, ctx) {
|
|
32
|
-
return targetsToOutputs(src, ctx.preCollected.operatorTargets.get(fromOp) ?? []);
|
|
32
|
+
return targetsToOutputs(src, ctx.preCollected.operatorTargets.get(fromOp) ?? [], toOp);
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
35
|
}
|
|
@@ -38,22 +38,15 @@ function makeOperatorMutator(name, description, fromOp, toOp) {
|
|
|
38
38
|
* mapKey distinguishes prefix vs postfix: 'pre++', 'post++', 'pre--', 'post--'
|
|
39
39
|
*/
|
|
40
40
|
function makeUpdateMutator(name, description, mapKey, toOp) {
|
|
41
|
-
function targetsToOutputs(src, targets) {
|
|
42
|
-
return targets.map((target) => ({
|
|
43
|
-
line: target.line,
|
|
44
|
-
col: target.col1,
|
|
45
|
-
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
48
41
|
return {
|
|
49
42
|
name,
|
|
50
43
|
description,
|
|
51
44
|
apply(src) {
|
|
52
45
|
const ctx = buildParseContext(src);
|
|
53
|
-
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
|
|
46
|
+
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? [], toOp);
|
|
54
47
|
},
|
|
55
48
|
applyWithContext(src, ctx) {
|
|
56
|
-
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
|
|
49
|
+
return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? [], toOp);
|
|
57
50
|
},
|
|
58
51
|
};
|
|
59
52
|
}
|
|
@@ -61,22 +54,15 @@ function makeUpdateMutator(name, description, mapKey, toOp) {
|
|
|
61
54
|
* Factory for AssignmentExpression mutators (+=, -=, *=, /=).
|
|
62
55
|
*/
|
|
63
56
|
function makeAssignmentMutator(name, description, fromOp, toOp) {
|
|
64
|
-
function targetsToOutputs(src, targets) {
|
|
65
|
-
return targets.map((target) => ({
|
|
66
|
-
line: target.line,
|
|
67
|
-
col: target.col1,
|
|
68
|
-
code: src.slice(0, target.start) + toOp + src.slice(target.end),
|
|
69
|
-
}));
|
|
70
|
-
}
|
|
71
57
|
return {
|
|
72
58
|
name,
|
|
73
59
|
description,
|
|
74
60
|
apply(src) {
|
|
75
61
|
const ctx = buildParseContext(src);
|
|
76
|
-
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
|
|
62
|
+
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? [], toOp);
|
|
77
63
|
},
|
|
78
64
|
applyWithContext(src, ctx) {
|
|
79
|
-
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
|
|
65
|
+
return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? [], toOp);
|
|
80
66
|
},
|
|
81
67
|
};
|
|
82
68
|
}
|
|
@@ -27,9 +27,7 @@ function collectReturnMutations(src, ast, ignoreLines, replacer) {
|
|
|
27
27
|
const node = path.node;
|
|
28
28
|
if (!node.argument)
|
|
29
29
|
return; // bare return; — nothing to replace
|
|
30
|
-
const line = node.loc
|
|
31
|
-
if (line === undefined)
|
|
32
|
-
return;
|
|
30
|
+
const line = node.loc.start.line;
|
|
33
31
|
if (ignoreLines.has(line))
|
|
34
32
|
return;
|
|
35
33
|
const replacement = replacer(node.argument);
|
|
@@ -37,9 +35,7 @@ function collectReturnMutations(src, ast, ignoreLines, replacer) {
|
|
|
37
35
|
return;
|
|
38
36
|
const argStart = node.argument.start;
|
|
39
37
|
const argEnd = node.argument.end;
|
|
40
|
-
|
|
41
|
-
return;
|
|
42
|
-
const col = getVisualColumn(src, node.start ?? 0);
|
|
38
|
+
const col = getVisualColumn(src, node.start);
|
|
43
39
|
const code = src.slice(0, argStart) + replacement + src.slice(argEnd);
|
|
44
40
|
outputs.push({ line, col, code });
|
|
45
41
|
},
|
|
@@ -53,7 +49,7 @@ function makeReturnMutator(name, description, replacer) {
|
|
|
53
49
|
apply(src) {
|
|
54
50
|
const ast = parseSource(src);
|
|
55
51
|
const fileAst = ast;
|
|
56
|
-
const ignoreLines = buildIgnoreLines(fileAst.comments
|
|
52
|
+
const ignoreLines = buildIgnoreLines(fileAst.comments);
|
|
57
53
|
return collectReturnMutations(src, ast, ignoreLines, replacer);
|
|
58
54
|
},
|
|
59
55
|
applyWithContext(src, ctx) {
|
package/dist/mutators/utils.d.ts
CHANGED
|
@@ -112,9 +112,9 @@ export declare function collectAllTargets(src: string, ast: t.File & {
|
|
|
112
112
|
export declare function buildParseContext(src: string): ParseContext;
|
|
113
113
|
/**
|
|
114
114
|
* Collect operator targets from a pre-built ParseContext.
|
|
115
|
-
*
|
|
115
|
+
* Reads directly from preCollected targets; no additional traversal needed.
|
|
116
116
|
*/
|
|
117
|
-
export declare function collectOperatorTargetsFromContext(
|
|
117
|
+
export declare function collectOperatorTargetsFromContext(_src: string, ctx: ParseContext, opValue: string): OperatorTarget[];
|
|
118
118
|
/**
|
|
119
119
|
* Collect the operator tokens for a given operator and return their exact locations.
|
|
120
120
|
* Uses AST traversal to find BinaryExpression/LogicalExpression nodes, then maps them
|