mutineer-mongoid 2.0.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +1194 -0
  3. data/LICENSE +21 -0
  4. data/README.md +659 -0
  5. data/bin/mutineer +6 -0
  6. data/lib/mutineer/baseline.rb +185 -0
  7. data/lib/mutineer/changed_lines.rb +136 -0
  8. data/lib/mutineer/child_stdout.rb +35 -0
  9. data/lib/mutineer/cli.rb +681 -0
  10. data/lib/mutineer/config.rb +372 -0
  11. data/lib/mutineer/coverage_map.rb +1366 -0
  12. data/lib/mutineer/daemon_backend.rb +351 -0
  13. data/lib/mutineer/daemon_client.rb +407 -0
  14. data/lib/mutineer/daemon_server.rb +351 -0
  15. data/lib/mutineer/external_backend.rb +288 -0
  16. data/lib/mutineer/file_swap.rb +194 -0
  17. data/lib/mutineer/isolation.rb +321 -0
  18. data/lib/mutineer/job_plan.rb +352 -0
  19. data/lib/mutineer/kill_channel.rb +284 -0
  20. data/lib/mutineer/kill_matrix.rb +88 -0
  21. data/lib/mutineer/minitest_integration/kill_recorder.rb +301 -0
  22. data/lib/mutineer/minitest_integration/outer_reporter.rb +126 -0
  23. data/lib/mutineer/minitest_integration/stop_at_first_failure.rb +145 -0
  24. data/lib/mutineer/minitest_integration.rb +137 -0
  25. data/lib/mutineer/mongoid_failures.rb +82 -0
  26. data/lib/mutineer/mongoid_worker_db.rb +97 -0
  27. data/lib/mutineer/mutant_id.rb +126 -0
  28. data/lib/mutineer/mutation.rb +27 -0
  29. data/lib/mutineer/mutator_registry.rb +109 -0
  30. data/lib/mutineer/mutators/arithmetic.rb +35 -0
  31. data/lib/mutineer/mutators/array_literal.rb +44 -0
  32. data/lib/mutineer/mutators/base.rb +105 -0
  33. data/lib/mutineer/mutators/boolean_connector.rb +52 -0
  34. data/lib/mutineer/mutators/boolean_literal.rb +56 -0
  35. data/lib/mutineer/mutators/chain_link.rb +132 -0
  36. data/lib/mutineer/mutators/collection_method.rb +44 -0
  37. data/lib/mutineer/mutators/comparison.rb +35 -0
  38. data/lib/mutineer/mutators/condition_forcing.rb +134 -0
  39. data/lib/mutineer/mutators/condition_negation.rb +49 -0
  40. data/lib/mutineer/mutators/literal_mutation.rb +51 -0
  41. data/lib/mutineer/mutators/negation_removal.rb +44 -0
  42. data/lib/mutineer/mutators/operand_removal.rb +58 -0
  43. data/lib/mutineer/mutators/operator_assignment.rb +113 -0
  44. data/lib/mutineer/mutators/range_literal.rb +54 -0
  45. data/lib/mutineer/mutators/regex_literal.rb +72 -0
  46. data/lib/mutineer/mutators/return_nil.rb +72 -0
  47. data/lib/mutineer/mutators/safe_navigation.rb +76 -0
  48. data/lib/mutineer/mutators/statement_removal.rb +33 -0
  49. data/lib/mutineer/mutators/string_literal.rb +34 -0
  50. data/lib/mutineer/orphan_guard.rb +28 -0
  51. data/lib/mutineer/pairing.rb +298 -0
  52. data/lib/mutineer/parser.rb +45 -0
  53. data/lib/mutineer/progress.rb +46 -0
  54. data/lib/mutineer/project.rb +506 -0
  55. data/lib/mutineer/project_path.rb +57 -0
  56. data/lib/mutineer/rails_worker_db.rb +212 -0
  57. data/lib/mutineer/reporter.rb +926 -0
  58. data/lib/mutineer/result.rb +240 -0
  59. data/lib/mutineer/runner.rb +418 -0
  60. data/lib/mutineer/statement_lines.rb +183 -0
  61. data/lib/mutineer/subject.rb +38 -0
  62. data/lib/mutineer/test_runners/minitest.rb +22 -0
  63. data/lib/mutineer/test_runners/rspec.rb +330 -0
  64. data/lib/mutineer/test_runners.rb +30 -0
  65. data/lib/mutineer/version.rb +6 -0
  66. data/lib/mutineer/worker_pool.rb +147 -0
  67. data/lib/mutineer.rb +48 -0
  68. metadata +143 -0
data/README.md ADDED
@@ -0,0 +1,659 @@
1
+ # Mutineer Mongoid
2
+
3
+ **mutineer-mongoid** is a fork of [Mutineer](https://github.com/davidteren/mutineer)
4
+ adding parallel mutation testing for Rails applications using Mongoid and RSpec.
5
+ It retains upstream Minitest, RSpec, and SQLite functionality; Mongoid support is
6
+ an addition, not a replacement for those backends. This fork is based on v1.6.0.
7
+ It adds Mongoid 9 / RSpec execution with `--rails --daemon --jobs N` and keeps the
8
+ `mutineer` executable and Ruby namespace. The original MIT license and author
9
+ attribution are preserved. Development is maintained by Justin Allen Marsh.
10
+
11
+ ## Migrating from mutineer-gaia
12
+
13
+ Version 2.0.0 renames the gem, repository, and action to `mutineer-mongoid`.
14
+ Replace `mutineer-gaia` in your Gemfile, run `bundle install`, and update action
15
+ references to `justinallenmarsh/mutineer-mongoid@v2.0.0`. The command remains
16
+ `bundle exec mutineer`; configuration and behavior are unchanged. The existing
17
+ v1.7.0 tag and its `mutineer-gaia-1.7.0.gem` release asset remain available.
18
+ GAIA is the application where this integration was developed and tested; the
19
+ fork contains no GAIA application code and can be used by other applications.
20
+
21
+ The new action release uses `v2`; `v1` remains on the original release.
22
+ GitHub does not redirect action calls after a repository rename: update the
23
+ repository path in every workflow, including workflows pinned to v1.7.0 or a SHA.
24
+
25
+ ## Compatibility
26
+
27
+ | Configuration | Serial execution | Parallel daemon execution |
28
+ | --- | --- | --- |
29
+ | Plain Ruby / Minitest 5 and 6 | Supported | Requires an application boot entry point |
30
+ | Plain Ruby / RSpec 3 | Supported | Mongoid applications only |
31
+ | Rails / ActiveRecord / SQLite / Minitest | Supported | Supported; inherited upstream integration tests |
32
+ | Rails / Mongoid 9 / RSpec 3 | Supported | Supported; Mongoid 9.0 and 9.1 integration tests |
33
+ | Rails / ActiveRecord / SQLite / RSpec | Supported | Not supported |
34
+ | Rails / ActiveRecord / PostgreSQL or MySQL | Serial backend available; adapter not integration-tested here | Not supported |
35
+
36
+ Ruby 3.4+ and Linux or macOS are required for the Mutineer process. The
37
+ `--test-command` backend can run suites under an older application Ruby.
38
+ `--matrix` works with Minitest and RSpec on the serial in-process backend;
39
+ it cannot be combined with `--daemon` or `--test-command`.
40
+ The supported Mongoid isolation scope and other limitations are listed below.
41
+
42
+ ## Install the fork
43
+
44
+ Use this gem in place of `mutineer` or `mutineer-gaia`; install only one of them
45
+ because they provide the same `mutineer` executable and Ruby namespace:
46
+
47
+ ```ruby
48
+ gem "mutineer-mongoid", git: "https://github.com/justinallenmarsh/mutineer-mongoid.git", tag: "v2.0.0", require: false
49
+ ```
50
+
51
+ ```sh
52
+ bundle exec mutineer run app/models/example.rb --rails --daemon --jobs 2
53
+ ```
54
+
55
+ The gem is available as an attachment on the [GitHub release](https://github.com/justinallenmarsh/mutineer-mongoid/releases/tag/v2.0.0).
56
+ It has not been published to RubyGems. The GitHub action builds and installs the
57
+ source at the action ref, so it needs no separate gem publication:
58
+
59
+ ```yaml
60
+ - uses: actions/checkout@v7
61
+ - uses: ruby/setup-ruby@v1
62
+ with:
63
+ ruby-version: "3.4"
64
+ bundler-cache: true
65
+ - uses: justinallenmarsh/mutineer-mongoid@v2.0.0
66
+ with:
67
+ sources: app/models/example.rb
68
+ test: spec/models/example_spec.rb
69
+ framework: rspec
70
+ rails: "true"
71
+ daemon: "true"
72
+ jobs: "2"
73
+ output: mutation-report.json
74
+ ```
75
+
76
+ Start MongoDB and configure the application's test client before this step.
77
+ For example, a GitHub-hosted Linux job can provide `mongo:8.2` through a service
78
+ container exposing port 27017. Use `use-bundler: "true"` when the application's
79
+ Gemfile includes this fork. The default standalone mode also supports a daemon
80
+ booting a separate application bundle. Pin an immutable commit SHA instead of a
81
+ tag when your dependency policy requires it.
82
+
83
+ Mongoid daemon runs capture the boot-time test database's documents, collection
84
+ options, and indexes in memory. Each coverage check and mutant gets a fresh,
85
+ randomly named database. The parent drops it after reaping the child, including
86
+ on timeout or error. The source database is read only. Coverage caches include the seed snapshot.
87
+ MongoDB connection failures inside examples or suite hooks are reported as errors,
88
+ not caught mutations. Mongoid clients disconnect
89
+ before forks and reconnect in children. Credentials must allow creation and
90
+ deletion of the disposable databases. A hard kill of the parent can leave one
91
+ behind; these have names matching `mutineer_<8 hex source hash>_<16 hex run token>_<child pid>`.
92
+
93
+ Supported scope: one default Mongoid client, ordinary collections, and
94
+ `RAILS_ENV=test`. Multiple clients, views, time-series collections, and mixed
95
+ ActiveRecord/Mongoid applications fail explicitly. Raw `Mongo::Client` instances
96
+ outside Mongoid, cached database handles created during boot, and specs that
97
+ reconfigure Mongoid are outside this isolation contract. Redis, object storage,
98
+ and other external resources keep the application's existing isolation rules.
99
+ `--matrix` remains available on the serial in-process backend. Daemon execution
100
+ still reloads whole source files, with upstream's callback and concern limitations.
101
+
102
+ To check the MongoDB integration independently of an application:
103
+
104
+ ```sh
105
+ BUNDLE_GEMFILE=test/fixtures/mongoid_app/Gemfile bundle install
106
+ # MongoDB must be running; set MUTINEER_MONGO_URL if it requires credentials.
107
+ bundle exec rake test:mongoid
108
+ ```
109
+
110
+ The integration tests check concurrent writes, seed data, unique indexes, cleanup
111
+ on timeout and error, source-data preservation, and serial/parallel report parity
112
+ with both killed and surviving mutations. Mongoid is only in the fixture bundle;
113
+ the gem still has zero runtime dependencies.
114
+
115
+ ---
116
+
117
+ ## General Mutineer usage
118
+
119
+ The following reference retains upstream behavior except where the compatibility
120
+ table and Mongoid notes above describe this fork's additions.
121
+
122
+ A clean-room mutation-testing tool for Ruby. Mutineer mutates your source one
123
+ change at a time, runs your test suite (Minitest or RSpec) against each mutant, and reports the
124
+ ones your tests failed to catch — the gaps where your suite isn't actually
125
+ testing anything.
126
+
127
+ "Clean-room" means Mutineer was written from scratch. It contains no code from
128
+ [Mutant](https://github.com/mbj/mutant) or any other mutation-testing tool.
129
+ Mutineer's own code is licensed under MIT. See
130
+ [Mutineer and Mutant](https://github.com/davidteren/mutineer#mutineer-and-mutant)
131
+ for how the two tools differ.
132
+
133
+ - **Prism + stdlib only** — zero runtime dependencies (Ruby ≥ 3.4).
134
+ - **One mutation per mutant**, validity-checked by re-parsing.
135
+ - **Fork-isolated**, parallel execution (Linux + macOS).
136
+ - **Coverage-guided** — each mutant runs only the test files that cover its line.
137
+ - **Stops at the first failing test** — in-process runs (not `--daemon` or
138
+ `--test-command`) stop a mutant's test run at the first failure, unless
139
+ `--matrix` asks for every covering test. The source's paired test files run
140
+ first, then the rest from fastest to slowest in doubling steps (under 1 s,
141
+ 1 to 3 s, 3 to 7 s, and so on); files in one step run in path order.
142
+
143
+ 📖 **[mutineer.github.io →](https://davidteren.github.io/mutineer/)** — overview, operators, and usage.
144
+
145
+ ## Usage
146
+
147
+ ```sh
148
+ mutineer run <source...> --test <test> [--test <test>...] [options]
149
+ ```
150
+
151
+ Mutate `lib/calculator.rb`, checking it against its test, and fail CI if the
152
+ mutation score drops below 90%:
153
+
154
+ ```sh
155
+ mutineer run lib/calculator.rb --test test/calculator_test.rb --threshold 90
156
+ ```
157
+
158
+ ### Options
159
+
160
+ | Flag | Meaning |
161
+ |------|---------|
162
+ | `--test FILE` | Test file covering the sources; one file per flag, so repeat it for each (`--test a_test.rb --test b_test.rb`) |
163
+ | `--operators LIST` | Comma-separated operator names (default: the Tier-1 set) |
164
+ | `--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) |
165
+ | `--only NAME` | Restrict to one fully-qualified subject, e.g. `Calculator#add` |
166
+ | `--framework NAME` | `minitest` (default) or `rspec`; auto-detected as rspec when most `--test` files end in `_spec.rb` |
167
+ | `--since REF` | Only mutate lines changed since git `REF` (e.g. `origin/main`). Untracked files are scored in full unless Git ignores them. |
168
+ | `--no-since` | Disable diff scoping; a typed no beats a `.mutineer.yml` `since:` key |
169
+ | `--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)) |
170
+ | `--baseline-epsilon FLOAT` | Score-drop tolerance for `--baseline` (default: 0) |
171
+ | `--jobs N` | Parallel worker count (default: processor count); forced to `1` by `--test-command`, `--fail-fast`, or `--rails` without `--daemon` |
172
+ | `--boot FILE` | Require an app entry point once before forking; select at least one test file |
173
+ | `--rails` | Boot `config/environment` and reconnect ActiveRecord per fork; without `--daemon`, defaults to `redefine` and runs serially |
174
+ | `--verbose` | Surface the real error when a fork capture fails (alias `--debug`) |
175
+ | `--strategy NAME` | Mutation application: `reload` whole-file (default) or `redefine` surgical (`7a`/`7b` accepted as deprecated aliases) |
176
+ | `--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 |
177
+ | `--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 |
178
+ | `--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 |
179
+ | `--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) |
180
+ | `--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) |
181
+ | `--format human\|json\|html` | Report format (default: human; `html` is a self-contained file) |
182
+ | `--output FILE` | Write the report to FILE instead of stdout |
183
+ | `--dry-run` | List candidate mutations without executing (honors suppression) |
184
+ | `--fail-fast` | Stop at the first surviving mutant (`--no-fail-fast` beats a `.mutineer.yml` `fail_fast:` key) |
185
+ | `--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) |
186
+ | `--list-operators` | List available operators (default vs optional) and exit |
187
+ | `--version`, `--help` | Print version / usage and exit |
188
+
189
+ ### Exit codes
190
+
191
+ <!-- contract:exit-codes -->
192
+ | Code | Meaning |
193
+ |------|---------|
194
+ | `0` | Score ≥ threshold (or no gate) **and** no baseline regression. |
195
+ | `1` | Score below `--threshold`, OR nothing could be scored and something broke, or more than one mutant produced no verdict and they exceed 10% of those attempted, OR a `--baseline` regression, OR a runtime error. |
196
+ | `2` | Usage / invalid-flag error (mistyped flag, bad path, unreadable baseline). |
197
+ <!-- /contract:exit-codes -->
198
+
199
+ ### Operators
200
+
201
+ Run `mutineer --list-operators` to see them. Default (Tier 1): `arithmetic`,
202
+ `comparison`, `boolean_connector`, `boolean_literal`, `statement_removal`.
203
+ Available but off by default (Tier 2, enable via `--operators`): `return_nil`,
204
+ `literal_mutation`, `condition_negation`, `string_literal`, `regex`,
205
+ `collection_method`, `safe_navigation`, `range`, `negation_removal`, `chain_link`,
206
+ `operand_removal`, `array_literal`, `condition_true`,
207
+ `condition_false`, `operator_assignment`.
208
+
209
+ ## Rails apps
210
+
211
+ Rails code needs its environment booted before the suite runs, so point Mutineer
212
+ at your app with `--rails` and run it inside the project's bundle:
213
+
214
+ ```sh
215
+ RAILS_ENV=test bundle exec mutineer run \
216
+ app/models/order.rb --test test/models/order_test.rb --rails
217
+ ```
218
+
219
+ `--rails` boots `config/environment` once in the parent process (every mutant
220
+ then forks and inherits it), defaults `--strategy` to `redefine` without `--daemon`
221
+ (surgical — it avoids reloading files into the app tree; `--daemon` keeps `reload`), and reconnects ActiveRecord in each
222
+ fork so the database connection is fork-safe. Use `--boot FILE` to boot a
223
+ different entry point. Boot mode requires at least one `--test` file and is
224
+ coverage-guided — each mutant runs only the test files that exercise its line
225
+ (coverage is captured by forking the booted app, then cached).
226
+
227
+ Some code runs while the app boots or a class loads, for example a class body
228
+ that calls a method to build a constant, a `to_prepare` initializer, or a
229
+ `--require` file that calls a source method. That run
230
+ happens before the mutant is applied, and the forked test does not repeat it.
231
+ A mutant on such a line is `ran_at_load`, not `survived` or `no_coverage`. It is
232
+ left out of the score and does not fail `--threshold`; a kill still counts.
233
+ This holds under both strategies: `reload` runs the mutated file's class body
234
+ again, but not an initializer or another file's code, so a survivor on such a
235
+ line is not trusted there either.
236
+ Run those mutants with `--test-command`, which boots a fresh process per mutant,
237
+ to get a verdict.
238
+
239
+ Add Mutineer to your Gemfile's test group:
240
+
241
+ ```ruby
242
+ gem "mutineer-mongoid", git: "https://github.com/justinallenmarsh/mutineer-mongoid.git", tag: "v2.0.0", group: :test, require: false
243
+ ```
244
+
245
+ ### Faster, parallel-safe Rails (the `--daemon` backend)
246
+
247
+ `--rails` boots your app once but runs mutants **serially** — parallel `--jobs`
248
+ under Rails is unsafe, because every worker shares one test database and clobbers
249
+ the others' fixtures. `--daemon` fixes both: it boots the app once in a persistent
250
+ helper and forks per mutant, and gives **each parallel worker its own database**,
251
+ so `--jobs N` is safe and its verdicts are proven identical to a serial run.
252
+
253
+ ```sh
254
+ RAILS_ENV=test bundle exec mutineer run \
255
+ app/models/order.rb --test test/models/order_test.rb \
256
+ --rails --daemon --jobs 4
257
+ ```
258
+
259
+ - **One boot, forked per mutant** — restores the shared-boot speed.
260
+ - **Coverage-guided** — each mutant runs only its covering tests (like `--rails`);
261
+ a mutant on an uncovered line is `no_coverage`, so the score stays comparable to
262
+ the in-process `--rails` score.
263
+ - **Safe `--jobs N`** — each worker routes to its own copy of the test database, so
264
+ parallel verdicts equal serial (no fixture cross-talk). On first use, a
265
+ worker's database is a copy of the test database after the app boots, so rows
266
+ written by initializers or `--require` files are there, as in-process. When
267
+ the copy's schema differs from `db/schema.rb` (another schema version or
268
+ `schema_sha1` checksum, or no stored checksum, as in an empty or out-of-date
269
+ test database, or one set up with a plain `load "db/schema.rb"`), the
270
+ worker loads `db/schema.rb` over it, which drops the copied rows of the
271
+ tables it defines.
272
+ - **One backend at a time** — `--daemon` can't be combined with `--test-command`
273
+ (choose one), and it needs an app to boot (`--rails` or `--boot`).
274
+
275
+ ActiveRecord daemon status: **SQLite with Minitest** (hermetic, CI-proven).
276
+ This fork also supports Mongoid 9 with RSpec as described above.
277
+ Per-worker provisioning for
278
+ **Postgres** and other adapters is not supported: on those, `--daemon` scores
279
+ every mutant as `error`. Use `--daemon` with a SQLite test database, or drop
280
+ `--daemon` to run serially on other adapters.
281
+
282
+ ### Apps on Ruby < 3.4
283
+
284
+ Mutineer's own process needs Ruby ≥ 3.4 (it parses with stdlib Prism), and the
285
+ `--rails` path above boots your app *inside Mutineer's process* — so it can't run
286
+ against an app pinned to an older Ruby (`ruby "3.1.6"` in the Gemfile), where the
287
+ bundle rejects 3.4.
288
+
289
+ `--test-command` decouples the two: Mutineer stays on ≥ 3.4, but your suite runs
290
+ as a **subprocess in your app's own runtime** (whatever Ruby its bundle resolves
291
+ to). Run Mutineer with a 3.4+ Ruby and hand it the command that runs your tests:
292
+
293
+ ```sh
294
+ RAILS_ENV=test mutineer run app/models/order.rb \
295
+ --test test/models/order_test.rb \
296
+ --test-command "bundle exec rails test %{files}"
297
+ ```
298
+
299
+ - **`%{files}`** is required; it expands to the `--test` paths as separate
300
+ arguments (a path with a space stays one argument — there is no shell).
301
+ - **Environment:** vars like `RAILS_ENV` / `DATABASE_URL` set on the Mutineer
302
+ command are inherited. Mutineer **unsets** `BUNDLE_*`, `GEM_*`, `RUBY*`,
303
+ `RBENV_VERSION`, `ASDF_RUBY_VERSION`, and `RBENV_DIR` in the child (so
304
+ Mutineer's own Ruby cannot pin the suite), and drops version-manager
305
+ **version bins** (e.g. `~/.rbenv/versions/3.4.x/bin`) from `PATH`, then
306
+ prepends rbenv/asdf shims when a pin was scrubbed. Do not rely on
307
+ `RBENV_VERSION=…` on the Mutineer command for the suite; use `.ruby-version`
308
+ or a wrapper. Don't put `KEY=val` prefixes *inside* `--test-command` (no shell;
309
+ that would be treated as the program name).
310
+
311
+ #### Under a version manager (rbenv / asdf / chruby)
312
+
313
+ Automatic scrub targets **rbenv** and **asdf** (shims + version bins). **chruby**
314
+ has no shims: Mutineer still strips `…/rubies/…/bin` so it cannot leave Mutineer's
315
+ Ruby pinned, but you need a wrapper that sources chruby and selects the app
316
+ version. If the smoke check still reports a **Ruby version mismatch**, wrap the
317
+ suite. Example rbenv wrapper (`bin/mutineer-test` in the app):
318
+
319
+ ```sh
320
+ #!/usr/bin/env bash
321
+ set -euo pipefail
322
+ cd "$(dirname "$0")/.."
323
+ unset GEM_HOME GEM_PATH RUBYLIB RUBYOPT BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION
324
+ export RBENV_VERSION="$(cat .ruby-version 2>/dev/null || true)"
325
+ export RAILS_ENV="${RAILS_ENV:-test}"
326
+ export PATH="${HOME}/.rbenv/shims:${PATH}"
327
+ exec bundle exec rails test "$@"
328
+ ```
329
+
330
+ ```sh
331
+ # Mutineer on 3.4+; suite on the app's Ruby via the wrapper
332
+ mutineer run app/models/order.rb \
333
+ --test test/models/order_test.rb \
334
+ --test-command "bin/mutineer-test %{files}"
335
+ ```
336
+
337
+ Mutineer also surfaces a targeted smoke-check message when Bundler prints
338
+ `RubyVersionMismatch`, instead of only blaming DB/migrations.
339
+
340
+ Tradeoffs — this path is correct but not free:
341
+
342
+ - **Slower:** your app re-boots for every mutant (no shared boot yet).
343
+ - **No coverage narrowing:** every mutant runs the *full* `--test` set, so the
344
+ score is an **upper bound and not comparable to an in-process (`--rails`)
345
+ score** — uncovered mutants count as survivors, and an infrastructure failure
346
+ is scored as a kill. Mutineer prints this caveat on every run and aborts up
347
+ front (a "smoke check") if your unmutated suite isn't green.
348
+ - **Reload strategy only** (`--strategy redefine` is rejected on this path) and
349
+ **serial** (`--jobs` is forced to 1). For apps on Ruby ≥ 3.4, `--daemon` gives
350
+ safe parallelism instead (see [the daemon backend](https://github.com/davidteren/mutineer#faster-parallel-safe-rails-the---daemon-backend)).
351
+
352
+ ## Timeouts and the score
353
+
354
+ The mutation score is `killed / (killed + survived)`. A mutant whose tests run
355
+ past `--timeout` is a `timeout`. It is neither killed nor survived, so it is
356
+ left out of the score, like `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `errored`, skipped
357
+ and ignored mutants. A timeout is not counted as a kill, because a hang the
358
+ mutant caused and a suite that is just slow look the same. The human report
359
+ shows the count in its `Timeout:` row, and the JSON report in `summary.timeout`.
360
+
361
+ Under `--threshold`, a timeout is a mutant with no verdict. The run exits 1 when
362
+ more than one mutant produced no verdict and they exceed 10% of those attempted,
363
+ or when nothing could be scored and something broke. If a slow suite times out
364
+ mutants that its tests would catch, raise `--timeout`. A limit set below what
365
+ the suite needs does not raise the score: it turns those mutants into timeouts,
366
+ and once they pass 10% of the attempted mutants the run fails.
367
+
368
+ ## Suppressing equivalent mutants
369
+
370
+ Some mutants are equivalent (behaviour-identical) and survive forever — keeping a
371
+ file off 100%. Suppress them so the score and `--threshold` gate stay meaningful:
372
+
373
+ - **Inline:** `some_line # mutineer:disable-line` (or scope it: `# mutineer:disable-line comparison`). Put a reason after `--`: `# mutineer:disable-line comparison -- the test checks only 20`.
374
+ - **Config:** a `.mutineer.yml` `ignore:` list of mutant ids. Each survivor's
375
+ `id` is printed in the JSON report, so copy it straight into `ignore:`.
376
+
377
+ Suppressed mutants are excluded from the score (so 100% becomes reachable).
378
+
379
+ ## Mutant ids
380
+
381
+ A mutant id is 12 hex characters. It hashes the file path (relative to the
382
+ project root), the method's qualified name, the operator, the mutated code, and
383
+ the mutant's position among identical mutants in that method. When one file has
384
+ two methods with the same qualified name (for example two top-level `def index`
385
+ in two DSL blocks), the second and later ones also hash their position among
386
+ those methods, so their ids differ. The first one's id does not change. An edit
387
+ outside the method does not change the id. Moving or renaming the file,
388
+ renaming the method or its class, or adding an identical mutant earlier in the
389
+ method does. Adding a method with the same name earlier in the same file also
390
+ does.
391
+
392
+ - The project root is the directory mutineer runs from (in the Action, the
393
+ `working-directory`). Run from the same root to get the same ids.
394
+ - A source outside the project root uses its absolute path, so its ids differ
395
+ between machines.
396
+
397
+ **Migrating from ids without the file path.** Before 1.3, ids did not include
398
+ the file path, so two files could share an id (#126). Old-format ids keep
399
+ working in this fork, with a migration warning:
400
+
401
+ - **`ignore:`** An old entry still suppresses its mutants. The run prints the
402
+ new ids for each old entry, each with its file and method. When it names one
403
+ mutant, replace the entry with that id. When it names several (in different
404
+ files, or same-named methods in one file), the old entry over-matched: it also
405
+ hid mutants you did not mean to ignore. The warning says so. Keep only the ids for the mutant you meant to
406
+ ignore, not all of them. The list covers only the sources and operators in
407
+ that run, so run over every source with every operator set you use (for
408
+ example your Tier-2 `--operators`) for the full list.
409
+ - **`--baseline`** An old baseline still matches: a survivor matches a stored
410
+ one with the same old id in the same file. A stored file that is an absolute
411
+ path outside the project root (a baseline written on another machine)
412
+ matches on the old id alone. The run tells you to
413
+ regenerate it. Regenerate it with `--format json`, but only after every gate
414
+ that reads it runs 1.3 or later (the Action's `version:` pin, your CI
415
+ `Gemfile.lock`). An older version treats every new-format survivor as new.
416
+
417
+ The JSON report's `summary.id_format` is `2` for the new format.
418
+ `summary.legacy_id_matches.ignore` counts the old-format ignore entries a run
419
+ matched, and `summary.legacy_id_matches.baseline` counts the survivors matched
420
+ only through an old baseline id.
421
+
422
+ Ids are relative to the directory you run mutineer from. mutineer finds
423
+ `.mutineer.yml` by walking up. When the file it loads is in a parent directory
424
+ (other than your home directory), it warns that the ignore ids will not match
425
+ and tells you which directory to run from.
426
+
427
+ ## Kill matrix
428
+
429
+ `--matrix` reports which tests kill each mutant, and from that, which tests
430
+ add nothing to the suite.
431
+
432
+ ```sh
433
+ mutineer run lib/calculator.rb --test test/calculator_test.rb --matrix
434
+ ```
435
+
436
+ For each mutant, Mutineer runs every test in its covering files instead of
437
+ stopping at the first failure, and records which tests failed. Coverage is
438
+ recorded per test file, so that set also holds tests that never reach the
439
+ mutated line; they pass and count as having run. A mutant's row is complete
440
+ when its whole covering set ran and the suite returned normally (under
441
+ Minitest, an `Interrupt` that cut the run short does not count as returning
442
+ normally). The report
443
+ names two kinds of test:
444
+
445
+ - **Blind:** the test ran in at least one complete row and killed no mutant
446
+ in any row. It would still pass with this run's mutations of the code, so
447
+ it guards none of them. It can still check behavior outside this run's
448
+ mutants (code in other files, or edits no operator makes), so look at what
449
+ it asserts before you delete it, or give it an assertion that can fail.
450
+ - **Redundant:** every mutant the test kills, another test kills too. That
451
+ makes it a candidate for deletion, one test at a time: two redundant tests
452
+ can be the only killers of one mutant, so re-run after each deletion.
453
+
454
+ The answers cover this run's mutants only. A test of code outside the sources
455
+ you passed kills nothing here, so mutate the code a test exercises before you
456
+ call the test blind.
457
+
458
+ The matrix changes no verdict, score or exit code: each mutant gets the
459
+ verdict a run without `--matrix` gives. That run skips every later test,
460
+ test class and example group after the first failure, so when a later one
461
+ exits the process, crashes or runs into the time limit, the mutant is still
462
+ `killed`. The run without `--matrix` still runs the code around the failing
463
+ test (the rest of its Minitest class `run` wrapper, the `after(:all)` hooks of
464
+ its RSpec groups) and the suite's own cleanup (RSpec's `after(:suite)`), so an
465
+ end there keeps the exit status. A failure in a Minitest `parallelize_me!`
466
+ class also keeps it: those run after every serial class and cannot be stopped
467
+ once queued, while a failure in a serial class still stops the run.
468
+
469
+ Under RSpec, `--matrix` needs RSpec 3.3 or later, whose example ids tell
470
+ apart examples on one line. With an older RSpec every row is incomplete.
471
+
472
+ The human report lists up to 20 blind and 20 redundant tests, and the HTML
473
+ report lists them all. `--format json` adds a `matrix` block with every test
474
+ and each mutant's killers (see the
475
+ [JSON schema](https://davidteren.github.io/mutineer/json-schema.html#matrix)).
476
+ A test is its file and its id. The name is `CalculatorTest#test_add` under
477
+ Minitest, or the example's full description under RSpec, where the example id
478
+ (`./spec/calc_spec.rb[1:2]`) tells apart examples that share a description
479
+ and keeps an example whose generated description changes with the mutant as
480
+ one test.
481
+
482
+ Each mutant runs its whole covering set, so every mutant costs what a survivor
483
+ costs. In 1.4, stopping at the first failure cut a full run of rack's
484
+ `lib/rack/utils.rb` from 86 to 89 seconds down to 35 to 41, so expect a matrix
485
+ run to take 2.1 to 2.5 times as long as a normal one. A mutant that reaches the
486
+ per-mutant time limit (`--timeout`, 10 seconds by default) after a test already failed stays `killed`
487
+ with its row marked incomplete. The report names each incomplete row and warns
488
+ that a blind test may have killed one of those mutants, so do not delete a
489
+ blind test until its rows are complete. Raise `--timeout` (or `timeout:` in
490
+ `.mutineer.yml`) to get complete rows.
491
+
492
+ `--matrix` runs on the in-process backend only. It exits 2 with `--daemon`,
493
+ `--test-command`, `--fail-fast` or `--dry-run`, and the message says whether
494
+ each setting came from the command line or `.mutineer.yml`. To run one of
495
+ those with `matrix: true` in the file, pass `--no-matrix`; to run `--matrix`
496
+ with `fail_fast: true` in the file, pass `--no-fail-fast`.
497
+
498
+ ## CI gating
499
+
500
+ Store a JSON run as a baseline, then fail the build only when a PR makes things
501
+ worse:
502
+
503
+ ```sh
504
+ mutineer run app/ --baseline .mutineer/baseline.json # exit 1 on NEW survivors or a score drop
505
+ ```
506
+
507
+ `--baseline` reports which survivors are new (by [mutant id](https://github.com/davidteren/mutineer#mutant-ids)) and any score drop. It
508
+ combines with `--threshold` (the worse of the two sets the exit code). Pass a
509
+ directory (or several sources) to audit a whole layer in one boot — tests are
510
+ auto-paired by convention and the report breaks down per source. A source
511
+ `app/foo/bar.rb` pairs with `test/foo/bar_test.rb` and with unclaimed
512
+ `test/foo/bar_*_test.rb` files in that directory, such as `bar_upsert_test.rb`.
513
+ It does not take `user_session_test.rb` when `user_session.rb` exists in the same directory.
514
+ It does not take `bar_upsert_guards_test.rb` when `bar_upsert.rb` exists.
515
+ A spec that already pairs is left as that one file.
516
+
517
+ ### GitHub Action
518
+
519
+ This repo ships a composite action (`action.yml`) that wraps the CLI for CI:
520
+
521
+ ```yaml
522
+ - uses: actions/checkout@v4
523
+ - uses: ruby/setup-ruby@v1
524
+ with: { ruby-version: "3.4", bundler-cache: true }
525
+ - uses: justinallenmarsh/mutineer-mongoid@v2
526
+ with:
527
+ sources: app/
528
+ baseline: .mutineer/baseline.json
529
+ threshold: "90"
530
+ ```
531
+
532
+ **Default change:** on `pull_request` events (not `pull_request_target`) the
533
+ action scopes the run to the PR's changed lines, diffing against the PR's exact
534
+ base commit (fetched by the action itself when the checkout is shallow; falls
535
+ back to the base branch tip). Pass `since: none` for a full scan, or an
536
+ explicit `since:` ref (which needs `fetch-depth: 0` on checkout).
537
+
538
+ With the default JSON format the action also:
539
+
540
+ - writes a score summary to the job's step summary;
541
+ - annotates surviving mutants on the PR diff, up to 50 (`error` level when the
542
+ gate failed, `warning` when it passed);
543
+ - exposes the report path via the `report` output for later steps (with
544
+ `format: human`/`html` this needs the `output` input).
545
+
546
+ The CLI prints a progress line to the log at every 10% of the run, whatever
547
+ the format.
548
+
549
+ ## For AI agents & pipelines
550
+
551
+ Mutineer is built for programmatic use — versioned JSON, [mutant ids](https://github.com/davidteren/mutineer#mutant-ids) that survive unrelated edits,
552
+ structured exit codes, and diff-scoped runs. See:
553
+
554
+ - **AI agents & CI recipes** — the agent inner-loop and CI-gate recipes (and how
555
+ to avoid infinite loops on equivalent mutants):
556
+ [rendered](https://davidteren.github.io/mutineer/agentic-coding.html) ·
557
+ [source](https://davidteren.github.io/mutineer/agentic-coding.md)
558
+ - **JSON schema reference** — the `--format json` schema and its versioning
559
+ contract:
560
+ [rendered](https://davidteren.github.io/mutineer/json-schema.html) ·
561
+ [source](https://davidteren.github.io/mutineer/json-schema.md)
562
+ - **Ruby API (YARD)** — class reference for the shipped gem:
563
+ [https://davidteren.github.io/mutineer/api/](https://davidteren.github.io/mutineer/api/)
564
+ - **Agent skill** ([`skills/mutineer/SKILL.md`](https://github.com/justinallenmarsh/mutineer-mongoid/blob/main/skills/mutineer/SKILL.md)): a short
565
+ card for coding agents with install, the agent loop, and exit codes. Install it
566
+ with `gh skill install justinallenmarsh/mutineer-mongoid mutineer` or
567
+ `npx skills add justinallenmarsh/mutineer-mongoid --skill mutineer`.
568
+
569
+ ## Configuration
570
+
571
+ Mutineer reads an optional `.mutineer.yml` from the project root (nearest one,
572
+ walking up). CLI flags override config; config overrides defaults.
573
+
574
+ Sources are positional CLI arguments and test files come from `--test`. The
575
+ config file accepts these keys:
576
+
577
+ | Key | Value and purpose |
578
+ |-----|-------------------|
579
+ | `operators` | An operator name or list of names; defaults to the Tier-1 set |
580
+ | `threshold` | A number from 0 to 100; 0 turns the score gate off |
581
+ | `jobs` | A positive integer; the default is the processor count. `test_command`, `fail_fast`, or `--rails` without `--daemon` forces 1. |
582
+ | `only` | A fully-qualified subject name, such as `Calculator#add` |
583
+ | `require` | A path or list of extra files to load before mutating |
584
+ | `boot` | The app entry point to require once before forking |
585
+ | `rails` | `true` or `false`; enables the Rails boot defaults |
586
+ | `since` | A nonblank git ref, or `false` to disable diff scoping |
587
+ | `framework` | `minitest` or `rspec`; an explicit value is kept during test pairing |
588
+ | `verbose` | `true` or `false`; shows capture diagnostics |
589
+ | `ignore` | A mutant id or list of ids to suppress |
590
+ | `baseline` | The path to a prior JSON report |
591
+ | `fail_fast` | `true` or `false`; stops scheduling after the first survivor |
592
+ | `test_command` | The external-runtime suite command, including `%{files}`; see [Apps on Ruby < 3.4](https://github.com/davidteren/mutineer#apps-on-ruby--34) |
593
+ | `daemon` | `true` or `false`; uses the persistent app daemon with worker DB isolation |
594
+ | `timeout` | A positive integer; the per-mutant time limit in seconds (default 10) |
595
+ | `capture_timeout` | A positive integer; the coverage-capture time limit in seconds (default 120) |
596
+ | `cache_dir` | The coverage cache directory (default `.mutineer`) |
597
+ | `matrix` | `true` or `false`; runs every covering test for each mutant and adds the kill matrix to the report |
598
+
599
+ Invalid values for known scalar keys, and a blank `operators` list, exit 2
600
+ with a message naming the file and key. An unknown operator name warns and
601
+ is skipped. If none of the names are known, the run exits 2. An empty
602
+ `require` or `ignore` list is valid. Boolean keys take `true` or `false` (quoted forms also work), not `"yes"`.
603
+ `jobs` must be positive; a string value contains digits only. String values for
604
+ `threshold` and the CLI-only `--baseline-epsilon` use plain decimals such as `90`
605
+ or `0.5`, not `+2`, `1e2`, or `1_0`. String options such as `only` and `baseline`
606
+ cannot be null or boolean. A blank `since` is invalid; use `since: false` to turn
607
+ 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.
608
+
609
+ `format`, `strategy`, `output`, `baseline_epsilon`, and `dry_run` are CLI-only.
610
+ For JSON output, use `--format json`, not a `format:` config key. To select RSpec
611
+ in the file, add `framework: rspec`.
612
+
613
+ ```yaml
614
+ # .mutineer.yml
615
+ operators: [arithmetic, comparison, boolean_connector, boolean_literal, statement_removal]
616
+ threshold: 90
617
+ jobs: 4
618
+ require:
619
+ - config/environment
620
+ ```
621
+
622
+ Coverage results are cached in `.mutineer/coverage.json` (digest-keyed; rebuilt
623
+ automatically when sources change). Add `.mutineer/` to your `.gitignore`, and
624
+ the directory you set with `--cache-dir` if it is inside the project. Keep
625
+ `.mutineer/` ignored even then: `--test-command` writes lock files to
626
+ `.mutineer/` beside each source file.
627
+
628
+ ## Mutineer and Mutant
629
+
630
+ [Mutant](https://github.com/mbj/mutant), by Markus Schirp, is the established
631
+ mutation-testing tool for Ruby. It has been in development since 2012 and is
632
+ the subject of published research. Mutineer is a separate tool, and it shares
633
+ no code with Mutant.
634
+
635
+ | Aspect | Mutineer | Mutant |
636
+ |---|---|---|
637
+ | License | MIT, for every use | Free for open source (`--usage opensource`); commercial use needs a paid subscription |
638
+ | Runtime dependencies | None (Prism + stdlib) | `parser`, `unparser`, `regexp_parser`, `sorbet-runtime` and others |
639
+ | 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 |
640
+ | Test frameworks | Minitest and RSpec, in one gem | RSpec, Minitest and Test::Unit, one integration gem each |
641
+ | 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*'`) |
642
+ | 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 |
643
+ | Operators | 20 (5 by default, 15 opt-in) | A larger set; the default `light` set applies almost all of it (`full` adds `#==` to `#eql?`) |
644
+ | CI gating | `--threshold`, `--baseline` deltas, `--since`, and a GitHub Action | Incremental mode (`--since`) and a recorded session history |
645
+ | Machine-readable output | Versioned JSON report schema | Session JSON schema |
646
+
647
+ Choose Mutineer when you want an MIT-licensed tool with no extra gems in your
648
+ bundle, file-based runs, and a JSON contract built for CI gates and AI agents.
649
+ Choose Mutant when you need its deeper operator set, Test::Unit support, or
650
+ subject expressions that select whole namespaces. The two tools do not
651
+ conflict, so you can run both on one project.
652
+
653
+ This comparison describes Mutant 0.17 (checked on 2026-10-06). Check
654
+ [Mutant's README](https://github.com/mbj/mutant#readme) for its current
655
+ license terms and features.
656
+
657
+ ## License
658
+
659
+ MIT — see [LICENSE](https://github.com/davidteren/mutineer/blob/main/LICENSE).
data/bin/mutineer ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/mutineer"
5
+
6
+ Mutineer::CLI.start(ARGV)