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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +109 -248
- data/exe/saboteur +2 -2
- data/lib/saboteur/baseline/command.rb +46 -2
- data/lib/saboteur/baseline/comparison.rb +6 -1
- data/lib/saboteur/baseline/completeness.rb +21 -0
- data/lib/saboteur/baseline/evidence.rb +11 -3
- data/lib/saboteur/baseline/loader.rb +54 -0
- data/lib/saboteur/baseline/manifest.rb +13 -4
- data/lib/saboteur/baseline/run_evidence.rb +4 -2
- data/lib/saboteur/baseline/run_gate.rb +51 -0
- data/lib/saboteur/baseline/workflow.rb +59 -24
- data/lib/saboteur/baseline_health.rb +4 -1
- data/lib/saboteur/cli/baseline_gate.rb +58 -0
- data/lib/saboteur/cli/dirty_tree_notice.rb +7 -1
- data/lib/saboteur/cli/doctor_command.rb +10 -1
- data/lib/saboteur/cli/error_cause.rb +16 -0
- data/lib/saboteur/cli/exit_status.rb +18 -16
- data/lib/saboteur/cli/explicit_sources.rb +1 -1
- data/lib/saboteur/cli/failure_output.rb +25 -2
- data/lib/saboteur/cli/output_options.rb +22 -2
- data/lib/saboteur/cli/presets.rb +11 -1
- data/lib/saboteur/cli/run_budget_options.rb +6 -0
- data/lib/saboteur/cli/run_command.rb +18 -2
- data/lib/saboteur/cli/run_reporting.rb +33 -6
- data/lib/saboteur/cli/run_validation.rb +36 -3
- data/lib/saboteur/cli/selection_options.rb +2 -0
- data/lib/saboteur/cli/survivors_command.rb +81 -17
- data/lib/saboteur/cli.rb +6 -1
- data/lib/saboteur/config.rb +23 -2
- data/lib/saboteur/crash_report.rb +18 -8
- data/lib/saboteur/doctor.rb +3 -1
- data/lib/saboteur/engine/baseline_guard.rb +2 -2
- data/lib/saboteur/engine/boot.rb +4 -2
- data/lib/saboteur/engine/coverage_partial.rb +8 -1
- data/lib/saboteur/engine/diagnostics.rb +120 -15
- data/lib/saboteur/engine/result_cache.rb +4 -2
- data/lib/saboteur/engine/run_fingerprint.rb +1 -1
- data/lib/saboteur/engine/scheduler.rb +22 -0
- data/lib/saboteur/engine/selection_announcement.rb +8 -1
- data/lib/saboteur/engine/subject_selection.rb +1 -1
- data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
- data/lib/saboteur/engine/test_guess.rb +1 -1
- data/lib/saboteur/engine/thread_warnings.rb +9 -4
- data/lib/saboteur/engine.rb +64 -9
- data/lib/saboteur/execution/fiddle_loader.rb +29 -2
- data/lib/saboteur/help.rb +6 -2
- data/lib/saboteur/ignore_list.rb +15 -2
- data/lib/saboteur/install.rb +18 -3
- data/lib/saboteur/mutation_source/builtin.rb +4 -0
- data/lib/saboteur/preview.rb +6 -1
- data/lib/saboteur/reporter/agent_commands.rb +6 -1
- data/lib/saboteur/reporter/agent_summary.rb +3 -1
- data/lib/saboteur/reporter/assets/mutation_report.LICENSE +201 -0
- data/lib/saboteur/reporter/assets/mutation_report.js +331 -0
- data/lib/saboteur/reporter/baseline_tail.rb +95 -0
- data/lib/saboteur/reporter/diff.rb +10 -0
- data/lib/saboteur/reporter/html.rb +207 -17
- data/lib/saboteur/reporter/kill_causes.rb +53 -0
- data/lib/saboteur/reporter/next_steps.rb +11 -8
- data/lib/saboteur/reporter/notice.rb +24 -0
- data/lib/saboteur/reporter/palette.rb +64 -13
- data/lib/saboteur/reporter/reuse_note.rb +37 -0
- data/lib/saboteur/reporter/review_comments.rb +11 -7
- data/lib/saboteur/reporter/safe_out.rb +1 -1
- data/lib/saboteur/reporter/score_table.rb +53 -18
- data/lib/saboteur/reporter/stryker.rb +15 -2
- data/lib/saboteur/reporter/survivor_hints.rb +70 -0
- data/lib/saboteur/reporter/survivor_list.rb +207 -99
- data/lib/saboteur/reporter/survivor_ranking.rb +13 -5
- data/lib/saboteur/reporter/terminal.rb +306 -69
- data/lib/saboteur/result.rb +56 -8
- data/lib/saboteur/score.rb +6 -0
- data/lib/saboteur/selection/git_diff.rb +19 -1
- data/lib/saboteur/survivor_guidance.rb +33 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 74914fa96e0e644c7615013b6ad8a014c3c74b01e9b154931f2087fa0685dd31
|
|
4
|
+
data.tar.gz: 3265c82c7383a4ec2ce6355c009fc5816822e96334224e05a3cf6dac05404504
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
+
<h1 align="center">saboteur</h1>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
[](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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-

|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
saboteur
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
the
|
|
270
|
-
|
|
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
|
-
##
|
|
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
|
-
- [
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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")
|