mutineer 1.3.0 → 1.5.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +317 -0
  3. data/README.md +200 -28
  4. data/lib/mutineer/changed_lines.rb +57 -2
  5. data/lib/mutineer/cli.rb +142 -92
  6. data/lib/mutineer/config.rb +215 -56
  7. data/lib/mutineer/coverage_map.rb +161 -14
  8. data/lib/mutineer/daemon_backend.rb +3 -0
  9. data/lib/mutineer/daemon_client.rb +170 -13
  10. data/lib/mutineer/daemon_server.rb +2 -1
  11. data/lib/mutineer/external_backend.rb +2 -2
  12. data/lib/mutineer/isolation.rb +91 -6
  13. data/lib/mutineer/kill_channel.rb +284 -0
  14. data/lib/mutineer/kill_matrix.rb +88 -0
  15. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  16. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  17. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +14 -68
  18. data/lib/mutineer/minitest_integration.rb +27 -10
  19. data/lib/mutineer/mutant_id.rb +5 -4
  20. data/lib/mutineer/mutator_registry.rb +12 -3
  21. data/lib/mutineer/mutators/array_literal.rb +0 -7
  22. data/lib/mutineer/mutators/base.rb +63 -4
  23. data/lib/mutineer/mutators/chain_link.rb +0 -7
  24. data/lib/mutineer/mutators/collection_method.rb +1 -1
  25. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  26. data/lib/mutineer/mutators/operand_removal.rb +0 -7
  27. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  28. data/lib/mutineer/mutators/return_nil.rb +5 -11
  29. data/lib/mutineer/mutators/string_literal.rb +1 -1
  30. data/lib/mutineer/pairing.rb +222 -15
  31. data/lib/mutineer/parser.rb +12 -3
  32. data/lib/mutineer/project.rb +7 -0
  33. data/lib/mutineer/rails_worker_db.rb +1 -1
  34. data/lib/mutineer/reporter.rb +288 -52
  35. data/lib/mutineer/result.rb +37 -1
  36. data/lib/mutineer/runner.rb +133 -16
  37. data/lib/mutineer/statement_lines.rb +143 -0
  38. data/lib/mutineer/test_runners/minitest.rb +4 -2
  39. data/lib/mutineer/test_runners/rspec.rb +243 -3
  40. data/lib/mutineer/test_runners.rb +5 -3
  41. data/lib/mutineer/version.rb +1 -1
  42. data/lib/mutineer/worker_pool.rb +4 -2
  43. data/lib/mutineer.rb +3 -0
  44. metadata +10 -3
data/README.md CHANGED
@@ -9,12 +9,19 @@ change at a time, runs your test suite (Minitest or RSpec) against each mutant,
9
9
  ones your tests failed to catch — the gaps where your suite isn't actually
10
10
  testing anything.
11
11
 
12
+ "Clean-room" means Mutineer was written from scratch. It contains no code from
13
+ [Mutant](https://github.com/mbj/mutant) or any other mutation-testing tool.
14
+ Mutineer's own code is licensed under MIT. See
15
+ [Mutineer and Mutant](https://github.com/davidteren/mutineer#mutineer-and-mutant)
16
+ for how the two tools differ.
17
+
12
18
  - **Prism + stdlib only** — zero runtime dependencies (Ruby ≥ 3.4).
13
19
  - **One mutation per mutant**, validity-checked by re-parsing.
14
20
  - **Fork-isolated**, parallel execution (Linux + macOS).
15
21
  - **Coverage-guided** — each mutant runs only the test files that cover its line.
16
22
  - **Stops at the first failing test** — in-process runs (not `--daemon` or
17
- `--test-command`) stop a mutant's test run at the first failure.
23
+ `--test-command`) stop a mutant's test run at the first failure, unless
24
+ `--matrix` asks for every covering test.
18
25
 
19
26
  📖 **[mutineer.github.io →](https://davidteren.github.io/mutineer/)** — overview, operators, and usage.
20
27
 
@@ -33,7 +40,7 @@ gem "mutineer", group: :test
33
40
  ## Usage
34
41
 
35
42
  ```sh
36
- mutineer run <source...> --test <test...> [options]
43
+ mutineer run <source...> --test <test> [--test <test>...] [options]
37
44
  ```
38
45
 
39
46
  Mutate `lib/calculator.rb`, checking it against its test, and fail CI if the
@@ -47,24 +54,30 @@ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
47
54
 
48
55
  | Flag | Meaning |
49
56
  |------|---------|
50
- | `--test FILE` | Test file covering the sources (repeatable) |
57
+ | `--test FILE` | Test file covering the sources; one file per flag, so repeat it for each (`--test a_test.rb --test b_test.rb`) |
51
58
  | `--operators LIST` | Comma-separated operator names (default: the Tier-1 set) |
52
59
  | `--threshold FLOAT` | Exit 1 when the score is below FLOAT, or when nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted (default: 0 = off) |
53
60
  | `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
54
61
  | `--framework NAME` | `minitest` (default) or `rspec`; auto-detected as rspec when most `--test` files end in `_spec.rb` |
55
- | `--since REF` | Only mutate lines changed since git `REF` (e.g. `origin/main`) — ideal for PR CI |
62
+ | `--since REF` | Only mutate lines changed since git `REF` (e.g. `origin/main`). Untracked files are scored in full unless Git ignores them. |
56
63
  | `--no-since` | Disable diff scoping; a typed no beats a `.mutineer.yml` `since:` key |
57
- | `--baseline FILE` | Compare against a prior `--format json` run; exit 1 on new survivors / score drop (score drop is skipped under `--since`, whose score covers a different denominator; see [CI](#ci-gating)) |
64
+ | `--baseline FILE` | Compare against a prior `--format json` run; exit 1 on new survivors / score drop (score drop is skipped under `--since`, whose score covers a different denominator; see [CI](https://github.com/davidteren/mutineer#ci-gating)) |
58
65
  | `--baseline-epsilon FLOAT` | Score-drop tolerance for `--baseline` (default: 0) |
59
- | `--jobs N` | Parallel worker count (default: processor count; `1` under `--rails`) |
66
+ | `--jobs N` | Parallel worker count (default: processor count); forced to `1` by `--test-command`, `--fail-fast`, or `--rails` without `--daemon` |
67
+ | `--boot FILE` | Require an app entry point once before forking; select at least one test file |
68
+ | `--rails` | Boot `config/environment` and reconnect ActiveRecord per fork; without `--daemon`, defaults to `redefine` and runs serially |
60
69
  | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
61
70
  | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
62
- | `--test-command CMD` | Run the suite as a subprocess in the app's own runtime (for apps on Ruby < 3.4); `CMD` must contain `%{files}`. See [Apps on Ruby < 3.4](#apps-on-ruby--34) |
63
- | `--daemon` | Boot the app once in a persistent daemon and fork per mutant, with per-worker DB isolation so `--jobs N` is safe under Rails (needs `--rails`/`--boot`; not with `--test-command`). See [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend) |
71
+ | `--timeout SECONDS` | Per-mutant time limit for in-process runs, in whole seconds (default: 10). A mutant whose tests run longer is a `timeout`; see [Timeouts and the score](https://github.com/davidteren/mutineer#timeouts-and-the-score). `--daemon` and `--test-command` keep their own limits |
72
+ | `--capture-timeout SECONDS` | Time limit for each in-process coverage-capture subprocess and for the clean run of the unmutated tests, in whole seconds (default: 120). A suite slower than this stops the run as not green. `--daemon` uses it for its coverage capture; `--test-command` ignores it and says so |
73
+ | `--cache-dir DIR` | Directory for the coverage cache (default: `.mutineer`). Give two runs from the same project root different directories so they do not share `coverage.json`. `--test-command` builds no cache, so it ignores it and says so |
74
+ | `--test-command CMD` | Run the suite as a subprocess in the app's own runtime (for apps on Ruby < 3.4); `CMD` must contain `%{files}`. See [Apps on Ruby < 3.4](https://github.com/davidteren/mutineer#apps-on-ruby--34) |
75
+ | `--daemon` | Boot the app once in a persistent daemon and fork per mutant, with per-worker DB isolation so `--jobs N` is safe under Rails (needs `--rails`/`--boot`; not with `--test-command`). See [the daemon backend](https://github.com/davidteren/mutineer#faster-parallel-safe-rails-the---daemon-backend) |
64
76
  | `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
65
77
  | `--output FILE` | Write the report to FILE instead of stdout |
66
78
  | `--dry-run` | List candidate mutations without executing (honors suppression) |
67
- | `--fail-fast` | Stop at the first surviving mutant |
79
+ | `--fail-fast` | Stop at the first surviving mutant (`--no-fail-fast` beats a `.mutineer.yml` `fail_fast:` key) |
80
+ | `--matrix` | Run every covering test for each mutant and report the blind and redundant tests; the JSON report also lists each mutant's killers. In-process only; exits 2 with `--daemon`, `--test-command`, `--fail-fast` or `--dry-run`. RSpec needs 3.3 or later. `--no-matrix` beats a `.mutineer.yml` `matrix:` key. See [Kill matrix](https://github.com/davidteren/mutineer#kill-matrix) |
68
81
  | `--list-operators` | List available operators (default vs optional) and exit |
69
82
  | `--version`, `--help` | Print version / usage and exit |
70
83
 
@@ -85,7 +98,8 @@ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
85
98
  Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
86
99
  `literal_mutation`, `condition_negation`, `string_literal`, `regex`,
87
100
  `collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`,
88
- `operand_removal`, `array_literal`.
101
+ `operand_removal`, `array_literal`, `condition_true`,
102
+ `condition_false`, `operator_assignment`.
89
103
 
90
104
  ## Rails apps
91
105
 
@@ -98,8 +112,8 @@ RAILS_ENV=test bundle exec mutineer run \
98
112
  ```
99
113
 
100
114
  `--rails` boots `config/environment` once in the parent process (every mutant
101
- then forks and inherits it), defaults `--strategy` to `redefine` (surgical — it
102
- avoids reloading files into the app tree), and reconnects ActiveRecord in each
115
+ then forks and inherits it), defaults `--strategy` to `redefine` without `--daemon`
116
+ (surgical — it avoids reloading files into the app tree; `--daemon` keeps `reload`), and reconnects ActiveRecord in each
103
117
  fork so the database connection is fork-safe. Use `--boot FILE` to boot a
104
118
  different entry point. Boot mode requires at least one `--test` file and is
105
119
  coverage-guided — each mutant runs only the test files that exercise its line
@@ -134,9 +148,10 @@ RAILS_ENV=test bundle exec mutineer run \
134
148
  - **One backend at a time** — `--daemon` can't be combined with `--test-command`
135
149
  (choose one), and it needs an app to boot (`--rails` or `--boot`).
136
150
 
137
- Status: **SQLite** today (hermetic, CI-proven). **Postgres** per-worker
138
- provisioning is in progress (#34/#35); until it lands, use `--daemon` with a
139
- SQLite test database, or drop `--jobs` to run serially on other adapters.
151
+ Status: **SQLite** only (hermetic, CI-proven). Per-worker provisioning for
152
+ **Postgres** and other adapters is not supported: on those, `--daemon` scores
153
+ every mutant as `error`. Use `--daemon` with a SQLite test database, or drop
154
+ `--daemon` to run serially on other adapters.
140
155
 
141
156
  ### Apps on Ruby < 3.4
142
157
 
@@ -206,7 +221,23 @@ Tradeoffs — this path is correct but not free:
206
221
  front (a "smoke check") if your unmutated suite isn't green.
207
222
  - **Reload strategy only** (`--strategy redefine` is rejected on this path) and
208
223
  **serial** (`--jobs` is forced to 1). For apps on Ruby ≥ 3.4, `--daemon` gives
209
- safe parallelism instead (see [the daemon backend](#faster-parallel-safe-rails-the---daemon-backend)).
224
+ safe parallelism instead (see [the daemon backend](https://github.com/davidteren/mutineer#faster-parallel-safe-rails-the---daemon-backend)).
225
+
226
+ ## Timeouts and the score
227
+
228
+ The mutation score is `killed / (killed + survived)`. A mutant whose tests run
229
+ past `--timeout` is a `timeout`. It is neither killed nor survived, so it is
230
+ left out of the score, like `no_coverage`, `uncapturable`, `errored`, skipped
231
+ and ignored mutants. A timeout is not counted as a kill, because a hang the
232
+ mutant caused and a suite that is just slow look the same. The human report
233
+ shows the count in its `Timeout:` row, and the JSON report in `summary.timeout`.
234
+
235
+ Under `--threshold`, a timeout is a mutant with no verdict. The run exits 1 when
236
+ more than one mutant produced no verdict and they exceed 10% of those attempted,
237
+ or when nothing could be scored and something broke. If a slow suite times out
238
+ mutants that its tests would catch, raise `--timeout`. A limit set below what
239
+ the suite needs does not raise the score: it turns those mutants into timeouts,
240
+ and once they pass 10% of the attempted mutants the run fails.
210
241
 
211
242
  ## Suppressing equivalent mutants
212
243
 
@@ -267,6 +298,77 @@ Ids are relative to the directory you run mutineer from. mutineer finds
267
298
  (other than your home directory), it warns that the ignore ids will not match
268
299
  and tells you which directory to run from.
269
300
 
301
+ ## Kill matrix
302
+
303
+ `--matrix` reports which tests kill each mutant, and from that, which tests
304
+ add nothing to the suite.
305
+
306
+ ```sh
307
+ mutineer run lib/calculator.rb --test test/calculator_test.rb --matrix
308
+ ```
309
+
310
+ For each mutant, Mutineer runs every test in its covering files instead of
311
+ stopping at the first failure, and records which tests failed. Coverage is
312
+ recorded per test file, so that set also holds tests that never reach the
313
+ mutated line; they pass and count as having run. A mutant's row is complete
314
+ when its whole covering set ran and the suite returned normally (under
315
+ Minitest, an `Interrupt` that cut the run short does not count as returning
316
+ normally). The report
317
+ names two kinds of test:
318
+
319
+ - **Blind:** the test ran in at least one complete row and killed no mutant
320
+ in any row. It would still pass with this run's mutations of the code, so
321
+ it guards none of them. It can still check behavior outside this run's
322
+ mutants (code in other files, or edits no operator makes), so look at what
323
+ it asserts before you delete it, or give it an assertion that can fail.
324
+ - **Redundant:** every mutant the test kills, another test kills too. That
325
+ makes it a candidate for deletion, one test at a time: two redundant tests
326
+ can be the only killers of one mutant, so re-run after each deletion.
327
+
328
+ The answers cover this run's mutants only. A test of code outside the sources
329
+ you passed kills nothing here, so mutate the code a test exercises before you
330
+ call the test blind.
331
+
332
+ The matrix changes no verdict, score or exit code: each mutant gets the
333
+ verdict a run without `--matrix` gives. That run skips every later test,
334
+ test class and example group after the first failure, so when a later one
335
+ exits the process, crashes or runs into the time limit, the mutant is still
336
+ `killed`. The run without `--matrix` still runs the code around the failing
337
+ test (the rest of its Minitest class `run` wrapper, the `after(:all)` hooks of
338
+ its RSpec groups) and the suite's own cleanup (RSpec's `after(:suite)`), so an
339
+ end there keeps the exit status. A failure in a Minitest `parallelize_me!`
340
+ class also keeps it: those run after every serial class and cannot be stopped
341
+ once queued, while a failure in a serial class still stops the run.
342
+
343
+ Under RSpec, `--matrix` needs RSpec 3.3 or later, whose example ids tell
344
+ apart examples on one line. With an older RSpec every row is incomplete.
345
+
346
+ The human report lists up to 20 blind and 20 redundant tests, and the HTML
347
+ report lists them all. `--format json` adds a `matrix` block with every test
348
+ and each mutant's killers (see the
349
+ [JSON schema](https://davidteren.github.io/mutineer/json-schema.html#matrix)).
350
+ A test is its file and its id. The name is `CalculatorTest#test_add` under
351
+ Minitest, or the example's full description under RSpec, where the example id
352
+ (`./spec/calc_spec.rb[1:2]`) tells apart examples that share a description
353
+ and keeps an example whose generated description changes with the mutant as
354
+ one test.
355
+
356
+ Each mutant runs its whole covering set, so every mutant costs what a survivor
357
+ costs. In 1.4, stopping at the first failure cut a full run of rack's
358
+ `lib/rack/utils.rb` from 86 to 89 seconds down to 35 to 41, so expect a matrix
359
+ run to take 2.1 to 2.5 times as long as a normal one. A mutant that reaches the
360
+ per-mutant time limit (`--timeout`, 10 seconds by default) after a test already failed stays `killed`
361
+ with its row marked incomplete. The report names each incomplete row and warns
362
+ that a blind test may have killed one of those mutants, so do not delete a
363
+ blind test until its rows are complete. Raise `--timeout` (or `timeout:` in
364
+ `.mutineer.yml`) to get complete rows.
365
+
366
+ `--matrix` runs on the in-process backend only. It exits 2 with `--daemon`,
367
+ `--test-command`, `--fail-fast` or `--dry-run`, and the message says whether
368
+ each setting came from the command line or `.mutineer.yml`. To run one of
369
+ those with `matrix: true` in the file, pass `--no-matrix`; to run `--matrix`
370
+ with `fail_fast: true` in the file, pass `--no-fail-fast`.
371
+
270
372
  ## CI gating
271
373
 
272
374
  Store a JSON run as a baseline, then fail the build only when a PR makes things
@@ -276,10 +378,15 @@ worse:
276
378
  mutineer run app/ --baseline .mutineer/baseline.json # exit 1 on NEW survivors or a score drop
277
379
  ```
278
380
 
279
- `--baseline` reports which survivors are new (by [mutant id](#mutant-ids)) and any score drop. It
381
+ `--baseline` reports which survivors are new (by [mutant id](https://github.com/davidteren/mutineer#mutant-ids)) and any score drop. It
280
382
  combines with `--threshold` (the worse of the two sets the exit code). Pass a
281
383
  directory (or several sources) to audit a whole layer in one boot — tests are
282
- auto-paired by convention and the report breaks down per source.
384
+ auto-paired by convention and the report breaks down per source. A source
385
+ `app/foo/bar.rb` pairs with `test/foo/bar_test.rb` and with unclaimed
386
+ `test/foo/bar_*_test.rb` files in that directory, such as `bar_upsert_test.rb`.
387
+ It does not take `user_session_test.rb` when `user_session.rb` exists in the same directory.
388
+ It does not take `bar_upsert_guards_test.rb` when `bar_upsert.rb` exists.
389
+ A spec that already pairs is left as that one file.
283
390
 
284
391
  ### GitHub Action
285
392
 
@@ -315,17 +422,17 @@ the format.
315
422
 
316
423
  ## For AI agents & pipelines
317
424
 
318
- Mutineer is built for programmatic use — versioned JSON, [mutant ids](#mutant-ids) that survive unrelated edits,
425
+ Mutineer is built for programmatic use — versioned JSON, [mutant ids](https://github.com/davidteren/mutineer#mutant-ids) that survive unrelated edits,
319
426
  structured exit codes, and diff-scoped runs. See:
320
427
 
321
428
  - **AI agents & CI recipes** — the agent inner-loop and CI-gate recipes (and how
322
429
  to avoid infinite loops on equivalent mutants):
323
430
  [rendered](https://davidteren.github.io/mutineer/agentic-coding.html) ·
324
- [source](docs/agentic-coding.md)
431
+ [source](https://davidteren.github.io/mutineer/agentic-coding.md)
325
432
  - **JSON schema reference** — the `--format json` schema and its versioning
326
433
  contract:
327
434
  [rendered](https://davidteren.github.io/mutineer/json-schema.html) ·
328
- [source](docs/json-schema.md)
435
+ [source](https://davidteren.github.io/mutineer/json-schema.md)
329
436
  - **Ruby API (YARD)** — class reference for the shipped gem:
330
437
  [https://davidteren.github.io/mutineer/api/](https://davidteren.github.io/mutineer/api/)
331
438
 
@@ -334,11 +441,44 @@ structured exit codes, and diff-scoped runs. See:
334
441
  Mutineer reads an optional `.mutineer.yml` from the project root (nearest one,
335
442
  walking up). CLI flags override config; config overrides defaults.
336
443
 
337
- Sources are positional CLI arguments and test files come from `--test`; the
338
- config file accepts these keys: `operators`, `threshold`, `jobs`, `only`,
339
- `require` (extra files to load before mutating), `boot`/`rails`, and
340
- `test_command` (the external-runtime suite command — see
341
- [Apps on Ruby < 3.4](#apps-on-ruby--34)).
444
+ Sources are positional CLI arguments and test files come from `--test`. The
445
+ config file accepts these keys:
446
+
447
+ | Key | Value and purpose |
448
+ |-----|-------------------|
449
+ | `operators` | An operator name or list of names; defaults to the Tier-1 set |
450
+ | `threshold` | A number from 0 to 100; 0 turns the score gate off |
451
+ | `jobs` | A positive integer; the default is the processor count. `test_command`, `fail_fast`, or `--rails` without `--daemon` forces 1. |
452
+ | `only` | A fully-qualified subject name, such as `Calculator#add` |
453
+ | `require` | A path or list of extra files to load before mutating |
454
+ | `boot` | The app entry point to require once before forking |
455
+ | `rails` | `true` or `false`; enables the Rails boot defaults |
456
+ | `since` | A nonblank git ref, or `false` to disable diff scoping |
457
+ | `framework` | `minitest` or `rspec`; an explicit value is kept during test pairing |
458
+ | `verbose` | `true` or `false`; shows capture diagnostics |
459
+ | `ignore` | A mutant id or list of ids to suppress |
460
+ | `baseline` | The path to a prior JSON report |
461
+ | `fail_fast` | `true` or `false`; stops scheduling after the first survivor |
462
+ | `test_command` | The external-runtime suite command, including `%{files}`; see [Apps on Ruby < 3.4](https://github.com/davidteren/mutineer#apps-on-ruby--34) |
463
+ | `daemon` | `true` or `false`; uses the persistent app daemon with worker DB isolation |
464
+ | `timeout` | A positive integer; the per-mutant time limit in seconds (default 10) |
465
+ | `capture_timeout` | A positive integer; the coverage-capture time limit in seconds (default 120) |
466
+ | `cache_dir` | The coverage cache directory (default `.mutineer`) |
467
+ | `matrix` | `true` or `false`; runs every covering test for each mutant and adds the kill matrix to the report |
468
+
469
+ Invalid values for known scalar keys, and a blank `operators` list, exit 2
470
+ with a message naming the file and key. An unknown operator name warns and
471
+ is skipped. If none of the names are known, the run exits 2. An empty
472
+ `require` or `ignore` list is valid. Boolean keys take `true` or `false` (quoted forms also work), not `"yes"`.
473
+ `jobs` must be positive; a string value contains digits only. String values for
474
+ `threshold` and the CLI-only `--baseline-epsilon` use plain decimals such as `90`
475
+ or `0.5`, not `+2`, `1e2`, or `1_0`. String options such as `only` and `baseline`
476
+ cannot be null or boolean. A blank `since` is invalid; use `since: false` to turn
477
+ scoping off. Unknown keys warn and are ignored. Unknown operator names warn and are skipped, and the run exits 2 when none remain. `--operators` replaces a blank or unknown file list.
478
+
479
+ `format`, `strategy`, `output`, `baseline_epsilon`, and `dry_run` are CLI-only.
480
+ For JSON output, use `--format json`, not a `format:` config key. To select RSpec
481
+ in the file, add `framework: rspec`.
342
482
 
343
483
  ```yaml
344
484
  # .mutineer.yml
@@ -350,8 +490,40 @@ require:
350
490
  ```
351
491
 
352
492
  Coverage results are cached in `.mutineer/coverage.json` (digest-keyed; rebuilt
353
- automatically when sources change). Add `.mutineer/` to your `.gitignore`.
493
+ automatically when sources change). Add `.mutineer/` to your `.gitignore`, and
494
+ the directory you set with `--cache-dir` if it is inside the project. Keep
495
+ `.mutineer/` ignored even then: `--test-command` writes lock files to
496
+ `.mutineer/` beside each source file.
497
+
498
+ ## Mutineer and Mutant
499
+
500
+ [Mutant](https://github.com/mbj/mutant), by Markus Schirp, is the established
501
+ mutation-testing tool for Ruby. It has been in development since 2012 and is
502
+ the subject of published research. Mutineer is a separate tool, and it shares
503
+ no code with Mutant.
504
+
505
+ | Aspect | Mutineer | Mutant |
506
+ |---|---|---|
507
+ | License | MIT, for every use | Free for open source (`--usage opensource`); commercial use needs a paid subscription |
508
+ | Runtime dependencies | None (Prism + stdlib) | `parser`, `unparser`, `regexp_parser`, `sorbet-runtime` and others |
509
+ | Ruby | 3.4 and later to run Mutineer; an app on an older Ruby works through `--test-command` (see [Apps on Ruby < 3.4](https://github.com/davidteren/mutineer#apps-on-ruby--34)) | 3.3 and later |
510
+ | Test frameworks | Minitest and RSpec, in one gem | RSpec, Minitest and Test::Unit, one integration gem each |
511
+ | What you mutate | Files (`mutineer run lib/foo.rb --test test/foo_test.rb`), narrowed to one method with `--only 'Foo#bar'` | Subjects named by expression (`mutant run 'Foo#bar'`, `'Foo*'`) |
512
+ | Which tests run | The test files whose coverage reaches the mutated line | The tests that declare the subject (an RSpec description or a Minitest `cover`), or the tests that ran it in a per-test coverage recording |
513
+ | Operators | 20 (5 by default, 15 opt-in) | A larger set; the default `light` set applies almost all of it (`full` adds `#==` to `#eql?`) |
514
+ | CI gating | `--threshold`, `--baseline` deltas, `--since`, and a GitHub Action | Incremental mode (`--since`) and a recorded session history |
515
+ | Machine-readable output | Versioned JSON report schema | Session JSON schema |
516
+
517
+ Choose Mutineer when you want an MIT-licensed tool with no extra gems in your
518
+ bundle, file-based runs, and a JSON contract built for CI gates and AI agents.
519
+ Choose Mutant when you need its deeper operator set, Test::Unit support, or
520
+ subject expressions that select whole namespaces. The two tools do not
521
+ conflict, so you can run both on one project.
522
+
523
+ This comparison describes Mutant 0.17 (checked on 2026-10-06). Check
524
+ [Mutant's README](https://github.com/mbj/mutant#readme) for its current
525
+ license terms and features.
354
526
 
355
527
  ## License
356
528
 
357
- MIT — see [LICENSE](LICENSE).
529
+ MIT — see [LICENSE](https://github.com/davidteren/mutineer/blob/main/LICENSE).
@@ -56,6 +56,12 @@ module Mutineer
56
56
  end
57
57
 
58
58
  # Returns the stdout of `git -C <root> diff --unified=0 <ref> -- <file>`.
59
+ # A file that is not in the index has no usable diff, so `new_file_diff`
60
+ # supplies one that marks every line new. That includes a file the ref still
61
+ # has, deleted and then written again: git prints only a deletion hunk for it.
62
+ # A Git-ignored file outside the index contributes no changed lines, with a
63
+ # warning, so the skip does not look like a file with no changes.
64
+ # Paths are literal, so a name such as `file[1].rb` is not read as a glob.
59
65
  #
60
66
  # A failure is warned, never silent: an empty result means "no changed
61
67
  # lines", which under `--since` removes every mutant for the file — a green
@@ -67,9 +73,15 @@ module Mutineer
67
73
  # @return [String] diff text, or `""` on failure (after a stderr warning).
68
74
  def git_diff(ref, abs_file, project_root)
69
75
  out, _err, status = Open3.capture3(
70
- "git", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
76
+ "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file
71
77
  )
72
- return out if status.success?
78
+ if status.success?
79
+ return out if indexed?(abs_file, project_root)
80
+ return new_file_diff(abs_file) unless ignored?(abs_file, project_root)
81
+
82
+ warn "[mutineer] #{abs_file} is ignored by Git; its lines will not be mutated (--since)"
83
+ return ""
84
+ end
73
85
 
74
86
  warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)"
75
87
  ""
@@ -77,5 +89,48 @@ module Mutineer
77
89
  warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
78
90
  ""
79
91
  end
92
+
93
+ # Tells whether the index holds the file. Only then is git's own diff the
94
+ # truth; the diff text cannot tell, because a mode-only diff must stay empty.
95
+ #
96
+ # @param abs_file [String] absolute path of the file being diffed.
97
+ # @param project_root [String] repository root for `git -C`.
98
+ # @return [Boolean] true when the index holds the file.
99
+ def indexed?(abs_file, project_root)
100
+ _out, _err, known = Open3.capture3(
101
+ "git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file
102
+ )
103
+ known.success?
104
+ end
105
+
106
+ # Tells whether Git ignores the file (`.gitignore`, `.git/info/exclude` or
107
+ # the global excludes file).
108
+ #
109
+ # @param abs_file [String] absolute path of the file.
110
+ # @param project_root [String] repository root for `git -C`.
111
+ # @return [Boolean] true when Git ignores the file. False when Git cannot
112
+ # tell, such as for a path beyond a symlink or inside a submodule (exit
113
+ # 128): that file is scored in full, as a file Git does not ignore is.
114
+ def ignored?(abs_file, project_root)
115
+ _out, _err, status = Open3.capture3(
116
+ "git", "-C", project_root, "check-ignore", "--quiet", "--", abs_file
117
+ )
118
+ status.success?
119
+ end
120
+
121
+ # Returns a diff that marks a file as entirely new. It returns `""` when
122
+ # the file is empty or cannot be read (after a warning). A file outside the
123
+ # index has every line new; read as "unchanged", `--since` would score
124
+ # nothing and a positive threshold would still exit 0.
125
+ #
126
+ # @param abs_file [String] absolute path of the file being diffed.
127
+ # @return [String] a one-hunk diff header, or `""`.
128
+ def new_file_diff(abs_file)
129
+ count = File.foreach(abs_file).count
130
+ count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n"
131
+ rescue SystemCallError => e
132
+ warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)"
133
+ ""
134
+ end
80
135
  end
81
136
  end