saboteur 0.10.0 → 0.11.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +109 -248
  4. data/exe/saboteur +2 -2
  5. data/lib/saboteur/baseline/command.rb +46 -2
  6. data/lib/saboteur/baseline/comparison.rb +6 -1
  7. data/lib/saboteur/baseline/completeness.rb +21 -0
  8. data/lib/saboteur/baseline/evidence.rb +11 -3
  9. data/lib/saboteur/baseline/loader.rb +54 -0
  10. data/lib/saboteur/baseline/manifest.rb +13 -4
  11. data/lib/saboteur/baseline/run_evidence.rb +4 -2
  12. data/lib/saboteur/baseline/run_gate.rb +51 -0
  13. data/lib/saboteur/baseline/workflow.rb +59 -24
  14. data/lib/saboteur/baseline_health.rb +4 -1
  15. data/lib/saboteur/cli/baseline_gate.rb +58 -0
  16. data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
  17. data/lib/saboteur/cli/doctor_command.rb +10 -1
  18. data/lib/saboteur/cli/error_cause.rb +16 -0
  19. data/lib/saboteur/cli/exit_status.rb +18 -16
  20. data/lib/saboteur/cli/explicit_sources.rb +1 -1
  21. data/lib/saboteur/cli/failure_output.rb +25 -2
  22. data/lib/saboteur/cli/output_options.rb +22 -2
  23. data/lib/saboteur/cli/presets.rb +11 -1
  24. data/lib/saboteur/cli/run_budget_options.rb +6 -0
  25. data/lib/saboteur/cli/run_command.rb +18 -2
  26. data/lib/saboteur/cli/run_reporting.rb +33 -6
  27. data/lib/saboteur/cli/run_validation.rb +36 -3
  28. data/lib/saboteur/cli/selection_options.rb +2 -0
  29. data/lib/saboteur/cli/survivors_command.rb +81 -17
  30. data/lib/saboteur/cli.rb +6 -1
  31. data/lib/saboteur/config.rb +23 -2
  32. data/lib/saboteur/crash_report.rb +18 -8
  33. data/lib/saboteur/doctor.rb +3 -1
  34. data/lib/saboteur/engine/baseline_guard.rb +2 -2
  35. data/lib/saboteur/engine/boot.rb +4 -2
  36. data/lib/saboteur/engine/coverage_partial.rb +8 -1
  37. data/lib/saboteur/engine/diagnostics.rb +120 -15
  38. data/lib/saboteur/engine/result_cache.rb +4 -2
  39. data/lib/saboteur/engine/run_fingerprint.rb +1 -1
  40. data/lib/saboteur/engine/scheduler.rb +22 -0
  41. data/lib/saboteur/engine/selection_announcement.rb +8 -1
  42. data/lib/saboteur/engine/subject_selection.rb +1 -1
  43. data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
  44. data/lib/saboteur/engine/test_guess.rb +1 -1
  45. data/lib/saboteur/engine/thread_warnings.rb +9 -4
  46. data/lib/saboteur/engine.rb +64 -9
  47. data/lib/saboteur/execution/fiddle_loader.rb +29 -2
  48. data/lib/saboteur/help.rb +6 -2
  49. data/lib/saboteur/ignore_list.rb +15 -2
  50. data/lib/saboteur/install.rb +18 -3
  51. data/lib/saboteur/mutation_source/builtin.rb +4 -0
  52. data/lib/saboteur/preview.rb +6 -1
  53. data/lib/saboteur/reporter/agent_commands.rb +6 -1
  54. data/lib/saboteur/reporter/agent_summary.rb +3 -1
  55. data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
  56. data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
  57. data/lib/saboteur/reporter/baseline_tail.rb +95 -0
  58. data/lib/saboteur/reporter/diff.rb +10 -0
  59. data/lib/saboteur/reporter/html.rb +207 -17
  60. data/lib/saboteur/reporter/kill_causes.rb +53 -0
  61. data/lib/saboteur/reporter/next_steps.rb +11 -8
  62. data/lib/saboteur/reporter/notice.rb +24 -0
  63. data/lib/saboteur/reporter/palette.rb +64 -13
  64. data/lib/saboteur/reporter/reuse_note.rb +37 -0
  65. data/lib/saboteur/reporter/review_comments.rb +11 -7
  66. data/lib/saboteur/reporter/safe_out.rb +1 -1
  67. data/lib/saboteur/reporter/score_table.rb +53 -18
  68. data/lib/saboteur/reporter/stryker.rb +15 -2
  69. data/lib/saboteur/reporter/survivor_hints.rb +70 -0
  70. data/lib/saboteur/reporter/survivor_list.rb +207 -99
  71. data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
  72. data/lib/saboteur/reporter/terminal.rb +306 -69
  73. data/lib/saboteur/result.rb +56 -8
  74. data/lib/saboteur/score.rb +6 -0
  75. data/lib/saboteur/selection/git_diff.rb +19 -1
  76. data/lib/saboteur/survivor_guidance.rb +33 -0
  77. data/lib/saboteur/version.rb +1 -1
  78. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7d127c4a50466eb11e40c8f1f8ac92b013621d76ddfd3f122823ec85f0ff726
4
- data.tar.gz: 8768ae6add39713a3ffb0222609c096ddb8e71e7edcb174f83e0ce3719c6296f
3
+ metadata.gz: 74914fa96e0e644c7615013b6ad8a014c3c74b01e9b154931f2087fa0685dd31
4
+ data.tar.gz: 3265c82c7383a4ec2ce6355c009fc5816822e96334224e05a3cf6dac05404504
5
5
  SHA512:
6
- metadata.gz: eb07cd559cf3b12dadd0e7b1a148f04e2e6aada3e961f9dfcd249f05da69cd7f59bc8a43335450df86ae4b6406be013ca7e737357ac3e07023169f6f9d434e52
7
- data.tar.gz: 1cb27f6614903b63907f1ddf54872bdcfa1e1ab2c288bd40f4ed3ce9e247bed320105afb33fcb03e845a03887f7343250c36d2b82f35a1c7da61227dd83f0244
6
+ metadata.gz: de0a4aa5db3d0403a46d9db2390fca263b9d7b433a9bbdb5d28049402c07d38ac75f2f68221bc4576ca3bc1a27c90c42aa7c62ba9f19d67eb6183507cfeef43b
7
+ data.tar.gz: 9d0286667bae0a37141efacd0547cb62d7185567ca8728f39be84902905f149f7d08f8eff2df07b2601083a8118a539d9d34fed06cfd47ec3840490c8ceb65a6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,101 @@ All notable changes are documented here. The format follows
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
+ ## [0.11.1] - 2026-10-22
9
+
10
+ ### Added
11
+
12
+ - Diff runs echo the real `--since` ref in hints and name the diff
13
+ scope in the reports (#1235, #1218, #1219), and `survivors` reads the
14
+ last report by default and refuses ambiguous digest prefixes (#1235,
15
+ #1189, #1188, #1192).
16
+ - `baseline propose` tolerates untested subjects: proposability is the
17
+ gate at propose time, while strict evidence completeness stays where
18
+ it is required (#1168, #1141). The baseline command names what it is
19
+ doing, the file it writes and the follow-up command at every step,
20
+ and blockers resolve to subject, operator and reason plus a
21
+ remediation line (#1217, #1195, #1196).
22
+ - The GitHub Action gains an opt-in `max-duration` input passed to
23
+ `--max-duration`, summary lines for exits 2/3/4/5/70, an annotation
24
+ on exit 4, and failure messages that each name the command or
25
+ setting that fixes them (#1220).
26
+ - Incremental runs report reuse feedback (#1221), and the HTML report
27
+ gains a copy button on survivor blocks, print rules and aria fixes
28
+ (#1231).
29
+
30
+ ### Changed
31
+
32
+ - Warning notices, the baseline tail and the CLI rescue paths render
33
+ through the colour palette (monochrome output is byte-identical), and
34
+ first runs are quieter: the Ruby 3.4 fiddle stdlib notice is dropped
35
+ during the guarded require, Rails mode no longer warns about the
36
+ harmless AR Pool Reaper thread, and the no-spec stop ends with a
37
+ clearer custom-map pointer (#1236, #1226, #1229, #1230).
38
+ - Survivors rank by per-statement density first (the statement with
39
+ the most surviving mutants leads), then by thin coverage; ranking is
40
+ review priority only, with no score or exit-code effect (#1163).
41
+ - `survivors --show` prints the complete detail - diff, replay command
42
+ with the run's seed, and a paste-correct ignore template (#1194,
43
+ #1217) - and unknown-command and doctor include errors are clearer
44
+ (#1194).
45
+ - First-run DX: the footer ignore template pastes into `.saboteur.rb`
46
+ unchanged, install prints the next step, and lifecycle warnings are
47
+ quieter (#1217, #1221). The survivor-confirmation skip notice names
48
+ the real reason (#1231, #1184, #1177).
49
+ - The HTML report is printable and collapsible, with clearer baseline
50
+ errors (#1179).
51
+
52
+ ### Fixed
53
+
54
+ - Survivor confirmation no longer crashes the run (exit 70): a
55
+ confirmation failure degrades to a warning (#1164, #1140).
56
+ - README hero output shows the right subject count (#1162), and the
57
+ exit-code docs match the gates (#1231, #1222).
58
+
59
+
60
+ ## [0.11.0] - 2026-10-21
61
+
62
+ ### Added
63
+
64
+ - The colour contract palette: six tokens with overrides and CI-aware
65
+ detection (#1112), the `c.palette` setting for text-report colour
66
+ overrides (#1114), and one palette per stream wired from the CLI
67
+ through the engine (#1117). The terminal report, score table and safe
68
+ output (#1115), the survivor list and next steps (#1116), and the
69
+ crash and failure exit lines (#1118) render with contract tokens.
70
+ - `run --baseline PATH` with `--epsilon POINTS`: an accepted baseline
71
+ absorbs legacy survivors; only new survivors or a score drop beyond
72
+ epsilon fail the run (#1120, #1121). The run tail reports matched
73
+ counts, the gate verdict, and a re-baseline hint (#1122).
74
+ - `run --ignore-empty`: a zero-mutant `--since` diff exits 0 with a
75
+ note (#1119).
76
+ - Quiet output: the terminal reporter gains a quiet mode - one line per
77
+ finding plus the score line and verdict (#1126) - wired through the
78
+ CLI as `--quiet` (#1133). Startup chatter moves behind `--verbose`
79
+ and runs open with three quiet lines (#1134). Clean runs close with a
80
+ success screen: win tally, untested note, and reports line (#1137),
81
+ plus baseline, coverage and dots lines when phase data is present
82
+ (#1139). Progress streams as width-sized glyph chunks on a redrawing
83
+ terminal line (#1127).
84
+ - Survivors render as per-statement blocks with fenced source and
85
+ killer-test details (#1136), built from prose templates and an
86
+ edit-span helper (#1123). Each survivor shows which covering tests
87
+ ran against it (#1106), survivor labels mark exception message edits
88
+ arid (#1095), and an opt-in pass confirms survivors against the
89
+ coverage map (#1089).
90
+ - The HTML report vendors and pins the mutation-testing-elements 3.9.0
91
+ viewer (#1124), mounts it in the report (#1125) with an end-to-end
92
+ smoke test (#1128), and names the changed line each mutant relates to
93
+ in diff mode (#1110).
94
+
95
+ ### Changed
96
+
97
+ - Exit-code split: a red baseline exits 5 and an internal crash exits
98
+ 70 (both were exit 3) (#1098), documented in the Action (#1099).
99
+ - The score is honest about untested subjects (#1104).
100
+ - The kill split counts cause-not-recorded kills and exposes them to
101
+ agents (#1109).
102
+ - The README is rewritten as a top-tier dev-tool README.
8
103
 
9
104
  ## [0.10.0] - 2026-10-21
10
105
 
data/README.md CHANGED
@@ -2,244 +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: 2 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. The terminal prints one finding per statement with
93
- the label mix and mutant count; `--all-mutants` lists every mutant.
94
-
95
- A run on a tree with uncommitted changes opens with a warning that splits
96
- the count into changes inside the mutation scope, outside it, and
97
- saboteur's own setup files. Mutants run your code, so run on a clean,
98
- committed tree or a CI checkout.
99
-
100
- Code that only runs while the app loads cannot be judged by replacing the
101
- method later, so it reports as `ran at load`, outside the score. The same
102
- holds for def-time macros - a `deprecate`-style helper, a `before_action`
103
- block, any DSL whose only caller is a class body: the closure captured at
104
- load keeps the pristine definition, so the boot's def-call counters mark
105
- the macro itself `ran at load` instead of letting its mutants survive.
106
-
107
- ## What you get
108
-
109
- - **Fast pull-request runs.** `--since main` scopes the run to the methods
110
- you changed, refuses (exit 2) when the resolved range has no changes -
111
- `saboteur pr $GITHUB_SHA` on a checkout at the merge commit diffs the
112
- merge against itself - and fails loudly (exit 3) when the changed code
113
- cannot be verified - for example when every changed method is skipped
114
- because a refinement is active where it sits. A changed method skipped
115
- for no coverage names the specs the run loaded; when its coverage
116
- lives in a sibling spec file, pass `--test spec/models` (the directory
117
- holding it) to load it. The GitHub Action runs on every pull request
118
- and caches the coverage map between runs. `--shard K/N` splits one
119
- planned inventory deterministically across CI jobs; each shard reports
120
- partial evidence.
121
- - **Honest verdicts.** Every kill is verified against the unchanged
122
- method; a kill the suite cannot confirm stays out of the score.
123
- Suspect a flaky test? `--flaky-check survivors` confirms each new
124
- survivor with one reordered rerun (`decisive` also confirms fresh
125
- kills); a verdict the rerun disagrees with becomes `unverified`,
126
- named with its tests, and stays out of the score. A spec
127
- file whose examples depend on earlier ones is retried in defined order
128
- before a kill is rejected, so its mutants are still scored.
129
- - **Reports for people and tools.** Terminal, HTML, a Stryker-compatible
130
- JSON report, and a stable agent JSON API with a deprecation policy.
131
- The GitHub Action posts surviving mutants as review comments on the
132
- diff lines of the pull request.
133
- - **Resumable runs.** `--resume DIR` keeps verdicts across runs and
134
- `--incremental` checkpoints them while the run is going, so a stopped
135
- run picks up where it left off.
136
- - **Faster coverage builds.** `c.coverage_parts` builds the coverage map
137
- in parallel parts, `partial_coverage = "use"` rebuilds only the stale
138
- spec files and merges them into the saved map, and
139
- `c.coverage_recorder = "trace"` builds the map with a TracePoint
140
- recorder.
141
- - **A plan before the run.** `saboteur run --dry-run --format json` prints
142
- the versioned [mutant inventory](docs/dry-run-json.md) - every planned
143
- mutant with its stable digest and edits - without booting the suite.
144
- - **A review workflow.** An ignore list with reasons and triage states,
145
- an opt-in survivor baseline, score thresholds and score history.
146
- `saboteur survivors .saboteur/report.json` browses survivors in the
147
- terminal straight from the JSON report - list, filter
148
- (`--file`/`--operator`/`--triage`/`--ranked`), and `--show DIGEST`
149
- for the diff, the exact `run --id` replay command and an ignore-file
150
- entry template. It never boots your project or edits the ignore file.
151
- - **A local README badge.** `saboteur badge` writes Shields endpoint JSON
152
- from the score history and `saboteur trend` prints the score delta -
153
- no hosted service:
154
-
155
- ```md
156
- ![mutation score](https://img.shields.io/endpoint?url=<raw-url-of-the-badge-json>)
157
- ```
158
-
159
- Incomplete runs are refused as badge evidence unless `--mark-partial`
160
- labels them, and the trend never compares runs with different
161
- selection, inventory or operator policy.
162
- - **Safety controls.** A hazard guard, canary values, and time, memory
163
- and process-count limits on every mutant in the default in-process
164
- mode; a Landlock sandbox on Linux
165
- 6.2 and newer.
166
- - **Extension points.** Add your own mutation operators through the
167
- public interface.
168
39
 
169
- Per-path score gates hold one subtree to a stricter floor: `c.score_gate
170
- "app/payments/**/*", min_score: 95` in config/saboteur.rb (repeatable). Each
171
- gate scores only the mutants under its pattern and fails the run under its
172
- floor; a gate with nothing executed under it passes vacuously and says so.
173
- See
174
- [choosing and raising a threshold](docs/best-practices.md#choosing-and-raising-a-threshold)
175
- for how to pick a number.
176
-
177
- ## Honest limits
178
-
179
- - saboteur is RSpec-only and needs `fork`: no Windows, no Minitest.
180
- - A survivor is review evidence, not proof of a bug in your tests. It can
181
- be a missing test, an equivalent change, a flaky test, or a limit of
182
- the tool. Read it before you write a test for it.
183
- - `--since` and `--incremental` are iteration aids. Run the whole project
184
- on main and before a release.
185
- - The hazard guard is syntactic, sandboxing varies by platform (Landlock
186
- on Linux 6.2+; on macOS mutants run unsandboxed unless `c.sandbox = "seatbelt"`
187
- opts into a probe-gated Seatbelt profile; see
188
- [the macOS Seatbelt sandbox](docs/guides/macos-seatbelt-sandbox.md)), and network access is
189
- never restricted. Stub real APIs and use a container when that matters.
190
-
191
- ### Custom spec mapping
192
-
193
- When the naming guess does not fit the project's layout, teach the mapping
194
- once in `config/saboteur_custom_map.rb` instead of passing `--test` on every
195
- run:
196
-
197
- ```ruby
198
- SABOTEUR_CUSTOM_MAP = {
199
- "lib/aasm/core/state.rb" => ["spec/unit/state_spec.rb"],
200
- "app/jobs/**/*.rb" => ["spec/jobs/**/*_spec.rb"],
201
- %r{\Aapp/jobs/(.*)\.rb\z} => ['spec/jobs/\1_spec.rb'],
202
- "app/generated/**/*.rb" => [], # intentionally untested
203
- }.freeze
40
+ # 3. Put it on every pull request - mutates only what you changed vs main
41
+ bundle exec saboteur pr
204
42
  ```
205
43
 
206
- A matching entry wins over the naming convention: the file stops being
207
- unmatched and its mapped specs run as the inferred ones. Keys are exact
208
- paths, globs or Regexps; an empty array marks the file intentionally
209
- untested, so it needs no inferred spec. The file loads before the app
210
- boots, so keep it plain Ruby. `saboteur doctor` validates it.
211
-
212
- ### Rejected kills
213
-
214
- The terminal lists them in a `Rejected kills` section and annotates the error
215
- count (`error 3 (2 rejected kills)`); JSON flags each rejected mutant with
216
- `rejectedKill: true` and keeps the `rejectedKills` count. These are fresh
217
- per-mutant kill claims whose tests also fail with the method unchanged. They
218
- stay errors outside the score. Timeout checks, other errors and reused cached
219
- kills are not included. See [rejected kills](docs/rejected-kills.md) for
220
- 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).
221
51
 
222
52
  ### Rails apps
223
53
 
224
- Quickstart for a Rails + RSpec app. The defaults target a plain gem, so a
225
- Rails app needs a few settings; the generator writes them for you:
226
-
227
- ```sh
228
- bundle add saboteur --group test
229
- bundle exec rails generate saboteur:install
230
- ```
231
-
232
- This writes `config/saboteur.rb`, a `bin/saboteur` binstub (it sets
233
- `RAILS_ENV=test` for you) and a `.gitignore` entry for `.saboteur/`. It
234
- never overwrites an existing file unless you pass `--force`.
235
-
236
- Until a config names your sources, a run whose include selects no Ruby
237
- file refuses with exit 2 and points at the install generator above -
238
- a first CI run on an unconfigured tree fails loud instead of passing
239
- with 0 subjects.
240
-
241
- Prefer to do it by hand, or on a version without the generator? Put this
242
- 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`:
243
56
 
244
57
  ```ruby
245
58
  Saboteur.configure do |c|
@@ -250,41 +63,89 @@ Saboteur.configure do |c|
250
63
  end
251
64
  ```
252
65
 
253
- 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
254
69
  [Rails engines and dummy apps](docs/guides/rails-engines-and-dummy-apps.md).
255
70
 
256
- ## SimpleCov
257
-
258
- saboteur needs Ruby's `Coverage` for itself, so leave SimpleCov off for
259
- saboteur runs - for example, start it only when `ENV["COVERAGE"]` is set.
260
- The [recipes](docs/recipes/README.md) cover this and VCR, WebMock and
261
- Sidekiq.
262
-
263
- <a id="dated-validation-runs"></a>
264
- <a id="where-it-has-run"></a>
265
- ## Benchmarks and validation
266
-
267
- Speed and memory numbers live with the harness and the raw data that
268
- reproduce them, framed as dated measurements rather than standing claims:
269
- the [benchmarks page](https://vamsipavanmahesh.github.io/saboteur/benchmark/)
270
- 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.
271
134
 
272
- ## Project
135
+ ## Docs
273
136
 
137
+ - [Quickstart and guides](https://vamsipavanmahesh.github.io/saboteur/quickstart.html) - setup, CI, recipes
138
+ - [All documentation](docs/README.md) - configuration, operators, recipes
274
139
  - [Changelog](CHANGELOG.md)
275
- - [All documentation](docs/README.md) - configuration, operators, CI, recipes
140
+ - [Benchmarks](https://vamsipavanmahesh.github.io/saboteur/benchmark/) - dated measurements with the raw data
276
141
  - [Contributing](CONTRIBUTING.md) - every commit needs a DCO `Signed-off-by` line
277
- - [Design notes](docs/design.md)
278
142
 
279
143
  ## Disclaimer
280
144
 
281
- saboteur is provided "as is", without warranty of any kind, express or
282
- implied. In no event shall the authors or copyright holders be liable
283
- for any claim, damages or other liability arising from its use -
284
- including damage caused by mutating your code or by running unusual
285
- DSLs and metaprogramming-heavy code. Mutation testing runs your test
286
- suite against deliberately broken code; run it on a clean, committed
287
- 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).
288
149
 
289
150
  ## License
290
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
@@ -20,6 +20,7 @@ module Saboteur
20
20
  opts.on("--config PATH", "Config file (default config/saboteur.rb)") { |value| set(:config, value) }
21
21
  opts.on("--subject EXPR", "Whole subject filter; repeatable") { |value| add_subject(value) }
22
22
  opts.on("--seed N", Integer, "Repeatable run seed") { |value| set(:seed, value) }
23
+ opts.on("--format FORMAT", "propose: terminal (default) or json") { |value| @format = value }
23
24
  end
24
25
  end
25
26
 
@@ -34,7 +35,7 @@ module Saboteur
34
35
  workflow = Baseline::Workflow.new(config: config, err: @err)
35
36
  baseline_action(workflow, action)
36
37
  end
37
- @out.puts(JSON.generate(payload))
38
+ action == "propose" && @format != "json" ? print_proposal(payload) : @out.puts(JSON.generate(payload))
38
39
  payload.fetch("exit_code", CLI::EXIT_OK)
39
40
  rescue ConfigError, SystemCallError => e
40
41
  @err.puts("saboteur: #{e.message}")
@@ -44,13 +45,17 @@ module Saboteur
44
45
  CLI::EXIT_TOOL
45
46
  end
46
47
 
48
+ def baseline_path_required(action)
49
+ "--baseline PATH is required (for example: saboteur baseline #{action} --baseline baseline.json)"
50
+ end
51
+
47
52
  def parse_baseline(action)
48
53
  raise CLI::UsageError, "baseline expects check, propose or accept" unless %w[check propose
49
54
  accept].include?(action)
50
55
 
51
56
  baseline_parser.parse!(@argv)
52
57
  raise CLI::UsageError, "unexpected arguments: #{@argv.join(' ')}" if @argv.any?
53
- raise CLI::UsageError, "--baseline PATH is required" unless @baseline_path
58
+ raise CLI::UsageError, baseline_path_required(action) unless @baseline_path
54
59
  raise CLI::UsageError, "--proposal PATH is required" if action != "check" && !@proposal_path
55
60
  if action == "check" && (@proposal_path || @previous)
56
61
  raise CLI::UsageError,
@@ -58,6 +63,15 @@ module Saboteur
58
63
  end
59
64
  validate_epsilon(action)
60
65
  raise CLI::UsageError, "--previous needs a SHA-256" if @previous && !@previous.match?(/\A[0-9a-f]{64}\z/)
66
+
67
+ validate_format(action)
68
+ end
69
+
70
+ def validate_format(action)
71
+ unless @format.nil? || %w[terminal json].include?(@format)
72
+ raise CLI::UsageError, "--format must be terminal or json"
73
+ end
74
+ raise CLI::UsageError, "--format applies to propose only" if @format && action != "propose"
61
75
  end
62
76
 
63
77
  def validate_epsilon(action)
@@ -67,6 +81,36 @@ module Saboteur
67
81
  raise CLI::UsageError, "--epsilon must be between 0 and 100" unless (0.0..100.0).cover?(@epsilon)
68
82
  end
69
83
 
84
+ def print_proposal(payload)
85
+ @out.puts("baseline proposal written: #{payload.fetch('proposal')}")
86
+ survivors = payload.fetch("survivors")
87
+ @out.puts(" matched mutants: #{payload.fetch('candidates')} " \
88
+ "(#{plural(survivors, 'survivor')} to reason about)")
89
+ gaps = payload.fetch("gaps", [])
90
+ unless gaps.empty?
91
+ names = gaps.map { |gap| gap.sub(/\A(subject_skipped|omitted):/, "") }
92
+ @out.puts(" proposed with #{plural(names.size, 'untested subject')}: #{names.join(', ')} - " \
93
+ "the baseline cannot speak for them")
94
+ end
95
+ @out.puts(" next step: give each accepted survivor a reason in the proposal, then run: " \
96
+ "saboteur baseline accept#{scope_flags} " \
97
+ "--baseline #{payload.fetch('destination')} --proposal #{payload.fetch('proposal')}")
98
+ end
99
+
100
+ def plural(count, noun)
101
+ count == 1 ? "#{count} #{noun}" : "#{count} #{noun}s"
102
+ end
103
+
104
+ # accept re-verifies the recorded run settings, so the printed command
105
+ # repeats the scope flags this propose ran with.
106
+ def scope_flags
107
+ flags = +""
108
+ Array(options[:subjects]).each { |expr| flags << " --subject #{expr}" }
109
+ flags << " --seed #{options[:seed]}" if options[:seed]
110
+ flags << " --config #{options[:config]}" if options[:config]
111
+ flags
112
+ end
113
+
70
114
  def baseline_action(workflow, action)
71
115
  case action
72
116
  when "check" then workflow.check(path: @baseline_path, epsilon: @epsilon)
@@ -62,7 +62,12 @@ module Saboteur
62
62
  end
63
63
 
64
64
  def outcome(current)
65
- return "incomplete" unless current.fetch("complete") && issues.empty? && !current.fetch("candidates").empty?
65
+ # The proposability tier is the comparability gate: a run over soft
66
+ # gaps still speaks for the subjects it executed. Legacy documents
67
+ # without the split fall back to the strict flag and full issue list.
68
+ proposable = current.fetch("proposable") { current.fetch("complete") }
69
+ blockers = current.fetch("blockers") { current.fetch("issues") }
70
+ return "incomplete" unless proposable && blockers.empty? && !current.fetch("candidates").empty?
66
71
  return "incomplete" unless categories.fetch("errors").empty?
67
72
  return "review_required" if %w[new changed unmatched].any? { |name| !categories.fetch(name).empty? }
68
73
  return "review_required" if score&.fetch("exceeded")