saboteur 0.4.0 → 0.6.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 +199 -0
- data/README.md +427 -20
- data/exe/saboteur +6 -2
- data/lib/saboteur/agent_skill.rb +6 -0
- data/lib/saboteur/baseline/acceptance.rb +218 -0
- data/lib/saboteur/baseline/command.rb +80 -0
- data/lib/saboteur/baseline/comparison.rb +106 -0
- data/lib/saboteur/baseline/completeness.rb +53 -0
- data/lib/saboteur/baseline/evidence.rb +124 -0
- data/lib/saboteur/baseline/identity.rb +78 -0
- data/lib/saboteur/baseline/manifest.rb +164 -0
- data/lib/saboteur/baseline/run_evidence.rb +117 -0
- data/lib/saboteur/baseline/workflow.rb +121 -0
- data/lib/saboteur/baseline_health.rb +1 -1
- data/lib/saboteur/cli/exit_status.rb +93 -0
- data/lib/saboteur/cli/run_reporting.rb +75 -0
- data/lib/saboteur/cli.rb +113 -122
- data/lib/saboteur/config.rb +96 -6
- data/lib/saboteur/doctor.rb +64 -9
- data/lib/saboteur/engine/baseline_guard.rb +33 -11
- data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
- data/lib/saboteur/engine/canary.rb +74 -0
- data/lib/saboteur/engine/candidate_selection.rb +18 -5
- data/lib/saboteur/engine/candidates.rb +62 -7
- data/lib/saboteur/engine/coverage_store.rb +34 -14
- data/lib/saboteur/engine/diagnostics.rb +150 -4
- data/lib/saboteur/engine/diff_scope.rb +10 -0
- data/lib/saboteur/engine/distribution.rb +54 -13
- data/lib/saboteur/engine/result_cache.rb +10 -5
- data/lib/saboteur/engine/run_fingerprint.rb +7 -5
- data/lib/saboteur/engine/sandbox_policy.rb +108 -0
- data/lib/saboteur/engine/scheduler.rb +92 -20
- data/lib/saboteur/engine/subject_selection.rb +24 -1
- data/lib/saboteur/engine/test_guess.rb +89 -0
- data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
- data/lib/saboteur/engine/verdict_check.rb +7 -6
- data/lib/saboteur/engine.rb +201 -19
- data/lib/saboteur/execution/protocol.rb +2 -1
- data/lib/saboteur/execution/reevaluator.rb +72 -10
- data/lib/saboteur/execution/runner.rb +120 -17
- data/lib/saboteur/execution/sandbox.rb +271 -0
- data/lib/saboteur/execution/stale_captures.rb +43 -0
- data/lib/saboteur/execution/supervisor.rb +101 -9
- data/lib/saboteur/execution/worker_pool.rb +20 -4
- data/lib/saboteur/execution/wrapper.rb +45 -0
- data/lib/saboteur/frameworks/database_hosts.rb +30 -0
- data/lib/saboteur/frameworks/rails.rb +8 -10
- data/lib/saboteur/gates.rb +83 -0
- data/lib/saboteur/help.rb +32 -7
- data/lib/saboteur/ignore_list.rb +5 -0
- data/lib/saboteur/inline_suppression.rb +65 -0
- data/lib/saboteur/integration/base.rb +1 -0
- data/lib/saboteur/integration/focus_guard.rb +83 -0
- data/lib/saboteur/integration/rspec.rb +21 -1
- data/lib/saboteur/mutation_source/builtin.rb +22 -3
- data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
- data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
- data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
- data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
- data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
- data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
- data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
- data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
- data/lib/saboteur/preview.rb +115 -0
- data/lib/saboteur/reporter/agent_commands.rb +19 -5
- data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
- data/lib/saboteur/reporter/agent_summary.rb +12 -6
- data/lib/saboteur/reporter/findings.rb +4 -19
- data/lib/saboteur/reporter/history_paths.rb +38 -0
- data/lib/saboteur/reporter/history_policy.rb +35 -0
- data/lib/saboteur/reporter/history_record.rb +131 -0
- data/lib/saboteur/reporter/html.rb +12 -1
- data/lib/saboteur/reporter/mutation_text.rb +36 -0
- data/lib/saboteur/reporter/next_steps.rb +30 -0
- data/lib/saboteur/reporter/palette.rb +37 -0
- data/lib/saboteur/reporter/score_history.rb +135 -0
- data/lib/saboteur/reporter/score_table.rb +57 -0
- data/lib/saboteur/reporter/stryker.rb +18 -3
- data/lib/saboteur/reporter/survivor_hints.rb +68 -0
- data/lib/saboteur/reporter/survivor_list.rb +126 -0
- data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
- data/lib/saboteur/reporter/terminal.rb +140 -79
- data/lib/saboteur/reporter/test_matrix.rb +57 -0
- data/lib/saboteur/result.rb +44 -6
- data/lib/saboteur/selection/coverage_map.rb +119 -13
- data/lib/saboteur/selection/git_diff.rb +28 -1
- data/lib/saboteur/selection/recorder.rb +55 -13
- data/lib/saboteur/subject.rb +40 -4
- data/lib/saboteur/subject_finder.rb +62 -12
- data/lib/saboteur/version.rb +1 -1
- data/lib/saboteur/working_tree.rb +8 -0
- data/lib/saboteur.rb +6 -0
- metadata +46 -2
data/README.md
CHANGED
|
@@ -29,17 +29,40 @@ fail?" for many small breakages and lists the ones nobody noticed. Read the
|
|
|
29
29
|
[design notes](docs/design.md) when you want the details. For adopting it on a team, see the
|
|
30
30
|
[best practices guide](docs/best-practices.md).
|
|
31
31
|
|
|
32
|
+
Declared external inputs are checked against cache and report paths using their
|
|
33
|
+
physical paths, including aliased parent directories. An overlap makes the run
|
|
34
|
+
fingerprint incomplete, so it cannot authorize cache reuse.
|
|
35
|
+
|
|
36
|
+
Internal run reports retain typed candidate-drop and cap evidence for gate
|
|
37
|
+
validation. These records do not change selection, scores, exit codes or the
|
|
38
|
+
public JSON format. Empty generation is unverified, not proof of equivalence.
|
|
39
|
+
|
|
40
|
+
The internal engine can opt in to fresh baseline evidence capture. It records
|
|
41
|
+
inputs before boot, planned candidate identities before scheduling and reconciled
|
|
42
|
+
terminal results after execution. Caps, uncovered work, invalid or empty
|
|
43
|
+
production, reused outcomes and changed inputs make that evidence incomplete.
|
|
44
|
+
The public opt-in `baseline propose`, `baseline accept` and `baseline check`
|
|
45
|
+
commands use this evidence to review and compare survivors. They never skip
|
|
46
|
+
accepted mutants or change their scores. See [Opt-in survivor baseline](#opt-in-survivor-baseline).
|
|
47
|
+
|
|
32
48
|
### The unchanged suite is checked first
|
|
33
49
|
|
|
34
50
|
Every run with selected subjects runs the loaded unchanged suite twice (the specs chosen with `--test`, if given) in fresh
|
|
35
51
|
supervised children before coverage lookup or incremental verdict reuse. This
|
|
36
52
|
costs two suite runs even on a cache hit, plus a coverage-building suite run on
|
|
37
53
|
a cache miss. Both baseline passes must be green, complete and in the same
|
|
38
|
-
RSpec order at the run seed. A failure exits 3, stops mutation
|
|
39
|
-
|
|
54
|
+
RSpec order at the run seed. A failure exits 3, stops mutation, prints the failing example ids and the first ten lines of the
|
|
55
|
+
failure inline, and names the log and fix. Long phases
|
|
56
|
+
print their elapsed time about every 30 seconds, with the examples finished so far, the percentage and a rough estimate of the time left (it assumes equally long examples) once the suite child has logged its first count, and each phase's log holds
|
|
40
57
|
the suite's progress and full failure detail. Each suite pass
|
|
41
58
|
uses `coverage_timeout` and configured memory/process limits (the suite memory
|
|
42
|
-
budget is four times `max_memory_mb`, as for coverage).
|
|
59
|
+
budget is four times `max_memory_mb`, as for coverage). A suite child that has
|
|
60
|
+
already reported its result gets up to 5 seconds to finish exiting, under the
|
|
61
|
+
same deadline and memory/process limits as before; if it is still running then,
|
|
62
|
+
saboteur stops it and keeps the result, so a slow exit is not reported as a
|
|
63
|
+
failure. A child that ends by itself in that window keeps its own exit status. A child killed by `SIGKILL` is called a possible
|
|
64
|
+
out-of-memory kill only when the process tree was seen growing toward the
|
|
65
|
+
memory limit. Older benchmark numbers
|
|
43
66
|
need a rerun before they describe this extra fixed cost.
|
|
44
67
|
A whole-suite coverage build that records no lines for any included file
|
|
45
68
|
means the setup hid the app from Coverage (Bootsnap's compile cache is the
|
|
@@ -50,6 +73,74 @@ selected sources.
|
|
|
50
73
|
Two passes detect observed instability, not every possible flaky test. See
|
|
51
74
|
[baseline stability](docs/baseline-stability.md) for limits and hook behaviour.
|
|
52
75
|
|
|
76
|
+
### Hazardous calls are not mutated, and a canary watches the tree
|
|
77
|
+
|
|
78
|
+
Mutants run in your checkout, so a mutant inside a destructive call could do
|
|
79
|
+
real damage: a string mutation inside `FileUtils.rm_rf(File.join(Dir.pwd,
|
|
80
|
+
"build"))` once turned the target into the working directory itself. The
|
|
81
|
+
hazard guard is a syntactic filter. It recognizes destructive calls written
|
|
82
|
+
as direct calls: `FileUtils.rm*`/`remove_entry*` (also as bare names under
|
|
83
|
+
`include FileUtils`), `File.delete`/`unlink`, `Dir.rmdir`/`delete`/`unlink`,
|
|
84
|
+
`rmtree`, `system`/`exec`/`spawn` (bare or on `Kernel`/`Process`), backticks
|
|
85
|
+
and `%x`, `IO.popen`, any `Open3` method, and `open` with a leading `|`. It
|
|
86
|
+
suppresses any mutant that would leave such a call in place while altering
|
|
87
|
+
code inside it or anywhere else in the same method. A mutant that removes
|
|
88
|
+
every hazardous call in the method still runs, provided its replacement
|
|
89
|
+
introduces no new hazard. The match is by name on the written call: calls
|
|
90
|
+
reached through aliases, variables, `send` or other indirection are not
|
|
91
|
+
recognized, so the guard cannot promise to find every destructive call. The
|
|
92
|
+
summary reports how many mutants were suppressed. Extend or replace the list
|
|
93
|
+
with `c.hazard` (same tag shapes: `"FileUtils.rm_rf"`, or a bare name like
|
|
94
|
+
`"system"`); `--allow-hazardous` runs the suppressed mutants anyway.
|
|
95
|
+
|
|
96
|
+
Behind the guard, a canary snapshots the tracked files (or every file,
|
|
97
|
+
outside a repository) before mutating and re-checks after each method's
|
|
98
|
+
mutants. If anything vanished, the run aborts with exit 3 and names the most
|
|
99
|
+
recent mutant's digest. That check happens after the damage and restores
|
|
100
|
+
nothing; it limits how far a bad mutant gets. Recover from your own backup,
|
|
101
|
+
a fresh checkout or a reclone - a deleted `.git` or uncommitted work is gone
|
|
102
|
+
unless you have another copy. The canary is the backstop for the destructive
|
|
103
|
+
forms the syntactic guard cannot see.
|
|
104
|
+
|
|
105
|
+
On Linux 6.2 and later (Landlock ABI 3) each mutant child also runs under
|
|
106
|
+
a Landlock sandbox: the child cannot create, write, rename, unlink or
|
|
107
|
+
truncate files in the project tree, except `tmp/`, the cache dir and the
|
|
108
|
+
system temp dir, plus anything in `c.sandbox_writable`. Every other path
|
|
109
|
+
is denied for writing, `/dev/shm` and `/dev/tty` included; `/dev/null`
|
|
110
|
+
stays writable. Inherited
|
|
111
|
+
descriptors already open for writing outside the granted dirs are closed
|
|
112
|
+
before the sandbox engages - stdio redirected to a regular file and files
|
|
113
|
+
already deleted from the tree included (stdio on a terminal, pipe or
|
|
114
|
+
`/dev/null` stays), and a descriptor whose target or flags cannot be
|
|
115
|
+
inspected is closed too rather than trusted. A `c.sandbox_writable` grant
|
|
116
|
+
that contains the project root or another granted dir warns, since it
|
|
117
|
+
defeats or duplicates the protection; reads and process execution are
|
|
118
|
+
unaffected.
|
|
119
|
+
This DOES change what your tests can write: a suite that writes elsewhere
|
|
120
|
+
in the project (`db/`, `log/`, ...) fails with EACCES in mutant children -
|
|
121
|
+
grant that directory with `c.sandbox_writable` or opt out with
|
|
122
|
+
`c.sandbox = false`. Landlock covers the filesystem only - network,
|
|
123
|
+
sockets and IPC are never restricted - and a checkout inside the system
|
|
124
|
+
temp dir cannot be protected, because everything under it stays writable
|
|
125
|
+
so that real test suites keep working - the run warns when the effective
|
|
126
|
+
write set (every grant realpath'd, the cache dir and the system temp dir
|
|
127
|
+
included) contains the project root, however it happened: a checkout
|
|
128
|
+
inside the system temp dir, `cache_dir = "."`, a symlinked `tmp`, or an
|
|
129
|
+
overlapping `c.sandbox_writable` grant. `c.sandbox = "strict"` refuses to
|
|
130
|
+
run at all in that situation, since the sandbox would protect nothing.
|
|
131
|
+
The sandbox applies to every mutant
|
|
132
|
+
child and fails closed: a child that cannot restrict itself reports an
|
|
133
|
+
error rather than run, and a kernel probe that errors (as opposed to
|
|
134
|
+
cleanly reporting Landlock absent) refuses the run.
|
|
135
|
+
|
|
136
|
+
On kernels from 5.13 to 6.1 (Landlock ABI 1-2) `truncate()` cannot be
|
|
137
|
+
restricted, so the tree is not fully read-only and the claim above does
|
|
138
|
+
not hold; the run notes once that children run with the guard and canary
|
|
139
|
+
only, exactly as on kernels without Landlock - and as on non-glibc Linux
|
|
140
|
+
whose C library cannot be located (musl is tried by soname first).
|
|
141
|
+
`c.sandbox = "strict"` turns that degraded note into a refusal;
|
|
142
|
+
`c.sandbox = false` opts out.
|
|
143
|
+
|
|
53
144
|
|
|
54
145
|
## 🚀 Try it in a minute
|
|
55
146
|
|
|
@@ -73,13 +164,14 @@ unified diff for every survivor, so you can read the exact change your tests
|
|
|
73
164
|
missed. The JSON report lands in `.saboteur/report.json`.
|
|
74
165
|
|
|
75
166
|
📋 Requirements: Ruby 3.3 or newer on Linux or macOS (it needs `fork`), and RSpec.
|
|
167
|
+
Minitest is not supported; see [Minitest](docs/minitest.md) for why and what it would take.
|
|
76
168
|
|
|
77
169
|
## 🧠 Why it is built the way it is
|
|
78
170
|
|
|
79
171
|
**It only runs tests that can matter.** Each mutant runs only the tests whose
|
|
80
172
|
line coverage touched the changed statement. Tests that killed earlier mutants
|
|
81
173
|
of the same method go first, then the fastest, and the first failure stops the
|
|
82
|
-
run. A change no test reaches is reported as "no coverage", not as a survivor.
|
|
174
|
+
run. A change no test reaches is reported as "no coverage", not as a survivor. Branch coverage separates arms on the same line: an edit inside an arm no test took reports "no coverage (branch not taken)". Ruby does not expose `&&`/`||` right operands as branches, so those keep line-based selection. `case/in` pattern edits use the enclosing case and clause execution lines; edits in an untested clause body still stay uncovered. Whole-body replacements use every test of the method, including methods that start with a heredoc. Endless defs (`def x = ...`) are matched to tests through per-test method call coverage, since line coverage never counts their bodies. Methods defined inside blocks (`Class.new do`, `Data.define do`, `included do`) are not mutated yet; the run lists them as skipped, "defined inside a block".
|
|
83
175
|
|
|
84
176
|
**It runs coarse mutants first, then fine mutants.** Each method first gets
|
|
85
177
|
whole-body mutants: `nil` (`true`/`false` for predicates) unless the body is empty or
|
|
@@ -118,7 +210,8 @@ schema, and you can add your own mutants through a small public interface.
|
|
|
118
210
|
|
|
119
211
|
```sh
|
|
120
212
|
bundle exec saboteur run # whole project
|
|
121
|
-
bundle exec saboteur doctor
|
|
213
|
+
bundle exec saboteur doctor # environment checks only
|
|
214
|
+
bundle exec saboteur doctor --suite --seed 42 # plus two unchanged suite checks
|
|
122
215
|
bundle exec saboteur run --since origin/main # methods changed on this branch
|
|
123
216
|
bundle exec saboteur run --subject 'Billing::Invoice#total'
|
|
124
217
|
bundle exec saboteur run --subject Billing # a namespace
|
|
@@ -127,6 +220,9 @@ bundle exec saboteur run --seed 1234 # repeat a run that used seed
|
|
|
127
220
|
bundle exec saboteur run lib/a.rb lib/b.rb --test spec/a_spec.rb --test spec/b_spec.rb
|
|
128
221
|
bundle exec saboteur run --threshold 90 # exit 1 when the score is below 90 %
|
|
129
222
|
bundle exec saboteur run --id DIGEST --seed 1234 # replay one full current mutant digest
|
|
223
|
+
bundle exec saboteur run --fail-fast # stop after the first subject with a survivor
|
|
224
|
+
bundle exec saboteur run --matrix # every covering test per mutant: blind and redundant tests
|
|
225
|
+
bundle exec saboteur run --verbose --no-color # add score split, timings and kill causes; no colour
|
|
130
226
|
```
|
|
131
227
|
|
|
132
228
|
Positional operands name the sources to mutate: Ruby files, or directories
|
|
@@ -148,9 +244,28 @@ unignored survivor exits 1. Either way, a run where more than half of the
|
|
|
148
244
|
executed mutants errored exits 3: errored mutants are outside the score,
|
|
149
245
|
so such a run tested almost nothing.
|
|
150
246
|
|
|
247
|
+
`--max-timeouts N` and `--max-errors N` (or `c.max_timeouts` / `c.max_errors`)
|
|
248
|
+
are two more CI gates. Each takes a count or a percentage of the executed
|
|
249
|
+
mutants (`--max-errors 5%`). The run exits 1 when the count (or share) is
|
|
250
|
+
above the limit, with `exitCause` `maxTimeouts` or `maxErrors` in the JSON report.
|
|
251
|
+
`--threshold` already compares the covered score, `(killed + timeout) /
|
|
252
|
+
(killed + survived + timeout)`, so no separate score flag exists. With
|
|
253
|
+
`--max-errors`, errored mutants are judged by that limit instead of making a
|
|
254
|
+
`--threshold` run exit 3. Survivors and a score below the threshold keep
|
|
255
|
+
their own causes when they fail too. The gates cannot be combined with `--agent`.
|
|
256
|
+
The GitHub Action has no input for them yet.
|
|
257
|
+
|
|
151
258
|
Other flags: `--jobs N` (concurrent workers), `--timeout-factor N`,
|
|
152
259
|
`--config PATH`, `--integration NAME`, `--reeval class_eval|nesting`,
|
|
153
|
-
`--json PATH`, `--no-cache` (rebuild the coverage map)
|
|
260
|
+
`--json PATH`, `--no-cache` (rebuild the coverage map), `--allow-focus`.
|
|
261
|
+
|
|
262
|
+
A stray `fit`, `fdescribe` or `:focus` example, with `filter_run_when_matching :focus`, makes RSpec skip
|
|
263
|
+
every other example. The baseline stays green and the score would cover only the focused ones, so
|
|
264
|
+
saboteur exits 2 and names the focused examples. Remove the focus, or pass `--allow-focus` (config
|
|
265
|
+
`c.allow_focus = true`) to run the narrowed suite. Filters you name in `rspec_args` or `.rspec`, such
|
|
266
|
+
as `--tag slow`, and example selectors such as `--test spec/a_spec.rb:12`, are on purpose and are not refused. The
|
|
267
|
+
check compares what RSpec runs with what it would run without the stray focus, so examples RSpec leaves out anyway
|
|
268
|
+
(`:if` / `:unless`, `--tag`, exclusions, selectors in another file) never count as hidden.
|
|
154
269
|
|
|
155
270
|
For pull requests, start with `--since` against your target branch. This is a
|
|
156
271
|
workflow recommendation, not a CLI default: plain `run` still checks the whole
|
|
@@ -158,18 +273,39 @@ configured project. To scope the specs as well, pass them with `--test` and no
|
|
|
158
273
|
source operands: `bundle exec saboteur run --since origin/main --test spec/billing_spec.rb`.
|
|
159
274
|
With `--since` and no `--test`, the run warns before it starts that the two baseline passes
|
|
160
275
|
run the loaded suite (a cold coverage cache adds one more pass), and how to
|
|
161
|
-
scope them.
|
|
276
|
+
scope them.
|
|
277
|
+
|
|
278
|
+
`--since origin/main --guess-tests` does that scoping for you: it picks the spec
|
|
279
|
+
named after each changed source file (`lib/a/b.rb` runs `spec/a/b_spec.rb`; `app/`
|
|
280
|
+
files and `spec/lib/` work too) and runs only those for the baseline passes, the
|
|
281
|
+
coverage pass and every mutant, as `--test` would. It is a naming guess, not
|
|
282
|
+
coverage: a spec that reaches the changed file only through another file does not
|
|
283
|
+
run: a mutant only that spec would kill can survive, and a line that none of the
|
|
284
|
+
guessed specs executes is reported as NoCoverage. The agent JSON records the guessed specs as its
|
|
285
|
+
`tests`, so its follow-up commands keep the same `--test` scope (with `--no-guess-tests`, so a `guess_tests`
|
|
286
|
+
in the config file cannot conflict) instead of widening to the whole suite. If any changed file has no such
|
|
287
|
+
spec, or nothing changed, the whole suite runs as before and the run says so.
|
|
288
|
+
`--guess-tests` needs `--since` and cannot be combined with `--test`. Keep whole-project runs on main: diff mode does not check every method affected by a
|
|
162
289
|
dependency change. [The pull-request guide](docs/pull-requests.md) covers
|
|
163
290
|
checkout history, a GitHub Actions example, configuration and limits.
|
|
164
291
|
|
|
292
|
+
Doctor warns when an ignore subject matches no method discovered in the included
|
|
293
|
+
source files. Check the exact `Class#method` or `Class.method` form and the
|
|
294
|
+
include/exclude settings; warnings never edit or delete ignore entries.
|
|
295
|
+
A malformed ignore file fails doctor with a config error before target boot.
|
|
296
|
+
|
|
165
297
|
`saboteur doctor` prints Ruby, coverage, integration, Rails/SimpleCov and boot-file
|
|
166
|
-
checks
|
|
298
|
+
checks. Add `--suite` to also run the unchanged suite twice with the chosen seed
|
|
299
|
+
(`--test PATH`, repeatable, limits those runs to some specs and implies `--suite`),
|
|
300
|
+
since that takes hours on a large suite. It writes no
|
|
167
301
|
mutation reports or coverage/verdict caches. Target boot files and tests still
|
|
168
302
|
run, so their normal side effects remain. See the [doctor guide](docs/guides/doctor.md).
|
|
169
303
|
|
|
170
304
|
`--id` runs exactly one current mutant with its coverage-selected tests, bypassing
|
|
171
305
|
per-method caps, ignore suppression and incremental verdict reuse.
|
|
172
|
-
It still honors `--subject` and `--since` changed-line selection
|
|
306
|
+
It still honors `--subject` and `--since` changed-line selection, and the
|
|
307
|
+
hazard guard: a digest that names a suppressed mutant is refused unless
|
|
308
|
+
`--allow-hazardous` is also given. See the
|
|
173
309
|
[replay guide](docs/guides/replay.md) for digest format, checks and limits.
|
|
174
310
|
|
|
175
311
|
Every test run starts from the same random seed (1 by default), so tests that
|
|
@@ -177,29 +313,67 @@ use `rand` see the same values in each mutant's run and in the check that
|
|
|
177
313
|
confirms a kill. The report prints the seed; pass it to `--seed` to repeat a
|
|
178
314
|
run.
|
|
179
315
|
|
|
180
|
-
`--incremental` reuses an earlier verdict only while the method, the
|
|
181
|
-
its covering tests and their files, the lockfile
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
316
|
+
`--incremental` reuses an earlier verdict only while the method, the whole
|
|
317
|
+
file it lives in, the mutant, its covering tests and their files, the lockfile
|
|
318
|
+
and the config are all unchanged. So a constant or helper changed in the same
|
|
319
|
+
file is seen. It cannot see a change in another file that the method uses (a
|
|
320
|
+
method it calls, a constant defined elsewhere), so run without it now and then
|
|
321
|
+
(and in CI on the main branch).
|
|
322
|
+
|
|
323
|
+
Output: survivors first, then a short summary last, so the summary stays on
|
|
324
|
+
screen. Survivors are grouped by file, then method, then line. Each line is
|
|
325
|
+
printed once as `path:line in Subject`, with the source line and the tests that
|
|
326
|
+
covered it, then one compact line per mutation: `operator original -> replacement digest`,
|
|
327
|
+
and a hint on what kind of test would kill it. The summary has the counts per
|
|
328
|
+
status, the score, a table of scores per file (worst first) and a one-line
|
|
329
|
+
legend. `--verbose` adds the score split, mean time, whole-body score and
|
|
330
|
+
kill causes. Colour is on when stdout is a terminal and `NO_COLOR` is not set;
|
|
331
|
+
`--color` and `--no-color` override that. While mutants run, stderr shows
|
|
332
|
+
`done/total (percent) eta`: a live counter on a terminal, and dots with a
|
|
333
|
+
counter every 50 mutants otherwise. Skipped and reused mutants are not
|
|
334
|
+
counted, so the count can end below the total.
|
|
335
|
+
The tests that covered the survivor's line and
|
|
336
|
+
still passed with the mutant applied are listed (the first five, likeliest killers
|
|
188
337
|
first; the rest are under `coveredBy` in the JSON report). The missing
|
|
189
338
|
check may belong in one of them. Line coverage can overstate which tests
|
|
190
339
|
exercise a line, so treat the list as a lead. A survivor reused with
|
|
191
340
|
`--incremental` was not run again, and the list says its tests passed in an
|
|
192
|
-
earlier run.
|
|
341
|
+
earlier run. `--fail-fast` (serial runs only) stops after the first subject
|
|
342
|
+
that has a survivor the ignore list does not accept. The report then covers
|
|
343
|
+
only the subjects that ran: the terminal says which subjects did not run, the
|
|
344
|
+
JSON report has an `earlyStop` field naming them (`exitCause` is `failFast`),
|
|
345
|
+
`--agent` reports status `partial` with the reason `fail_fast`, and the run
|
|
346
|
+
always exits 1, so no `--threshold` can pass it. It cannot be used with
|
|
347
|
+
`baseline` evidence. Survivors that share a line are printed together only
|
|
348
|
+
when their covering tests, reuse status and changed lines are identical;
|
|
349
|
+
otherwise each distinct set of evidence is printed above its own mutations.
|
|
350
|
+
Hints are suggestions, not diagnoses: a survivor can also be an equivalent
|
|
351
|
+
mutant. The HTML page shows the same list in the
|
|
193
352
|
survivor's notes. The JSON report goes to `.saboteur/report.json` and a self-contained HTML
|
|
194
353
|
report (one file, no scripts, no network) to `.saboteur/report.html`. Change
|
|
195
354
|
the paths with `--json` / `--html` or `c.json_report` / `c.html_report` in
|
|
196
355
|
`config/saboteur.rb`; set either to `nil` to turn that report off. The two paths
|
|
197
356
|
must differ. The HTML page shows the covered mutation score
|
|
198
|
-
`(killed + timeout) / (killed + survived + timeout)`, which leaves NoCoverage
|
|
199
|
-
out and keeps survivors accepted on the ignore list
|
|
357
|
+
`(killed + timeout) / (killed + survived + timeout)`, which leaves NoCoverage,
|
|
358
|
+
RanAtLoad and Unreachable out and keeps survivors accepted on the ignore list
|
|
359
|
+
in the denominator. A tool
|
|
200
360
|
that reads the JSON report may show a different score, because the schema counts
|
|
201
361
|
NoCoverage as undetected.
|
|
202
362
|
|
|
363
|
+
`--matrix` runs every covering test against every mutant instead of stopping at
|
|
364
|
+
the first failing one, so a killed mutant lists all of its killers. After the
|
|
365
|
+
survivors the report prints two lists: blind tests (they covered mutants and
|
|
366
|
+
killed none) and redundant tests (they killed some, and every one of those
|
|
367
|
+
mutants had another killer). The run is slower, by up to the number of covering
|
|
368
|
+
tests per mutant. `--matrix` cannot be combined with `--incremental`, because a
|
|
369
|
+
reused verdict lists only the first killer. A redundant test is a lead, not a
|
|
370
|
+
verdict: it may guard behavior no selected mutant touches. A selection with
|
|
371
|
+
`--since` or `--subject` describes only the selected methods.
|
|
372
|
+
|
|
373
|
+
[Scores](docs/scores.md) defines each number on this page: the covered score, the
|
|
374
|
+
score a Stryker dashboard shows, the assertion and crash split, and what makes a run
|
|
375
|
+
incomplete.
|
|
376
|
+
|
|
203
377
|
The score is split by how the mutant was killed. A crash kill (a `NoMethodError`,
|
|
204
378
|
a `TypeError`, a parse error) shows the code ran and broke, not that a test
|
|
205
379
|
checked a value. The terminal prints
|
|
@@ -239,6 +413,8 @@ not `line`, to put a note on a changed line of the pull-request diff. A
|
|
|
239
413
|
whole-body mutant of a method whose only change is its signature touches no
|
|
240
414
|
changed line and prints none.
|
|
241
415
|
|
|
416
|
+
New to survivors? The [reading survivors guide](docs/guides/reading-survivors.md) walks one from the diff to the test that kills it.
|
|
417
|
+
|
|
242
418
|
Exit codes: 0 no survivors (or all on the ignore list), or the score meets
|
|
243
419
|
`--threshold`; 1 survivors, or the score is below `--threshold` or missing;
|
|
244
420
|
2 bad configuration or a missing source or test; 3 the tool failed (with
|
|
@@ -264,14 +440,19 @@ Saboteur.configure do |c|
|
|
|
264
440
|
c.include = ["lib/**/*.rb"]
|
|
265
441
|
c.exclude = ["lib/my_gem/version.rb"]
|
|
266
442
|
c.require = [] # boot files, e.g. ./config/environment
|
|
443
|
+
c.whole_file = [] # subject ids or globs to re-evaluate as a whole file (see below)
|
|
267
444
|
c.reeval = "nesting" # or "class_eval"; see docs/design.md 6.1
|
|
268
445
|
c.timeout_factor = 3 # per-mutant deadline = factor x its tests' recorded time
|
|
269
446
|
c.timeout_min = 5
|
|
270
447
|
c.max_memory_mb = 2048 # memory growth cap per mutant process tree
|
|
271
448
|
c.max_processes = 64
|
|
272
449
|
c.max_mutants_per_method = 100 # positive integer; raise it for a broader run
|
|
450
|
+
c.hazard = Saboteur::MutationSource::HazardGuard::DEFAULT_HAZARD # calls whose arguments are never mutated
|
|
451
|
+
c.sandbox = true # read-only project tree for mutant children (Linux 6.2+); "strict" refuses to run degraded
|
|
452
|
+
c.sandbox_writable = [] # extra dirs mutant children may write, e.g. ["db", "log"]
|
|
273
453
|
c.ignore_file = "config/saboteur_ignore.rb"
|
|
274
454
|
c.seed = 1 # Ruby's random seed in every test run
|
|
455
|
+
c.allow_focus = false # true runs a suite narrowed by fit/fdescribe/:focus instead of exit 2
|
|
275
456
|
c.fingerprint_extra = [] # extra globs whose contents invalidate caches, e.g. ["data/*.csv"]
|
|
276
457
|
end
|
|
277
458
|
```
|
|
@@ -319,6 +500,13 @@ Doctor boots your app, checks the config and the test database host, and runs
|
|
|
319
500
|
your suite twice before any mutation. Fix whatever it reports first. Turn
|
|
320
501
|
SimpleCov off for the run if you use it.
|
|
321
502
|
|
|
503
|
+
The database check refuses any test database whose host is not local
|
|
504
|
+
(`localhost`, `127.0.0.1`, `::1`, `0.0.0.0` or a socket), so a remote
|
|
505
|
+
database is never mutated by accident. `0.0.0.0` counts as local: compose
|
|
506
|
+
setups write it into database URLs, and connecting to it reaches this
|
|
507
|
+
machine. For another disposable test database host, assert it yourself:
|
|
508
|
+
`c.allow_db_hosts = ["db.internal"]` in `config/saboteur.rb`.
|
|
509
|
+
|
|
322
510
|
**4. Test what your branch changed**
|
|
323
511
|
|
|
324
512
|
```sh
|
|
@@ -375,6 +563,72 @@ Rails fixtures (`spec/fixtures`, `test/fixtures`), `db/structure.sql`,
|
|
|
375
563
|
cache fingerprint automatically. Use `fingerprint_extra` for any other file your
|
|
376
564
|
tests read.
|
|
377
565
|
|
|
566
|
+
### Load-time code, captured methods and Sorbet signatures
|
|
567
|
+
|
|
568
|
+
Code that runs while the target boots, before tests run, cannot be checked by
|
|
569
|
+
replacing a method later. saboteur reports these changes as `ran_at_load` rather
|
|
570
|
+
than survivors. Calls from test setup, including RSpec `before(:all)`, are still
|
|
571
|
+
checked. Load-time detection follows the `ran_at_load` idea in
|
|
572
|
+
[mutineer #187](https://github.com/davidteren/mutineer/pull/187).
|
|
573
|
+
|
|
574
|
+
A saved `Method` or `UnboundMethod` can keep calling the old definition after
|
|
575
|
+
saboteur replaces it. When tests pass without calling the replacement and a
|
|
576
|
+
saved method still points to the old definition, the result is `unreachable`.
|
|
577
|
+
A targeted execution check must also confirm the replacement was not called.
|
|
578
|
+
Each mutant is checked separately; one unreachable result does not skip later mutants.
|
|
579
|
+
The reason names the old definition's source location. A lambda that looks up
|
|
580
|
+
the method when it runs is still checked normally.
|
|
581
|
+
|
|
582
|
+
Coverage caches now store both per-test method hits and load-time line hits.
|
|
583
|
+
Older caches are rebuilt; load-time accounting preserves endless-method coverage.
|
|
584
|
+
|
|
585
|
+
One-line and endless method definitions need boot method-call counters to prove
|
|
586
|
+
the body ran at load; merely defining them is not execution evidence. If methods
|
|
587
|
+
share a physical line and any sibling is uncalled, the line is ambiguous and is
|
|
588
|
+
conservatively reported as no coverage, even for its called sibling.
|
|
589
|
+
Agent mode reports ran-at-load and unreachable work as incomplete.
|
|
590
|
+
|
|
591
|
+
A whole-suite map with only load-time hits is valid and reports load-only subjects
|
|
592
|
+
with their ran-at-load reason, rather than failing as an empty coverage build.
|
|
593
|
+
It has no mutation score when no mutant can be tested.
|
|
594
|
+
|
|
595
|
+
Both statuses are listed separately in terminal output and excluded from the
|
|
596
|
+
mutation score. Stryker JSON reports them as `Ignored`, with the explanation in
|
|
597
|
+
`statusReason`; they are not entries on your survivor ignore list.
|
|
598
|
+
|
|
599
|
+
Methods wrapped by `sorbet-runtime` signatures are checked rather than skipped
|
|
600
|
+
as shadowed definitions. saboteur re-evaluates the adjacent `sig` with the
|
|
601
|
+
changed method so the replacement keeps its signature wrapper. Missing adjacent signatures and unknown wrappers are skipped when saboteur cannot
|
|
602
|
+
preserve the loaded method semantics.
|
|
603
|
+
|
|
604
|
+
### Whole-file re-evaluation (opt-in)
|
|
605
|
+
|
|
606
|
+
Surgical re-evaluation refuses a method whose owner it cannot resolve or whose
|
|
607
|
+
loaded definition is not the one in the file. The skip reason ends with a hint.
|
|
608
|
+
List such subjects in `c.whole_file` (ids or globs such as `"Point#distance"` or
|
|
609
|
+
`"Geometry::*"`) and the child evaluates the whole mutated file instead. This
|
|
610
|
+
applies to methods saboteur already finds as subjects; it does not make new
|
|
611
|
+
kinds of definitions selectable (methods inside `Data.define` or `Struct.new`
|
|
612
|
+
blocks are still not found). The file's top-level code then runs again in every
|
|
613
|
+
child, after the run's seed is set: load-time side effects repeat and constants
|
|
614
|
+
it assigns are rebuilt, with redefinition warnings silenced. If a later
|
|
615
|
+
definition in the same file replaces the mutated method, the mutant could never
|
|
616
|
+
run, so a survivor there is reported as unreachable instead. It is off by
|
|
617
|
+
default, and the cache key includes the list.
|
|
618
|
+
Signature text alone does not remove mutants. `body_nil` and last-statement
|
|
619
|
+
removal remain available even when `sig` declares a non-nil return type.
|
|
620
|
+
`checked(:never)`, unchecked `checked(:tests)` signatures, configured default
|
|
621
|
+
levels, custom validation handlers and wrapper shims can all allow nil to
|
|
622
|
+
reach real assertions. saboteur does not verify enforcement, evaluate return
|
|
623
|
+
type expressions or infer types from constant names. Aliases, enclosing
|
|
624
|
+
namespace constants and transformed generic types therefore cannot change
|
|
625
|
+
mutation generation. RBS signatures are not read yet. Type-only crash kills
|
|
626
|
+
remain visible in the crash share, not silently pruned.
|
|
627
|
+
|
|
628
|
+
The Sorbet E2E fixture is a wrapper/registry shim without runtime type checking.
|
|
629
|
+
It tests re-evaluation and assertion detection, not real Sorbet enforcement or
|
|
630
|
+
proof of type-check noise reduction.
|
|
631
|
+
|
|
378
632
|
### Skipped subjects and the mutant cap
|
|
379
633
|
|
|
380
634
|
Subjects with no test coverage are skipped before mutation sources run. They
|
|
@@ -461,6 +715,27 @@ No speed, type-safety or real-fault detection improvement is claimed. See
|
|
|
461
715
|
[the literal operator rationale](docs/literal-operators.md) for fault evidence
|
|
462
716
|
and limits, including string encoding/frozen-value behavior.
|
|
463
717
|
|
|
718
|
+
## Chain removal
|
|
719
|
+
|
|
720
|
+
Builtin drops one link from the middle of a dotted method chain: `user.name.strip`
|
|
721
|
+
becomes `user.strip`, and `a.b.c.d` gives `a.c.d` and `a.b.d`. The first receiver
|
|
722
|
+
and the last call stay, so a test has to notice that a step in between went
|
|
723
|
+
missing. The link goes whole, with its arguments and block. The operator and
|
|
724
|
+
source name is `chain_removal`.
|
|
725
|
+
|
|
726
|
+
Operators, index calls and bare-name receivers are not chains and are unchanged.
|
|
727
|
+
The target of a write (`user.account.profile.name = 1`, `+=`, `||=`, `a.b[0] = 1`)
|
|
728
|
+
is left whole: no link of its receiver chain is dropped, however long. Only
|
|
729
|
+
the value on the right can be mutated. `freeze`, `dup`, `clone`, `itself`
|
|
730
|
+
and `tap` links are never removed. Tests can observe frozenness, copies and a
|
|
731
|
+
`tap` block's effects, but those are other contracts than a missing step in a
|
|
732
|
+
query chain, and dropping such a link mostly yields noise, so this operator
|
|
733
|
+
leaves them out. Arid statements and nested definitions are skipped.
|
|
734
|
+
|
|
735
|
+
Chain removal is on by default, like `argument_removal` and `pair_removal`.
|
|
736
|
+
Its proposals follow the other fine proposals, with the same validation,
|
|
737
|
+
deduplication and `max_mutants_per_method` cap.
|
|
738
|
+
|
|
464
739
|
## Argument removal
|
|
465
740
|
|
|
466
741
|
Builtin removes one ordinary positional argument from a parenthesized call.
|
|
@@ -548,6 +823,34 @@ per-process RSS, which counts shared pages more than once, and it was measured
|
|
|
548
823
|
before the fork-memory work. Read it as history, not a ranking of physical
|
|
549
824
|
memory.
|
|
550
825
|
|
|
826
|
+
### Independent review, saboteur 0.4.0 vs mutineer 1.6.0
|
|
827
|
+
|
|
828
|
+
A private consultant review ("saboteur 0.4.0 - Deep Review", 12 October 2026,
|
|
829
|
+
not published) ran saboteur 0.4.0 at 38b1834 and mutineer 1.6.0 at a766c96 on
|
|
830
|
+
the same machine and projects: Linux x86_64, 1 vCPU, 4 GB, Ruby 3.4.5, Prism
|
|
831
|
+
1.2.0, RSpec 3.13, no Rails or sqlite3. mutineer ran with its default 5
|
|
832
|
+
operators and with all 20. The project and timing rows are one run each. The
|
|
833
|
+
rows describe saboteur at 38b1834; several of its findings have been fixed on
|
|
834
|
+
main since. The rerun that replaces this table is still pending.
|
|
835
|
+
|
|
836
|
+
| Measure | saboteur 0.4.0 | mutineer 1.6.0 |
|
|
837
|
+
|---|---|---|
|
|
838
|
+
| Snippets project (11 planted gaps) | 177 mutants, 14 s, 10/11 gaps found, 9 noise survivors | Default: 56 mutants, 18 s, about 5/11. All operators: 191 mutants, 51 s, 10/11, 8 noise and duplicates |
|
|
839
|
+
| Dependency project (A tested only via B) | 239 mutants, 6 s | 254 mutants, 26 s (needed hand-ordered source files) |
|
|
840
|
+
| dry-inflector (real gem) | 551 mutants, 24 survivors, 95.6 %, 133 s | 480 mutants, 47 survivors (31 distinct), 90.1 %, 134 s |
|
|
841
|
+
| Test selection | Whole suite, per example, automatic | Only files passed with --test, per file |
|
|
842
|
+
| Kill verification | Yes (re-runs killers on unchanged code) | No |
|
|
843
|
+
| sorbet sig methods | Skipped at 0.4.0 | Mutated, 24/24 killed |
|
|
844
|
+
| Data.define / Struct.new block methods | Absent at 0.4.0 | Mutated |
|
|
845
|
+
| Load-time code | False survivors at 0.4.0 | ran_at_load status |
|
|
846
|
+
| Endless defs | Tested ones reported as no coverage at 0.4.0 | Tested ones work; untested ones show as survived |
|
|
847
|
+
| Order-dependent specs | Smell exposed, bogus kills rejected | Hidden (whole file runs) |
|
|
848
|
+
| Destructive mutant | Project wiped except .saboteur | Project wiped |
|
|
849
|
+
| Baseline stability | Race at 0.4.0: 4 of 30 green runs failed (13 %), 5 of 25 under CPU contention (20 %) | No failures observed (15/15) |
|
|
850
|
+
|
|
851
|
+
Not in the review and still pending: a rerun on current main, memory and
|
|
852
|
+
per-mutant time on the benchmark apps, and any mutineer release after 1.6.0.
|
|
853
|
+
|
|
551
854
|
### Memory after the fork-memory work (hashie @ 9c81e83)
|
|
552
855
|
|
|
553
856
|
Paired A/B runs against the pre-PR commit (32c0f21), 5 pairs per job level with
|
|
@@ -589,6 +892,14 @@ details are in [docs/design.md](docs/design.md), prior art in
|
|
|
589
892
|
|
|
590
893
|
See [CONTRIBUTING.md](CONTRIBUTING.md). Commits need a DCO `Signed-off-by` line.
|
|
591
894
|
|
|
895
|
+
Before you ask for review, run the local preflight check on your exact head:
|
|
896
|
+
|
|
897
|
+
```sh
|
|
898
|
+
ruby script/preflight --base <full main sha> --head <full head sha> --base-ref main --mode full
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
It runs scope, sign-off, SPDX, full RuboCop and the specs, and writes a report under `tmp/preflight/`. A pass is not an approval; fast mode is partial. See [docs/preflight.md](docs/preflight.md).
|
|
902
|
+
|
|
592
903
|
## Acknowledgements
|
|
593
904
|
|
|
594
905
|
Thanks to [Glomo](https://glomo.one) for providing the AI tokens used to build saboteur.
|
|
@@ -596,3 +907,99 @@ Thanks to [Glomo](https://glomo.one) for providing the AI tokens used to build s
|
|
|
596
907
|
## License
|
|
597
908
|
|
|
598
909
|
MIT. Copyright (c) 2026 Vamsi Pavan Mahesh Gunturu. See [LICENSE](LICENSE).
|
|
910
|
+
|
|
911
|
+
Internal baseline capture blocks a subject with empty generation, including a subject for which the builtin source yields no candidates. Context comparison is an extra consistency check; file fingerprint verification normally also catches source drift. Neither check proves external state stability.
|
|
912
|
+
|
|
913
|
+
### Opt-in survivor baseline
|
|
914
|
+
|
|
915
|
+
`baseline check` runs every selected mutant fresh, then compares accepted survivors. It does not skip accepted mutants, change verdicts or change scores. Use whole configured projects or `--subject` filters; diff, replay, incremental reuse and score thresholds are refused. Caps, uncovered work, empty generation, errors, resource limits, omitted malformed files, changed inputs and missing results are incomplete, even with zero survivors.
|
|
916
|
+
|
|
917
|
+
```sh
|
|
918
|
+
saboteur baseline propose --baseline baseline.json --proposal proposal.json
|
|
919
|
+
# Edit only document.accepted[*].reason in proposal.json. Give each survivor a review reason.
|
|
920
|
+
saboteur baseline accept --baseline baseline.json --proposal proposal.json
|
|
921
|
+
saboteur baseline check --baseline baseline.json
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Proposal writes independent owner-private execution evidence under the configured cache directory. Keep that directory until acceptance; deleting it prevents acceptance. Acceptance verifies the private record and current input fingerprint before atomically publishing the manifest. For replacement, give both propose and accept `--previous SHA256`, the hash you reviewed of the current baseline bytes. Output files must not overlap project inputs or the private evidence directory. A baseline normally lives in a separate JSON file outside fixture/config input trees.
|
|
925
|
+
|
|
926
|
+
Check prints one JSON object: accepted, new, changed, resolved_kills, detected_timeouts, unmatched and errors are separate categories. Exit 0 means a compatible complete clean check; 1 needs survivor review; 2 means incompatible or invalid configuration; 3 means incomplete execution or tool failure. Propose and accept exit 0 only after their file operation succeeds. Context changes require fresh review. Timeouts remain a distinct detected category, not assertion kills.
|
|
927
|
+
|
|
928
|
+
Score-drop gate: `saboteur baseline check --baseline baseline.json --epsilon POINTS` also fails (exit 1,
|
|
929
|
+
state `review_required`) when the fresh score is more than POINTS percentage points below the score recorded in
|
|
930
|
+
the baseline. Both scores use saboteur's own formula, (killed + timeout) / (killed + timeout + survived), over
|
|
931
|
+
every candidate. The baseline already stores each candidate's status, so no new file format is needed. This
|
|
932
|
+
catches what the survivor comparison cannot: a score that falls because killed mutants disappeared (deleted or
|
|
933
|
+
rewritten code) while every accepted survivor is still matched. A drop of exactly POINTS passes; a rise never
|
|
934
|
+
fails. The check prints a `score` object (`baseline`, `current`, `drop`, `epsilon`, `exceeded`) only when
|
|
935
|
+
`--epsilon` is given. Without it, behavior is unchanged. POINTS is 0 to 100. It lives on `baseline check`,
|
|
936
|
+
not on `run`: the delta gate needs the baseline's fresh whole-scope evidence, which `run` does not produce.
|
|
937
|
+
It is not the same as `--threshold`, which gates an absolute score.
|
|
938
|
+
|
|
939
|
+
In GitHub Actions, the action's `baseline` and `baseline-epsilon` inputs run this check; see
|
|
940
|
+
[docs/github-action.md](docs/github-action.md).
|
|
941
|
+
|
|
942
|
+
Local evidence protects against edits to the public proposal except review reasons. It is not remote attestation, a signature, or protection against the account owner changing their private files. File fingerprints cannot prove external database, network or environment stability. `config/saboteur_ignore.rb` remains a separate legacy ignore mechanism; this gate compares every survivor regardless of ignore status.
|
|
943
|
+
|
|
944
|
+
A baseline that changes during `check` is rejected as configuration/evidence error (exit 2), not an incomplete execution result. Acceptance leaves a private `baseline.json.lock` beside the destination: keep it for cooperating-process publication locks. It contains no evidence or results. Symlinked checkout parents are normalized; symlink artifacts themselves and private-store/lock symlinks are still refused.
|
|
945
|
+
|
|
946
|
+
The builtin `endpoint_replacement` operator swaps explicit, no-argument `first`/`last` calls. It targets wrong-end/order-selection faults in collection handling. Argument-bearing slices, bare calls and block-bearing APIs are left unchanged. Receivers are not statically typed: a custom object may implement either name differently, so a crash remains a crash kill, not proof of a collection assertion.
|
|
947
|
+
|
|
948
|
+
JSON findings are ordered by a deterministic [review ranking](docs/survivor-ranking.md): coarse first, then covering-test count, edited-line impact and changed-line proximity, with file/line/digest ties. The terminal groups survivors by file, method and line first; the ranking only orders the survivors on one line, and every survivor is listed, in review mode too. Only consecutive survivors with matching evidence share a test/evidence block; repeated evidence after another group is printed again to keep that order. Ranking does not change scores or execution.
|
|
949
|
+
|
|
950
|
+
Cache and baseline identities include the policy. Re-propose and review old survivor baselines with `baseline propose`; never auto-accept new survivors. Mutation digests for unchanged edits stay stable.
|
|
951
|
+
|
|
952
|
+
### Preview and choose operators
|
|
953
|
+
|
|
954
|
+
`bundle exec saboteur run --dry-run` discovers source methods and generates candidates without booting the app, running tests, forking, or writing reports/caches. It honors `--since`, `--subject` and source arguments. It prints per-method counts and cap omissions. After a matching normal run, a cached estimate shows baseline covering-test work (cached original-test durations times planned mutants; it is not a bound on mutant runtime and excludes boot and fork costs). If any file cannot be parsed, the total is marked INCOMPLETE and no estimate is given.
|
|
955
|
+
|
|
956
|
+
Trust boundary: `--dry-run` never boots the app or runs its tests, but custom `sources` entries (`path.rb:ClassName`) are still `require`d to generate candidates, so their code runs. Only preview with sources you trust.
|
|
957
|
+
|
|
958
|
+
`bundle exec saboteur run --list-operators` lists every builtin name and description. All builtin operators run by default, including [`range`](docs/operators/range.md), [`operator_assignment`](docs/operators/operator_assignment.md), and [`condition_negation`](docs/operators/condition_negation.md). `--operators arithmetic,comparison` or `c.operators = ["arithmetic", "comparison"]` still selects a subset; `all` selects every name. There is no opt-in tier. `--exclude-operators range,condition_negation` or `c.exclude_operators = ["range", "condition_negation"]` removes named operators from that selection; exclusion wins if a name is in both lists. An empty selection means all, while an empty exclusion means none. CLI lists replace their corresponding config lists. These controls apply to builtin operators, not custom source plugins. Selection and exclusions invalidate cached verdict identity and are carried into agent rerun/replay commands.
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
All operators run unless you select a smaller set; exclusions always remove operators from that set.
|
|
962
|
+
Repeated `--operators` or `--exclude-operators` flags use the last list, not a union.
|
|
963
|
+
To re-enable an operator excluded in config: `bundle exec saboteur run --operators range --exclude-operators ''`.
|
|
964
|
+
To keep the 0.5.0 default inventory: `bundle exec saboteur run --exclude-operators range,operator_assignment,condition_negation`.
|
|
965
|
+
The old `core` and `extended` names now give a migration error, not a changed selection in disguise.
|
|
966
|
+
An empty effective selection is a usage error before boot. `--list-operators` loads current config and shows on/off plus examples; preview and the run header show the effective set.
|
|
967
|
+
|
|
968
|
+
```ruby
|
|
969
|
+
Saboteur.configure do |c|
|
|
970
|
+
c.operators = [] # all by default; or ["arithmetic", "range"] for a subset
|
|
971
|
+
c.exclude_operators = ["condition_negation"]
|
|
972
|
+
end
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
Inspired by mutineer's --dry-run, --list-operators and --operators tiers (github.com/davidteren/mutineer, MIT, by David Teren).
|
|
976
|
+
|
|
977
|
+
### Agent survivor hints and score history
|
|
978
|
+
|
|
979
|
+
`--agent` findings include operator-specific `hint` suggestions and a shared
|
|
980
|
+
`hintCaveat`. Hints do not diagnose a missing test or assertion; equivalent
|
|
981
|
+
mutants and limited observation are still possible. Unknown operators have no
|
|
982
|
+
hint unless marked coarse. The normal JSON byte budget still applies.
|
|
983
|
+
|
|
984
|
+
Use `--score-history PATH` (or `c.score_history = PATH` in your config) to append
|
|
985
|
+
one score-history JSONL record per finished report. History is off by default;
|
|
986
|
+
changes are printed to stderr, not agent stdout. See [score definitions and
|
|
987
|
+
history limits](docs/scores.md). The file can be uploaded by your own artifact
|
|
988
|
+
step; the bundled Action's upload behavior is unchanged.
|
|
989
|
+
|
|
990
|
+
Score history now refuses non-regular files, symlink paths and report/history
|
|
991
|
+
inode aliases before running the engine or writing reports. Imported records
|
|
992
|
+
have a 64 KiB per-line bound and strict validation of supported version 1
|
|
993
|
+
and version 2 records, including counts, scores and duplicate keys. History is an output, excluded from baseline
|
|
994
|
+
fingerprints. See [history safety and comparison limits](docs/scores.md).
|
|
995
|
+
|
|
996
|
+
Operator policy version 2, selected names, excluded names and effective names are in JSON/agent/baseline evidence and fresh score-history v2 rows. Old history v1 rows remain readable but are not compared across policy versions.
|
|
997
|
+
### Inline suppression comments
|
|
998
|
+
|
|
999
|
+
`total = a + b # saboteur:disable-line arithmetic reason="rounding checked elsewhere"` suppresses edits starting on that line, restricted to the named operators. Omit names to suppress all operators. Multiple names are space-separated. Only actual Ruby comments count; directive text in strings does not. A missing or blank reason or unknown operator is a usage error naming file:line. Terminal, JSON and HTML list every suppressed candidate with its reason and digest, outside the score. Moving the commented code keeps the suppression. Multi-line edits use their starting line.
|
|
1000
|
+
|
|
1001
|
+
Inspired by mutineer's inline suppression comments (github.com/davidteren/mutineer, MIT, by David Teren).
|
|
1002
|
+
|
|
1003
|
+
Inline suppressions bound completeness: classic scores still exclude suppressed candidates, but baseline evidence is incomplete when any inline candidate is suppressed. Agent output reports `inline_suppressed` with its count and points to the full audit. Reasons are audited, not proof that suppressed work was checked.
|
|
1004
|
+
|
|
1005
|
+
Source-only `--dry-run` lists the suppressed count and each audit row in selected methods, before diff restriction and the per-method cap. They are outside the planned total. Preview still does not boot the app or write reports.
|
data/exe/saboteur
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Line coverage must be set up before any target code loads, so it happens
|
|
6
|
-
# first.
|
|
6
|
+
# first. For `run` it also counts from here: what executes while the app and
|
|
7
|
+
# spec files load is how ran-at-load mutants are told apart from uncovered
|
|
8
|
+
# ones. The engine suspends counting right after boot and reads the
|
|
9
|
+
# counters as the map's load lines (a forked child would not inherit them).
|
|
7
10
|
require "coverage"
|
|
8
|
-
Coverage.setup(lines: true) if Coverage.state == :idle
|
|
11
|
+
Coverage.setup(lines: true, methods: true, branches: true) if Coverage.state == :idle
|
|
12
|
+
Coverage.resume if ARGV.first == "run" && Coverage.state == :suspended
|
|
9
13
|
|
|
10
14
|
require "saboteur/cli"
|
|
11
15
|
|
data/lib/saboteur/agent_skill.rb
CHANGED
|
@@ -20,10 +20,16 @@ module Saboteur
|
|
|
20
20
|
The exit code is strict: 0 means complete and clean, 1 means a survivor needs
|
|
21
21
|
review, 2 means a bad command or config, 3 means the run was incomplete or failed.
|
|
22
22
|
|
|
23
|
+
To preview cost without booting the app or running tests, use `saboteur run --dry-run`.
|
|
24
|
+
`--list-operators` shows names and tiers; `--operators arithmetic,comparison` restricts the run.
|
|
25
|
+
|
|
23
26
|
When a mutant survives:
|
|
24
27
|
|
|
25
28
|
1. Pick one live survivor from `findings.shown`.
|
|
26
29
|
2. Add the smallest test that checks the observable behavior the mutant changed.
|
|
30
|
+
A finding's `testHint`, when present, says where to look (for a comparison, the
|
|
31
|
+
`case` of operands that separates the two). It is a suggestion; the survivor
|
|
32
|
+
can also be an equivalent mutant.
|
|
27
33
|
3. Replay it with the command in `next.replay` (it uses the recorded seed) and
|
|
28
34
|
confirm the mutant is now killed.
|
|
29
35
|
4. Run the diff again. Repeat until the exit code is 0.
|