saboteur 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +109 -247
  4. data/exe/saboteur +2 -2
  5. data/lib/saboteur/baseline/loader.rb +41 -0
  6. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  7. data/lib/saboteur/baseline/run_gate.rb +51 -0
  8. data/lib/saboteur/baseline/workflow.rb +2 -15
  9. data/lib/saboteur/cli/baseline_gate.rb +58 -0
  10. data/lib/saboteur/cli/error_cause.rb +16 -0
  11. data/lib/saboteur/cli/exit_status.rb +14 -13
  12. data/lib/saboteur/cli/failure_output.rb +23 -0
  13. data/lib/saboteur/cli/output_options.rb +24 -2
  14. data/lib/saboteur/cli/run_budget_options.rb +6 -0
  15. data/lib/saboteur/cli/run_command.rb +14 -1
  16. data/lib/saboteur/cli/run_reporting.rb +34 -6
  17. data/lib/saboteur/cli/run_validation.rb +36 -3
  18. data/lib/saboteur/cli/selection_options.rb +2 -0
  19. data/lib/saboteur/cli.rb +6 -1
  20. data/lib/saboteur/config.rb +23 -2
  21. data/lib/saboteur/crash_report.rb +16 -7
  22. data/lib/saboteur/engine/baseline_guard.rb +2 -2
  23. data/lib/saboteur/engine/coverage_partial.rb +8 -1
  24. data/lib/saboteur/engine/coverage_store.rb +4 -5
  25. data/lib/saboteur/engine/diagnostics.rb +120 -15
  26. data/lib/saboteur/engine/run_fingerprint.rb +1 -1
  27. data/lib/saboteur/engine/scheduler.rb +20 -0
  28. data/lib/saboteur/engine/selection_announcement.rb +8 -1
  29. data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
  30. data/lib/saboteur/engine.rb +43 -6
  31. data/lib/saboteur/help.rb +4 -1
  32. data/lib/saboteur/mutation_source/builtin.rb +9 -3
  33. data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
  34. data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
  35. data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
  36. data/lib/saboteur/mutation_source/operator_registry.rb +3 -1
  37. data/lib/saboteur/reporter/agent_summary.rb +3 -1
  38. data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
  39. data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
  40. data/lib/saboteur/reporter/baseline_tail.rb +93 -0
  41. data/lib/saboteur/reporter/diff.rb +10 -0
  42. data/lib/saboteur/reporter/html.rb +69 -14
  43. data/lib/saboteur/reporter/kill_causes.rb +53 -0
  44. data/lib/saboteur/reporter/next_steps.rb +4 -3
  45. data/lib/saboteur/reporter/palette.rb +64 -13
  46. data/lib/saboteur/reporter/safe_out.rb +1 -1
  47. data/lib/saboteur/reporter/score_table.rb +53 -18
  48. data/lib/saboteur/reporter/stryker.rb +4 -1
  49. data/lib/saboteur/reporter/survivor_classification.rb +12 -3
  50. data/lib/saboteur/reporter/survivor_hints.rb +72 -0
  51. data/lib/saboteur/reporter/survivor_list.rb +207 -75
  52. data/lib/saboteur/reporter/terminal.rb +315 -69
  53. data/lib/saboteur/result.rb +28 -1
  54. data/lib/saboteur/score.rb +6 -0
  55. data/lib/saboteur/version.rb +1 -1
  56. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30c6af46486b9fc1138b568c45934cc895f786a79ea5215607f493a5a7d5af68
4
- data.tar.gz: a7a8038afc6166347fac807028cf7f45acecbb7e4c27e32f8917c4167b805bac
3
+ metadata.gz: 0dd9ced23a90f2f769ccf934e50949a8d9b04324fe86aeec72c8d0de896129ed
4
+ data.tar.gz: 3c9f5628615aefcaecac88474d2badb925e57d32d17066fc0afeef642eced780
5
5
  SHA512:
6
- metadata.gz: 66817191adf235c1fb6adb41d3218704557d2d3305fab299f17cc38bb3b392733f0a52615d84aadb5e4fd940046db9336de9efa178b205d58cc8e1c61374c25a
7
- data.tar.gz: 132649f6b81d5ee312341a032df36b890af481704deee6c4b5cd4a013736c857ddc7887b27318409a1db05ea2621cbc8b2791ff4d0c40d3509810a6e13f7a405
6
+ metadata.gz: 44eb555775d94aa27ff1bf1f6b8a425faf973157bc8da3c9cbf66a337551699c63cbeba11dae6e5d9860f80002125dc60a08569d381a8f48034537b99c622a7b
7
+ data.tar.gz: bd5801edf6723c1442fed00d5c54cc82a278b23875f677427e9d3b3cc47d0c52a26bf0e7d0155a06e4de6c1eba71d25f3e724e1cfe22f180977efa09107e3256
data/CHANGELOG.md CHANGED
@@ -6,6 +6,72 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.11.0] - 2026-10-21
10
+
11
+ ### Added
12
+
13
+ - The colour contract palette: six tokens with overrides and CI-aware
14
+ detection (#1112), the `c.palette` setting for text-report colour
15
+ overrides (#1114), and one palette per stream wired from the CLI
16
+ through the engine (#1117). The terminal report, score table and safe
17
+ output (#1115), the survivor list and next steps (#1116), and the
18
+ crash and failure exit lines (#1118) render with contract tokens.
19
+ - `run --baseline PATH` with `--epsilon POINTS`: an accepted baseline
20
+ absorbs legacy survivors; only new survivors or a score drop beyond
21
+ epsilon fail the run (#1120, #1121). The run tail reports matched
22
+ counts, the gate verdict, and a re-baseline hint (#1122).
23
+ - `run --ignore-empty`: a zero-mutant `--since` diff exits 0 with a
24
+ note (#1119).
25
+ - Quiet output: the terminal reporter gains a quiet mode - one line per
26
+ finding plus the score line and verdict (#1126) - wired through the
27
+ CLI as `--quiet` (#1133). Startup chatter moves behind `--verbose`
28
+ and runs open with three quiet lines (#1134). Clean runs close with a
29
+ success screen: win tally, untested note, and reports line (#1137),
30
+ plus baseline, coverage and dots lines when phase data is present
31
+ (#1139). Progress streams as width-sized glyph chunks on a redrawing
32
+ terminal line (#1127).
33
+ - Survivors render as per-statement blocks with fenced source and
34
+ killer-test details (#1136), built from prose templates and an
35
+ edit-span helper (#1123). Each survivor shows which covering tests
36
+ ran against it (#1106), survivor labels mark exception message edits
37
+ arid (#1095), and an opt-in pass confirms survivors against the
38
+ coverage map (#1089).
39
+ - The HTML report vendors and pins the mutation-testing-elements 3.9.0
40
+ viewer (#1124), mounts it in the report (#1125) with an end-to-end
41
+ smoke test (#1128), and names the changed line each mutant relates to
42
+ in diff mode (#1110).
43
+
44
+ ### Changed
45
+
46
+ - Exit-code split: a red baseline exits 5 and an internal crash exits
47
+ 70 (both were exit 3) (#1098), documented in the Action (#1099).
48
+ - The score is honest about untested subjects (#1104).
49
+ - The kill split counts cause-not-recorded kills and exposes them to
50
+ agents (#1109).
51
+ - The README is rewritten as a top-tier dev-tool README.
52
+
53
+ ## [0.10.0] - 2026-10-21
54
+
55
+ ### Added
56
+
57
+ - Survivor labels in the terminal: surviving mutants carry a label and
58
+ the findings collapse per statement, ignored mutants are reported with
59
+ their reasons, and external-runner runs emit the `tool_limit` label
60
+ (#1065, #1071).
61
+ - Pins covering the terminal collapse, the `--all-mutants` wiring, and
62
+ the phase1 end-to-end default (#1065).
63
+
64
+ ### Changed
65
+
66
+ - The terminal disambiguates the two no-coverage skip counts (#1087).
67
+ - `test_selection = :all` is gone: the 17 mirror-breaking files are
68
+ mapped explicitly instead (#1077).
69
+
70
+ ### Fixed
71
+
72
+ - The `element_removal` help text shows that any array literal works
73
+ (#1086).
74
+
9
75
  ## [0.9.0] - 2026-10-20
10
76
 
11
77
  ### Added
data/README.md CHANGED
@@ -2,243 +2,57 @@
2
2
  <img src="assets/logo.png" alt="saboteur logo: a ninja beetle with a ruby shell carrying a lit bomb" width="200">
3
3
  </p>
4
4
 
5
- # saboteur
5
+ <h1 align="center">saboteur</h1>
6
6
 
7
- **Mutation testing for Ruby and RSpec, built for pull requests.** 🧨
8
-
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)
12
-
13
- Line coverage tells you which code ran. It does not tell you if a test
14
- would notice when that code breaks. Mutation testing does.
15
-
16
- saboteur makes one small change to your code at a time and runs only the
17
- tests that touch it, so it is fast. If the tests still pass, you see the
18
- exact change they missed. Every kill is re-checked against the unchanged
19
- code, so a broken suite never counts as a kill.
7
+ <p align="center">
8
+ <strong>Mutation testing for Ruby and RSpec, built for pull requests.</strong><br>
9
+ Line coverage tells you which code ran. saboteur tells you whether your tests would notice when it breaks.
10
+ </p>
20
11
 
21
- **Docs, benchmarks and guides live on the site:
22
- [vamsipavanmahesh.github.io/saboteur](https://vamsipavanmahesh.github.io/saboteur/)**
12
+ <p align="center">
13
+ <a href="https://github.com/vamsipavanmahesh/saboteur/actions/workflows/ci.yml"><img src="https://github.com/vamsipavanmahesh/saboteur/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
14
+ <a href="https://rubygems.org/gems/saboteur"><img src="https://img.shields.io/gem/v/saboteur" alt="Gem Version"></a>
15
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
16
+ </p>
23
17
 
24
- <a id="30-second-quick-start"></a>
25
- ## 30-second quick start
18
+ ```text
19
+ $ bundle exec saboteur pr
20
+ saboteur 0.11.0: 1 subjects, 10 mutants, wall 0.70 s, seed 1 (repeat with --seed 1)
21
+ Score: 80.0% (8/10 killed, tested subjects)
22
+ saboteur: 8 killed, 2 survived in 1s - score 80.0%
23
+ ```
26
24
 
27
- You need Ruby 3.3 or newer, RSpec, and Linux or macOS with `fork`.
25
+ A survivor is a change to your code that your tests waved through.
26
+ saboteur breaks your code one small change at a time, runs only the tests
27
+ that touch it, and shows you the exact change they missed. Every kill is
28
+ re-checked against the unchanged code, so a broken suite never counts as
29
+ a kill. A green run here actually means something.
28
30
 
29
- Prerequisite: the sandbox layers use fiddle, which ships with Ruby
30
- (a default gem on Ruby 3.x). On Ruby 4.0+, where fiddle is a bundled
31
- gem, add `gem "fiddle"` to your Gemfile. If saboteur refuses a run
32
- because fiddle cannot load: reinstall Ruby, run `gem install fiddle`,
33
- or install libffi headers (libffi-dev on Debian/Ubuntu, libffi-devel
34
- on Fedora/RHEL) and reinstall fiddle. libffi headers are required only
35
- when Ruby's shipped fiddle is missing or must be installed standalone.
31
+ ## Get started in 3 steps
36
32
 
37
33
  ```sh
38
- # Add it to your project's Gemfile:
34
+ # 1. Add it to your project's Gemfile
39
35
  bundle add saboteur --group test
40
36
 
41
- # Then:
37
+ # 2. Run it (the first run builds a cached coverage map)
42
38
  bundle exec saboteur run
43
- ```
44
-
45
- Outside a Bundler project, `gem install saboteur` gives the same
46
- executable - run it there as plain `saboteur run`, without
47
- `bundle exec`.
48
-
49
- The first run builds a coverage map of which tests touch which code. The
50
- map is cached and reused while your code and tests stay unchanged, so
51
- later runs start fast.
52
-
53
- If the first run stops with exit 2 and `no spec found for ...`, saboteur
54
- could not map a source file to its spec: it matches by path first, then
55
- by parent directory and basename when the layout strips directories,
56
- and it refuses to guess wrong. Point it at the specs yourself - set
57
- `test_selection = :all` in `config/saboteur.rb` to run the whole suite
58
- against each mutant, pass `--test spec/...` to name the specs, or
59
- declare a custom map
60
- ([monorepos and the custom map](docs/guides/monorepo-and-custom-map.md)).
61
-
62
- On a branch, `bundle exec saboteur pr` mutates only the methods you
63
- changed relative to `main`, with one baseline pass and a ten-minute
64
- budget. If the budget stops the run, it exits 4 and prints `partial: X
65
- of Y mutants not run` (or says the count is unknown when planning did
66
- not finish). Pass `--max-duration-ok` to accept a partial verdict; the
67
- warning stays, and other failure gates still apply. Pass `--since` for a
68
- different base, for example `bundle exec saboteur pr --since master`.
69
-
70
- Setup, configuration and CI:
71
- [the quickstart](https://vamsipavanmahesh.github.io/saboteur/quickstart.html).
72
-
73
- ## Why RSpec-only
74
-
75
- A mutation verdict is only worth anything if the test selection behind it
76
- is exact. RSpec's example structure lets saboteur map each method to the
77
- examples that cover it, rerun just those against each change, and re-check
78
- every kill against the code before the change. One framework, done
79
- properly - that is the bet. Minitest is not supported today;
80
- [why, and what it would take](docs/minitest.md).
81
-
82
- ## What a run tells you
83
-
84
- ```text
85
- saboteur 0.7.0: 1 subjects, 10 mutants, wall 0.70 s, seed 1
86
- killed 8 survived 2 timeout 0 resource limit 0 error 0 score 80.0 %
87
- ```
88
-
89
- Each survivor comes with its diff, the tests that ran, and a triage label
90
- (`likely_equivalent`, `arid`, `missing_test`) to sort what to read first.
91
- The labels are heuristics for reading order, never a diagnosis, and they
92
- never change the score.
93
-
94
- A run on a tree with uncommitted changes opens with a warning that splits
95
- the count into changes inside the mutation scope, outside it, and
96
- saboteur's own setup files. Mutants run your code, so run on a clean,
97
- committed tree or a CI checkout.
98
-
99
- Code that only runs while the app loads cannot be judged by replacing the
100
- method later, so it reports as `ran at load`, outside the score. The same
101
- holds for def-time macros - a `deprecate`-style helper, a `before_action`
102
- block, any DSL whose only caller is a class body: the closure captured at
103
- load keeps the pristine definition, so the boot's def-call counters mark
104
- the macro itself `ran at load` instead of letting its mutants survive.
105
-
106
- ## What you get
107
-
108
- - **Fast pull-request runs.** `--since main` scopes the run to the methods
109
- you changed, refuses (exit 2) when the resolved range has no changes -
110
- `saboteur pr $GITHUB_SHA` on a checkout at the merge commit diffs the
111
- merge against itself - and fails loudly (exit 3) when the changed code
112
- cannot be verified - for example when every changed method is skipped
113
- because a refinement is active where it sits. A changed method skipped
114
- for no coverage names the specs the run loaded; when its coverage
115
- lives in a sibling spec file, pass `--test spec/models` (the directory
116
- holding it) to load it. The GitHub Action runs on every pull request
117
- and caches the coverage map between runs. `--shard K/N` splits one
118
- planned inventory deterministically across CI jobs; each shard reports
119
- partial evidence.
120
- - **Honest verdicts.** Every kill is verified against the unchanged
121
- method; a kill the suite cannot confirm stays out of the score.
122
- Suspect a flaky test? `--flaky-check survivors` confirms each new
123
- survivor with one reordered rerun (`decisive` also confirms fresh
124
- kills); a verdict the rerun disagrees with becomes `unverified`,
125
- named with its tests, and stays out of the score. A spec
126
- file whose examples depend on earlier ones is retried in defined order
127
- before a kill is rejected, so its mutants are still scored.
128
- - **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
129
- JSON report, and a stable agent JSON API with a deprecation policy.
130
- The GitHub Action posts surviving mutants as review comments on the
131
- diff lines of the pull request.
132
- - **Resumable runs.** `--resume DIR` keeps verdicts across runs and
133
- `--incremental` checkpoints them while the run is going, so a stopped
134
- run picks up where it left off.
135
- - **Faster coverage builds.** `c.coverage_parts` builds the coverage map
136
- in parallel parts, `partial_coverage = "use"` rebuilds only the stale
137
- spec files and merges them into the saved map, and
138
- `c.coverage_recorder = "trace"` builds the map with a TracePoint
139
- recorder.
140
- - **A plan before the run.** `saboteur run --dry-run --format json` prints
141
- the versioned [mutant inventory](docs/dry-run-json.md) - every planned
142
- mutant with its stable digest and edits - without booting the suite.
143
- - **A review workflow.** An ignore list with reasons and triage states,
144
- an opt-in survivor baseline, score thresholds and score history.
145
- `saboteur survivors .saboteur/report.json` browses survivors in the
146
- terminal straight from the JSON report - list, filter
147
- (`--file`/`--operator`/`--triage`/`--ranked`), and `--show DIGEST`
148
- for the diff, the exact `run --id` replay command and an ignore-file
149
- entry template. It never boots your project or edits the ignore file.
150
- - **A local README badge.** `saboteur badge` writes Shields endpoint JSON
151
- from the score history and `saboteur trend` prints the score delta -
152
- no hosted service:
153
-
154
- ```md
155
- ![mutation score](https://img.shields.io/endpoint?url=<raw-url-of-the-badge-json>)
156
- ```
157
-
158
- Incomplete runs are refused as badge evidence unless `--mark-partial`
159
- labels them, and the trend never compares runs with different
160
- selection, inventory or operator policy.
161
- - **Safety controls.** A hazard guard, canary values, and time, memory
162
- and process-count limits on every mutant in the default in-process
163
- mode; a Landlock sandbox on Linux
164
- 6.2 and newer.
165
- - **Extension points.** Add your own mutation operators through the
166
- public interface.
167
39
 
168
- Per-path score gates hold one subtree to a stricter floor: `c.score_gate
169
- "app/payments/**/*", min_score: 95` in config/saboteur.rb (repeatable). Each
170
- gate scores only the mutants under its pattern and fails the run under its
171
- floor; a gate with nothing executed under it passes vacuously and says so.
172
- See
173
- [choosing and raising a threshold](docs/best-practices.md#choosing-and-raising-a-threshold)
174
- for how to pick a number.
175
-
176
- ## Honest limits
177
-
178
- - saboteur is RSpec-only and needs `fork`: no Windows, no Minitest.
179
- - A survivor is review evidence, not proof of a bug in your tests. It can
180
- be a missing test, an equivalent change, a flaky test, or a limit of
181
- the tool. Read it before you write a test for it.
182
- - `--since` and `--incremental` are iteration aids. Run the whole project
183
- on main and before a release.
184
- - The hazard guard is syntactic, sandboxing varies by platform (Landlock
185
- on Linux 6.2+; on macOS mutants run unsandboxed unless `c.sandbox = "seatbelt"`
186
- opts into a probe-gated Seatbelt profile; see
187
- [the macOS Seatbelt sandbox](docs/guides/macos-seatbelt-sandbox.md)), and network access is
188
- never restricted. Stub real APIs and use a container when that matters.
189
-
190
- ### Custom spec mapping
191
-
192
- When the naming guess does not fit the project's layout, teach the mapping
193
- once in `config/saboteur_custom_map.rb` instead of passing `--test` on every
194
- run:
195
-
196
- ```ruby
197
- SABOTEUR_CUSTOM_MAP = {
198
- "lib/aasm/core/state.rb" => ["spec/unit/state_spec.rb"],
199
- "app/jobs/**/*.rb" => ["spec/jobs/**/*_spec.rb"],
200
- %r{\Aapp/jobs/(.*)\.rb\z} => ['spec/jobs/\1_spec.rb'],
201
- "app/generated/**/*.rb" => [], # intentionally untested
202
- }.freeze
40
+ # 3. Put it on every pull request - mutates only what you changed vs main
41
+ bundle exec saboteur pr
203
42
  ```
204
43
 
205
- A matching entry wins over the naming convention: the file stops being
206
- unmatched and its mapped specs run as the inferred ones. Keys are exact
207
- paths, globs or Regexps; an empty array marks the file intentionally
208
- untested, so it needs no inferred spec. The file loads before the app
209
- boots, so keep it plain Ruby. `saboteur doctor` validates it.
210
-
211
- ### Rejected kills
212
-
213
- The terminal lists them in a `Rejected kills` section and annotates the error
214
- count (`error 3 (2 rejected kills)`); JSON flags each rejected mutant with
215
- `rejectedKill: true` and keeps the `rejectedKills` count. These are fresh
216
- per-mutant kill claims whose tests also fail with the method unchanged. They
217
- stay errors outside the score. Timeout checks, other errors and reused cached
218
- kills are not included. See [rejected kills](docs/rejected-kills.md) for
219
- details.
44
+ You need Ruby 3.3 or newer, RSpec, and Linux or macOS with `fork`. The
45
+ sandbox layers use fiddle, which ships with Ruby; on Ruby 4.0+, where
46
+ fiddle is a bundled gem, add `gem "fiddle"` to your Gemfile. Outside
47
+ a Bundler project, `gem install saboteur` gives the same executable.
48
+ Rails app? `bundle exec rails generate saboteur:install` writes the config
49
+ for you. Full setup:
50
+ [the quickstart](https://vamsipavanmahesh.github.io/saboteur/quickstart.html).
220
51
 
221
52
  ### Rails apps
222
53
 
223
- Quickstart for a Rails + RSpec app. The defaults target a plain gem, so a
224
- Rails app needs a few settings; the generator writes them for you:
225
-
226
- ```sh
227
- bundle add saboteur --group test
228
- bundle exec rails generate saboteur:install
229
- ```
230
-
231
- This writes `config/saboteur.rb`, a `bin/saboteur` binstub (it sets
232
- `RAILS_ENV=test` for you) and a `.gitignore` entry for `.saboteur/`. It
233
- never overwrites an existing file unless you pass `--force`.
234
-
235
- Until a config names your sources, a run whose include selects no Ruby
236
- file refuses with exit 2 and points at the install generator above -
237
- a first CI run on an unconfigured tree fails loud instead of passing
238
- with 0 subjects.
239
-
240
- Prefer to do it by hand, or on a version without the generator? Put this
241
- in `config/saboteur.rb`:
54
+ `bundle exec rails generate saboteur:install` writes the config for you.
55
+ By hand, put this in `config/saboteur.rb`:
242
56
 
243
57
  ```ruby
244
58
  Saboteur.configure do |c|
@@ -249,41 +63,89 @@ Saboteur.configure do |c|
249
63
  end
250
64
  ```
251
65
 
252
- An engine gem whose specs live under `spec/dummy` needs an exclude for them; see
66
+ The defaults target a plain gem, so a first run on an unconfigured Rails
67
+ tree refuses with exit 2 and points at the generator. Engine gem with
68
+ `spec/dummy`? See
253
69
  [Rails engines and dummy apps](docs/guides/rails-engines-and-dummy-apps.md).
254
70
 
255
- ## SimpleCov
256
-
257
- saboteur needs Ruby's `Coverage` for itself, so leave SimpleCov off for
258
- saboteur runs - for example, start it only when `ENV["COVERAGE"]` is set.
259
- The [recipes](docs/recipes/README.md) cover this and VCR, WebMock and
260
- Sidekiq.
261
-
262
- <a id="dated-validation-runs"></a>
263
- <a id="where-it-has-run"></a>
264
- ## Benchmarks and validation
265
-
266
- Speed and memory numbers live with the harness and the raw data that
267
- reproduce them, framed as dated measurements rather than standing claims:
268
- the [benchmarks page](https://vamsipavanmahesh.github.io/saboteur/benchmark/)
269
- on the site and [docs/benchmark](docs/benchmark/) in this repo.
71
+ ## Highlights
72
+
73
+ - ⚡ **Fast by scope.** `--since main` mutates only the methods you
74
+ changed, `saboteur pr` adds one baseline pass and a ten-minute budget,
75
+ and `--shard K/N` splits one planned inventory deterministically across
76
+ CI jobs.
77
+ - 🎯 **A score that tells the truth.** The score covers tested subjects
78
+ only. A method no test covers is named in a `Not scored` list instead of
79
+ padding the number, kills split by cause (`kills: 7 by a failed
80
+ assertion, 1 by a crash`), and an error-dominated run prints
81
+ `Score: n/a` instead of a number you would misread.
82
+ - 🔍 **Survivors you can act on.** Survivors group per statement with a
83
+ triage label (`likely_equivalent`, `arid`, `missing_test`) and their
84
+ covering tests - split into the tests that ran against the mutant and
85
+ the ones that never did. `saboteur survivors` browses them read-only:
86
+ one digest shows the full diff and the exact replay command.
87
+ - 🧪 **Flakiness contained.** `--flaky-check survivors` re-runs each new
88
+ survivor in a reordered confirmation pass; a verdict the rerun disagrees
89
+ with becomes `unverified` and stays out of the score. With
90
+ `partial_coverage = "use"`, open survivors get one budgeted confirmation
91
+ run against the covering specs that never ran them.
92
+ - 📊 **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
93
+ JSON report, and a stable agent JSON API with a deprecation policy. The
94
+ GitHub Action posts survivors as review comments on your PR diff lines.
95
+ `saboteur badge` and `saboteur trend` track the score history with no
96
+ hosted service.
97
+ - 🛡️ **Safe to point at real code.** A hazard guard, canary values, and
98
+ time, memory and process-count limits on every mutant; a Landlock
99
+ sandbox on Linux 6.2+ and an opt-in Seatbelt profile on macOS.
100
+ - 🧭 **A plan before the run.** `--dry-run --format json` prints the
101
+ versioned mutant inventory - every planned mutant with its stable digest
102
+ and edits - without booting your suite.
103
+ - 🔁 **Built for big suites.** `--resume` and `--incremental` pick up
104
+ where a stopped run left off, parallel coverage builds
105
+ (`c.coverage_parts`) and a TracePoint recorder keep the map cheap, and
106
+ per-path score gates hold one subtree to a stricter floor.
107
+
108
+ ## Exit codes that mean something
109
+
110
+ | Exit | Meaning |
111
+ | --- | --- |
112
+ | 0 | Clean: no unignored survivors, every gate passed |
113
+ | 1 | Survivors to review, or a gate failed |
114
+ | 2 | Usage or configuration error |
115
+ | 3 | Incomplete: tested nothing, mostly errored, or the change is unverified |
116
+ | 4 | The `--max-duration` budget stopped the run early |
117
+ | 5 | Your unchanged suite already fails its baseline - fix your tests first |
118
+ | 70 | An internal crash: a saboteur bug, never your code; the error names a log with the full backtrace |
119
+
120
+ ## Know the limits
121
+
122
+ - RSpec-only, and it needs `fork`: no Windows, and no Minitest
123
+ ([why, and what it would take](docs/minitest.md)).
124
+ - saboteur needs Ruby's `Coverage` for itself, so leave SimpleCov off for
125
+ saboteur runs - start it only when `ENV["COVERAGE"]` is set
126
+ ([recipes](docs/recipes/README.md)).
127
+ - A survivor is review evidence, not proof of a bug in your tests. It can
128
+ be a missing test, an equivalent change, a flaky test, or a limit of
129
+ the tool. Read it before you write a test for it.
130
+ - `--since` and `--incremental` are iteration aids. Run the whole project
131
+ on main and before a release.
132
+ - Sandboxing varies by platform and network access is never restricted.
133
+ Stub real APIs and use a container when that matters.
270
134
 
271
- ## Project
135
+ ## Docs
272
136
 
137
+ - [Quickstart and guides](https://vamsipavanmahesh.github.io/saboteur/quickstart.html) - setup, CI, recipes
138
+ - [All documentation](docs/README.md) - configuration, operators, recipes
273
139
  - [Changelog](CHANGELOG.md)
274
- - [All documentation](docs/README.md) - configuration, operators, CI, recipes
140
+ - [Benchmarks](https://vamsipavanmahesh.github.io/saboteur/benchmark/) - dated measurements with the raw data
275
141
  - [Contributing](CONTRIBUTING.md) - every commit needs a DCO `Signed-off-by` line
276
- - [Design notes](docs/design.md)
277
142
 
278
143
  ## Disclaimer
279
144
 
280
- saboteur is provided "as is", without warranty of any kind, express or
281
- implied. In no event shall the authors or copyright holders be liable
282
- for any claim, damages or other liability arising from its use -
283
- including damage caused by mutating your code or by running unusual
284
- DSLs and metaprogramming-heavy code. Mutation testing runs your test
285
- suite against deliberately broken code; run it on a clean, committed
286
- tree or a CI checkout. The full text is in [LICENSE](LICENSE).
145
+ saboteur is provided "as is", without warranty of any kind. Mutation
146
+ testing runs your test suite against deliberately broken code; run it on a
147
+ clean, committed tree or a CI checkout. The full text is in
148
+ [LICENSE](LICENSE).
287
149
 
288
150
  ## License
289
151
 
data/exe/saboteur CHANGED
@@ -8,7 +8,7 @@
8
8
  # is being required, gets the full report (the bounded one-liner, the log
9
9
  # file, the --verbose backtrace and agent JSON when the CLI loaded far
10
10
  # enough). If even the reporter cannot load, a bare normalized one-liner
11
- # (one line, invalid bytes scrubbed, bounded length) still exits 3.
11
+ # (one line, invalid bytes scrubbed, bounded length) still exits 70.
12
12
  begin
13
13
  require "coverage"
14
14
  Coverage.setup(lines: true, branches: true) if Coverage.state == :idle
@@ -19,7 +19,7 @@ rescue LoadError, StandardError => e
19
19
  message = "#{message[0...160]}..." if message.length > 160
20
20
  warn "saboteur: internal error (#{e.class}: #{message}) - please report it: " \
21
21
  "https://github.com/vamsipavanmahesh/saboteur/issues"
22
- exit 3
22
+ exit 70
23
23
  end
24
24
 
25
25
  # For `run` coverage counts from here: what executes while the app and
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+
7
+ require_relative "acceptance"
8
+ require_relative "evidence"
9
+ require_relative "manifest"
10
+ require_relative "../text"
11
+
12
+ module Saboteur
13
+ module Baseline
14
+ # Loading an accepted baseline manifest, shared by the baseline workflow
15
+ # and the `run --baseline` gate: size cap, strict parse, then manifest and
16
+ # evidence validation. Returns [manifest, sha256 of the exact bytes], so a
17
+ # caller can notice the file changing while the run executes.
18
+ module Loader
19
+ module_function
20
+
21
+ def call(path)
22
+ raise ConfigError, "baseline file too large" if File.size(path) > Acceptance::MAX_BYTES
23
+
24
+ bytes = File.read(path)
25
+ digest = Digest::SHA256.hexdigest(bytes)
26
+ manifest = Manifest.new(JSON.parse(Text.utf8_strict(bytes)))
27
+ metadata = manifest.to_h.fetch("metadata")
28
+ candidates = manifest.candidates.values
29
+ restored = metadata.slice("snapshot", "subjects", "complete", "issues").merge(
30
+ "compatibility" => manifest.compatibility, "candidates" => candidates,
31
+ "planned" => candidates.map { |entry| entry.slice(*Evidence::PLAN_FIELDS) },
32
+ "generation_events" => [], "omissions" => [], "discovery_failures" => [], "inline_suppressions" => []
33
+ )
34
+ Evidence.new(restored)
35
+ [manifest, digest]
36
+ rescue SystemCallError, JSON::ParserError, ArgumentError => e
37
+ raise ConfigError, "baseline file: #{e.message}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -12,7 +12,7 @@ module Saboteur
12
12
  # This does not attest external database, network or environment state.
13
13
  class RunEvidence
14
14
  SAFE_DROPS = %w[unchanged duplicate].freeze
15
- OUTPUT_SETTINGS = Config::OUTPUT_KEYS
15
+ OUTPUT_SETTINGS = Config::OUTPUT_KEYS + Config::PRESENTATION_KEYS
16
16
 
17
17
  def initialize(config:, extra_files: [])
18
18
  @config = config
@@ -0,0 +1,51 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "comparison"
5
+
6
+ module Saboteur
7
+ module Baseline
8
+ # The `saboteur run --baseline PATH` verdict: after a run, its execution
9
+ # evidence is compared with the accepted baseline. Comparison owns the
10
+ # classification and the decimal score gate; this names the run-surface
11
+ # outcome: new survivors by digest or a score drop beyond the epsilon
12
+ # fail the run, while accepted legacy survivors no longer do.
13
+ class RunGate
14
+ attr_reader :comparison
15
+
16
+ def initialize(comparison)
17
+ @comparison = comparison
18
+ end
19
+
20
+ def self.build(manifest:, report:, epsilon: nil)
21
+ evidence = report.run_evidence
22
+ raise Error, "--baseline needs the run's execution evidence; the run captured none" unless evidence
23
+
24
+ new(Comparison.new(baseline: { "compatibility" => manifest.compatibility,
25
+ "accepted" => manifest.accepted.values,
26
+ "candidates" => manifest.candidates.values },
27
+ current: evidence, epsilon: epsilon))
28
+ end
29
+
30
+ def new_survivor_ids
31
+ comparison.categories.fetch("new").map { |entry| entry.fetch("id") }
32
+ end
33
+
34
+ def incompatible?
35
+ comparison.state == "incompatible"
36
+ end
37
+
38
+ def incomplete?
39
+ comparison.state == "incomplete"
40
+ end
41
+
42
+ def score_exceeded?
43
+ comparison.score ? comparison.score.fetch("exceeded") : false
44
+ end
45
+
46
+ def failed?
47
+ new_survivor_ids.any? || score_exceeded?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative "acceptance"
5
5
  require_relative "comparison"
6
+ require_relative "loader"
6
7
  require_relative "../engine"
7
8
  require_relative "../text"
8
9
 
@@ -67,22 +68,8 @@ module Saboteur
67
68
  private
68
69
 
69
70
  def load_manifest(path)
70
- raise ConfigError, "baseline file too large" if File.size(path) > Acceptance::MAX_BYTES
71
-
72
- bytes = File.read(path)
73
- @baseline_digest = Digest::SHA256.hexdigest(bytes)
74
- manifest = Manifest.new(JSON.parse(Text.utf8_strict(bytes)))
75
- metadata = manifest.to_h.fetch("metadata")
76
- candidates = manifest.candidates.values
77
- restored = metadata.slice("snapshot", "subjects", "complete", "issues").merge(
78
- "compatibility" => manifest.compatibility, "candidates" => candidates,
79
- "planned" => candidates.map { |entry| entry.slice(*Evidence::PLAN_FIELDS) },
80
- "generation_events" => [], "omissions" => [], "discovery_failures" => [], "inline_suppressions" => []
81
- )
82
- Evidence.new(restored)
71
+ manifest, @baseline_digest = Loader.call(path)
83
72
  manifest
84
- rescue SystemCallError, JSON::ParserError, ArgumentError => e
85
- raise ConfigError, "baseline file: #{e.message}"
86
73
  end
87
74
 
88
75
  def store(snapshot)