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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +199 -0
  3. data/README.md +427 -20
  4. data/exe/saboteur +6 -2
  5. data/lib/saboteur/agent_skill.rb +6 -0
  6. data/lib/saboteur/baseline/acceptance.rb +218 -0
  7. data/lib/saboteur/baseline/command.rb +80 -0
  8. data/lib/saboteur/baseline/comparison.rb +106 -0
  9. data/lib/saboteur/baseline/completeness.rb +53 -0
  10. data/lib/saboteur/baseline/evidence.rb +124 -0
  11. data/lib/saboteur/baseline/identity.rb +78 -0
  12. data/lib/saboteur/baseline/manifest.rb +164 -0
  13. data/lib/saboteur/baseline/run_evidence.rb +117 -0
  14. data/lib/saboteur/baseline/workflow.rb +121 -0
  15. data/lib/saboteur/baseline_health.rb +1 -1
  16. data/lib/saboteur/cli/exit_status.rb +93 -0
  17. data/lib/saboteur/cli/run_reporting.rb +75 -0
  18. data/lib/saboteur/cli.rb +113 -122
  19. data/lib/saboteur/config.rb +96 -6
  20. data/lib/saboteur/doctor.rb +64 -9
  21. data/lib/saboteur/engine/baseline_guard.rb +33 -11
  22. data/lib/saboteur/engine/bytecode_equivalence.rb +95 -0
  23. data/lib/saboteur/engine/canary.rb +74 -0
  24. data/lib/saboteur/engine/candidate_selection.rb +18 -5
  25. data/lib/saboteur/engine/candidates.rb +62 -7
  26. data/lib/saboteur/engine/coverage_store.rb +34 -14
  27. data/lib/saboteur/engine/diagnostics.rb +150 -4
  28. data/lib/saboteur/engine/diff_scope.rb +10 -0
  29. data/lib/saboteur/engine/distribution.rb +54 -13
  30. data/lib/saboteur/engine/result_cache.rb +10 -5
  31. data/lib/saboteur/engine/run_fingerprint.rb +7 -5
  32. data/lib/saboteur/engine/sandbox_policy.rb +108 -0
  33. data/lib/saboteur/engine/scheduler.rb +92 -20
  34. data/lib/saboteur/engine/subject_selection.rb +24 -1
  35. data/lib/saboteur/engine/test_guess.rb +89 -0
  36. data/lib/saboteur/engine/unchanged_syntax.rb +87 -0
  37. data/lib/saboteur/engine/verdict_check.rb +7 -6
  38. data/lib/saboteur/engine.rb +201 -19
  39. data/lib/saboteur/execution/protocol.rb +2 -1
  40. data/lib/saboteur/execution/reevaluator.rb +72 -10
  41. data/lib/saboteur/execution/runner.rb +120 -17
  42. data/lib/saboteur/execution/sandbox.rb +271 -0
  43. data/lib/saboteur/execution/stale_captures.rb +43 -0
  44. data/lib/saboteur/execution/supervisor.rb +101 -9
  45. data/lib/saboteur/execution/worker_pool.rb +20 -4
  46. data/lib/saboteur/execution/wrapper.rb +45 -0
  47. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  48. data/lib/saboteur/frameworks/rails.rb +8 -10
  49. data/lib/saboteur/gates.rb +83 -0
  50. data/lib/saboteur/help.rb +32 -7
  51. data/lib/saboteur/ignore_list.rb +5 -0
  52. data/lib/saboteur/inline_suppression.rb +65 -0
  53. data/lib/saboteur/integration/base.rb +1 -0
  54. data/lib/saboteur/integration/focus_guard.rb +83 -0
  55. data/lib/saboteur/integration/rspec.rb +21 -1
  56. data/lib/saboteur/mutation_source/builtin.rb +22 -3
  57. data/lib/saboteur/mutation_source/chain_removal.rb +80 -0
  58. data/lib/saboteur/mutation_source/condition_negation.rb +50 -0
  59. data/lib/saboteur/mutation_source/endpoint_replacement.rb +61 -0
  60. data/lib/saboteur/mutation_source/hazard_guard.rb +136 -0
  61. data/lib/saboteur/mutation_source/operator_assignment.rb +52 -0
  62. data/lib/saboteur/mutation_source/operator_examples.rb +35 -0
  63. data/lib/saboteur/mutation_source/operator_registry.rb +82 -0
  64. data/lib/saboteur/mutation_source/range_replacement.rb +43 -0
  65. data/lib/saboteur/preview.rb +115 -0
  66. data/lib/saboteur/reporter/agent_commands.rb +19 -5
  67. data/lib/saboteur/reporter/agent_incomplete.rb +24 -1
  68. data/lib/saboteur/reporter/agent_summary.rb +12 -6
  69. data/lib/saboteur/reporter/findings.rb +4 -19
  70. data/lib/saboteur/reporter/history_paths.rb +38 -0
  71. data/lib/saboteur/reporter/history_policy.rb +35 -0
  72. data/lib/saboteur/reporter/history_record.rb +131 -0
  73. data/lib/saboteur/reporter/html.rb +12 -1
  74. data/lib/saboteur/reporter/mutation_text.rb +36 -0
  75. data/lib/saboteur/reporter/next_steps.rb +30 -0
  76. data/lib/saboteur/reporter/palette.rb +37 -0
  77. data/lib/saboteur/reporter/score_history.rb +135 -0
  78. data/lib/saboteur/reporter/score_table.rb +57 -0
  79. data/lib/saboteur/reporter/stryker.rb +18 -3
  80. data/lib/saboteur/reporter/survivor_hints.rb +68 -0
  81. data/lib/saboteur/reporter/survivor_list.rb +126 -0
  82. data/lib/saboteur/reporter/survivor_ranking.rb +34 -0
  83. data/lib/saboteur/reporter/terminal.rb +140 -79
  84. data/lib/saboteur/reporter/test_matrix.rb +57 -0
  85. data/lib/saboteur/result.rb +44 -6
  86. data/lib/saboteur/selection/coverage_map.rb +119 -13
  87. data/lib/saboteur/selection/git_diff.rb +28 -1
  88. data/lib/saboteur/selection/recorder.rb +55 -13
  89. data/lib/saboteur/subject.rb +40 -4
  90. data/lib/saboteur/subject_finder.rb +62 -12
  91. data/lib/saboteur/version.rb +1 -1
  92. data/lib/saboteur/working_tree.rb +8 -0
  93. data/lib/saboteur.rb +6 -0
  94. 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 and names the log and fix. Long phases
39
- print their elapsed time about every 30 seconds, and each phase's log holds
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). Older benchmark numbers
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 --seed 42 # environment + two unchanged suite checks
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. Keep whole-project runs on main: diff mode does not check every method affected by a
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, then runs the unchanged suite twice with the chosen seed. It writes no
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. See the
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 mutant,
181
- its covering tests and their files, the lockfile and the config are all
182
- unchanged. It cannot see a change in a method this one calls, so run without
183
- it now and then (and in CI on the main branch).
184
-
185
- Output: counts per status, the score, timing, and a unified diff for every
186
- survivor. Under each diff the terminal lists the tests that covered that line
187
- and still passed with the mutant applied (the first five, likeliest killers
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. The HTML page shows the same list in the
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 in the denominator. A tool
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. It stays suspended (no counting) except while the coverage map is built.
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
 
@@ -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.