saboteur 0.2.0 β†’ 0.4.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +153 -1
  3. data/README.md +526 -73
  4. data/lib/generators/saboteur/install/install_generator.rb +21 -0
  5. data/lib/saboteur/agent_skill.rb +40 -0
  6. data/lib/saboteur/baseline_health.rb +116 -0
  7. data/lib/saboteur/cli.rb +305 -30
  8. data/lib/saboteur/config.rb +65 -19
  9. data/lib/saboteur/config_file.rb +97 -0
  10. data/lib/saboteur/custom_map.rb +170 -0
  11. data/lib/saboteur/doctor.rb +88 -0
  12. data/lib/saboteur/engine/baseline_guard.rb +116 -0
  13. data/lib/saboteur/engine/boot.rb +9 -0
  14. data/lib/saboteur/engine/candidate_selection.rb +35 -0
  15. data/lib/saboteur/engine/coverage_store.rb +40 -6
  16. data/lib/saboteur/engine/data_inputs.rb +43 -0
  17. data/lib/saboteur/engine/diagnostics.rb +28 -0
  18. data/lib/saboteur/engine/diff_scope.rb +8 -0
  19. data/lib/saboteur/engine/distribution.rb +4 -2
  20. data/lib/saboteur/engine/run_fingerprint.rb +344 -0
  21. data/lib/saboteur/engine/scheduler.rb +7 -16
  22. data/lib/saboteur/engine/suite_files.rb +8 -1
  23. data/lib/saboteur/engine/verdict_check.rb +4 -2
  24. data/lib/saboteur/engine.rb +125 -25
  25. data/lib/saboteur/execution/fork_readiness.rb +46 -0
  26. data/lib/saboteur/execution/reevaluator.rb +7 -4
  27. data/lib/saboteur/execution/supervisor.rb +23 -11
  28. data/lib/saboteur/frameworks/rails.rb +329 -13
  29. data/lib/saboteur/help.rb +210 -0
  30. data/lib/saboteur/ignore_list.rb +71 -17
  31. data/lib/saboteur/install.rb +132 -0
  32. data/lib/saboteur/integration/base.rb +8 -0
  33. data/lib/saboteur/integration/rspec.rb +69 -4
  34. data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
  35. data/lib/saboteur/mutation_source/builtin.rb +14 -3
  36. data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
  37. data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
  38. data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
  39. data/lib/saboteur/replay.rb +33 -0
  40. data/lib/saboteur/reporter/agent.rb +87 -0
  41. data/lib/saboteur/reporter/agent_clipping.rb +29 -0
  42. data/lib/saboteur/reporter/agent_commands.rb +61 -0
  43. data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
  44. data/lib/saboteur/reporter/agent_summary.rb +131 -0
  45. data/lib/saboteur/reporter/findings.rb +131 -0
  46. data/lib/saboteur/reporter/html.rb +213 -0
  47. data/lib/saboteur/reporter/replay.rb +23 -0
  48. data/lib/saboteur/reporter/stryker.rb +21 -4
  49. data/lib/saboteur/reporter/terminal.rb +76 -2
  50. data/lib/saboteur/result.rb +61 -9
  51. data/lib/saboteur/score.rb +89 -0
  52. data/lib/saboteur/version.rb +1 -1
  53. data/lib/saboteur/working_tree.rb +48 -0
  54. data/lib/saboteur.rb +7 -0
  55. metadata +30 -2
data/README.md CHANGED
@@ -1,49 +1,181 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="saboteur logo: a ninja beetle with a ruby shell carrying a lit bomb" width="200">
3
+ </p>
4
+
1
5
  # saboteur
2
6
 
3
- Mutation testing for Ruby 3.3+.
7
+ **Mutation testing for Ruby and RSpec, built for pull requests.** 🧨
4
8
 
5
- saboteur makes small deliberate changes to your methods (mutants), runs only
6
- the tests that execute each change, and reports the changes no test notices
7
- (survivors). Each survivor points at a missing test or at code that does not
8
- matter.
9
+ [![CI](https://github.com/vamsipavanmahesh/saboteur/actions/workflows/ci.yml/badge.svg)](https://github.com/vamsipavanmahesh/saboteur/actions/workflows/ci.yml)
10
+ [![Gem Version](https://img.shields.io/gem/v/saboteur)](https://rubygems.org/gems/saboteur)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
12
 
10
- Status: an early release (0.2.0). Tested on the RSpec suites of plain gems
11
- (dry-inflector, tty-color, hashie, addressable) and of two Rails 8.1 apps:
12
- human-essentials on Postgres (4,080 mutants, 6 workers) and lobsters on
13
- SQLite, both on macOS. Not yet tried on MySQL or older Rails versions. RSpec
14
- only, by design.
15
-
16
- ## What makes it different
17
-
18
- - **Tests picked per mutant by line coverage.** Each mutant runs only the
19
- tests that executed the changed statement. A change no test reaches is
20
- reported as "no coverage", not as a survivor.
21
- - **Likeliest killers first.** Tests that killed earlier mutants of the same
22
- method run first, then the fastest; the first failure stops the run.
23
- - **Coarse first.** Each method's body is first replaced with `nil` and with
24
- `raise`. If a test suite does not notice that, saboteur reports it and
25
- skips the fine-grained mutants for that method.
26
- - **Diff mode.** `--since origin/main` mutates only methods changed on a branch.
27
- - **Safe to run.** Every mutant runs in a supervised child; anything it
28
- starts is tracked and killed, with deadline, memory and process caps.
29
- - **Open report format.** JSON in the Stryker mutation-testing-report schema.
30
- - **Pluggable mutation sources.** Add your own mutants by file and byte range.
31
-
32
- ## Usage
13
+ Your suite is green and coverage says 95%. That tells you the tests *ran* your
14
+ code. It does not tell you they would *notice* if the code were wrong.
15
+
16
+ saboteur finds out. It makes small deliberate changes to your methods (flips a
17
+ `<` to `<=`, swaps `+` for `-`, replaces a body with `nil`), runs the tests that
18
+ cover each change, and reports every change your suite let through. Each of
19
+ those survivors can point at a missing test. It can also be an equivalent
20
+ change that no test could notice, a flaky test, or a limit of the tool, so
21
+ each one needs a human look.
22
+
23
+ ```
24
+ $ bundle exec saboteur run --since origin/main
25
+ ```
26
+
27
+ Never heard of mutation testing? It asks "if I broke this, would a test
28
+ fail?" for many small breakages and lists the ones nobody noticed. Read the
29
+ [design notes](docs/design.md) when you want the details. For adopting it on a team, see the
30
+ [best practices guide](docs/best-practices.md).
31
+
32
+ ### The unchanged suite is checked first
33
+
34
+ Every run with selected subjects runs the loaded unchanged suite twice (the specs chosen with `--test`, if given) in fresh
35
+ supervised children before coverage lookup or incremental verdict reuse. This
36
+ costs two suite runs even on a cache hit, plus a coverage-building suite run on
37
+ a cache miss. Both baseline passes must be green, complete and in the same
38
+ RSpec order at the run seed. A failure exits 3, stops mutation and names the log and fix. Long phases
39
+ print their elapsed time about every 30 seconds, and each phase's log holds
40
+ the suite's progress and full failure detail. Each suite pass
41
+ uses `coverage_timeout` and configured memory/process limits (the suite memory
42
+ budget is four times `max_memory_mb`, as for coverage). Older benchmark numbers
43
+ need a rerun before they describe this extra fixed cost.
44
+ A whole-suite coverage build that records no lines for any included file
45
+ means the setup hid the app from Coverage (Bootsnap's compile cache is the
46
+ common cause), so the run fails with exit 3 instead of testing nothing, and
47
+ the empty map is not cached. With `--test` an empty map instead stays a
48
+ legitimate "no coverage" outcome, because the chosen specs may not touch the
49
+ selected sources.
50
+ Two passes detect observed instability, not every possible flaky test. See
51
+ [baseline stability](docs/baseline-stability.md) for limits and hook behaviour.
52
+
53
+
54
+ ## πŸš€ Try it in a minute
55
+
56
+ Add saboteur to the test group of your project's Gemfile (Ruby 3.3+, RSpec):
57
+
58
+ ```ruby
59
+ gem "saboteur", group: :test
60
+ ```
61
+
62
+ ```sh
63
+ bundle install
64
+ bundle exec saboteur run --subject 'YourClass#your_method'
65
+ ```
66
+
67
+ Outside a Bundler project, `gem install saboteur` installs the same executable.
68
+ Rails app? Follow the [Rails quickstart](#rails-apps) below: the defaults are for
69
+ plain gems (`lib/**/*.rb`, `rails: false`, `jobs: 1`) and are not Rails-aware.
70
+
71
+ Start with one method. You get counts per status, a score, timing, and a
72
+ unified diff for every survivor, so you can read the exact change your tests
73
+ missed. The JSON report lands in `.saboteur/report.json`.
74
+
75
+ πŸ“‹ Requirements: Ruby 3.3 or newer on Linux or macOS (it needs `fork`), and RSpec.
76
+
77
+ ## 🧠 Why it is built the way it is
78
+
79
+ **It only runs tests that can matter.** Each mutant runs only the tests whose
80
+ line coverage touched the changed statement. Tests that killed earlier mutants
81
+ of the same method go first, then the fastest, and the first failure stops the
82
+ run. A change no test reaches is reported as "no coverage", not as a survivor.
83
+
84
+ **It runs coarse mutants first, then fine mutants.** Each method first gets
85
+ whole-body mutants: `nil` (`true`/`false` for predicates) unless the body is empty or
86
+ only logs, and `raise` unless a catch-all rescue would absorb it. Fine-grained mutants run
87
+ regardless of the coarse verdict, including a survivor or a reused verdict.
88
+ Deleting a body unnoticed does not prove its smaller changes are harmless.
89
+ Subjects with no tests are skipped before any mutants are generated, and each
90
+ method is capped at `max_mutants_per_method` mutants (100 by default). Running
91
+ fine mutants after coarse survivors can increase run time and change scores.
92
+
93
+ **It prepares memory before it forks.** Before workers fork, saboteur warms and
94
+ compacts the Ruby heap ([`ForkReadiness`](lib/saboteur/execution/fork_readiness.rb)),
95
+ which is meant to let workers share more pages with their parent and copy less.
96
+ Observed on the pinned hashie workload (paired runs, below): about 17-18% lower peak PSS at 2 and 6 workers. Not a general guarantee.
97
+
98
+ **It is aimed at pull requests.** `--since origin/main` mutates only the methods
99
+ your branch changed: fine-grained mutants on the changed lines, plus the
100
+ whole-body mutants of those methods. On one real change in saboteur's own history, that took a file
101
+ from 139 mutants to 7, and median wall time fell 22.9% (measured at d2b7abb, before the coarse-survivor gate was removed; a rerun is pending). The time saving is
102
+ smaller than the mutant count because building the coverage map is a fixed
103
+ cost, and this is one file, not a Rails app, so it is not a general speed
104
+ claim. The full method, raw timings and
105
+ caveats are in [bench/since](bench/since/README.md).
106
+
107
+ **It checks its own kills.** After a method's mutants run, saboteur re-runs the
108
+ killing tests against the *unchanged* method. If they fail there too, the kill
109
+ is not credited and becomes an error. In one comparison on lobsters, 0 of 20
110
+ randomly sampled kills were fake. Every mutant runs in a supervised child with deadline,
111
+ memory and process limits, and saboteur tracks and kills the processes it
112
+ starts.
113
+
114
+ **It is open.** The report is JSON in the Stryker mutation-testing-report
115
+ schema, and you can add your own mutants through a small public interface.
116
+
117
+ ## πŸ› οΈ Usage
33
118
 
34
119
  ```sh
35
120
  bundle exec saboteur run # whole project
121
+ bundle exec saboteur doctor --seed 42 # environment + two unchanged suite checks
36
122
  bundle exec saboteur run --since origin/main # methods changed on this branch
37
123
  bundle exec saboteur run --subject 'Billing::Invoice#total'
38
124
  bundle exec saboteur run --subject Billing # a namespace
39
125
  bundle exec saboteur run --incremental # reuse verdicts whose inputs did not change
40
126
  bundle exec saboteur run --seed 1234 # repeat a run that used seed 1234
127
+ bundle exec saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb
128
+ bundle exec saboteur run --threshold 90 # exit 1 when the score is below 90 %
129
+ bundle exec saboteur run --id DIGEST --seed 1234 # replay one full current mutant digest
41
130
  ```
42
131
 
43
- Every test run starts from the same random seed (1 by default), so tests
44
- that use `rand` see the same values in each mutant's run and in the check
45
- that confirms a kill. The report prints the seed; pass it to `--seed` to
46
- repeat a run.
132
+ Positional operands name the sources to mutate: Ruby files, or directories
133
+ (all their `.rb` files). Each must exist; a typo or a non-Ruby file exits 2
134
+ before anything runs. They replace `include` and `exclude`. `--test`
135
+ (repeatable; spec files or directories, with an optional `:LINE` or `[1:2]`
136
+ selector; needs at least one source, unless `--since` is given - the changed
137
+ files are the sources) replaces only the files RSpec runs. Other
138
+ `rspec_args` and `.rspec` options such as `--require` stay. The coverage map
139
+ is built from only those specs, each mutant runs only the ones that cover it,
140
+ and the selection is part of the coverage and `--incremental` cache keys.
141
+
142
+ `--threshold PERCENT` (0 to 100, or `c.threshold` in `config/saboteur.rb`) is for CI.
143
+ The run exits 1 when the score is below PERCENT and 0 when it is at or above,
144
+ even with survivors. It fails closed: a run that tested no mutant has no score
145
+ and exits 1, and a run where any mutant errored or hit a resource limit exits 3,
146
+ because the score covers only part of the run. Without a threshold, any
147
+ unignored survivor exits 1. Either way, a run where more than half of the
148
+ executed mutants errored exits 3: errored mutants are outside the score,
149
+ so such a run tested almost nothing.
150
+
151
+ Other flags: `--jobs N` (concurrent workers), `--timeout-factor N`,
152
+ `--config PATH`, `--integration NAME`, `--reeval class_eval|nesting`,
153
+ `--json PATH`, `--no-cache` (rebuild the coverage map).
154
+
155
+ For pull requests, start with `--since` against your target branch. This is a
156
+ workflow recommendation, not a CLI default: plain `run` still checks the whole
157
+ configured project. To scope the specs as well, pass them with `--test` and no
158
+ source operands: `bundle exec saboteur run --since origin/main --test spec/billing_spec.rb`.
159
+ With `--since` and no `--test`, the run warns before it starts that the two baseline passes
160
+ run the loaded suite (a cold coverage cache adds one more pass), and how to
161
+ scope them. Keep whole-project runs on main: diff mode does not check every method affected by a
162
+ dependency change. [The pull-request guide](docs/pull-requests.md) covers
163
+ checkout history, a GitHub Actions example, configuration and limits.
164
+
165
+ `saboteur doctor` prints Ruby, coverage, integration, Rails/SimpleCov and boot-file
166
+ checks, then runs the unchanged suite twice with the chosen seed. It writes no
167
+ mutation reports or coverage/verdict caches. Target boot files and tests still
168
+ run, so their normal side effects remain. See the [doctor guide](docs/guides/doctor.md).
169
+
170
+ `--id` runs exactly one current mutant with its coverage-selected tests, bypassing
171
+ per-method caps, ignore suppression and incremental verdict reuse.
172
+ It still honors `--subject` and `--since` changed-line selection. See the
173
+ [replay guide](docs/guides/replay.md) for digest format, checks and limits.
174
+
175
+ Every test run starts from the same random seed (1 by default), so tests that
176
+ use `rand` see the same values in each mutant's run and in the check that
177
+ confirms a kill. The report prints the seed; pass it to `--seed` to repeat a
178
+ run.
47
179
 
48
180
  `--incremental` reuses an earlier verdict only while the method, the mutant,
49
181
  its covering tests and their files, the lockfile and the config are all
@@ -51,37 +183,230 @@ unchanged. It cannot see a change in a method this one calls, so run without
51
183
  it now and then (and in CI on the main branch).
52
184
 
53
185
  Output: counts per status, the score, timing, and a unified diff for every
54
- survivor. The JSON report goes to `.saboteur/report.json`.
55
-
56
- Exit codes: 0 no survivors (or all on the ignore list), 1 survivors,
57
- 2 bad configuration, 3 the tool failed.
58
-
59
- ## Configuration
60
-
61
- `.saboteur.yml` in the project root; CLI flags override it.
62
-
63
- ```yaml
64
- include: [lib/**/*.rb]
65
- exclude: [lib/my_gem/version.rb]
66
- require: [] # boot files, e.g. ./config/environment
67
- reeval: nesting # or class_eval; see docs/design.md 6.1
68
- timeout_factor: 3 # per-mutant deadline = factor x its tests' recorded time
69
- timeout_min: 5
70
- max_memory_mb: 2048 # memory growth cap per mutant process tree
71
- max_processes: 64
72
- ignore_file: .saboteur-ignore.yml
73
- seed: 1 # Ruby's random seed in every test run
186
+ survivor. Under each diff the terminal lists the tests that covered that line
187
+ and still passed with the mutant applied (the first five, likeliest killers
188
+ first; the rest are under `coveredBy` in the JSON report). The missing
189
+ check may belong in one of them. Line coverage can overstate which tests
190
+ exercise a line, so treat the list as a lead. A survivor reused with
191
+ `--incremental` was not run again, and the list says its tests passed in an
192
+ earlier run. The HTML page shows the same list in the
193
+ survivor's notes. The JSON report goes to `.saboteur/report.json` and a self-contained HTML
194
+ report (one file, no scripts, no network) to `.saboteur/report.html`. Change
195
+ the paths with `--json` / `--html` or `c.json_report` / `c.html_report` in
196
+ `config/saboteur.rb`; set either to `nil` to turn that report off. The two paths
197
+ must differ. The HTML page shows the covered mutation score
198
+ `(killed + timeout) / (killed + survived + timeout)`, which leaves NoCoverage
199
+ out and keeps survivors accepted on the ignore list in the denominator. A tool
200
+ that reads the JSON report may show a different score, because the schema counts
201
+ NoCoverage as undetected.
202
+
203
+ The score is split by how the mutant was killed. A crash kill (a `NoMethodError`,
204
+ a `TypeError`, a parse error) shows the code ran and broke, not that a test
205
+ checked a value. The terminal prints
206
+ `score 100.0 % (assertion 50.0 %, crash 50.0 %)`: each share is a part of
207
+ tested mutants, so the parts add up to the score. Timeouts and kills with no
208
+ recorded cause (a verdict reused from an older run, a test that called `exit`,
209
+ a failure outside any example) are named when there are some. Whole-body
210
+ mutants (`body_nil`, `body_true`, `body_false`, `body_raise`) are killed by
211
+ almost any test that runs the method, so a second line scores the run without
212
+ them, and shows them alone. The headline score and `--threshold` still count every
213
+ mutant. The JSON report carries both splits under `saboteur.scores`
214
+ (`all` and `withoutWholeBodyMutants`), and the HTML page prints them under
215
+ the score. The JSON also records why the run exited the way it did under
216
+ `saboteur.exitCause` (`ok`, `survivors`, `belowThreshold`,
217
+ `errorMajority`, `incompleteUnderThreshold`). In `--agent` mode the cause
218
+ follows the agent verdict, so the report and the process exit classify the
219
+ run the same way: `ok` for clean, `survivors` for survivors, and otherwise
220
+ `errorMajority` when most mutants errored or `incomplete`.
221
+
222
+ For code-review surfaces, the JSON report also carries a `saboteur.findings`
223
+ section: a bounded, deterministically ordered list of survivors, coarse
224
+ mutants first (a surviving coarse mutant hints that no test pins the method's
225
+ behavior - it can also be a re-evaluation limit or an equivalent mutant),
226
+ then file, line and digest. Each finding holds the file, line, digest,
227
+ subject, operator, original and replacement a note on a pull-request diff
228
+ needs, plus one entry per edit for mutants that edit more than one spot.
229
+ Survivors accepted on the ignore list are listed separately with their
230
+ reason, and `survivorsTruncated` and `acceptedTruncated` count what the bound
231
+ left out; the full data stays in the schema's `files` section.
232
+
233
+ In diff mode (`--since`), a mutant is reported at the line where its statement
234
+ starts. A statement that spans several lines can start a line or two above the
235
+ line that changed. Under each survivor the terminal therefore prints
236
+ `changed line: path:N`, the changed line or lines (the first three) the mutant
237
+ touches, and each finding carries them as `changedLines`. Use `changedLines`,
238
+ not `line`, to put a note on a changed line of the pull-request diff. A
239
+ whole-body mutant of a method whose only change is its signature touches no
240
+ changed line and prints none.
241
+
242
+ Exit codes: 0 no survivors (or all on the ignore list), or the score meets
243
+ `--threshold`; 1 survivors, or the score is below `--threshold` or missing;
244
+ 2 bad configuration or a missing source or test; 3 the tool failed (with
245
+ `--threshold`, also any errored or resource-limited mutant).
246
+
247
+ ### Rejected kills
248
+
249
+ The terminal prints `kills rejected by unchanged-method check: N`; JSON adds
250
+ `rejectedKills`. This counts fresh per-mutant kill claims whose tests also fail
251
+ with the method unchanged. They stay errors outside the score. Timeout checks,
252
+ other errors and reused cached kills are not included. See
253
+ [rejected kills](docs/rejected-kills.md) for details.
254
+
255
+ ## βš™οΈ Configuration
256
+
257
+ `config/saboteur.rb` in the project root; CLI flags override it. Keep the shared
258
+ config usable for whole-project runs and pass `--since` in the pull-request
259
+ job. A PR-only config may set `c.since = "origin/main"`; omit `since` (or use
260
+ `c.since = nil`) in the config for audits on main. Use your actual target branch.
261
+
262
+ ```ruby
263
+ Saboteur.configure do |c|
264
+ c.include = ["lib/**/*.rb"]
265
+ c.exclude = ["lib/my_gem/version.rb"]
266
+ c.require = [] # boot files, e.g. ./config/environment
267
+ c.reeval = "nesting" # or "class_eval"; see docs/design.md 6.1
268
+ c.timeout_factor = 3 # per-mutant deadline = factor x its tests' recorded time
269
+ c.timeout_min = 5
270
+ c.max_memory_mb = 2048 # memory growth cap per mutant process tree
271
+ c.max_processes = 64
272
+ c.max_mutants_per_method = 100 # positive integer; raise it for a broader run
273
+ c.ignore_file = "config/saboteur_ignore.rb"
274
+ c.seed = 1 # Ruby's random seed in every test run
275
+ c.fingerprint_extra = [] # extra globs whose contents invalidate caches, e.g. ["data/*.csv"]
276
+ end
277
+ ```
278
+
279
+ ### Rails apps
280
+
281
+ Quickstart for a Rails + RSpec app. The defaults target a plain gem, so a Rails
282
+ app needs a few settings; the generator writes them for you.
283
+
284
+ **1. Add the gem**
285
+
286
+ ```sh
287
+ bundle add saboteur --group test
288
+ ```
289
+
290
+ **2. Generate the setup**
291
+
292
+ ```sh
293
+ bundle exec rails generate saboteur:install
294
+ ```
295
+
296
+ This writes `config/saboteur.rb`, a `bin/saboteur` binstub (it sets `RAILS_ENV=test`
297
+ for you) and a `.gitignore` entry for `.saboteur/`. It never overwrites an
298
+ existing file unless you pass `--force`, and never edits your Gemfile.
299
+
300
+ Prefer to do it by hand, or on a version without the generator? Put this in
301
+ `config/saboteur.rb`:
302
+
303
+ ```ruby
304
+ Saboteur.configure do |c|
305
+ c.include = ["app/**/*.rb"]
306
+ c.require = ["./config/environment"] # boots the app
307
+ c.rails = true # reconnects ActiveRecord after fork, per-worker databases
308
+ c.jobs = 4 # optional; the default is 1
309
+ end
74
310
  ```
75
311
 
312
+ **3. Check the setup**
313
+
314
+ ```sh
315
+ bin/saboteur doctor
316
+ ```
317
+
318
+ Doctor boots your app, checks the config and the test database host, and runs
319
+ your suite twice before any mutation. Fix whatever it reports first. Turn
320
+ SimpleCov off for the run if you use it.
321
+
322
+ **4. Test what your branch changed**
323
+
324
+ ```sh
325
+ bin/saboteur run --since main
326
+ ```
327
+
328
+ It mutates only the methods changed since `main` and reports each survivor with
329
+ its diff: a change your tests did not notice. Write a test that fails on that
330
+ diff, then run again. Use `--since origin/main` in CI. Without the generator, use
331
+ `bundle exec saboteur` and `RAILS_ENV=test` instead of `bin/saboteur`.
332
+
333
+ saboteur puts the app's `spec/` directory on the load path before
334
+ processing `require`, matching rspec, so a boot file like a dummy app's
335
+ `spec/rails_helper.rb` can require its siblings.
336
+
337
+ With `jobs` above 1 on SQLite, each extra worker gets a copy of the test
338
+ database made through SQLite's backup API, so the copy is a consistent
339
+ snapshot. A relative `database:` path resolves against `Rails.root`, the
340
+ same as Rails 7.1+ does, for the copy and for its cleanup. The copy
341
+ checkpoints the source first (`PRAGMA wal_checkpoint(TRUNCATE)`) and
342
+ refuses to run when a reader keeps the WAL busy, because copying on
343
+ would silently miss committed rows. A source file that does not exist is
344
+ an error, not a skipped copy. An in-memory SQLite database (`:memory:`) is
345
+ never copied, renamed or disconnected. After the app boots, each in-memory
346
+ database is snapshotted to a temporary file through SQLite's backup API,
347
+ and every forked child restores the snapshot into its own fresh database -
348
+ a child cannot keep the parent's connection (ActiveRecord discards
349
+ inherited pools and sqlite3 closes inherited writable handles), so without
350
+ the snapshot it would see an empty database.
351
+
352
+ For a Rails engine, point `require` at the dummy app's environment -
353
+ `test/dummy/config/environment.rb`, usually loaded for you by
354
+ `spec/rails_helper.rb`. Only a dynamically created dummy app has no
355
+ environment file; there, require the helper that calls setup instead,
356
+ such as `DummyApp.setup` in `spec/rails_helper.rb`.
357
+
358
+ Bootsnap needs no extra setting: bytecode from its compile cache carries no
359
+ coverage counters, so saboteur sets `DISABLE_BOOTSNAP_COMPILE_CACHE=1` for its
360
+ own process when the variable is not already set (Bootsnap treats any set
361
+ value as off) and runs the eager load with Coverage active. App code is seen
362
+ by Coverage with the app unchanged.
363
+
364
+ With `jobs: 4` (or more), each extra worker gets a copy of every test
365
+ database, named `<database>_saboteur2` and up, made with
366
+ `CREATE DATABASE ... TEMPLATE` on Postgres or a file copy on SQLite.
367
+ saboteur drops the copies when the run ends, on a best-effort basis: a
368
+ drop that fails is reported as a warning and never changes the run's
369
+ outcome. A copy left behind - by a failed drop or a killed run - is
370
+ reclaimed by the next run, whose prepare starts from
371
+ `DROP DATABASE IF EXISTS`. MySQL is not supported for `jobs` above 1.
372
+
373
+ Rails fixtures (`spec/fixtures`, `test/fixtures`), `db/structure.sql`,
374
+ `db/schema.rb` and `config/*.yml` (including `database.yml`) are part of the
375
+ cache fingerprint automatically. Use `fingerprint_extra` for any other file your
376
+ tests read.
377
+
378
+ ### Skipped subjects and the mutant cap
379
+
380
+ Subjects with no test coverage are skipped before mutation sources run. They
381
+ remain in the subject count, are listed as skipped subjects in terminal output,
382
+ and appear under `skippedSubjects` in JSON. They are not invented mutants and do
383
+ not enter saboteur's mutation score. Stryker dashboards may ignore the extra
384
+ `skippedSubjects` field and calculate scores from mutant entries alone, so
385
+ removing previously generated `NoCoverage` mutants can raise a dashboard's score
386
+ even when test detection has not improved. Changes inside a covered method that
387
+ no test reaches still produce mutant-level "no coverage" results.
388
+
389
+ Each covered method keeps at most `max_mutants_per_method` valid, distinct
390
+ mutants (100 by default). Coarse mutants come first, then fine mutants in
391
+ configured mutation-source order and each source's proposal order. The cap keeps
392
+ a prefix of that order, so earlier sources can use the entire limit within a
393
+ method. Later methods have their own limit. In diff mode, changed-line selection
394
+ happens before the cap. A warning names each capped method and the number
395
+ omitted; JSON keeps these warnings under `warnings`. The score describes this
396
+ selected subset, not every possible mutation. Raise the limit to examine more
397
+ mutants; the limit does not bound how many proposals a custom source creates.
398
+
76
399
  ### Ignoring a survivor
77
400
 
78
401
  Every survivor is printed with a digest. Accept it with a reason:
79
402
 
80
- ```yaml
81
- # .saboteur-ignore.yml
82
- - subject: "Billing::Invoice#total"
83
- mutation: "1a2b3c4d5e6f7a8b"
84
- reason: "equivalent: amounts are always whole cents"
403
+ ```ruby
404
+ # config/saboteur_ignore.rb
405
+ Saboteur.ignore do |i|
406
+ i.mutation "1a2b3c4d5e6f7a8b",
407
+ subject: "Billing::Invoice#total",
408
+ reason: "equivalent: amounts are always whole cents"
409
+ end
85
410
  ```
86
411
 
87
412
  An entry without a reason is rejected.
@@ -91,48 +416,176 @@ An entry without a reason is rejected.
91
416
  saboteur needs Ruby's `Coverage` for itself. Disable SimpleCov for the run
92
417
  (most projects already guard it with an environment variable).
93
418
 
94
- ## Extending
419
+ ## Filter mutations
420
+
421
+ The built-in source also swaps `select` / `reject` and `select!` / `reject!`.
422
+ These catch a filter that keeps the items it should discard (or discards the
423
+ items it should keep). For example, a command replacement should remove the
424
+ old command, not keep only that command:
425
+
426
+ ```ruby
427
+ commands.reject! { |name, _| name == replaced_name }
428
+ # mutant: commands.select! { |name, _| name == replaced_name }
429
+ ```
430
+
431
+ Only explicit-receiver calls with a block and no ordinary arguments are
432
+ changed. A block passed as `&predicate` counts; safe navigation is preserved.
433
+ `relation.select(:name)`, bare `select { ... }`, and `items.select` without a
434
+ block are not changed. Arid statements and nested definitions remain skipped.
435
+ Ruby dispatch is dynamic: a matching name does not prove the receiver is an
436
+ Enumerable, and a custom receiver can make the replacement fail. No type or
437
+ real-fault detection guarantee is made.
438
+
439
+ These are fine mutants, appended after the existing built-in operators. For unchanged subjects, old
440
+ mutant digests and relative order stay unchanged, but runs can produce more
441
+ mutants, change their score, and hit the per-method cap. The operator and source
442
+ name in reports is `filter_replacement`.
443
+
444
+ See [the operator rationale](docs/filter-operators.md) for the Ruby fault used
445
+ to choose this family and the limits of that evidence.
446
+
447
+ ## Literal mutations
448
+
449
+ Builtin now swaps `true` / `false` literals and replaces nonempty static strings
450
+ with `""`. These test boolean choices and the difference between meaningful
451
+ text and empty input. Operator names are `literal_boolean` and `literal_string`;
452
+ the source name is `literal_replacement`.
453
+
454
+ Only method bodies are visited. Default arguments, arid statements, nested
455
+ definitions, empty strings, heredocs, interpolation, word/symbol lists, symbols,
456
+ regexes and command strings are unchanged. Quoted and static percent strings
457
+ are supported. The new proposals follow the earlier built-in operators; the
458
+ normal duplicate check and per-method cap still apply. Old digests and order
459
+ remain stable for unchanged subjects, but more mutants can change a run's score.
460
+ No speed, type-safety or real-fault detection improvement is claimed. See
461
+ [the literal operator rationale](docs/literal-operators.md) for fault evidence
462
+ and limits, including string encoding/frozen-value behavior.
463
+
464
+ ## Argument removal
465
+
466
+ Builtin removes one ordinary positional argument from a parenthesized call.
467
+ For `Model.includes(:a, :b, c: :d)`, the two proposals remove `:a` or `:b`,
468
+ without deleting the statement or changing `c: :d`. This lets a preload check
469
+ exercise missing associations instead of only a missing return value.
470
+ The operator and source name is `argument_removal`.
471
+
472
+ Only parenthesized calls are supported in this first version. Keyword hashes,
473
+ splats, argument forwarding, bracket indexing/assignment syntax and arguments containing
474
+ heredocs are unchanged. Explicit hash arguments count as positional arguments.
475
+ Blocks and keyword pairs remain intact; separators come from Prism tokens,
476
+ so commas inside strings or comments are not mistaken for separators.
477
+ Arid statements, parameter defaults and nested definitions are skipped.
478
+
479
+ These fine proposals follow existing builtin proposals, with the same validation,
480
+ deduplication and `max_mutants_per_method` cap. Earlier digests and relative order
481
+ stay unchanged for unchanged subjects. Extra proposals can change scores or be
482
+ omitted by the cap. Removing an argument can still raise an arity error; no
483
+ non-crashing guarantee is made. The regression uses a small preload-like receiver
484
+ and checks the missing associations, not an ActiveRecord SQL benchmark. See
485
+ [the validation notes](docs/argument-removal.md) for the pinned snapshot migration.
486
+
487
+ ## Hash and keyword pair removal
488
+
489
+ Builtin removes one key/value association from a hash literal or keyword list.
490
+ `Model.includes(:a, :b, c: :d)` becomes `Model.includes(:a, :b )`, so a nested
491
+ preload guard can be exercised without deleting the whole statement. A sole
492
+ hash pair becomes `{}`. Operator and source name: `pair_removal`.
493
+
494
+ Both parenthesized and command calls are supported. Splats stay intact; pairs
495
+ containing heredocs, parameter defaults, arid statements and nested definitions
496
+ are skipped. Token-based separator edits preserve comments and blocks. Nested
497
+ hash values and callable bodies are visited. Removing a required keyword can
498
+ still crash; the regression uses a preload-like receiver, not an ActiveRecord
499
+ SQL benchmark. These fine proposals follow earlier builtin proposals and use
500
+ the usual validation, deduplication, order and `max_mutants_per_method` cap.
501
+ More proposals can change scores and cap coverage. See
502
+ [the validation notes](docs/pair-removal.md) for snapshot changes.
503
+
504
+ ## πŸ”Œ Extending
95
505
 
96
506
  Public API, stable across minor versions:
97
507
 
98
508
  - `Saboteur::MutationSource::Base#mutations_for(subject)` returning
99
509
  `Saboteur::MutationSource::Mutation` objects made of `Edit`s (file, byte
100
510
  range, replacement). Register with `sources: [builtin, path.rb:ClassName]`.
101
- - `Saboteur::Integration::Base` for other test frameworks (RSpec is the
102
- only one that ships).
511
+ - `Saboteur::Integration::Base` for other test frameworks (RSpec is the only one
512
+ that ships).
103
513
  - `Saboteur.hook(:before_boot | :after_boot | :before_fork | :after_fork |
104
514
  :around_mutant)` in files listed under `hooks:`.
105
515
 
106
- ## How it works
516
+ ## 🌍 Where it has run
107
517
 
108
- See [docs/design.md](docs/design.md). Validation results are in
109
- [docs/validation](docs/validation), research in [docs/research](docs/research),
110
- and the project checklist in [docs/status.md](docs/status.md).
518
+ An early release (0.4.0). Tested on the RSpec suites of plain gems
519
+ (dry-inflector, tty-color, hashie, addressable) and of two Rails 8.1 apps:
520
+ human-essentials on Postgres (4,080 mutants, 6 workers; the 2026-10-10 run, before
521
+ the baseline check and the coarse-gate removal) and lobsters on SQLite,
522
+ both on macOS. Not yet tried on MySQL or older Rails versions. RSpec only, by
523
+ design. Results: [docs/validation](docs/validation).
111
524
 
112
- ## Benchmark
525
+ ## βš–οΈ How it compares
113
526
 
114
527
  A small, non-exhaustive comparison with [mutineer](https://github.com/davidteren/mutineer)
115
528
  1.6.0 on four RSpec projects (two libraries, two Rails apps). Each configuration
116
- ran once on sampled files, so treat the numbers as indicative.
529
+ ran once on sampled files, so treat the numbers as indicative. The table was
530
+ measured at 2b6ec02, before the literal, filter and argument operators and
531
+ before the coarse-survivor gate was removed, so mutant counts and times will
532
+ change; a rerun is pending. mutineer is ahead
533
+ on number of operators and CI extras, and it supports Minitest.
117
534
 
118
535
  | Metric | saboteur | mutineer |
119
536
  |---|---|---|
120
- | Fake kills (lobsters) | 0 of 20 checked | 8 of 8 checked |
537
+ | Fake kills (lobsters) | 0 of 20 sampled at random | 8 of 8 hand-checked (identical mutants, not a random sample) |
121
538
  | Rails app with 6 workers | yes, same verdicts as 1 worker | 1 worker only |
122
539
  | Runs on the Postgres app | yes | no |
123
540
  | Time per mutant, hashie | 0.008 s | 0.092 s |
124
- | Peak memory, hashie | 595 MB | 471 MB |
125
- | Choice of operators, CI features | fixed set, fewer | 20 operators, baseline, HTML, GitHub Action |
541
+ | Peak memory, hashie (summed process RSS) | 595 MB | 471 MB |
542
+ | Choice of operators, CI features | fixed set, fewer | 22 operators, baseline, HTML, GitHub Action |
126
543
 
127
544
  Full results, method and limitations:
128
545
  [benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
129
- Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer).
546
+ Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer). The memory row sums
547
+ per-process RSS, which counts shared pages more than once, and it was measured
548
+ before the fork-memory work. Read it as history, not a ranking of physical
549
+ memory.
550
+
551
+ ### Memory after the fork-memory work (hashie @ 9c81e83)
552
+
553
+ Paired A/B runs against the pre-PR commit (32c0f21), 5 pairs per job level with
554
+ AB/BA alternation and cold caches, on a 2 vCPU / 2 GB shared sandbox. Peak
555
+ memory is per-process-tree PSS from /proc/smaps_rollup sampled at 0.3s. Verdict
556
+ parity: **0 verdict differences in 23,700 mutant outcomes** (1,580 mutants x 15
557
+ pairs); kill reasons (assertion vs crash class) vary run to run on both
558
+ variants, as before.
559
+
560
+ | jobs | peak PSS baseline (median, range) | peak PSS PR (median, range) | shared-memory delta |
561
+ |------|-----------------------------------|-----------------------------|---------------------|
562
+ | 1 | 101 MB (101-101) | 100 MB (100-102) | ~0 (no workers to share with) |
563
+ | 2 | 133 MB (116-134) | 109 MB (106-121) | -18% |
564
+ | 6 | 235 MB (192-269) | 196 MB (175-201) | -17% |
565
+
566
+ Peak RSS is unchanged (median ~660 MB at jobs=6 both variants): the PR does not
567
+ touch less memory, it keeps more of it shared between workers after fork, which
568
+ is what PSS measures. On this workload the PSS drop is about 17-18% at 2 and 6 workers on a
569
+ 2 GB sandbox. Swap was not measured.
570
+
571
+ Wall time on uncontended runs, per-cell minimums, PR first: 48s vs 49s at jobs=1, 32s vs 31s at jobs=2, 30s vs
572
+ 32s at jobs=6. These are noisy per-cell minima from historical runs and do not
573
+ show a direction either way. The shared sandbox had multi-minute CPU-starvation
574
+ windows during this session (walls up to 594s); affected runs are flagged via
575
+ sampler cadence and excluded from wall analysis. Absolute times are specific to
576
+ this 2-core sandbox. Environment, method and the contention log are on the
577
+ [benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
130
578
 
131
- ## Requirements
579
+ ## πŸ—ΊοΈ Where it is going
132
580
 
133
- Ruby 3.3 or newer on Linux or macOS (it needs `fork`).
581
+ [ROADMAP.md](ROADMAP.md) lays out the plan: CI-fast runs, trusted results, a
582
+ team workflow (GitHub Action, baseline, HTML report), then 1.0. saboteur stays
583
+ RSpec-only. Design
584
+ details are in [docs/design.md](docs/design.md), prior art in
585
+ [docs/prior-art.md](docs/prior-art.md), and project status in
586
+ [docs/status.md](docs/status.md).
134
587
 
135
- ## Contributing
588
+ ## 🀝 Contributing
136
589
 
137
590
  See [CONTRIBUTING.md](CONTRIBUTING.md). Commits need a DCO `Signed-off-by` line.
138
591