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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6007efb7a83e5669822a1622876457e9e654187acc923981e641835258c7637d
4
- data.tar.gz: 373680f8e52d7cbd24f1eb3dde2322101eccf565c2752e10a174bd5ff9e6d8e2
3
+ metadata.gz: 767101a27863dfddcfd14ed0f71737ba34764feebd1824625c2354119f5144cc
4
+ data.tar.gz: 237fca7fc890cf682a4f4b10f2e2fbdfc23759d575ba5dfd804d60bc28febcb3
5
5
  SHA512:
6
- metadata.gz: c0d37ea099ca68c5dc3608e2229446cb4e4494c623e63bb8033eb85a8a10bd7953e2cc84031678a15adda5da0b0da1f6bb1177badde04fa06dbd472f2d674534
7
- data.tar.gz: 7484ed59697969071030c78ab81b8a716da93b09d4e95900b69b110f087809a6e1293153375104732457205af0e0cfb94f64eb34810242febd1e63be613fb97a
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 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 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
- 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.
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
- 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.
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
- ## Extending
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
- only one that ships).
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
- ## How it works
335
+ ## 🌍 Where it has run
107
336
 
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).
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
- ## Benchmark
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
- ## Requirements
394
+ ## πŸ—ΊοΈ Where it is going
132
395
 
133
- Ruby 3.3 or newer on Linux or macOS (it needs `fork`).
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