@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.
- package/README.md +42 -2
- package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
- package/dist/bin/__tests__/mutineer.spec.js +43 -0
- package/dist/bin/mutineer.d.ts +2 -1
- package/dist/bin/mutineer.js +41 -0
- package/dist/core/__tests__/variant-utils.spec.js +24 -2
- package/dist/core/sfc.js +6 -1
- package/dist/core/variant-utils.js +6 -1
- package/dist/mutators/__tests__/operator.spec.js +16 -0
- package/dist/mutators/__tests__/return-value.spec.js +37 -0
- package/dist/mutators/__tests__/utils.spec.js +96 -2
- package/dist/mutators/operator.js +11 -5
- package/dist/mutators/return-value.js +36 -22
- package/dist/mutators/types.d.ts +2 -0
- package/dist/mutators/utils.d.ts +67 -0
- package/dist/mutators/utils.js +90 -15
- package/dist/runner/__tests__/args.spec.js +45 -1
- package/dist/runner/__tests__/changed.spec.js +85 -2
- package/dist/runner/__tests__/config.spec.js +2 -13
- package/dist/runner/__tests__/coverage-resolver.spec.js +7 -2
- package/dist/runner/__tests__/discover.spec.js +52 -1
- package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
- package/dist/runner/__tests__/orchestrator.spec.js +141 -0
- package/dist/runner/__tests__/pool-executor.spec.js +40 -0
- package/dist/runner/args.d.ts +5 -0
- package/dist/runner/args.js +13 -0
- package/dist/runner/changed.js +15 -43
- package/dist/runner/config.js +1 -1
- package/dist/runner/discover.js +21 -12
- package/dist/runner/jest/__tests__/pool.spec.js +41 -0
- package/dist/runner/jest/pool.js +3 -3
- package/dist/runner/orchestrator.js +16 -1
- package/dist/runner/pool-executor.js +7 -1
- package/dist/runner/vitest/__tests__/adapter.spec.js +19 -0
- package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
- package/dist/runner/vitest/adapter.js +3 -0
- package/dist/runner/vitest/pool.js +3 -3
- package/dist/types/config.d.ts +2 -0
- 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
|
|
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
|
|
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
|
+
});
|
package/dist/bin/mutineer.d.ts
CHANGED
|
@@ -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;
|
package/dist/bin/mutineer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
};
|
package/dist/mutators/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/mutators/utils.d.ts
CHANGED
|
@@ -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
|