@mutineerjs/mutineer 0.6.0 → 0.8.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 (69) hide show
  1. package/README.md +32 -15
  2. package/dist/bin/__tests__/mutineer.spec.js +67 -2
  3. package/dist/bin/mutineer.d.ts +6 -1
  4. package/dist/bin/mutineer.js +58 -2
  5. package/dist/core/__tests__/schemata.spec.d.ts +1 -0
  6. package/dist/core/__tests__/schemata.spec.js +165 -0
  7. package/dist/core/schemata.d.ts +22 -0
  8. package/dist/core/schemata.js +236 -0
  9. package/dist/mutators/__tests__/operator.spec.js +97 -1
  10. package/dist/mutators/__tests__/registry.spec.js +8 -0
  11. package/dist/mutators/operator.d.ts +8 -0
  12. package/dist/mutators/operator.js +58 -1
  13. package/dist/mutators/registry.js +9 -1
  14. package/dist/mutators/utils.d.ts +2 -0
  15. package/dist/mutators/utils.js +58 -1
  16. package/dist/runner/__tests__/args.spec.js +89 -1
  17. package/dist/runner/__tests__/cache.spec.js +65 -8
  18. package/dist/runner/__tests__/cleanup.spec.js +37 -0
  19. package/dist/runner/__tests__/coverage-resolver.spec.js +5 -0
  20. package/dist/runner/__tests__/discover.spec.js +128 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +332 -2
  22. package/dist/runner/__tests__/pool-executor.spec.js +107 -1
  23. package/dist/runner/__tests__/ts-checker.spec.d.ts +1 -0
  24. package/dist/runner/__tests__/ts-checker.spec.js +115 -0
  25. package/dist/runner/args.d.ts +18 -0
  26. package/dist/runner/args.js +37 -0
  27. package/dist/runner/cache.d.ts +19 -3
  28. package/dist/runner/cache.js +14 -7
  29. package/dist/runner/cleanup.d.ts +3 -1
  30. package/dist/runner/cleanup.js +19 -2
  31. package/dist/runner/coverage-resolver.js +1 -1
  32. package/dist/runner/discover.d.ts +1 -1
  33. package/dist/runner/discover.js +30 -20
  34. package/dist/runner/orchestrator.d.ts +1 -0
  35. package/dist/runner/orchestrator.js +114 -19
  36. package/dist/runner/pool-executor.d.ts +7 -0
  37. package/dist/runner/pool-executor.js +29 -7
  38. package/dist/runner/shared/__tests__/mutant-paths.spec.js +30 -1
  39. package/dist/runner/shared/index.d.ts +1 -1
  40. package/dist/runner/shared/index.js +1 -1
  41. package/dist/runner/shared/mutant-paths.d.ts +17 -0
  42. package/dist/runner/shared/mutant-paths.js +24 -0
  43. package/dist/runner/ts-checker-worker.d.ts +5 -0
  44. package/dist/runner/ts-checker-worker.js +66 -0
  45. package/dist/runner/ts-checker.d.ts +36 -0
  46. package/dist/runner/ts-checker.js +210 -0
  47. package/dist/runner/types.d.ts +2 -0
  48. package/dist/runner/vitest/__tests__/adapter.spec.js +41 -0
  49. package/dist/runner/vitest/__tests__/plugin.spec.js +151 -0
  50. package/dist/runner/vitest/__tests__/pool.spec.js +85 -0
  51. package/dist/runner/vitest/__tests__/worker-runtime.spec.js +126 -0
  52. package/dist/runner/vitest/adapter.js +14 -9
  53. package/dist/runner/vitest/plugin.d.ts +3 -0
  54. package/dist/runner/vitest/plugin.js +49 -11
  55. package/dist/runner/vitest/pool.d.ts +4 -1
  56. package/dist/runner/vitest/pool.js +25 -4
  57. package/dist/runner/vitest/worker-runtime.d.ts +1 -0
  58. package/dist/runner/vitest/worker-runtime.js +57 -18
  59. package/dist/runner/vitest/worker.mjs +10 -0
  60. package/dist/types/config.d.ts +16 -0
  61. package/dist/types/mutant.d.ts +5 -2
  62. package/dist/utils/CompileErrors.d.ts +7 -0
  63. package/dist/utils/CompileErrors.js +24 -0
  64. package/dist/utils/__tests__/CompileErrors.spec.d.ts +1 -0
  65. package/dist/utils/__tests__/CompileErrors.spec.js +96 -0
  66. package/dist/utils/__tests__/summary.spec.js +126 -2
  67. package/dist/utils/summary.d.ts +23 -1
  68. package/dist/utils/summary.js +63 -3
  69. package/package.json +2 -1
package/README.md CHANGED
@@ -13,13 +13,11 @@ Mutineer is a fast, targeted mutation testing framework for JavaScript and TypeS
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
- **Author**: [Billy Jones](https://www.linkedin.com/in/billyjonesy/)
17
-
18
16
  ## How It Works
19
17
 
20
18
  1. **Baseline** -- runs your test suite to make sure everything passes before mutating
21
19
  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 temp files in `__mutineer__` dirs loaded via Vite plugin / Jest resolver
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
23
21
  4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
24
22
 
25
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).
@@ -95,18 +93,22 @@ npm run mutineer
95
93
 
96
94
  ### CLI Options (for `mutineer run`)
97
95
 
98
- | Flag | Description | Default |
99
- | ------------------------ | ------------------------------------------ | ------------- |
100
- | `--runner <type>` | Test runner: `vitest` or `jest` | `vitest` |
101
- | `--config`, `-c` | Path to config file | auto-detected |
102
- | `--concurrency <n>` | Parallel workers (min 1) | CPUs - 1 |
103
- | `--changed` | Only mutate files changed vs base branch | -- |
104
- | `--changed-with-deps` | Include dependents of changed files | -- |
105
- | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
106
- | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
107
- | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
108
- | `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
109
- | `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
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
+ | `--only-covered-lines` | Skip mutations on uncovered lines | -- |
104
+ | `--per-test-coverage` | Run only tests that cover the mutated line | -- |
105
+ | `--coverage-file <path>` | Path to Istanbul coverage JSON | auto-detected |
106
+ | `--min-kill-percent <n>` | Fail if kill rate is below threshold | -- |
107
+ | `--progress <mode>` | Display mode: `bar`, `list`, or `quiet` | `bar` |
108
+ | `--timeout <ms>` | Per-mutant test timeout | `30000` |
109
+ | `--report <format>` | Output format: `text` or `json` (writes `mutineer-report.json`) | `text` |
110
+ | `--shard <n>/<total>` | Run a slice of mutants (e.g. `--shard 1/4`) | -- |
111
+ | `--skip-baseline` | Skip the baseline test run | -- |
110
112
 
111
113
  ### Examples
112
114
 
@@ -168,6 +170,17 @@ export default defineMutineerConfig({
168
170
 
169
171
  Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
170
172
 
173
+ When you run `mutineer run` without `--changed` or `--changed-with-deps` on an interactive terminal, mutineer warns you and lets you narrow scope before starting:
174
+
175
+ ```
176
+ Warning: Running on the full codebase may take a while.
177
+
178
+ [1] Continue (full codebase)
179
+ [2] --changed (git-changed files only)
180
+ [3] --changed-with-deps (changed + their local deps)
181
+ [4] Abort
182
+ ```
183
+
171
184
  ### 1. PR-scoped runs (CI) — `--changed-with-deps`
172
185
 
173
186
  Run only on files changed in the branch plus their direct dependents:
@@ -262,6 +275,10 @@ export function createMyRunnerAdapter(
262
275
 
263
276
  The key requirement is the **file-swap mechanism** -- the adapter needs a way to intercept module resolution so the mutated source code is loaded instead of the original file on disk. See the Vitest adapter (Vite plugin + ESM loader) and Jest adapter (custom resolver) for working reference implementations in `src/runner/vitest/` and `src/runner/jest/`.
264
277
 
278
+ ## Maintainers
279
+
280
+ - [Billy Jones](https://www.linkedin.com/in/billyjonesy/)
281
+
265
282
  ## License
266
283
 
267
284
  MIT
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
- import { describe, it, expect } from 'vitest';
3
- import { HELP_TEXT, getVersion } from '../mutineer.js';
2
+ import readline from 'node:readline';
3
+ import { describe, it, expect, vi, afterEach } from 'vitest';
4
+ import { HELP_TEXT, getVersion, confirmFullRun } from '../mutineer.js';
4
5
  describe('HELP_TEXT', () => {
5
6
  const flags = [
6
7
  '--config',
@@ -31,6 +32,70 @@ describe('HELP_TEXT', () => {
31
32
  expect(HELP_TEXT).toContain('local dependencies');
32
33
  });
33
34
  });
35
+ describe('confirmFullRun()', () => {
36
+ afterEach(() => {
37
+ vi.restoreAllMocks();
38
+ });
39
+ function mockTTY(isTTY) {
40
+ Object.defineProperty(process.stdin, 'isTTY', {
41
+ value: isTTY,
42
+ configurable: true,
43
+ });
44
+ }
45
+ function mockReadline(answers) {
46
+ let callIndex = 0;
47
+ vi.spyOn(readline, 'createInterface').mockReturnValue({
48
+ question(_prompt, cb) {
49
+ cb(answers[callIndex++] ?? '');
50
+ },
51
+ close: vi.fn(),
52
+ });
53
+ }
54
+ it('returns args unchanged when --changed is present', async () => {
55
+ mockTTY(true);
56
+ const args = ['--changed', '--concurrency', '4'];
57
+ expect(await confirmFullRun(args)).toBe(args);
58
+ });
59
+ it('returns args unchanged when --changed-with-deps is present', async () => {
60
+ mockTTY(true);
61
+ const args = ['--changed-with-deps'];
62
+ expect(await confirmFullRun(args)).toBe(args);
63
+ });
64
+ it('skips prompt and returns args unchanged when stdin is not a TTY', async () => {
65
+ mockTTY(false);
66
+ const createSpy = vi.spyOn(readline, 'createInterface');
67
+ const args = ['--concurrency', '2'];
68
+ expect(await confirmFullRun(args)).toBe(args);
69
+ expect(createSpy).not.toHaveBeenCalled();
70
+ });
71
+ it('choice 1 (default Enter) returns args unchanged', async () => {
72
+ mockTTY(true);
73
+ mockReadline(['']);
74
+ const args = ['--concurrency', '2'];
75
+ expect(await confirmFullRun(args)).toEqual(args);
76
+ });
77
+ it('choice 1 returns args unchanged', async () => {
78
+ mockTTY(true);
79
+ mockReadline(['1']);
80
+ const args = [];
81
+ expect(await confirmFullRun(args)).toEqual([]);
82
+ });
83
+ it('choice 2 appends --changed', async () => {
84
+ mockTTY(true);
85
+ mockReadline(['2']);
86
+ expect(await confirmFullRun([])).toEqual(['--changed']);
87
+ });
88
+ it('choice 3 appends --changed-with-deps', async () => {
89
+ mockTTY(true);
90
+ mockReadline(['3']);
91
+ expect(await confirmFullRun([])).toEqual(['--changed-with-deps']);
92
+ });
93
+ it('invalid input re-prompts, then accepts valid choice', async () => {
94
+ mockTTY(true);
95
+ mockReadline(['9', 'x', '2']);
96
+ expect(await confirmFullRun([])).toEqual(['--changed']);
97
+ });
98
+ });
34
99
  describe('getVersion()', () => {
35
100
  it('returns a semver string', () => {
36
101
  expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
@@ -1,3 +1,8 @@
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 --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";
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 --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";
3
3
  export declare function getVersion(): string;
4
+ /**
5
+ * When running in full-codebase mode on an interactive TTY, warn the user and
6
+ * let them narrow scope or abort. Returns args (possibly with a flag appended).
7
+ */
8
+ export declare function confirmFullRun(args: string[]): Promise<string[]>;
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import readline from 'node:readline';
4
5
  import { createRequire } from 'node:module';
5
6
  import { runOrchestrator } from '../runner/orchestrator.js';
6
7
  import { cleanupMutineerDirs } from '../runner/cleanup.js';
@@ -28,6 +29,9 @@ Options (run):
28
29
  --coverage-file <path> Path to coverage JSON
29
30
  --min-kill-percent <n> Minimum kill % threshold (0–100)
30
31
  --timeout <ms> Per-mutant test timeout in ms (default: 30000)
32
+ --report <text|json> Output format: text (default) or json (writes mutineer-report.json)
33
+ --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
34
+ --skip-baseline Skip the baseline test run
31
35
 
32
36
  --help, -h Show this help
33
37
  --version, -V Show version
@@ -43,6 +47,57 @@ export default defineMutineerConfig({
43
47
  source: 'src',
44
48
  })
45
49
  `;
50
+ const FULL_RUN_WARNING = `
51
+ Warning: Running on the full codebase may take a while.
52
+
53
+ [1] Continue (full codebase)
54
+ [2] --changed (git-changed files only)
55
+ [3] --changed-with-deps (changed + their local deps)
56
+ [4] Abort
57
+
58
+ `;
59
+ /**
60
+ * When running in full-codebase mode on an interactive TTY, warn the user and
61
+ * let them narrow scope or abort. Returns args (possibly with a flag appended).
62
+ */
63
+ export async function confirmFullRun(args) {
64
+ const isFullRun = !args.includes('--changed') && !args.includes('--changed-with-deps');
65
+ if (!isFullRun || !process.stdin.isTTY)
66
+ return args;
67
+ process.stdout.write(FULL_RUN_WARNING);
68
+ const rl = readline.createInterface({
69
+ input: process.stdin,
70
+ output: process.stdout,
71
+ });
72
+ return new Promise((resolve) => {
73
+ const ask = () => {
74
+ rl.question('Choice [1]: ', (answer) => {
75
+ const choice = answer.trim() || '1';
76
+ if (choice === '1') {
77
+ rl.close();
78
+ resolve(args);
79
+ }
80
+ else if (choice === '2') {
81
+ rl.close();
82
+ resolve([...args, '--changed']);
83
+ }
84
+ else if (choice === '3') {
85
+ rl.close();
86
+ resolve([...args, '--changed-with-deps']);
87
+ }
88
+ else if (choice === '4') {
89
+ rl.close();
90
+ process.exit(0);
91
+ }
92
+ else {
93
+ process.stdout.write('Please enter 1, 2, 3, or 4.\n');
94
+ ask();
95
+ }
96
+ });
97
+ };
98
+ ask();
99
+ });
100
+ }
46
101
  /**
47
102
  * Main entry point - routes to orchestrator or clean
48
103
  */
@@ -61,7 +116,8 @@ async function main() {
61
116
  process.stdout.write(HELP_TEXT);
62
117
  process.exit(0);
63
118
  }
64
- await runOrchestrator(args.slice(1), process.cwd());
119
+ const runArgs = await confirmFullRun(args.slice(1));
120
+ await runOrchestrator(runArgs, process.cwd());
65
121
  }
66
122
  else if (args[0] === INIT_COMMAND) {
67
123
  const configFile = path.join(process.cwd(), 'mutineer.config.ts');
@@ -76,7 +132,7 @@ async function main() {
76
132
  }
77
133
  else if (args[0] === CLEAN_COMMAND) {
78
134
  console.log('Cleaning up __mutineer__ directories...');
79
- await cleanupMutineerDirs(process.cwd());
135
+ await cleanupMutineerDirs(process.cwd(), { includeCacheFiles: true });
80
136
  console.log('Done.');
81
137
  }
82
138
  else {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateSchema } from '../schemata.js';
3
+ function makeVariant(id, code) {
4
+ return {
5
+ id,
6
+ name: 'test',
7
+ file: '/src/foo.ts',
8
+ code,
9
+ line: 1,
10
+ col: 1,
11
+ tests: [],
12
+ };
13
+ }
14
+ describe('generateSchema', () => {
15
+ it('returns original code with ts-nocheck header for empty variants', () => {
16
+ const original = 'const x = 1 + 2';
17
+ const { schemaCode, fallbackIds } = generateSchema(original, []);
18
+ expect(schemaCode).toBe('// @ts-nocheck\n' + original);
19
+ expect(fallbackIds.size).toBe(0);
20
+ });
21
+ it('embeds an operator mutation using the enclosing expression', () => {
22
+ // '+' is operator-only char diff — uses AST to find BinaryExpression 'x + y'
23
+ const original = 'x + y';
24
+ const { schemaCode, fallbackIds } = generateSchema(original, [
25
+ makeVariant('f#0', 'x - y'),
26
+ ]);
27
+ expect(fallbackIds.size).toBe(0);
28
+ expect(schemaCode).toContain("'f#0'");
29
+ expect(schemaCode).toContain('x - y');
30
+ expect(schemaCode).toContain('x + y');
31
+ });
32
+ it('embeds === to !== operator mutation using the enclosing BinaryExpression', () => {
33
+ const original = 'x === true';
34
+ const { schemaCode, fallbackIds } = generateSchema(original, [
35
+ makeVariant('f#0', 'x !== true'),
36
+ ]);
37
+ expect(fallbackIds.size).toBe(0);
38
+ expect(schemaCode).toContain("'f#0'");
39
+ expect(schemaCode).toContain('x !== true');
40
+ expect(schemaCode).toContain('x === true');
41
+ });
42
+ it('handles variable-length operator replacement (> → >=) without truncating operands', () => {
43
+ // '>' is 1 char, '>=' is 2 chars — the AST end from original must be shifted by +1
44
+ const original = 'hitCount > 0';
45
+ const { schemaCode, fallbackIds } = generateSchema(original, [
46
+ makeVariant('f#0', 'hitCount >= 0'),
47
+ ]);
48
+ expect(fallbackIds.size).toBe(0);
49
+ expect(schemaCode).toContain('hitCount >= 0');
50
+ expect(schemaCode).toContain('hitCount > 0');
51
+ });
52
+ it('handles variable-length operator replacement (>= → >) without truncating operands', () => {
53
+ // '>=' is 2 chars, '>' is 1 char — AST end shifts by -1
54
+ const original = 'hitCount >= 0';
55
+ const { schemaCode, fallbackIds } = generateSchema(original, [
56
+ makeVariant('f#0', 'hitCount > 0'),
57
+ ]);
58
+ expect(fallbackIds.size).toBe(0);
59
+ expect(schemaCode).toContain('hitCount > 0');
60
+ expect(schemaCode).toContain('hitCount >= 0');
61
+ });
62
+ it('chains multiple operator variants on the same expression site', () => {
63
+ const original = 'x + y';
64
+ const v0 = makeVariant('f#0', 'x - y');
65
+ const v1 = makeVariant('f#1', 'x * y');
66
+ const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
67
+ expect(fallbackIds.size).toBe(0);
68
+ expect(schemaCode).toContain("'f#0'");
69
+ expect(schemaCode).toContain("'f#1'");
70
+ expect(schemaCode).toContain('x - y');
71
+ expect(schemaCode).toContain('x * y');
72
+ expect(schemaCode).toContain('x + y');
73
+ });
74
+ it('wraps a value mutation site in a ternary', () => {
75
+ // 'true' → 'false': both valid identifiers, char diff path
76
+ const original = 'return true';
77
+ const { schemaCode, fallbackIds } = generateSchema(original, [
78
+ makeVariant('f#0', 'return false'),
79
+ ]);
80
+ expect(fallbackIds.size).toBe(0);
81
+ expect(schemaCode).toContain("'f#0'");
82
+ expect(schemaCode).toContain('false');
83
+ expect(schemaCode).toContain('true');
84
+ });
85
+ it('chains multiple value variants on the same site', () => {
86
+ const original = 'return true';
87
+ const v0 = makeVariant('f#0', 'return false');
88
+ const v1 = makeVariant('f#1', 'return null');
89
+ const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
90
+ expect(fallbackIds.size).toBe(0);
91
+ expect(schemaCode).toContain("'f#0'");
92
+ expect(schemaCode).toContain("'f#1'");
93
+ expect(schemaCode).toContain('false');
94
+ expect(schemaCode).toContain('null');
95
+ expect(schemaCode).toContain('true');
96
+ });
97
+ it('produces separate ternaries for non-overlapping sites', () => {
98
+ // 'return true || false': two value sites, non-overlapping
99
+ const original = 'return true || false';
100
+ const v0 = makeVariant('f#0', 'return false || false'); // first 'true' → 'false'
101
+ const v1 = makeVariant('f#1', 'return true || true'); // second 'false' → 'true'
102
+ const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
103
+ expect(fallbackIds.size).toBe(0);
104
+ expect(schemaCode).toContain("'f#0'");
105
+ expect(schemaCode).toContain("'f#1'");
106
+ });
107
+ it('marks outer expression as fallback when inner and outer sites overlap (nested binary)', () => {
108
+ // 'x + y - z': '+' → inner BinaryExpression 'x + y', '-' → outer 'x + y - z'
109
+ // Inner is kept in schema; outer (containing inner) is fallback.
110
+ const original = 'x + y - z';
111
+ const vInner = makeVariant('f#0', 'x - y - z'); // '+' → '-'
112
+ const vOuter = makeVariant('f#1', 'x + y + z'); // '-' → '+'
113
+ const { schemaCode, fallbackIds } = generateSchema(original, [
114
+ vInner,
115
+ vOuter,
116
+ ]);
117
+ // Inner (x + y site) kept in schema, outer (x + y - z site) fallback
118
+ expect(fallbackIds.has('f#1')).toBe(true);
119
+ expect(fallbackIds.has('f#0')).toBe(false);
120
+ expect(schemaCode).toContain("'f#0'");
121
+ });
122
+ it('embeds two value-mutation variants on the same site', () => {
123
+ const orig2 = 'return true';
124
+ const vX = makeVariant('g#0', 'return false');
125
+ const vY = makeVariant('g#1', 'return null');
126
+ const { schemaCode: sc2, fallbackIds: fb2 } = generateSchema(orig2, [
127
+ vX,
128
+ vY,
129
+ ]);
130
+ expect(fb2.size).toBe(0);
131
+ expect(sc2).toContain("'g#0'");
132
+ expect(sc2).toContain("'g#1'");
133
+ });
134
+ it('marks variant as fallback when diff produces empty range', () => {
135
+ // Variant identical to original → empty diff
136
+ const original = 'const x = 1';
137
+ const identical = makeVariant('f#0', 'const x = 1');
138
+ const { fallbackIds } = generateSchema(original, [identical]);
139
+ expect(fallbackIds.has('f#0')).toBe(true);
140
+ });
141
+ it('escapes single quotes in variant IDs', () => {
142
+ const original = 'return true';
143
+ const v = makeVariant("it's#0", 'return false');
144
+ const { schemaCode } = generateSchema(original, [v]);
145
+ expect(schemaCode).toContain("it\\'s#0");
146
+ });
147
+ it('handles multi-character replacements correctly', () => {
148
+ const original = 'return true';
149
+ const v = makeVariant('r#0', 'return false');
150
+ const { schemaCode, fallbackIds } = generateSchema(original, [v]);
151
+ expect(fallbackIds.size).toBe(0);
152
+ expect(schemaCode).toContain("'r#0'");
153
+ expect(schemaCode).toMatch(/globalThis\.__mutineer_active_id__/);
154
+ });
155
+ it('merges variants into same site when word-boundary extension unifies their spans', () => {
156
+ // 'abcde' (all word chars): both variants extend to the full 5-char span [0,5]
157
+ const original = 'abcde';
158
+ const v0 = makeVariant('f#0', 'xyzde');
159
+ const v1 = makeVariant('f#1', 'aUVWe');
160
+ const { schemaCode, fallbackIds } = generateSchema(original, [v0, v1]);
161
+ expect(fallbackIds.size).toBe(0);
162
+ expect(schemaCode).toContain("'f#0'");
163
+ expect(schemaCode).toContain("'f#1'");
164
+ });
165
+ });
@@ -0,0 +1,22 @@
1
+ import type { Variant } from '../types/mutant.js';
2
+ export interface SchemaResult {
3
+ schemaCode: string;
4
+ fallbackIds: Set<string>;
5
+ }
6
+ /**
7
+ * Generate a schema file that embeds all mutation variants as a ternary chain.
8
+ *
9
+ * The schema uses `globalThis.__mutineer_active_id__` at call time to select
10
+ * which mutant is active, avoiding per-mutant module reloads.
11
+ *
12
+ * For value mutations (true→false, null→undefined), a character-level diff with
13
+ * word-boundary extension finds the span. For operator mutations (+→-, ===→!==),
14
+ * the Babel AST is used to find the enclosing expression (x + y, x === y) so
15
+ * the ternary branch is a valid JS expression.
16
+ *
17
+ * @param originalCode - The original source file contents
18
+ * @param variants - All variants to embed (must all be from the same source file)
19
+ * @returns schemaCode (the embedded schema) and fallbackIds (variants that
20
+ * couldn't be embedded due to overlapping diff ranges or parse errors)
21
+ */
22
+ export declare function generateSchema(originalCode: string, variants: readonly Variant[]): SchemaResult;