saboteur 0.2.0 β 0.3.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 +40 -0
- data/README.md +322 -54
- data/lib/saboteur/baseline_health.rb +116 -0
- data/lib/saboteur/cli.rb +169 -22
- data/lib/saboteur/config.rb +39 -5
- data/lib/saboteur/doctor.rb +72 -0
- data/lib/saboteur/engine/baseline_guard.rb +99 -0
- data/lib/saboteur/engine/candidate_selection.rb +35 -0
- data/lib/saboteur/engine/coverage_store.rb +9 -2
- data/lib/saboteur/engine/distribution.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +316 -0
- data/lib/saboteur/engine/verdict_check.rb +4 -2
- data/lib/saboteur/engine.rb +53 -14
- data/lib/saboteur/execution/fork_readiness.rb +46 -0
- data/lib/saboteur/help.rb +194 -0
- data/lib/saboteur/integration/base.rb +8 -0
- data/lib/saboteur/integration/rspec.rb +23 -2
- data/lib/saboteur/mutation_source/builtin.rb +6 -1
- data/lib/saboteur/mutation_source/filter_replacement.rb +60 -0
- data/lib/saboteur/mutation_source/literal_replacement.rb +74 -0
- data/lib/saboteur/replay.rb +33 -0
- data/lib/saboteur/reporter/findings.rb +123 -0
- data/lib/saboteur/reporter/html.rb +165 -0
- data/lib/saboteur/reporter/replay.rb +23 -0
- data/lib/saboteur/reporter/stryker.rb +11 -1
- data/lib/saboteur/reporter/terminal.rb +11 -0
- data/lib/saboteur/result.rb +16 -5
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur.rb +3 -0
- metadata +15 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 767101a27863dfddcfd14ed0f71737ba34764feebd1824625c2354119f5144cc
|
|
4
|
+
data.tar.gz: 237fca7fc890cf682a4f4b10f2e2fbdfc23759d575ba5dfd804d60bc28febcb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b7e6f93cd8844cf886905e009157a7227389b1a11f47ead943f8075d52e45c8533925b60ee04c41e9678039090c0072b3ad19f64a7ea5135b8e311ada65e2bd
|
|
7
|
+
data.tar.gz: 24faf1ebcfecaa018a4b415f86ceb40c73c2889fe49ff65718fba3e9709971e4c431756a6e4990436bc1fefbdb1fb8cdee72bf66953d5d76aa02d5b166435b70
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,46 @@ All notable changes are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Fine `literal_boolean` and `literal_string` mutants: true/false swaps and
|
|
13
|
+
nonempty static string replacement with `""`. Body-only traversal excludes
|
|
14
|
+
arid/nested scopes, interpolation, heredocs and non-string text literals.
|
|
15
|
+
New proposals can change scores and per-method-cap coverage.
|
|
16
|
+
- `saboteur run --id DIGEST`: replay one exact current mutant with selected tests,
|
|
17
|
+
independent of caps, coarse gating, ignore suppression and incremental reuse.
|
|
18
|
+
Prints the mutation diff and selected test ids for every replay result.
|
|
19
|
+
- Review findings in the JSON report (`saboteur.findings`,
|
|
20
|
+
`Saboteur::Reporter::Findings`): a bounded, deterministically ordered list
|
|
21
|
+
of survivors (coarse first, then file, line and digest) with the file,
|
|
22
|
+
line, digest, subject, operator, original and replacement a note on a
|
|
23
|
+
pull-request diff needs. Survivors accepted on the ignore list are listed
|
|
24
|
+
separately with their reason; `survivorsTruncated` counts the rest, which
|
|
25
|
+
stay in the full `files` data.
|
|
26
|
+
- Self-contained HTML report (`Saboteur::Reporter::Html`): one file with inline
|
|
27
|
+
style and no script or network use, written to `.saboteur/report.html`.
|
|
28
|
+
Change the path with `--html PATH` or `html_report:`. It shows the score,
|
|
29
|
+
per-file mutants, survivors, warnings and skipped subjects.
|
|
30
|
+
- A GitHub Action (`uses: vamsipavanmahesh/saboteur`) that runs diff mode on pull
|
|
31
|
+
requests. Advisory by default, with a job summary, outputs and a cached
|
|
32
|
+
coverage map. See `docs/github-action.md`.
|
|
33
|
+
- Built-in `filter_replacement` mutants: `select` / `reject` and
|
|
34
|
+
`select!` / `reject!`, for explicit-receiver calls with a block and no ordinary
|
|
35
|
+
arguments. For unchanged subjects, existing mutants retain their digests and order.
|
|
36
|
+
Runs may now include more mutants and report a different score.
|
|
37
|
+
- `saboteur doctor [--config PATH] [--seed N]`: environment checks and two fresh
|
|
38
|
+
unchanged-suite checks, without mutation reports or coverage/verdict cache writes.
|
|
39
|
+
- `saboteur run SOURCE... --test TEST...`: mutate the named source files and
|
|
40
|
+
run only the named spec files or directories. Positional sources replace
|
|
41
|
+
`include` and `exclude`; `--test` replaces the spec paths in `rspec_args`.
|
|
42
|
+
- `--threshold PERCENT` (and `threshold:` in `.saboteur.yml`): exit 1 when the
|
|
43
|
+
score is below PERCENT. The score decides the exit code instead of survivors.
|
|
44
|
+
It fails closed: no score exits 1, and errored or resource-limited mutants
|
|
45
|
+
exit 3.
|
|
46
|
+
- Positional sources must exist (exit 2 otherwise), and the selected tests are
|
|
47
|
+
part of the coverage-map and `--incremental` cache keys.
|
|
48
|
+
|
|
9
49
|
## [0.2.0] - 2026-10-10
|
|
10
50
|
|
|
11
51
|
### Added
|
data/README.md
CHANGED
|
@@ -1,49 +1,165 @@
|
|
|
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 entire unchanged suite twice 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. Each suite pass
|
|
39
|
+
uses `coverage_timeout` and configured memory/process limits (the suite memory
|
|
40
|
+
budget is four times `max_memory_mb`, as for coverage). Older benchmark numbers
|
|
41
|
+
need a rerun before they describe this extra fixed cost.
|
|
42
|
+
Two passes detect observed instability, not every possible flaky test. See
|
|
43
|
+
[baseline stability](docs/baseline-stability.md) for limits and hook behaviour.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## π Try it in a minute
|
|
47
|
+
|
|
48
|
+
Add saboteur to the test group of your project's Gemfile (Ruby 3.3+, RSpec):
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
gem "saboteur", group: :test
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
bundle install
|
|
56
|
+
bundle exec saboteur run --subject 'YourClass#your_method'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Outside a Bundler project, `gem install saboteur` installs the same executable.
|
|
60
|
+
Rails apps need a Rails block in `.saboteur.yml` (boot file, `app/**/*.rb`,
|
|
61
|
+
`rails: true`); see [Rails apps](#rails-apps) below. The defaults are for plain
|
|
62
|
+
gems (`lib/**/*.rb`, `rails: false`, `jobs: 1`) and are not Rails-aware.
|
|
63
|
+
|
|
64
|
+
Start with one method. You get counts per status, a score, timing, and a
|
|
65
|
+
unified diff for every survivor, so you can read the exact change your tests
|
|
66
|
+
missed. The JSON report lands in `.saboteur/report.json`.
|
|
67
|
+
|
|
68
|
+
π Requirements: Ruby 3.3 or newer on Linux or macOS (it needs `fork`), and RSpec.
|
|
69
|
+
|
|
70
|
+
## π§ Why it is built the way it is
|
|
71
|
+
|
|
72
|
+
**It only runs tests that can matter.** Each mutant runs only the tests whose
|
|
73
|
+
line coverage touched the changed statement. Tests that killed earlier mutants
|
|
74
|
+
of the same method go first, then the fastest, and the first failure stops the
|
|
75
|
+
run. A change no test reaches is reported as "no coverage", not as a survivor.
|
|
76
|
+
|
|
77
|
+
**It does not waste time on dead ends.** Each method first gets whole-body
|
|
78
|
+
mutants (`nil` and `raise`, or `true`/`false` for predicates; a few methods,
|
|
79
|
+
such as some that rescue or log, get none). If your suite does not notice
|
|
80
|
+
that, saboteur says so and skips the fine-grained mutants for that method. Subjects with no tests
|
|
81
|
+
are skipped before any mutants are generated, and each method is capped at
|
|
82
|
+
`max_mutants_per_method` mutants (100 by default).
|
|
83
|
+
|
|
84
|
+
**It prepares memory before it forks.** Before workers fork, saboteur warms and
|
|
85
|
+
compacts the Ruby heap ([`ForkReadiness`](lib/saboteur/execution/fork_readiness.rb)),
|
|
86
|
+
which is meant to let workers share more pages with their parent and copy less.
|
|
87
|
+
The physical-memory benefit is not yet established by a published benchmark.
|
|
88
|
+
|
|
89
|
+
**It is aimed at pull requests.** `--since origin/main` mutates only the methods
|
|
90
|
+
your branch changed: fine-grained mutants on the changed lines, plus the
|
|
91
|
+
whole-body mutants of those methods. On one real change in saboteur's own history, that took a file
|
|
92
|
+
from 139 mutants to 7, and median wall time fell 22.9%. The time saving is
|
|
93
|
+
smaller than the mutant count because building the coverage map is a fixed
|
|
94
|
+
cost, and this is one file, not a Rails app, so it is not a general speed
|
|
95
|
+
claim. The full method, raw timings and
|
|
96
|
+
caveats are in [bench/since](bench/since/README.md).
|
|
97
|
+
|
|
98
|
+
**It checks its own kills.** After a method's mutants run, saboteur re-runs the
|
|
99
|
+
killing tests against the *unchanged* method. If they fail there too, the kill
|
|
100
|
+
is not credited and becomes an error. In our comparison on lobsters, 0 of 20
|
|
101
|
+
checked kills were fake. Every mutant runs in a supervised child with deadline,
|
|
102
|
+
memory and process limits, and saboteur tracks and kills the processes it
|
|
103
|
+
starts.
|
|
104
|
+
|
|
105
|
+
**It is open.** The report is JSON in the Stryker mutation-testing-report
|
|
106
|
+
schema, and you can add your own mutants through a small public interface.
|
|
107
|
+
|
|
108
|
+
## π οΈ Usage
|
|
33
109
|
|
|
34
110
|
```sh
|
|
35
111
|
bundle exec saboteur run # whole project
|
|
112
|
+
bundle exec saboteur doctor --seed 42 # environment + two unchanged suite checks
|
|
36
113
|
bundle exec saboteur run --since origin/main # methods changed on this branch
|
|
37
114
|
bundle exec saboteur run --subject 'Billing::Invoice#total'
|
|
38
115
|
bundle exec saboteur run --subject Billing # a namespace
|
|
39
116
|
bundle exec saboteur run --incremental # reuse verdicts whose inputs did not change
|
|
40
117
|
bundle exec saboteur run --seed 1234 # repeat a run that used seed 1234
|
|
118
|
+
bundle exec saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb
|
|
119
|
+
bundle exec saboteur run --threshold 90 # exit 1 when the score is below 90 %
|
|
120
|
+
bundle exec saboteur run --id DIGEST --seed 1234 # replay one full current mutant digest
|
|
41
121
|
```
|
|
42
122
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
123
|
+
Positional operands name the sources to mutate: Ruby files, or directories
|
|
124
|
+
(all their `.rb` files). Each must exist; a typo or a non-Ruby file exits 2
|
|
125
|
+
before anything runs. They replace `include` and `exclude`. `--test`
|
|
126
|
+
(repeatable; spec files or directories, with an optional `:LINE` or `[1:2]`
|
|
127
|
+
selector; needs at least one source) replaces only the files RSpec runs. Other
|
|
128
|
+
`rspec_args` and `.rspec` options such as `--require` stay. The coverage map
|
|
129
|
+
is built from only those specs, each mutant runs only the ones that cover it,
|
|
130
|
+
and the selection is part of the coverage and `--incremental` cache keys.
|
|
131
|
+
|
|
132
|
+
`--threshold PERCENT` (0 to 100, or `threshold:` in `.saboteur.yml`) is for CI.
|
|
133
|
+
The run exits 1 when the score is below PERCENT and 0 when it is at or above,
|
|
134
|
+
even with survivors. It fails closed: a run that tested no mutant has no score
|
|
135
|
+
and exits 1, and a run where any mutant errored or hit a resource limit exits 3,
|
|
136
|
+
because the score covers only part of the run. Without a threshold, any
|
|
137
|
+
unignored survivor exits 1.
|
|
138
|
+
|
|
139
|
+
Other flags: `--jobs N` (concurrent workers), `--timeout-factor N`,
|
|
140
|
+
`--config PATH`, `--integration NAME`, `--reeval class_eval|nesting`,
|
|
141
|
+
`--json PATH`, `--no-cache` (rebuild the coverage map).
|
|
142
|
+
|
|
143
|
+
For pull requests, start with `--since` against your target branch. This is a
|
|
144
|
+
workflow recommendation, not a CLI default: plain `run` still checks the whole
|
|
145
|
+
configured project. Keep whole-project runs on main: diff mode does not check every method affected by a
|
|
146
|
+
dependency change. [The pull-request guide](docs/pull-requests.md) covers
|
|
147
|
+
checkout history, a GitHub Actions example, configuration and limits.
|
|
148
|
+
|
|
149
|
+
`saboteur doctor` prints Ruby, coverage, integration, Rails/SimpleCov and boot-file
|
|
150
|
+
checks, then runs the unchanged suite twice with the chosen seed. It writes no
|
|
151
|
+
mutation reports or coverage/verdict caches. Target boot files and tests still
|
|
152
|
+
run, so their normal side effects remain. See the [doctor guide](docs/guides/doctor.md).
|
|
153
|
+
|
|
154
|
+
`--id` runs exactly one current mutant with its coverage-selected tests, bypassing
|
|
155
|
+
per-method caps, coarse gating, ignore suppression and incremental verdict reuse.
|
|
156
|
+
It still honors `--subject` and `--since` changed-line selection. See the
|
|
157
|
+
[replay guide](docs/guides/replay.md) for digest format, checks and limits.
|
|
158
|
+
|
|
159
|
+
Every test run starts from the same random seed (1 by default), so tests that
|
|
160
|
+
use `rand` see the same values in each mutant's run and in the check that
|
|
161
|
+
confirms a kill. The report prints the seed; pass it to `--seed` to repeat a
|
|
162
|
+
run.
|
|
47
163
|
|
|
48
164
|
`--incremental` reuses an earlier verdict only while the method, the mutant,
|
|
49
165
|
its covering tests and their files, the lockfile and the config are all
|
|
@@ -51,14 +167,46 @@ unchanged. It cannot see a change in a method this one calls, so run without
|
|
|
51
167
|
it now and then (and in CI on the main branch).
|
|
52
168
|
|
|
53
169
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
170
|
+
survivor. The JSON report goes to `.saboteur/report.json` and a self-contained HTML
|
|
171
|
+
report (one file, no scripts, no network) to `.saboteur/report.html`. Change
|
|
172
|
+
the paths with `--json` / `--html` or `json_report` / `html_report` in
|
|
173
|
+
`.saboteur.yml`; set either to `null` to turn that report off. The two paths
|
|
174
|
+
must differ. The HTML page shows the covered mutation score
|
|
175
|
+
`(killed + timeout) / (killed + survived + timeout)`, which leaves NoCoverage
|
|
176
|
+
out and keeps survivors accepted on the ignore list in the denominator. A tool
|
|
177
|
+
that reads the JSON report may show a different score, because the schema counts
|
|
178
|
+
NoCoverage as undetected.
|
|
179
|
+
|
|
180
|
+
For code-review surfaces, the JSON report also carries a `saboteur.findings`
|
|
181
|
+
section: a bounded, deterministically ordered list of survivors, coarse
|
|
182
|
+
mutants first (a surviving coarse mutant hints that no test pins the method's
|
|
183
|
+
behavior - it can also be a re-evaluation limit or an equivalent mutant),
|
|
184
|
+
then file, line and digest. Each finding holds the file, line, digest,
|
|
185
|
+
subject, operator, original and replacement a note on a pull-request diff
|
|
186
|
+
needs, plus one entry per edit for mutants that edit more than one spot.
|
|
187
|
+
Survivors accepted on the ignore list are listed separately with their
|
|
188
|
+
reason, and `survivorsTruncated` and `acceptedTruncated` count what the bound
|
|
189
|
+
left out; the full data stays in the schema's `files` section.
|
|
190
|
+
|
|
191
|
+
Exit codes: 0 no survivors (or all on the ignore list), or the score meets
|
|
192
|
+
`--threshold`; 1 survivors, or the score is below `--threshold` or missing;
|
|
193
|
+
2 bad configuration or a missing source or test; 3 the tool failed (with
|
|
194
|
+
`--threshold`, also any errored or resource-limited mutant).
|
|
195
|
+
|
|
196
|
+
### Rejected kills
|
|
197
|
+
|
|
198
|
+
The terminal prints `kills rejected by unchanged-method check: N`; JSON adds
|
|
199
|
+
`rejectedKills`. This counts fresh per-mutant kill claims whose tests also fail
|
|
200
|
+
with the method unchanged. They stay errors outside the score. Timeout checks,
|
|
201
|
+
other errors and reused cached kills are not included. See
|
|
202
|
+
[rejected kills](docs/rejected-kills.md) for details.
|
|
203
|
+
|
|
204
|
+
## βοΈ Configuration
|
|
205
|
+
|
|
206
|
+
`.saboteur.yml` in the project root; CLI flags override it. Keep the shared
|
|
207
|
+
config usable for whole-project runs and pass `--since` in the pull-request
|
|
208
|
+
job. A PR-only config may set `since: origin/main`; omit `since` (or use
|
|
209
|
+
`since: null`) in the config for audits on main. Use your actual target branch.
|
|
62
210
|
|
|
63
211
|
```yaml
|
|
64
212
|
include: [lib/**/*.rb]
|
|
@@ -69,10 +217,46 @@ timeout_factor: 3 # per-mutant deadline = factor x its tests' recorded
|
|
|
69
217
|
timeout_min: 5
|
|
70
218
|
max_memory_mb: 2048 # memory growth cap per mutant process tree
|
|
71
219
|
max_processes: 64
|
|
220
|
+
max_mutants_per_method: 100 # positive integer; raise it for a broader run
|
|
72
221
|
ignore_file: .saboteur-ignore.yml
|
|
73
222
|
seed: 1 # Ruby's random seed in every test run
|
|
74
223
|
```
|
|
75
224
|
|
|
225
|
+
### Rails apps
|
|
226
|
+
|
|
227
|
+
The defaults target a plain gem, so a Rails app needs this in `.saboteur.yml`:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
include: [app/**/*.rb]
|
|
231
|
+
require: [./config/environment] # boots the app
|
|
232
|
+
rails: true # reconnects ActiveRecord after fork, per-worker databases
|
|
233
|
+
jobs: 4 # optional; the default is 1
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Run with `RAILS_ENV=test` and turn SimpleCov off for the run. An install
|
|
237
|
+
generator is planned; until then this block is the supported setup.
|
|
238
|
+
|
|
239
|
+
### Skipped subjects and the mutant cap
|
|
240
|
+
|
|
241
|
+
Subjects with no test coverage are skipped before mutation sources run. They
|
|
242
|
+
remain in the subject count, are listed as skipped subjects in terminal output,
|
|
243
|
+
and appear under `skippedSubjects` in JSON. They are not invented mutants and do
|
|
244
|
+
not enter saboteur's mutation score. Stryker dashboards may ignore the extra
|
|
245
|
+
`skippedSubjects` field and calculate scores from mutant entries alone, so
|
|
246
|
+
removing previously generated `NoCoverage` mutants can raise a dashboard's score
|
|
247
|
+
even when test detection has not improved. Changes inside a covered method that
|
|
248
|
+
no test reaches still produce mutant-level "no coverage" results.
|
|
249
|
+
|
|
250
|
+
Each covered method keeps at most `max_mutants_per_method` valid, distinct
|
|
251
|
+
mutants (100 by default). Coarse mutants come first, then fine mutants in
|
|
252
|
+
configured mutation-source order and each source's proposal order. The cap keeps
|
|
253
|
+
a prefix of that order, so earlier sources can use the entire limit within a
|
|
254
|
+
method. Later methods have their own limit. In diff mode, changed-line selection
|
|
255
|
+
happens before the cap. A warning names each capped method and the number
|
|
256
|
+
omitted; JSON keeps these warnings under `warnings`. The score describes this
|
|
257
|
+
selected subset, not every possible mutation. Raise the limit to examine more
|
|
258
|
+
mutants; the limit does not bound how many proposals a custom source creates.
|
|
259
|
+
|
|
76
260
|
### Ignoring a survivor
|
|
77
261
|
|
|
78
262
|
Every survivor is printed with a digest. Accept it with a reason:
|
|
@@ -91,29 +275,77 @@ An entry without a reason is rejected.
|
|
|
91
275
|
saboteur needs Ruby's `Coverage` for itself. Disable SimpleCov for the run
|
|
92
276
|
(most projects already guard it with an environment variable).
|
|
93
277
|
|
|
94
|
-
##
|
|
278
|
+
## Filter mutations
|
|
279
|
+
|
|
280
|
+
The built-in source also swaps `select` / `reject` and `select!` / `reject!`.
|
|
281
|
+
These catch a filter that keeps the items it should discard (or discards the
|
|
282
|
+
items it should keep). For example, a command replacement should remove the
|
|
283
|
+
old command, not keep only that command:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
commands.reject! { |name, _| name == replaced_name }
|
|
287
|
+
# mutant: commands.select! { |name, _| name == replaced_name }
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Only explicit-receiver calls with a block and no ordinary arguments are
|
|
291
|
+
changed. A block passed as `&predicate` counts; safe navigation is preserved.
|
|
292
|
+
`relation.select(:name)`, bare `select { ... }`, and `items.select` without a
|
|
293
|
+
block are not changed. Arid statements and nested definitions remain skipped.
|
|
294
|
+
Ruby dispatch is dynamic: a matching name does not prove the receiver is an
|
|
295
|
+
Enumerable, and a custom receiver can make the replacement fail. No type or
|
|
296
|
+
real-fault detection guarantee is made.
|
|
297
|
+
|
|
298
|
+
These are fine mutants, appended after the existing built-in operators. For unchanged subjects, old
|
|
299
|
+
mutant digests and relative order stay unchanged, but runs can produce more
|
|
300
|
+
mutants, change their score, and hit the per-method cap. The operator and source
|
|
301
|
+
name in reports is `filter_replacement`.
|
|
302
|
+
|
|
303
|
+
See [the operator rationale](docs/filter-operators.md) for the Ruby fault used
|
|
304
|
+
to choose this family and the limits of that evidence.
|
|
305
|
+
|
|
306
|
+
## Literal mutations
|
|
307
|
+
|
|
308
|
+
Builtin now swaps `true` / `false` literals and replaces nonempty static strings
|
|
309
|
+
with `""`. These test boolean choices and the difference between meaningful
|
|
310
|
+
text and empty input. Operator names are `literal_boolean` and `literal_string`;
|
|
311
|
+
the source name is `literal_replacement`.
|
|
312
|
+
|
|
313
|
+
Only method bodies are visited. Default arguments, arid statements, nested
|
|
314
|
+
definitions, empty strings, heredocs, interpolation, word/symbol lists, symbols,
|
|
315
|
+
regexes and command strings are unchanged. Quoted and static percent strings
|
|
316
|
+
are supported. The new proposals follow the earlier built-in operators; the
|
|
317
|
+
normal duplicate check and per-method cap still apply. Old digests and order
|
|
318
|
+
remain stable for unchanged subjects, but more mutants can change a run's score.
|
|
319
|
+
No speed, type-safety or real-fault detection improvement is claimed. See
|
|
320
|
+
[the literal operator rationale](docs/literal-operators.md) for fault evidence
|
|
321
|
+
and limits, including string encoding/frozen-value behavior.
|
|
322
|
+
|
|
323
|
+
## π Extending
|
|
95
324
|
|
|
96
325
|
Public API, stable across minor versions:
|
|
97
326
|
|
|
98
327
|
- `Saboteur::MutationSource::Base#mutations_for(subject)` returning
|
|
99
328
|
`Saboteur::MutationSource::Mutation` objects made of `Edit`s (file, byte
|
|
100
329
|
range, replacement). Register with `sources: [builtin, path.rb:ClassName]`.
|
|
101
|
-
- `Saboteur::Integration::Base` for other test frameworks (RSpec is the
|
|
102
|
-
|
|
330
|
+
- `Saboteur::Integration::Base` for other test frameworks (RSpec is the only one
|
|
331
|
+
that ships).
|
|
103
332
|
- `Saboteur.hook(:before_boot | :after_boot | :before_fork | :after_fork |
|
|
104
333
|
:around_mutant)` in files listed under `hooks:`.
|
|
105
334
|
|
|
106
|
-
##
|
|
335
|
+
## π Where it has run
|
|
107
336
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
337
|
+
An early release (0.3.0). Tested on the RSpec suites of plain gems
|
|
338
|
+
(dry-inflector, tty-color, hashie, addressable) and of two Rails 8.1 apps:
|
|
339
|
+
human-essentials on Postgres (4,080 mutants, 6 workers) and lobsters on SQLite,
|
|
340
|
+
both on macOS. Not yet tried on MySQL or older Rails versions. RSpec only, by
|
|
341
|
+
design. Results: [docs/validation](docs/validation).
|
|
111
342
|
|
|
112
|
-
##
|
|
343
|
+
## βοΈ How it compares
|
|
113
344
|
|
|
114
345
|
A small, non-exhaustive comparison with [mutineer](https://github.com/davidteren/mutineer)
|
|
115
346
|
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.
|
|
347
|
+
ran once on sampled files, so treat the numbers as indicative. mutineer is ahead
|
|
348
|
+
on number of operators and CI extras, and it supports Minitest.
|
|
117
349
|
|
|
118
350
|
| Metric | saboteur | mutineer |
|
|
119
351
|
|---|---|---|
|
|
@@ -121,18 +353,54 @@ ran once on sampled files, so treat the numbers as indicative.
|
|
|
121
353
|
| Rails app with 6 workers | yes, same verdicts as 1 worker | 1 worker only |
|
|
122
354
|
| Runs on the Postgres app | yes | no |
|
|
123
355
|
| Time per mutant, hashie | 0.008 s | 0.092 s |
|
|
124
|
-
| Peak memory, hashie | 595 MB | 471 MB |
|
|
356
|
+
| Peak memory, hashie (summed process RSS) | 595 MB | 471 MB |
|
|
125
357
|
| Choice of operators, CI features | fixed set, fewer | 20 operators, baseline, HTML, GitHub Action |
|
|
126
358
|
|
|
127
359
|
Full results, method and limitations:
|
|
128
360
|
[benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
|
|
129
|
-
Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer).
|
|
361
|
+
Scripts and raw data: [bench/vs-mutineer](bench/vs-mutineer). The memory row sums
|
|
362
|
+
per-process RSS, which counts shared pages more than once, and it was measured
|
|
363
|
+
before the fork-memory work. Read it as history, not a ranking of physical
|
|
364
|
+
memory.
|
|
365
|
+
|
|
366
|
+
### Memory after the fork-memory work (hashie @ 9c81e83)
|
|
367
|
+
|
|
368
|
+
Paired A/B runs against the pre-PR commit (32c0f21), 5 pairs per job level with
|
|
369
|
+
AB/BA alternation and cold caches, on a 2 vCPU / 2 GB shared sandbox. Peak
|
|
370
|
+
memory is per-process-tree PSS from /proc/smaps_rollup sampled at 0.3s. Verdict
|
|
371
|
+
parity: **0 verdict differences in 23,700 mutant outcomes** (1,580 mutants x 15
|
|
372
|
+
pairs); kill reasons (assertion vs crash class) vary run to run on both
|
|
373
|
+
variants, as before.
|
|
374
|
+
|
|
375
|
+
| jobs | peak PSS baseline (median, range) | peak PSS PR (median, range) | shared-memory delta |
|
|
376
|
+
|------|-----------------------------------|-----------------------------|---------------------|
|
|
377
|
+
| 1 | 101 MB (101-101) | 100 MB (100-102) | ~0 (no workers to share with) |
|
|
378
|
+
| 2 | 133 MB (116-134) | 109 MB (106-121) | -18% |
|
|
379
|
+
| 6 | 235 MB (192-269) | 196 MB (175-201) | -17% |
|
|
380
|
+
|
|
381
|
+
Peak RSS is unchanged (median ~660 MB at jobs=6 both variants): the PR does not
|
|
382
|
+
touch less memory, it keeps more of it shared between workers after fork, which
|
|
383
|
+
is what PSS measures. On machines running many parallel workers this is the
|
|
384
|
+
difference that keeps the box out of swap.
|
|
385
|
+
|
|
386
|
+
Wall time on uncontended runs: 30-36s for both variants at every job level (no
|
|
387
|
+
regression; per-cell minimums 48s vs 49s at jobs=1, 32s vs 31s at jobs=2, 30s
|
|
388
|
+
vs 32s at jobs=6, PR first). The shared sandbox had multi-minute CPU-starvation
|
|
389
|
+
windows during this session (walls up to 594s); affected runs are flagged via
|
|
390
|
+
sampler cadence and excluded from wall analysis. Absolute times are specific to
|
|
391
|
+
this 2-core sandbox. Environment, method and the contention log are on the
|
|
392
|
+
[benchmark page](https://vamsipavanmahesh.github.io/saboteur/benchmark/).
|
|
130
393
|
|
|
131
|
-
##
|
|
394
|
+
## πΊοΈ Where it is going
|
|
132
395
|
|
|
133
|
-
|
|
396
|
+
[ROADMAP.md](ROADMAP.md) lays out the plan: CI-fast runs, trusted results, a
|
|
397
|
+
team workflow (GitHub Action, baseline, HTML report), then 1.0. saboteur stays
|
|
398
|
+
RSpec-only. Design
|
|
399
|
+
details are in [docs/design.md](docs/design.md), prior art in
|
|
400
|
+
[docs/prior-art.md](docs/prior-art.md), and the project checklist in
|
|
401
|
+
[docs/status.md](docs/status.md).
|
|
134
402
|
|
|
135
|
-
## Contributing
|
|
403
|
+
## π€ Contributing
|
|
136
404
|
|
|
137
405
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Commits need a DCO `Signed-off-by` line.
|
|
138
406
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Saboteur
|
|
8
|
+
# Two fresh supervised children check the full suite without changing methods
|
|
9
|
+
# or writing caches. This samples stability; it cannot prove absence of flakes.
|
|
10
|
+
class BaselineHealth
|
|
11
|
+
class TestSequence
|
|
12
|
+
attr_reader :executed
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@executed = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_started(_id, _file); end
|
|
19
|
+
|
|
20
|
+
def test_finished(id)
|
|
21
|
+
@executed << id
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(config, integration:, hooks: Hooks.new, supervisor: nil)
|
|
26
|
+
@config = config
|
|
27
|
+
@integration = integration
|
|
28
|
+
@hooks = hooks
|
|
29
|
+
limits = Execution::Supervisor::Limits.new(max_memory_mb: config["max_memory_mb"],
|
|
30
|
+
max_processes: config["max_processes"])
|
|
31
|
+
@supervisor = supervisor || Execution::Supervisor.new(limits: limits)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
expected = @integration.all_tests.map(&:id)
|
|
36
|
+
raise Error, "baseline has no tests; check rspec_args and spec paths" if expected.empty?
|
|
37
|
+
|
|
38
|
+
first = check_run(1, expected)
|
|
39
|
+
second = check_run(2, expected)
|
|
40
|
+
if first != second
|
|
41
|
+
raise Error,
|
|
42
|
+
"baseline repeated with different test order; check random ordering and suite setup"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def check_run(number, expected)
|
|
51
|
+
Tempfile.create("saboteur-baseline") do |log|
|
|
52
|
+
result = @supervisor.run(timeout: @config["baseline_timeout"]) { |writer| record(writer, log.path) }
|
|
53
|
+
data = read_result(result, number)
|
|
54
|
+
verify_execution(data, number, expected)
|
|
55
|
+
raise Error, "baseline run #{number} left processes running; fix suite cleanup" if result.leftover_pids.any?
|
|
56
|
+
|
|
57
|
+
data["executed"]
|
|
58
|
+
end
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
raise Error, "baseline run #{number} returned an invalid result"
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
raise e if e.is_a?(Error)
|
|
63
|
+
|
|
64
|
+
raise Error, "baseline check failed: #{e.class}: #{e.message}; check suite hooks and setup"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_result(result, number)
|
|
68
|
+
raise Error, "baseline run #{number} stopped: #{result.detail}" if result.stopped_by
|
|
69
|
+
raise Error, "baseline run #{number} exited without a result; check suite boot and exits" if result.payload.empty?
|
|
70
|
+
if result.exit_status.is_a?(Process::Status) && !result.exit_status.success?
|
|
71
|
+
raise Error, "baseline run #{number} exited unsuccessfully; fix suite cleanup and exit handlers"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
data = JSON.parse(result.payload)
|
|
75
|
+
raise Error, "baseline run #{number} returned an invalid result" unless data.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
data
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def verify_execution(data, number, expected)
|
|
81
|
+
unless data["passed"] == true
|
|
82
|
+
raise Error, "baseline run #{number} failed: #{data['error'] || 'fix the unchanged suite'}"
|
|
83
|
+
end
|
|
84
|
+
return if data["executed"].is_a?(Array) && data["executed"].sort == expected.sort
|
|
85
|
+
|
|
86
|
+
raise Error, "baseline run #{number} did not execute every requested test; check filters and suite hooks"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def failure_detail
|
|
90
|
+
return nil unless defined?(::RSpec)
|
|
91
|
+
|
|
92
|
+
::RSpec.world.all_examples.filter_map do |example|
|
|
93
|
+
exception = example.execution_result.exception
|
|
94
|
+
next unless exception
|
|
95
|
+
|
|
96
|
+
message = exception.message.to_s.lines.map(&:strip).find { |line| !line.empty? }.to_s[0, 200]
|
|
97
|
+
"#{example.id}: #{example.full_description.to_s[0, 200]}: #{message}"
|
|
98
|
+
end.first(5).join("; ")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record(writer, log_path)
|
|
102
|
+
$stdout.reopen(log_path, "w")
|
|
103
|
+
$stderr.reopen($stdout)
|
|
104
|
+
@hooks.run(:after_fork)
|
|
105
|
+
Kernel.srand(@config["seed"])
|
|
106
|
+
::RSpec.configuration.order = "rand:#{@config['seed']}" if defined?(::RSpec)
|
|
107
|
+
sequence = TestSequence.new
|
|
108
|
+
passed = @integration.run_all(sequence)
|
|
109
|
+
writer.write(JSON.generate(passed: passed, executed: sequence.executed, error: failure_detail))
|
|
110
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
111
|
+
writer.write(JSON.generate(passed: false, error: "#{e.class}: #{e.message}"))
|
|
112
|
+
ensure
|
|
113
|
+
writer.close
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|