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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +153 -1
- data/README.md +526 -73
- data/lib/generators/saboteur/install/install_generator.rb +21 -0
- data/lib/saboteur/agent_skill.rb +40 -0
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +305 -30
- data/lib/saboteur/config.rb +65 -19
- data/lib/saboteur/config_file.rb +97 -0
- data/lib/saboteur/custom_map.rb +170 -0
- data/lib/saboteur/doctor.rb +88 -0
- data/lib/saboteur/engine/baseline_guard.rb +116 -0
- data/lib/saboteur/engine/boot.rb +9 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +40 -6
- data/lib/saboteur/engine/data_inputs.rb +43 -0
- data/lib/saboteur/engine/diagnostics.rb +28 -0
- data/lib/saboteur/engine/diff_scope.rb +8 -0
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +344 -0
- data/lib/saboteur/engine/scheduler.rb +7 -16
- data/lib/saboteur/engine/suite_files.rb +8 -1
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +125 -25
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/execution/reevaluator.rb +7 -4
- data/lib/saboteur/execution/supervisor.rb +23 -11
- data/lib/saboteur/frameworks/rails.rb +329 -13
- data/lib/saboteur/help.rb +210 -0
- data/lib/saboteur/ignore_list.rb +71 -17
- data/lib/saboteur/install.rb +132 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +69 -4
- data/lib/saboteur/mutation_source/argument_removal.rb +86 -0
- data/lib/saboteur/mutation_source/builtin.rb +14 -3
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/mutation_source/pair_removal.rb +101 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/agent.rb +87 -0
- data/lib/saboteur/reporter/agent_clipping.rb +29 -0
- data/lib/saboteur/reporter/agent_commands.rb +61 -0
- data/lib/saboteur/reporter/agent_incomplete.rb +37 -0
- data/lib/saboteur/reporter/agent_summary.rb +131 -0
- data/lib/saboteur/reporter/findings.rb +131 -0
- data/lib/saboteur/reporter/html.rb +213 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +21 -4
- data/lib/saboteur/reporter/terminal.rb +76 -2
- data/lib/saboteur/result.rb +61 -9
- data/lib/saboteur/score.rb +89 -0
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +48 -0
- data/lib/saboteur.rb +7 -0
- 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
|
|
7
|
+
**Mutation testing for Ruby and RSpec, built for pull requests.** π§¨
|
|
4
8
|
|
|
5
|
-
saboteur
|
|
6
|
-
|
|
7
|
-
(
|
|
8
|
-
matter.
|
|
9
|
+
[](https://github.com/vamsipavanmahesh/saboteur/actions/workflows/ci.yml)
|
|
10
|
+
[](https://rubygems.org/gems/saboteur)
|
|
11
|
+
[](LICENSE)
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
```
|
|
81
|
-
# .
|
|
82
|
-
|
|
83
|
-
mutation
|
|
84
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
##
|
|
516
|
+
## π Where it has run
|
|
107
517
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
##
|
|
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
|
|
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 |
|
|
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
|
-
##
|
|
579
|
+
## πΊοΈ Where it is going
|
|
132
580
|
|
|
133
|
-
|
|
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
|
|