@mutineerjs/mutineer 0.5.1 → 0.7.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 (48) hide show
  1. package/README.md +42 -2
  2. package/dist/bin/__tests__/mutineer.spec.d.ts +1 -0
  3. package/dist/bin/__tests__/mutineer.spec.js +43 -0
  4. package/dist/bin/mutineer.d.ts +2 -1
  5. package/dist/bin/mutineer.js +44 -1
  6. package/dist/mutators/__tests__/operator.spec.js +97 -1
  7. package/dist/mutators/__tests__/registry.spec.js +8 -0
  8. package/dist/mutators/operator.d.ts +8 -0
  9. package/dist/mutators/operator.js +58 -1
  10. package/dist/mutators/registry.js +9 -1
  11. package/dist/mutators/utils.d.ts +2 -0
  12. package/dist/mutators/utils.js +58 -1
  13. package/dist/runner/__tests__/args.spec.js +101 -1
  14. package/dist/runner/__tests__/cache.spec.js +65 -8
  15. package/dist/runner/__tests__/changed.spec.js +85 -2
  16. package/dist/runner/__tests__/cleanup.spec.js +30 -0
  17. package/dist/runner/__tests__/config.spec.js +2 -13
  18. package/dist/runner/__tests__/coverage-resolver.spec.js +9 -2
  19. package/dist/runner/__tests__/discover.spec.js +128 -0
  20. package/dist/runner/__tests__/orchestrator.spec.d.ts +1 -0
  21. package/dist/runner/__tests__/orchestrator.spec.js +306 -0
  22. package/dist/runner/__tests__/pool-executor.spec.js +60 -1
  23. package/dist/runner/args.d.ts +18 -0
  24. package/dist/runner/args.js +40 -0
  25. package/dist/runner/cache.d.ts +19 -3
  26. package/dist/runner/cache.js +14 -7
  27. package/dist/runner/changed.js +15 -43
  28. package/dist/runner/cleanup.d.ts +3 -1
  29. package/dist/runner/cleanup.js +18 -1
  30. package/dist/runner/config.js +1 -1
  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/jest/__tests__/pool.spec.js +41 -0
  35. package/dist/runner/jest/pool.js +3 -3
  36. package/dist/runner/orchestrator.d.ts +1 -0
  37. package/dist/runner/orchestrator.js +38 -9
  38. package/dist/runner/pool-executor.d.ts +5 -0
  39. package/dist/runner/pool-executor.js +15 -4
  40. package/dist/runner/vitest/__tests__/adapter.spec.js +60 -0
  41. package/dist/runner/vitest/__tests__/pool.spec.js +57 -0
  42. package/dist/runner/vitest/adapter.js +16 -9
  43. package/dist/runner/vitest/pool.js +3 -3
  44. package/dist/types/config.d.ts +4 -0
  45. package/dist/utils/__tests__/summary.spec.js +43 -1
  46. package/dist/utils/summary.d.ts +18 -0
  47. package/dist/utils/summary.js +25 -0
  48. package/package.json +2 -1
package/README.md CHANGED
@@ -19,10 +19,10 @@ Built for **Vitest** with first-class **Jest** support. Other test runners can b
19
19
 
20
20
  1. **Baseline** -- runs your test suite to make sure everything passes before mutating
21
21
  2. **Mutate** -- applies AST-safe operator replacements to your source files (not your tests)
22
- 3. **Test** -- re-runs only the tests that import the mutated file, using a fast file-swap mechanism
22
+ 3. **Test** -- re-runs only the tests that import the mutated file, using temp files in `__mutineer__` dirs loaded via Vite plugin / Jest resolver
23
23
  4. **Report** -- prints a summary with kill rate, escaped mutants, and per-file breakdowns
24
24
 
25
- Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is injected at runtime via Vite plugins (Vitest) or custom resolvers (Jest) -- no files on disk are modified.
25
+ Mutations are applied using Babel AST analysis, so operators inside strings and comments are never touched. Mutated code is written to a temporary `__mutineer__` directory next to each source file, then loaded at runtime via Vite plugins (Vitest) or custom resolvers (Jest).
26
26
 
27
27
  ## Supported Mutations (WIP)
28
28
 
@@ -164,6 +164,46 @@ export default defineMutineerConfig({
164
164
  | `testPatterns` | `string[]` | Globs for test file discovery |
165
165
  | `extensions` | `string[]` | File extensions to consider |
166
166
 
167
+ ## Recommended Workflow
168
+
169
+ Large repos can generate thousands of mutations. These strategies keep runs fast and incremental.
170
+
171
+ ### 1. PR-scoped runs (CI) — `--changed-with-deps`
172
+
173
+ Run only on files changed in the branch plus their direct dependents:
174
+
175
+ ```bash
176
+ mutineer run --changed-with-deps
177
+ ```
178
+
179
+ - Tune the dependency graph depth with `dependencyDepth` in config (default: `1`)
180
+ - Add `--per-test-coverage` to only run tests that cover the mutated line
181
+ - Recommended `package.json` script:
182
+
183
+ ```json
184
+ "mutineer:ci": "mutineer run --changed-with-deps --per-test-coverage"
185
+ ```
186
+
187
+ ### 2. Split configs by domain
188
+
189
+ Create a `mutineer.config.ts` per domain and run selectively:
190
+
191
+ ```bash
192
+ mutineer run -c src/api/mutineer.config.ts
193
+ ```
194
+
195
+ Each config sets its own `source` glob and `minKillPercent`. Good for monorepos or large modular projects — domains can also be parallelized in CI.
196
+
197
+ ### 3. Combine filters to reduce scope
198
+
199
+ - `--only-covered-lines` — skips lines not covered by any test (requires a coverage file)
200
+ - `maxMutantsPerFile` — caps mutations per file as a safety valve
201
+ - Combine for maximum focus:
202
+
203
+ ```bash
204
+ mutineer run --changed-with-deps --only-covered-lines --per-test-coverage
205
+ ```
206
+
167
207
  ## File Support
168
208
 
169
209
  - TypeScript and JavaScript modules (`.ts`, `.js`, `.tsx`, `.jsx`)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { createRequire } from 'node:module';
2
+ import { describe, it, expect } from 'vitest';
3
+ import { HELP_TEXT, getVersion } from '../mutineer.js';
4
+ describe('HELP_TEXT', () => {
5
+ const flags = [
6
+ '--config',
7
+ '-c',
8
+ '--concurrency',
9
+ '--runner',
10
+ '--progress',
11
+ '--changed',
12
+ '--changed-with-deps',
13
+ '--only-covered-lines',
14
+ '--per-test-coverage',
15
+ '--coverage-file',
16
+ '--min-kill-percent',
17
+ '--help',
18
+ '-h',
19
+ '--version',
20
+ '-V',
21
+ ];
22
+ it.each(flags)('includes %s', (flag) => {
23
+ expect(HELP_TEXT).toContain(flag);
24
+ });
25
+ it('includes all three commands', () => {
26
+ expect(HELP_TEXT).toContain('init');
27
+ expect(HELP_TEXT).toContain('run');
28
+ expect(HELP_TEXT).toContain('clean');
29
+ });
30
+ it('--changed-with-deps description mentions local dependencies', () => {
31
+ expect(HELP_TEXT).toContain('local dependencies');
32
+ });
33
+ });
34
+ describe('getVersion()', () => {
35
+ it('returns a semver string', () => {
36
+ expect(getVersion()).toMatch(/^\d+\.\d+\.\d+/);
37
+ });
38
+ it('matches package.json version', () => {
39
+ const require = createRequire(import.meta.url);
40
+ const pkg = require('../../../package.json');
41
+ expect(getVersion()).toBe(pkg.version);
42
+ });
43
+ });
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare const HELP_TEXT = "Usage: mutineer <command> [options]\n\nCommands:\n init Create a mutineer.config.ts template\n run Run mutation testing\n clean Remove __mutineer__ temp directories\n\nOptions (run):\n --config, -c <path> Config file path\n --concurrency <n> Worker count (default: CPU count - 1)\n --runner <vitest|jest> Test runner (default: vitest)\n --progress <bar|list|quiet> Progress display (default: bar)\n --changed Mutate only git-changed files\n --changed-with-deps Mutate changed files + their local dependencies\n --only-covered-lines Mutate only lines covered by tests\n --per-test-coverage Collect per-test coverage data\n --coverage-file <path> Path to coverage JSON\n --min-kill-percent <n> Minimum kill % threshold (0\u2013100)\n --timeout <ms> Per-mutant test timeout in ms (default: 30000)\n --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\n --help, -h Show this help\n --version, -V Show version\n";
3
+ export declare function getVersion(): string;
@@ -1,12 +1,43 @@
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
+ --report <text|json> Output format: text (default) or json (writes mutineer-report.json)
32
+ --shard <n>/<total> Run a shard of mutants (e.g. --shard 1/4)
33
+
34
+ --help, -h Show this help
35
+ --version, -V Show version
36
+ `;
37
+ export function getVersion() {
38
+ const require = createRequire(import.meta.url);
39
+ return require('../../package.json').version;
40
+ }
10
41
  const CONFIG_TEMPLATE = `\
11
42
  import { defineMutineerConfig } from 'mutineer'
12
43
 
@@ -19,7 +50,19 @@ export default defineMutineerConfig({
19
50
  */
20
51
  async function main() {
21
52
  const args = process.argv.slice(2);
53
+ if (args[0] === '--help' || args[0] === '-h') {
54
+ process.stdout.write(HELP_TEXT);
55
+ process.exit(0);
56
+ }
57
+ if (args[0] === '--version' || args[0] === '-V') {
58
+ console.log(getVersion());
59
+ process.exit(0);
60
+ }
22
61
  if (args[0] === RUN_COMMAND) {
62
+ if (args.includes('--help') || args.includes('-h')) {
63
+ process.stdout.write(HELP_TEXT);
64
+ process.exit(0);
65
+ }
23
66
  await runOrchestrator(args.slice(1), process.cwd());
24
67
  }
25
68
  else if (args[0] === INIT_COMMAND) {
@@ -35,7 +78,7 @@ async function main() {
35
78
  }
36
79
  else if (args[0] === CLEAN_COMMAND) {
37
80
  console.log('Cleaning up __mutineer__ directories...');
38
- await cleanupMutineerDirs(process.cwd());
81
+ await cleanupMutineerDirs(process.cwd(), { includeCacheFiles: true });
39
82
  console.log('Done.');
40
83
  }
41
84
  else {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, } from '../operator.js';
2
+ import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, preInc, preDec, postInc, postDec, addAssignToSub, subAssignToAdd, mulAssignToDiv, divAssignToMul, } from '../operator.js';
3
3
  import { buildParseContext } from '../utils.js';
4
4
  // ---------------------------------------------------------------------------
5
5
  // Shared behaviour (tested once; all mutators use the same factory)
@@ -183,3 +183,99 @@ describe('powerToMul', () => {
183
183
  expect(result.code).toBe(`const n = a * b`);
184
184
  });
185
185
  });
186
+ // ---------------------------------------------------------------------------
187
+ // Increment/decrement mutators
188
+ // ---------------------------------------------------------------------------
189
+ describe('preInc', () => {
190
+ it("replaces '++x' to '--x'", () => {
191
+ const src = `const n = ++i`;
192
+ const [result] = preInc.apply(src);
193
+ expect(result.code).toBe(`const n = --i`);
194
+ });
195
+ it('does not match postfix x++', () => {
196
+ const src = `i++`;
197
+ expect(preInc.apply(src)).toHaveLength(0);
198
+ });
199
+ it('applyWithContext matches apply', () => {
200
+ const src = `const n = ++i`;
201
+ const ctx = buildParseContext(src);
202
+ expect(preInc.applyWithContext(src, ctx)).toEqual(preInc.apply(src));
203
+ });
204
+ });
205
+ describe('preDec', () => {
206
+ it("replaces '--x' to '++x'", () => {
207
+ const src = `const n = --i`;
208
+ const [result] = preDec.apply(src);
209
+ expect(result.code).toBe(`const n = ++i`);
210
+ });
211
+ it('does not match postfix x--', () => {
212
+ const src = `i--`;
213
+ expect(preDec.apply(src)).toHaveLength(0);
214
+ });
215
+ });
216
+ describe('postInc', () => {
217
+ it("replaces 'x++' to 'x--'", () => {
218
+ const src = `i++`;
219
+ const [result] = postInc.apply(src);
220
+ expect(result.code).toBe(`i--`);
221
+ });
222
+ it('does not match prefix ++x', () => {
223
+ const src = `const n = ++i`;
224
+ expect(postInc.apply(src)).toHaveLength(0);
225
+ });
226
+ it('applyWithContext matches apply', () => {
227
+ const src = `i++`;
228
+ const ctx = buildParseContext(src);
229
+ expect(postInc.applyWithContext(src, ctx)).toEqual(postInc.apply(src));
230
+ });
231
+ });
232
+ describe('postDec', () => {
233
+ it("replaces 'x--' to 'x++'", () => {
234
+ const src = `i--`;
235
+ const [result] = postDec.apply(src);
236
+ expect(result.code).toBe(`i++`);
237
+ });
238
+ it('does not match prefix --x', () => {
239
+ const src = `const n = --i`;
240
+ expect(postDec.apply(src)).toHaveLength(0);
241
+ });
242
+ });
243
+ // ---------------------------------------------------------------------------
244
+ // Compound assignment mutators
245
+ // ---------------------------------------------------------------------------
246
+ describe('addAssignToSub', () => {
247
+ it("replaces '+=' with '-='", () => {
248
+ const src = `x += 1`;
249
+ const [result] = addAssignToSub.apply(src);
250
+ expect(result.code).toBe(`x -= 1`);
251
+ });
252
+ it('returns no results when operator absent', () => {
253
+ expect(addAssignToSub.apply(`x -= 1`)).toHaveLength(0);
254
+ });
255
+ it('applyWithContext matches apply', () => {
256
+ const src = `x += 1`;
257
+ const ctx = buildParseContext(src);
258
+ expect(addAssignToSub.applyWithContext(src, ctx)).toEqual(addAssignToSub.apply(src));
259
+ });
260
+ });
261
+ describe('subAssignToAdd', () => {
262
+ it("replaces '-=' with '+='", () => {
263
+ const src = `x -= 1`;
264
+ const [result] = subAssignToAdd.apply(src);
265
+ expect(result.code).toBe(`x += 1`);
266
+ });
267
+ });
268
+ describe('mulAssignToDiv', () => {
269
+ it("replaces '*=' with '/='", () => {
270
+ const src = `x *= 2`;
271
+ const [result] = mulAssignToDiv.apply(src);
272
+ expect(result.code).toBe(`x /= 2`);
273
+ });
274
+ });
275
+ describe('divAssignToMul', () => {
276
+ it("replaces '/=' with '*='", () => {
277
+ const src = `x /= 2`;
278
+ const [result] = divAssignToMul.apply(src);
279
+ expect(result.code).toBe(`x *= 2`);
280
+ });
281
+ });
@@ -18,6 +18,14 @@ const ALL_NAMES = [
18
18
  'divToMul',
19
19
  'modToMul',
20
20
  'powerToMul',
21
+ 'preInc',
22
+ 'preDec',
23
+ 'postInc',
24
+ 'postDec',
25
+ 'addAssignToSub',
26
+ 'subAssignToAdd',
27
+ 'mulAssignToDiv',
28
+ 'divAssignToMul',
21
29
  'returnToNull',
22
30
  'returnToUndefined',
23
31
  'returnFlipBool',
@@ -23,3 +23,11 @@ export declare const mulToDiv: ASTMutator;
23
23
  export declare const divToMul: ASTMutator;
24
24
  export declare const modToMul: ASTMutator;
25
25
  export declare const powerToMul: ASTMutator;
26
+ export declare const preInc: ASTMutator;
27
+ export declare const preDec: ASTMutator;
28
+ export declare const postInc: ASTMutator;
29
+ export declare const postDec: ASTMutator;
30
+ export declare const addAssignToSub: ASTMutator;
31
+ export declare const subAssignToAdd: ASTMutator;
32
+ export declare const mulAssignToDiv: ASTMutator;
33
+ export declare const divAssignToMul: ASTMutator;
@@ -5,7 +5,7 @@
5
5
  * using AST traversal and produces a mutated source string with that operator
6
6
  * replaced by its counterpart.
7
7
  */
8
- import { collectOperatorTargets } from './utils.js';
8
+ import { collectOperatorTargets, buildParseContext } from './utils.js';
9
9
  /**
10
10
  * Factory to build an operator mutator using AST traversal and token analysis.
11
11
  *
@@ -33,6 +33,53 @@ function makeOperatorMutator(name, description, fromOp, toOp) {
33
33
  },
34
34
  };
35
35
  }
36
+ /**
37
+ * Factory for UpdateExpression mutators (++/--).
38
+ * mapKey distinguishes prefix vs postfix: 'pre++', 'post++', 'pre--', 'post--'
39
+ */
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
+ return {
49
+ name,
50
+ description,
51
+ apply(src) {
52
+ const ctx = buildParseContext(src);
53
+ return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
54
+ },
55
+ applyWithContext(src, ctx) {
56
+ return targetsToOutputs(src, ctx.preCollected.updateTargets.get(mapKey) ?? []);
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * Factory for AssignmentExpression mutators (+=, -=, *=, /=).
62
+ */
63
+ 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
+ return {
72
+ name,
73
+ description,
74
+ apply(src) {
75
+ const ctx = buildParseContext(src);
76
+ return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
77
+ },
78
+ applyWithContext(src, ctx) {
79
+ return targetsToOutputs(src, ctx.preCollected.assignmentTargets.get(fromOp) ?? []);
80
+ },
81
+ };
82
+ }
36
83
  /* === Boundary mutators === */
37
84
  export const relaxLE = makeOperatorMutator('relaxLE', "Change '<=' to '<' (relax boundary)", '<=', '<');
38
85
  export const relaxGE = makeOperatorMutator('relaxGE', "Change '>=' to '>' (relax boundary)", '>=', '>');
@@ -54,3 +101,13 @@ export const mulToDiv = makeOperatorMutator('mulToDiv', "Change '*' to '/'", '*'
54
101
  export const divToMul = makeOperatorMutator('divToMul', "Change '/' to '*'", '/', '*');
55
102
  export const modToMul = makeOperatorMutator('modToMul', "Change '%' to '*'", '%', '*');
56
103
  export const powerToMul = makeOperatorMutator('powerToMul', "Change '**' to '*'", '**', '*');
104
+ /* === Increment/decrement mutators === */
105
+ export const preInc = makeUpdateMutator('preInc', "Change '++x' to '--x'", 'pre++', '--');
106
+ export const preDec = makeUpdateMutator('preDec', "Change '--x' to '++x'", 'pre--', '++');
107
+ export const postInc = makeUpdateMutator('postInc', "Change 'x++' to 'x--'", 'post++', '--');
108
+ export const postDec = makeUpdateMutator('postDec', "Change 'x--' to 'x++'", 'post--', '++');
109
+ /* === Compound assignment mutators === */
110
+ export const addAssignToSub = makeAssignmentMutator('addAssignToSub', "Change '+=' to '-='", '+=', '-=');
111
+ export const subAssignToAdd = makeAssignmentMutator('subAssignToAdd', "Change '-=' to '+='", '-=', '+=');
112
+ export const mulAssignToDiv = makeAssignmentMutator('mulAssignToDiv', "Change '*=' to '/='", '*=', '/=');
113
+ export const divAssignToMul = makeAssignmentMutator('divAssignToMul', "Change '/=' to '*='", '/=', '*=');
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Aggregates all mutators and exposes `getRegistry` for filtered access.
5
5
  */
6
- import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, } from './operator.js';
6
+ import { relaxLE, relaxGE, tightenLT, tightenGT, andToOr, orToAnd, nullishToOr, flipEQ, flipNEQ, flipStrictEQ, flipStrictNEQ, addToSub, subToAdd, mulToDiv, divToMul, modToMul, powerToMul, preInc, preDec, postInc, postDec, addAssignToSub, subAssignToAdd, mulAssignToDiv, divAssignToMul, } from './operator.js';
7
7
  import { returnToNull, returnToUndefined, returnFlipBool, returnZero, returnEmptyStr, returnEmptyArr, } from './return-value.js';
8
8
  const ALL = [
9
9
  relaxLE,
@@ -23,6 +23,14 @@ const ALL = [
23
23
  divToMul,
24
24
  modToMul,
25
25
  powerToMul,
26
+ preInc,
27
+ preDec,
28
+ postInc,
29
+ postDec,
30
+ addAssignToSub,
31
+ subAssignToAdd,
32
+ mulAssignToDiv,
33
+ divAssignToMul,
26
34
  returnToNull,
27
35
  returnToUndefined,
28
36
  returnFlipBool,
@@ -81,6 +81,8 @@ export interface ReturnStatementInfo {
81
81
  export interface PreCollected {
82
82
  readonly operatorTargets: Map<string, OperatorTarget[]>;
83
83
  readonly returnStatements: ReturnStatementInfo[];
84
+ readonly updateTargets: Map<string, OperatorTarget[]>;
85
+ readonly assignmentTargets: Map<string, OperatorTarget[]>;
84
86
  }
85
87
  /**
86
88
  * Pre-parsed context for a source file.
@@ -128,6 +128,8 @@ export function isBinaryOrLogical(node) {
128
128
  export function collectAllTargets(src, ast, tokens, ignoreLines) {
129
129
  const operatorTargets = new Map();
130
130
  const returnStatements = [];
131
+ const updateTargets = new Map();
132
+ const assignmentTargets = new Map();
131
133
  function handleBinaryOrLogical(n) {
132
134
  const nodeStart = n.start ?? 0;
133
135
  const nodeEnd = n.end ?? 0;
@@ -152,6 +154,55 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
152
154
  });
153
155
  }
154
156
  }
157
+ function handleUpdate(n) {
158
+ const nodeStart = n.start ?? 0;
159
+ const nodeEnd = n.end ?? 0;
160
+ const opValue = n.operator;
161
+ const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
162
+ if (tok) {
163
+ const line = tok.loc.start.line;
164
+ if (ignoreLines.has(line))
165
+ return;
166
+ const visualCol = getVisualColumn(src, tok.start);
167
+ const mapKey = (n.prefix ? 'pre' : 'post') + opValue;
168
+ let arr = updateTargets.get(mapKey);
169
+ if (!arr) {
170
+ arr = [];
171
+ updateTargets.set(mapKey, arr);
172
+ }
173
+ arr.push({
174
+ start: tok.start,
175
+ end: tok.end,
176
+ line,
177
+ col1: visualCol,
178
+ op: opValue,
179
+ });
180
+ }
181
+ }
182
+ function handleAssignment(n) {
183
+ const nodeStart = n.start ?? 0;
184
+ const nodeEnd = n.end ?? 0;
185
+ const opValue = n.operator;
186
+ const tok = tokens.find((tk) => tk.start >= nodeStart && tk.end <= nodeEnd && tk.value === opValue);
187
+ if (tok) {
188
+ const line = tok.loc.start.line;
189
+ if (ignoreLines.has(line))
190
+ return;
191
+ const visualCol = getVisualColumn(src, tok.start);
192
+ let arr = assignmentTargets.get(opValue);
193
+ if (!arr) {
194
+ arr = [];
195
+ assignmentTargets.set(opValue, arr);
196
+ }
197
+ arr.push({
198
+ start: tok.start,
199
+ end: tok.end,
200
+ line,
201
+ col1: visualCol,
202
+ op: opValue,
203
+ });
204
+ }
205
+ }
155
206
  traverse(ast, {
156
207
  BinaryExpression(p) {
157
208
  handleBinaryOrLogical(p.node);
@@ -159,6 +210,12 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
159
210
  LogicalExpression(p) {
160
211
  handleBinaryOrLogical(p.node);
161
212
  },
213
+ UpdateExpression(p) {
214
+ handleUpdate(p.node);
215
+ },
216
+ AssignmentExpression(p) {
217
+ handleAssignment(p.node);
218
+ },
162
219
  ReturnStatement(p) {
163
220
  const node = p.node;
164
221
  if (!node.argument)
@@ -182,7 +239,7 @@ export function collectAllTargets(src, ast, tokens, ignoreLines) {
182
239
  });
183
240
  },
184
241
  });
185
- return { operatorTargets, returnStatements };
242
+ return { operatorTargets, returnStatements, updateTargets, assignmentTargets };
186
243
  }
187
244
  /**
188
245
  * Parse a source file once and build a reusable ParseContext.
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, } from '../args.js';
2
+ import { parseFlagNumber, readNumberFlag, readStringFlag, validatePercent, validatePositiveMs, parseConcurrency, parseProgressMode, parseCliOptions, extractConfigPath, parseShardOption, } from '../args.js';
3
3
  describe('parseFlagNumber', () => {
4
4
  it('parses valid integers', () => {
5
5
  expect(parseFlagNumber('42', '--flag')).toBe(42);
@@ -202,6 +202,106 @@ describe('parseCliOptions', () => {
202
202
  it('rejects invalid --min-kill-percent', () => {
203
203
  expect(() => parseCliOptions(['--min-kill-percent', '150'], emptyCfg)).toThrow('expected value between 0 and 100');
204
204
  });
205
+ it('parses --timeout flag', () => {
206
+ const opts = parseCliOptions(['--timeout', '5000'], emptyCfg);
207
+ expect(opts.timeout).toBe(5000);
208
+ });
209
+ it('parses --timeout with = syntax', () => {
210
+ const opts = parseCliOptions(['--timeout=5000'], emptyCfg);
211
+ expect(opts.timeout).toBe(5000);
212
+ });
213
+ it('returns undefined timeout when flag absent', () => {
214
+ const opts = parseCliOptions([], emptyCfg);
215
+ expect(opts.timeout).toBeUndefined();
216
+ });
217
+ it('config timeout does not affect opts.timeout (resolved in orchestrator)', () => {
218
+ const opts = parseCliOptions([], { timeout: 10000 });
219
+ expect(opts.timeout).toBeUndefined();
220
+ });
221
+ it('rejects --timeout 0', () => {
222
+ expect(() => parseCliOptions(['--timeout', '0'], emptyCfg)).toThrow('expected a positive number');
223
+ });
224
+ it('rejects --timeout -1', () => {
225
+ expect(() => parseCliOptions(['--timeout', '-1'], emptyCfg)).toThrow('expected a positive number');
226
+ });
227
+ it('rejects --timeout abc', () => {
228
+ expect(() => parseCliOptions(['--timeout', 'abc'], emptyCfg)).toThrow('Invalid value for --timeout: abc');
229
+ });
230
+ it('defaults reportFormat to text', () => {
231
+ const opts = parseCliOptions([], emptyCfg);
232
+ expect(opts.reportFormat).toBe('text');
233
+ });
234
+ it('parses --report json', () => {
235
+ const opts = parseCliOptions(['--report', 'json'], emptyCfg);
236
+ expect(opts.reportFormat).toBe('json');
237
+ });
238
+ it('reads report from config', () => {
239
+ const opts = parseCliOptions([], { report: 'json' });
240
+ expect(opts.reportFormat).toBe('json');
241
+ });
242
+ it('CLI --report takes precedence over config', () => {
243
+ const opts = parseCliOptions(['--report', 'json'], {
244
+ report: 'text',
245
+ });
246
+ expect(opts.reportFormat).toBe('json');
247
+ });
248
+ });
249
+ describe('validatePositiveMs', () => {
250
+ it('returns undefined for undefined', () => {
251
+ expect(validatePositiveMs(undefined, 'test')).toBeUndefined();
252
+ });
253
+ it('returns the value for a positive number', () => {
254
+ expect(validatePositiveMs(5000, '--timeout')).toBe(5000);
255
+ expect(validatePositiveMs(1, '--timeout')).toBe(1);
256
+ });
257
+ it('throws for zero', () => {
258
+ expect(() => validatePositiveMs(0, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received 0)');
259
+ });
260
+ it('throws for negative values', () => {
261
+ expect(() => validatePositiveMs(-1, '--timeout')).toThrow('Invalid --timeout: expected a positive number (received -1)');
262
+ });
263
+ it('throws for non-finite values', () => {
264
+ expect(() => validatePositiveMs(Infinity, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
265
+ expect(() => validatePositiveMs(NaN, '--timeout')).toThrow('Invalid --timeout: expected a positive number');
266
+ });
267
+ });
268
+ describe('parseShardOption', () => {
269
+ it('returns undefined when --shard is absent', () => {
270
+ expect(parseShardOption([])).toBeUndefined();
271
+ expect(parseShardOption(['--runner', 'vitest'])).toBeUndefined();
272
+ });
273
+ it('parses valid shard with space syntax', () => {
274
+ expect(parseShardOption(['--shard', '1/2'])).toEqual({ index: 1, total: 2 });
275
+ expect(parseShardOption(['--shard', '2/2'])).toEqual({ index: 2, total: 2 });
276
+ expect(parseShardOption(['--shard', '3/4'])).toEqual({ index: 3, total: 4 });
277
+ });
278
+ it('parses valid shard with = syntax', () => {
279
+ expect(parseShardOption(['--shard=1/2'])).toEqual({ index: 1, total: 2 });
280
+ });
281
+ it('throws on 5/4 (index > total)', () => {
282
+ expect(() => parseShardOption(['--shard', '5/4'])).toThrow('Invalid --shard');
283
+ });
284
+ it('throws on 0/4 (index < 1)', () => {
285
+ expect(() => parseShardOption(['--shard', '0/4'])).toThrow('Invalid --shard');
286
+ });
287
+ it('throws on 1/0 (total < 1)', () => {
288
+ expect(() => parseShardOption(['--shard', '1/0'])).toThrow('Invalid --shard');
289
+ });
290
+ it('throws on bad format', () => {
291
+ expect(() => parseShardOption(['--shard', 'bad'])).toThrow('Invalid --shard format');
292
+ expect(() => parseShardOption(['--shard', '1-2'])).toThrow('Invalid --shard format');
293
+ });
294
+ });
295
+ describe('parseCliOptions shard', () => {
296
+ const emptyCfg = {};
297
+ it('parses --shard into opts.shard', () => {
298
+ const opts = parseCliOptions(['--shard', '2/4'], emptyCfg);
299
+ expect(opts.shard).toEqual({ index: 2, total: 4 });
300
+ });
301
+ it('opts.shard is undefined when flag absent', () => {
302
+ const opts = parseCliOptions([], emptyCfg);
303
+ expect(opts.shard).toBeUndefined();
304
+ });
205
305
  });
206
306
  describe('extractConfigPath', () => {
207
307
  it('extracts --config with separate value', () => {