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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +109 -247
- data/exe/saboteur +2 -2
- data/lib/saboteur/baseline/loader.rb +41 -0
- data/lib/saboteur/baseline/run_evidence.rb +1 -1
- data/lib/saboteur/baseline/run_gate.rb +51 -0
- data/lib/saboteur/baseline/workflow.rb +2 -15
- data/lib/saboteur/cli/baseline_gate.rb +58 -0
- data/lib/saboteur/cli/error_cause.rb +16 -0
- data/lib/saboteur/cli/exit_status.rb +14 -13
- data/lib/saboteur/cli/failure_output.rb +23 -0
- data/lib/saboteur/cli/output_options.rb +24 -2
- data/lib/saboteur/cli/run_budget_options.rb +6 -0
- data/lib/saboteur/cli/run_command.rb +14 -1
- data/lib/saboteur/cli/run_reporting.rb +34 -6
- data/lib/saboteur/cli/run_validation.rb +36 -3
- data/lib/saboteur/cli/selection_options.rb +2 -0
- data/lib/saboteur/cli.rb +6 -1
- data/lib/saboteur/config.rb +23 -2
- data/lib/saboteur/crash_report.rb +16 -7
- data/lib/saboteur/engine/baseline_guard.rb +2 -2
- data/lib/saboteur/engine/coverage_partial.rb +8 -1
- data/lib/saboteur/engine/coverage_store.rb +4 -5
- data/lib/saboteur/engine/diagnostics.rb +120 -15
- data/lib/saboteur/engine/run_fingerprint.rb +1 -1
- data/lib/saboteur/engine/scheduler.rb +20 -0
- data/lib/saboteur/engine/selection_announcement.rb +8 -1
- data/lib/saboteur/engine/survivor_confirmation.rb +159 -0
- data/lib/saboteur/engine.rb +43 -6
- data/lib/saboteur/help.rb +4 -1
- data/lib/saboteur/mutation_source/builtin.rb +9 -3
- data/lib/saboteur/mutation_source/copy_replacement.rb +72 -0
- data/lib/saboteur/mutation_source/ensure_removal.rb +49 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +2 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +3 -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 +93 -0
- data/lib/saboteur/reporter/diff.rb +10 -0
- data/lib/saboteur/reporter/html.rb +69 -14
- data/lib/saboteur/reporter/kill_causes.rb +53 -0
- data/lib/saboteur/reporter/next_steps.rb +4 -3
- data/lib/saboteur/reporter/palette.rb +64 -13
- data/lib/saboteur/reporter/safe_out.rb +1 -1
- data/lib/saboteur/reporter/score_table.rb +53 -18
- data/lib/saboteur/reporter/stryker.rb +4 -1
- data/lib/saboteur/reporter/survivor_classification.rb +12 -3
- data/lib/saboteur/reporter/survivor_hints.rb +72 -0
- data/lib/saboteur/reporter/survivor_list.rb +207 -75
- data/lib/saboteur/reporter/terminal.rb +315 -69
- data/lib/saboteur/result.rb +28 -1
- data/lib/saboteur/score.rb +6 -0
- data/lib/saboteur/version.rb +1 -1
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0dd9ced23a90f2f769ccf934e50949a8d9b04324fe86aeec72c8d0de896129ed
|
|
4
|
+
data.tar.gz: 3c9f5628615aefcaecac88474d2badb925e57d32d17066fc0afeef642eced780
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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: 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
|
-
|
|
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.
|
|
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
|
-

|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
saboteur
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
the
|
|
269
|
-
|
|
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
|
-
##
|
|
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
|
-
- [
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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)
|