saboteur 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +772 -0
  3. data/README.md +221 -1072
  4. data/lib/saboteur/attribute_finder.rb +113 -0
  5. data/lib/saboteur/cli/exit_status.rb +32 -1
  6. data/lib/saboteur/cli/help_options.rb +2 -1
  7. data/lib/saboteur/cli/history_command.rb +135 -0
  8. data/lib/saboteur/cli/presets.rb +47 -3
  9. data/lib/saboteur/cli/run_budget_options.rb +3 -1
  10. data/lib/saboteur/cli/selection_options.rb +31 -0
  11. data/lib/saboteur/cli/shard_options.rb +17 -0
  12. data/lib/saboteur/cli/source_set.rb +35 -0
  13. data/lib/saboteur/cli/survivors_command.rb +157 -0
  14. data/lib/saboteur/cli.rb +74 -55
  15. data/lib/saboteur/config.rb +102 -6
  16. data/lib/saboteur/config_file.rb +19 -7
  17. data/lib/saboteur/constant_reads.rb +182 -0
  18. data/lib/saboteur/constant_subject.rb +70 -0
  19. data/lib/saboteur/constant_subject_finder.rb +281 -0
  20. data/lib/saboteur/coverage_reads.rb +29 -0
  21. data/lib/saboteur/custom_map.rb +12 -1
  22. data/lib/saboteur/dirty_tree_warning.rb +130 -0
  23. data/lib/saboteur/doctor.rb +13 -0
  24. data/lib/saboteur/engine/baseline_guard.rb +135 -21
  25. data/lib/saboteur/engine/boot.rb +11 -3
  26. data/lib/saboteur/engine/bytecode_equivalence.rb +17 -1
  27. data/lib/saboteur/engine/candidate_selection.rb +20 -0
  28. data/lib/saboteur/engine/candidates.rb +43 -3
  29. data/lib/saboteur/engine/coverage_store.rb +58 -14
  30. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  31. data/lib/saboteur/engine/diagnostics.rb +19 -1
  32. data/lib/saboteur/engine/distribution.rb +10 -0
  33. data/lib/saboteur/engine/early_stop_recording.rb +3 -2
  34. data/lib/saboteur/engine/gated_run.rb +37 -0
  35. data/lib/saboteur/engine/load_coverage.rb +63 -0
  36. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  37. data/lib/saboteur/engine/pipelines.rb +115 -0
  38. data/lib/saboteur/engine/result_cache.rb +65 -8
  39. data/lib/saboteur/engine/run_budget.rb +32 -2
  40. data/lib/saboteur/engine/sandbox_policy.rb +59 -3
  41. data/lib/saboteur/engine/scheduler.rb +131 -17
  42. data/lib/saboteur/engine/selection_announcement.rb +59 -0
  43. data/lib/saboteur/engine/shard.rb +69 -0
  44. data/lib/saboteur/engine/subject_selection.rb +380 -21
  45. data/lib/saboteur/engine/test_guess.rb +407 -27
  46. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  47. data/lib/saboteur/engine.rb +103 -129
  48. data/lib/saboteur/execution/external_runner.rb +489 -0
  49. data/lib/saboteur/execution/fiddle_loader.rb +34 -0
  50. data/lib/saboteur/execution/marker_sweep.rb +80 -0
  51. data/lib/saboteur/execution/peak_memory.rb +28 -0
  52. data/lib/saboteur/execution/process_table.rb +19 -3
  53. data/lib/saboteur/execution/process_tree.rb +65 -5
  54. data/lib/saboteur/execution/reevaluator.rb +75 -12
  55. data/lib/saboteur/execution/runner.rb +46 -7
  56. data/lib/saboteur/execution/sandbox.rb +25 -6
  57. data/lib/saboteur/execution/seatbelt.rb +233 -0
  58. data/lib/saboteur/execution/supervisor.rb +29 -11
  59. data/lib/saboteur/execution/test_command_shape.rb +149 -0
  60. data/lib/saboteur/execution/worker_pool.rb +23 -1
  61. data/lib/saboteur/frameworks/sqlite_memory_database.rb +79 -0
  62. data/lib/saboteur/gates.rb +43 -2
  63. data/lib/saboteur/help.rb +60 -9
  64. data/lib/saboteur/ignore_list.rb +29 -6
  65. data/lib/saboteur/integration/rspec.rb +223 -5
  66. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  67. data/lib/saboteur/mutation_source/argument_swap.rb +54 -0
  68. data/lib/saboteur/mutation_source/builtin.rb +28 -5
  69. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  70. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  71. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  72. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  73. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  74. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  75. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  76. data/lib/saboteur/mutation_source/hazard_guard.rb +189 -0
  77. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  78. data/lib/saboteur/mutation_source/method_swap.rb +148 -0
  79. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  80. data/lib/saboteur/mutation_source/operator_examples.rb +4 -1
  81. data/lib/saboteur/mutation_source/operator_registry.rb +5 -2
  82. data/lib/saboteur/mutation_source/operator_visit.rb +1 -1
  83. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  84. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  86. data/lib/saboteur/preview.rb +125 -12
  87. data/lib/saboteur/refinement_activations.rb +60 -0
  88. data/lib/saboteur/reporter/agent.rb +9 -1
  89. data/lib/saboteur/reporter/agent_commands.rb +10 -3
  90. data/lib/saboteur/reporter/agent_incomplete.rb +31 -5
  91. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  92. data/lib/saboteur/reporter/findings.rb +5 -2
  93. data/lib/saboteur/reporter/history_policy.rb +11 -0
  94. data/lib/saboteur/reporter/history_reader.rb +47 -0
  95. data/lib/saboteur/reporter/html.rb +26 -4
  96. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  97. data/lib/saboteur/reporter/stryker.rb +27 -3
  98. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  99. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  100. data/lib/saboteur/reporter/terminal.rb +56 -8
  101. data/lib/saboteur/result.rb +19 -3
  102. data/lib/saboteur/selection/coverage_map.rb +120 -10
  103. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  104. data/lib/saboteur/selection/external_test_map.rb +27 -0
  105. data/lib/saboteur/selection/git_diff.rb +93 -11
  106. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  107. data/lib/saboteur/selection/recorder.rb +22 -4
  108. data/lib/saboteur/selection/source_scope.rb +22 -0
  109. data/lib/saboteur/subject.rb +42 -7
  110. data/lib/saboteur/subject_finder.rb +173 -29
  111. data/lib/saboteur/version.rb +1 -1
  112. data/lib/saboteur/working_tree.rb +38 -15
  113. data/lib/saboteur.rb +14 -0
  114. metadata +39 -16
data/CHANGELOG.md CHANGED
@@ -6,6 +6,778 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.1] - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - Positional call-argument swap operator (#653): swaps adjacent positional
14
+ arguments at call sites, catching specs that do not pin argument order.
15
+ - After the first timed baseline pass, saboteur projects the remaining
16
+ baseline+coverage cost against `--max-duration` and warns up front when
17
+ the budget barely fits or is exceeded, naming how much time mutants get
18
+ and the remedies (#637). A `--max-duration` run that ends with exit 4
19
+ and 0 mutants now headlines "no mutants ran", and the flag's help states
20
+ what the clock covers: it starts at engine entry and includes the
21
+ baseline and coverage phases.
22
+ - fiddle is no longer a hard dependency: it loads lazily through one door
23
+ that refuses with every working fix named when it is missing, and the
24
+ sandbox layers fail closed on refusal (#639).
25
+
26
+ ### Fixed
27
+
28
+ - The DCO checks no longer disagree: CI's check-dco, preflight, and the
29
+ panel gate all require the Signed-off-by line to be a real git trailer
30
+ after a blank line (#646).
31
+ - When a changed file matches no spec by id hint, saboteur names the exact
32
+ ids it looked for (`warn_unmatched_whole_file`, `--since` scoped) (#635).
33
+ - `saboteur pr --since` on a spec-only or docs-only diff no longer warns
34
+ that "the whole suite runs" - it announces there is nothing to test,
35
+ matching the zero-subject run that follows (#607).
36
+ - On macOS the degraded-sandbox message no longer claims the kernel "lacks
37
+ Landlock (needs Linux 6.2+)"; it says macOS has no Landlock and points
38
+ at `c.sandbox = "seatbelt"` (#667).
39
+ - The preset banner shows the effective flags, including explicit
40
+ `--since` refs and flag=value spellings (#666).
41
+ - Running saboteur without rspec-core in the bundle (for example a
42
+ Minitest project) fails as a usage error (exit 2) naming docs/minitest.md,
43
+ instead of an internal error pointing at the RSpec-only `c.integration`
44
+ remedy (#610). A baseline that fails with NoMethodError in a suite whose
45
+ spec_helper requires minitest now warns the suite looks like Minitest
46
+ running under RSpec, with the symptom and count (#626).
47
+ - Test-file guessing looks for specs below a flattened namespace
48
+ (rubocop-ast style layouts) (#586), and when a layout strips directories
49
+ between source and spec trees it picks the unique spec by parent
50
+ directory and basename instead of failing or guessing wrong (#600).
51
+ - A `--subject` pattern that names an attr_reader, attr_writer or
52
+ attr_accessor method no longer says "matches no subject"; the method
53
+ exists but has no body to mutate, and the message says so (#373).
54
+ - Load errors in the target app's boot are reported with the tool's own
55
+ frames filtered out, with `--verbose` restoring the full backtrace (#608).
56
+ - If a spec file loaded Ruby code before saboteur started coverage,
57
+ whole-suite runs no longer report "5 subjects, 0 mutants"; saboteur
58
+ names up to 5 preloaded files with cause and remedy and exits 3. With
59
+ `--test` it warns and continues (#686).
60
+ - An empty or docs-only `--since` range exits 0 with a "nothing to test"
61
+ notice instead of failing (#659), and a run no longer floods stderr with
62
+ Ruby's "possibly useless use of nil in void context" compile warnings
63
+ when the host suite enables warnings (#656).
64
+ - Running saboteur in a project with no RSpec suite stops with a single
65
+ line naming the problem and pointing to docs/minitest.md, instead of one
66
+ line per source file; specs outside spec/ fall back to the per-file stop
67
+ with `--test`/`--all`. Exit code unchanged (2) (#606).
68
+ - Whole-file targets no longer error "already initialized constant" in
69
+ strict-warning suites when the app lazily requires the same file (#632).
70
+ - Doctor's macOS degraded-sandbox output names the way out:
71
+ `c.sandbox = "seatbelt"` with the guide link, or `c.sandbox = false` (#667).
72
+ - Load-time vetting no longer rejects `class << self` or `/re/.freeze`,
73
+ and the constant-skip warning cites a one-line excerpt plus line instead
74
+ of whole class bodies (#664). Vetting clears Struct/Data factories,
75
+ public_constant/private_constant, and lambda literals so those files
76
+ keep their mutation subjects (#537).
77
+ - A spec load that quits because the suite auto-starts SimpleCov/SingleCov
78
+ names the suspect and both guard styles, instead of an opaque "No
79
+ examples found" (#645).
80
+ - A sqlite :memory: database with a schema but zero rows hints at
81
+ require-time seed data lost across the fork, with the
82
+ before(:suite)/after_fork remedy and the `c.hooks` wiring (#594).
83
+ - The early-stopped summary headline reads selected versus attempted, for
84
+ example "57 mutants selected, 4 attempted" (#582).
85
+ - The dirty-tree warning headline says "uncommitted or untracked change(s)"
86
+ so a tree of only scratch files is not read as edits (#604).
87
+ - Custom-map load errors name unsupported template syntax before glob
88
+ validation, literal map paths are preserved, and mixing numbered and
89
+ named map templates is rejected at load with a clear error (#642).
90
+ - The sqlite in-memory database fork-loss hint includes the exact
91
+ `c.hooks` configuration needed to load the suggested after-fork hooks
92
+ file (#593).
93
+ - A subject the `--max-duration` budget stops mid-list no longer drops the
94
+ kills it already measured: the truncated subject's completed kills are
95
+ confirmed uncapped (#588).
96
+ - Shell-exported RUBYLIB entries that bundler's scrub drops are restored
97
+ (#584), `spec/` is on the load path for non-Rails boots too (#615), and
98
+ RSpec core loads before configured boot helpers so non-Rails helpers can
99
+ call RSpec.configure (#616).
100
+ - `--test-command` warns when one ruby is handed several test files: ruby
101
+ executes only the first, so the rest never ran and their mutants
102
+ silently survived (#611).
103
+ - Dry-run excludes statically unsupported subjects, such as methods
104
+ defined inside blocks, from planned mutant totals and reports the skip
105
+ reason (#651).
106
+ - Non-String integration assignments fail with a file-and-line
107
+ configuration error before fingerprinting or boot (#614).
108
+ - Baseline failures outside examples say so and show the log tail instead
109
+ of an empty failed-example list (#581).
110
+
111
+ ### Documentation
112
+
113
+ - The flaky-tests and survivors guides catch up with 0.8.0 (#683), and
114
+ design.md explains the load-time vetting safelist for constant replay
115
+ under `--since`, including what it cannot account for (#537).
116
+ - Docs drift: the pinned saboteur-vs-mutineer benchmark report names the
117
+ version it measured (0.1.0), and the compare page scopes its kill
118
+ re-check claim to fresh kills (#657).
119
+ - The README quick start covers the inference-miss exit 2 and its
120
+ recovery, the install block leads with Bundler, the quick-start anchor
121
+ matches its heading, and a no-warranty disclaimer joins the license
122
+ section (#658, #657, #599).
123
+
124
+ ## [0.8.0] - 2026-10-18
125
+
126
+ ### Added
127
+
128
+ - `--test-command` runs a mutation-visibility check before the first
129
+ mutant (#441). The command runs once with the first subject's file
130
+ replaced by a load-time error; if it still passes, the command cannot
131
+ see mutants in that file - a `RUBYLIB`/`-I` path or an installed gem
132
+ anchors it to the real tree, or the suite never loads the file - and
133
+ the run warns loudly that the score would read 0.0 % instead of
134
+ silently reporting every mutant Survived.
135
+
136
+
137
+ - `--shard K/N` (#470): run one deterministic slice of the planned candidate
138
+ inventory - `K` is the 1-based shard and `N` the number of shards. Each
139
+ planned mutant is assigned by a SHA-256 of its candidate identity, never by
140
+ position, so the split stays stable when an earlier subject gains or loses a
141
+ mutant. The terminal, JSON and agent outputs name the shard, the inventory
142
+ digest and the selected/planned counts, and a shard run is always reported
143
+ partial. See docs/guides/sharding.md for the CI matrix setup.
144
+
145
+ - `saboteur survivors <report.json>` (#475): a read-only terminal browser
146
+ for the Stryker JSON report - list and filter survivors (with the
147
+ ranked-findings order available via `--ranked`), and `--show DIGEST`
148
+ prints the diff, the exact replay command and an ignore-file entry
149
+ template. It never boots the project or edits the ignore file.
150
+ - `--flaky-check survivors|decisive` (#476): one reordered confirmation
151
+ rerun per new survivor (and per fresh kill in `decisive` mode). A
152
+ verdict the confirmation disagrees with becomes `unverified` - named
153
+ with its decisive tests, outside the score, never retried until it
154
+ passes.
155
+ - `saboteur badge` writes Shields endpoint JSON from the opt-in score
156
+ history and `saboteur trend` prints the covered-score delta against the
157
+ last comparable record (#474). A README badge needs no hosted service:
158
+ commit the written JSON and point a Shields endpoint badge at its raw
159
+ URL. An incomplete run is refused as badge evidence unless
160
+ `--mark-partial` labels the badge as partial, and the trend never
161
+ compares records with different selection, inventory, schema version
162
+ or operator policy.
163
+ - `c.sandbox = "seatbelt"`, an opt-in macOS sandbox for mutant children
164
+ (#436): each child applies a Seatbelt profile (writes denied except the
165
+ granted dirs, the system temp dir, and its own inherited logs) via
166
+ `sandbox_init`, after a forked probe proves a deny-default profile
167
+ really blocks a write. A macOS without Seatbelt degrades to the hazard
168
+ guard and canary with a one-time note; an errored probe or a profile
169
+ that does not enforce refuses the run (the Landlock probe's pinned
170
+ semantics, #178), and a child whose profile refuses to apply fails
171
+ closed instead of running the mutant.
172
+ On Linux the value behaves like `true`. Opt-in because Apple deprecates
173
+ `sandbox_init` without a published replacement; #436 tracks the
174
+ ecosystem's response.
175
+ - `saboteur run --dry-run --format json` prints a versioned machine-readable
176
+ mutant inventory without booting the suite (#469): schema version, run
177
+ metadata, subjects, every planned mutant with its stable digest, operator,
178
+ file, line and byte-range edits, and every skipped candidate with its
179
+ reason (inline suppression, hazard refusal or the per-method cap).
180
+ `--dry-run --agent` now names this flag instead of only refusing.
181
+ - A `regex_replacement` operator (#135): drops each regex anchor
182
+ (`\A`, `\z`, `\Z`, `^`, `$`) in turn and swaps each bare `+` or `*`
183
+ quantifier, so a validation like `/\A\d+\z/` finally gets mutants.
184
+ Escaped characters, character classes and lazy or possessive stacks stay
185
+ untouched; interpolated regexes stay out. The operator policy version
186
+ moves to 3: histories stamped with an older policy stay readable but
187
+ never trend-compare against new runs.
188
+ - Methods defined by a non-interpolated `class_eval`/`module_eval` heredoc
189
+ are mutation subjects (#544): the heredoc body is re-parsed against the
190
+ real file's bytes, so mutants inside it are generated and judged like
191
+ ordinary defs (the faraday shape that used to produce 0 subjects). An
192
+ interpolated heredoc still yields no subjects and keeps the
193
+ code-in-strings warning (#467).
194
+ - Per-path score gate config (#473, slice 1): `score_gate "app/payments/**/*",
195
+ min_score: 95` in `saboteur.rb` declares a per-subtree score floor,
196
+ validated at load (min_score 0-100, pattern must match an included file).
197
+ Enforcement lands with slice 2 (#570).
198
+ - Per-path score gates are enforced (#473, slice 2): each `score_gate`
199
+ subtree from slice 1 (#565) is scored with the run's own formula. A gate under its floor fails the run like the
200
+ count gates (`scoreGate` cause in the JSON report); a gate with nothing
201
+ executed under it passes vacuously and says so. Score gates cannot combine
202
+ with `--agent`, like `--threshold`.
203
+ - Peak-memory measurement (#202, slice 1): each worker reports its kernel
204
+ peak RSS (VmHWM) when it retires, and the JSON report records it under
205
+ `saboteur.memory` as per-worker `peakMb` values plus the supervisor's own
206
+ peak. No total is printed: fork copy-on-write makes per-process RSS
207
+ non-additive. The section is omitted where procfs is unavailable.
208
+
209
+ ### Changed
210
+
211
+ - Any `--since` run whose resolved range has no changes now refuses with
212
+ exit 2 instead of passing with 0 subjects (#643) - scheduled or no-op
213
+ runs included. The common hit is `saboteur pr <merge_sha>` on a
214
+ checkout AT the merge commit, which diffed the merge against itself;
215
+ the refusal names the base and the resolved range and says to fetch
216
+ the merge's parent or point `--since` at the change's base.
217
+ - A run whose effective include set selects no Ruby file now refuses with
218
+ exit 2 instead of passing with 0 subjects (#447). The default include
219
+ targets plain gems, so a first run on a Rails-shaped tree used to be a
220
+ green gate that mutated nothing on every CI run; the refusal names the
221
+ stale pattern and points at the install generator.
222
+ - A `--subject` pattern that matches no subject now fails the run (exit 2)
223
+ instead of passing silently with zero subjects (#379). When some patterns
224
+ match, the run keeps them and the summary names the missing pattern and
225
+ the matched count.
226
+
227
+ ### Fixed
228
+
229
+ - A single `using` anywhere in a file used to skip every subject in the
230
+ file. Refinement skips are now scope-granular (#634): a bare `using`
231
+ skips only subjects after it in its own class/module body (or the
232
+ rest of the file at the top level), so one module's refinement no
233
+ longer skips a sibling module's methods. A `using` inside a method
234
+ or block body still skips the whole file.
235
+
236
+ - `scope :name, -> { ... }` and `define_singleton_method` bodies are now
237
+ subjects (#194): the human-essentials dogfood showed a scope's lambda
238
+ going unmutated. The call is a singleton subject of the enclosing owner;
239
+ the lambda or block body mutates, and re-evaluating the whole call
240
+ re-registers the method.
241
+ - An arid statement (a logging or instrumentation call) whose block carries
242
+ real work is now mutated inside the block (#464). The walk used to skip
243
+ the whole statement, so a method like one wrapped in
244
+ `Notifications.instrument ... do |payload| ... end` produced only the
245
+ coarse `body_raise` and no statement_removal, boolean_logic or other
246
+ fine mutants on the changed lines. The block body is walked like any
247
+ other, and such a statement also keeps the method out of the
248
+ nothing-to-remove coarse check.
249
+ - A loaded class defining a singleton `hash` that does not return an
250
+ Integer (forem's `ReactionCategory`) no longer crashes the boot as an
251
+ internal TypeError: every `Coverage.result` read now refuses with a
252
+ diagnostic explaining the violated `#hash` contract and blaming the
253
+ application's code (#564).
254
+ - A `--since` diff that deletes a method no longer reports the deletion as
255
+ "differ only in comments or formatting" and no longer names the method the
256
+ deleted lines drifted onto. The run says what was deleted instead: `N
257
+ changed subject(s) were deleted, nothing to mutate: ...` (#466).
258
+ - An error-dominated run no longer prints a bare score above the "tested almost nothing" warning: the terminal reports "score n/a (N of M mutants scored, X % of scored)" so the scored share cannot be misread as a run-wide score (#557).
259
+ - Preflight no longer refuses a precompiled Ruby (mise, ruby-build) whose
260
+ binary tops the 50 MB evidence-document cap (#364): tool identity
261
+ digests stream with an executable-sized bound instead. The cap for JSON
262
+ evidence documents is unchanged.
263
+ - A missing `rspec-core` no longer crashes saboteur into the internal-error
264
+ report with a bare `LoadError`: the RSpec integration refuses with
265
+ "rspec is not in the bundle" and names the remedy, adding the gem to the
266
+ test group or pointing `c.integration` at the suite's framework (#482).
267
+ Only the framework's own `LoadError` converts; a `LoadError` raised by
268
+ the app's own files, or for a different feature such as `rspec/core_ext`,
269
+ keeps the crash route.
270
+ - A `--since` run whose changed methods were all skipped - refinements,
271
+ no coverage, or any other skip reason - used to exit 0 with `score n/a`,
272
+ a green run that tested nothing. It now exits 3 and names each skipped
273
+ subject with its reason, and a partially skipped diff announces each
274
+ skip up front instead of only in the end-of-run summary (#574).
275
+
276
+ - `saboteur badge` escapes incomplete-run reasons with `.inspect` in its
277
+ refusal message, so a reason carrying newlines or control characters
278
+ cannot spoof extra output lines (#474).
279
+ - `c.sandbox = "seatbelt"` grants now match real paths on macOS: the
280
+ kernel resolves `/var` to `/private/var` when it checks a write
281
+ against a Seatbelt subpath grant, so a granted dir under a symlinked
282
+ parent (like `Dir.mktmpdir` under `/var/folders`) silently matched
283
+ nothing and the child saw its own writable dir denied. Granted dirs
284
+ are now resolved with `File.realpath` before the profile is built,
285
+ the same view `fcntl(F_GETPATH)` reports back.
286
+
287
+ - The artifact dir now ignores itself (#426): a run writes logs,
288
+ coverage maps and reports under the cache dir, and the marker
289
+ `.gitignore` keeps a `git add -A` from committing them. An existing
290
+ `.gitignore` there is left alone.
291
+ - Rejected kills no longer surface as generic errors: the terminal
292
+ lists them in a `Rejected kills` section and annotates the error count,
293
+ and report.json flags each rejected mutant with `rejectedKill: true`
294
+ instead of carrying only the `rejectedKills` count (#375).
295
+
296
+ - A changed file the diff only deletes from no longer hard-stops a
297
+ --since run for want of a spec (#484): a deletion-only file yields no
298
+ subject and no fine-grained mutant, so test inference treats it as
299
+ subjectless instead of demanding a spec, and the run goes ahead.
300
+ - The pr preset no longer hard-stops when a changed file's coverage
301
+ lives in a consolidated spec the diff itself touches: a spec added
302
+ or changed in the diff that reads a constant the uncovered file
303
+ defines covers that file and runs, instead of exit 2 (#538). The
304
+ stop remains for a mutable change no changed spec exercises.
305
+ - Dependency trees are never treated as changed source on a `--since`
306
+ run (#504): a vendored bundle or `node_modules`, tracked or
307
+ untracked, git-ignored or not, is rejected at enumeration, so the
308
+ diff scope never lists, reads or warns on it. The `.bundle/config`
309
+ file itself is project configuration and stays visible.
310
+ - The project-local bundler path (`.bundle/config`'s `BUNDLE_PATH`) now
311
+ joins the dependency shield in its quoted and non-canonical forms
312
+ (#567): matching quotes are stripped, an inline `#` comment is ignored
313
+ the way Bundler::YAMLSerializer ignores it, a relative path is
314
+ normalized against the project root, and an absolute path (the system
315
+ bundle) stays outside the shield. A config that is not a regular file
316
+ or cannot be parsed - including a NUL byte in the value - means no
317
+ shield rather than a crash or a hang.
318
+
319
+ - A `--max-duration` budget that cannot cover the cold coverage map build
320
+ now stops the run before the build starts, instead of spending the
321
+ whole budget mid-build and keeping nothing - the trap where every
322
+ `--incremental` resume redid the baselines and died at the same point.
323
+ Once the baseline passes are timed, a build costing about one more
324
+ full pass than the budget holds is refused up front, with the
325
+ shortfall and the remedies (a higher budget, fewer
326
+ `--baseline-passes`, or one unbudgeted run to warm the coverage
327
+ cache) in the message (#427).
328
+
329
+ - The first-pass ETA no longer reads like a second baseline check when
330
+ the baseline is done (#465): with a cold coverage cache the remaining
331
+ pass is the coverage map build, and the message now names the coverage
332
+ build and any remaining baseline passes separately.
333
+ - Def-time macro mutants - a `deprecate`-style helper, a `before_action`
334
+ block, any DSL whose only caller is a class body - no longer report as
335
+ survived: the closure captured while the app loads keeps the pristine
336
+ definition, so replacing the method later cannot judge them. They now
337
+ report as `ran at load`, outside the score (#409).
338
+ - A mutant on the continuation line of a multi-line interpolated
339
+ statement is no longer misclassified missing_test (#498): attribution
340
+ now spans the whole enclosing statement, because an interpolation's own
341
+ statements are eager and Ruby's per-line events on continuation lines
342
+ are not reliable per example.
343
+ - `--incremental` now sees dependency-declaration edits on projects
344
+ that commit no lockfile (the library convention): the verdict
345
+ fingerprint hashed only `Gemfile.lock` / `gems.locked`, so the digest
346
+ was a constant and every cached verdict was reused across a `Gemfile`,
347
+ `gems.rb` or `*.gemspec` edit (#408). Manifests and gemspecs anywhere
348
+ under the project root are hashed always.
349
+
350
+ - A `--since` change inside a constant literal saboteur refuses to mutate
351
+ (unvettable load-time code, or an unsupported literal form) no longer
352
+ reads like an unexplained miss in the umbrella "no subjects covering the
353
+ changed lines" warning: the warning now says how many of the files sit
354
+ in a constant literal that is not mutated, named by the constant
355
+ warnings above it (#537).
356
+
357
+ - ERB generator templates (`.rb` files with ERB interpolation, as Rails
358
+ generators carry under `lib/generators/**/templates/`) no longer print a
359
+ per-file "does not parse" warning on every discovery pass: they can
360
+ never parse as plain Ruby, so they are skipped with one summarized note
361
+ per run. A real syntax error in a plain Ruby file still warns per file
362
+ (#372).
363
+ - A `c.sources` entry that looks like a source glob now fails with a
364
+ pointer to `c.include` (#488): `c.sources` registers custom operator
365
+ sources (`builtin` or `path.rb:ClassName`), not the files to mutate.
366
+ - A baseline stopped by a containment limit (process count or memory)
367
+ now names the knob and its current value in the refusal (#459): a
368
+ suite that forks heavily by design, or legitimately needs more
369
+ memory, is pointed at `c.max_processes` / `c.max_memory_mb` instead
370
+ of reading as a suite failure.
371
+
372
+ - A baseline that passed but leaked child processes no longer reads as a
373
+ suite failure quoting an empty "Failures:" section (#486): the refusal
374
+ says the suite passed, names each leaked process (pid and command,
375
+ captured before the supervisor's kill) and skips the log excerpt, which
376
+ only holds RSpec output.
377
+ - Engine setup load failures are reported clearly instead of as a bare
378
+ quit (#378): when RSpec dies while loading spec files, the error names
379
+ the files that failed to load, parsed from RSpec's own load-failure
380
+ output, and suggests the remedy - exclude the broken subtrees with
381
+ `--exclude-pattern` (for a Rails engine's embedded spec/dummy) or name
382
+ the suite with `--test`.
383
+
384
+ - A spec file whose later examples rely on state left by earlier ones no
385
+ longer turns its mutants into errors (#557). When a covering example
386
+ fails even with the method unchanged, saboteur retries once with the
387
+ file's examples in defined order, from the start through the last
388
+ covering example. The kill stands only if that run passes unchanged.
389
+
390
+ - Spec and test files vendored under `lib/` (capybara ships its driver
391
+ suite at `lib/capybara/spec/**`) are no longer swept into the default
392
+ mutation scope (#504): the default exclude now shields
393
+ `lib/**/spec/**`, `lib/**/test/**`, `lib/**/tests/**` and the
394
+ `*_spec.rb` / `*_test.rb` / `test_*.rb` shapes under `lib/`. A config's
395
+ own `c.exclude` replaces the shields wholesale, and the shields never
396
+ trip the stale-pattern warning.
397
+
398
+ - A --since run whose changed lines sit inside a heredoc now says so
399
+ (#467). Code embedded in a string - a class_eval/module_eval argument or
400
+ a template constant - evaluates methods the static walk cannot see, so
401
+ the generic "may not parse, or every change may sit outside a method"
402
+ hint misdiagnosed it; the warning now names the files and the
403
+ code-in-a-string blind spot.
404
+
405
+ - The "nothing to mutate" note on an empty selection now prints only
406
+ once (#468). An empty-selection run already ends with the
407
+ "0 subjects, 0 mutants" summary, and announcing the note again as a
408
+ report warning at the end of the run read like a second failure; it
409
+ is now a one-time inform when nothing has a subject.
410
+
411
+ - `--incremental` no longer reuses a stored verdict after the project source
412
+ set changes: a file added after the coverage map was built (a conditional
413
+ require, a Zeitwerk autoload) can load into a mutant run unseen, and the
414
+ reused verdict read as a false green. The set is paths only; content edits
415
+ to other files stay the documented cross-file tradeoff (#396).
416
+ - A factory assigned inside `class << self` now keeps its own scope in the
417
+ owner path, so a constant defined there is named after the factory's
418
+ singleton: `Host::W.singleton_class::V#n`. An outer factory with no
419
+ `class << self` of its own still opens no scope, even when a `class <<
420
+ self` encloses it (#438).
421
+
422
+ - A class factory assigned inside `class << self` no longer shares its
423
+ method ids with a same-named factory in the class body (#406). The two
424
+ unrelated classes each keep their own subjects: the singleton-side one is
425
+ now named `Host.singleton_class::Widget#doubled`, so reports, `--subject`
426
+ filters, ignore entries and the unchanged-syntax comparison can name it.
427
+ - `--test-command` keeps env-exported bundler settings the child needs:
428
+ `Bundler.with_unbundled_env` strips every `BUNDLE_*` key, so a
429
+ shell-exported `BUNDLE_PATH` (common in CI without a `.bundle/config`)
430
+ left the child's bundler unable to find the app's gems and the run
431
+ failed at baseline (#449). Values present in the pre-bundler
432
+ environment are re-added; bundler-activated values and anchor keys
433
+ stay dropped.
434
+ - `--test-command` no longer inherits bundler anchors (`BUNDLE_GEMFILE`,
435
+ `BUNDLE_BIN_PATH`, `BUNDLER_SETUP`, ...) from a `bundle exec` parent or
436
+ an exported shell environment (#435). The anchored child resolved the
437
+ project gem from the real tree, never exercised the mutated copy, and
438
+ reported every mutant Survived; the child now gets the unbundled
439
+ environment and resolves its Gemfile from the mutated copy.
440
+ - Test selection consults `config/saboteur_custom_map.rb` again (#369).
441
+ The map is the documented escape hatch for layouts the naming guess
442
+ cannot match, but guess-tests never called it, so a mapped file still
443
+ stopped the run as unmatched. A matching entry now wins over the naming
444
+ convention, its specs run as the inferred ones, and an empty entry marks
445
+ the file intentionally untested so it needs no inferred spec. The map is
446
+ now documented in the README. Mapped spec values that are globs are
447
+ expanded against the project root (the literal glob string never
448
+ reached RSpec), a winning entry whose specs match no file stops the
449
+ run with a config error naming the entry, and an empty entry's file
450
+ is dropped from mutation with a named diagnostic instead of running
451
+ the whole suite (#369 review).
452
+
453
+ - The marker sweep no longer kills a marked process it cannot identify
454
+ (#389). A process that started before the run's root carries the marker
455
+ only through an exec chain the run did not spawn, so it is left alone,
456
+ and an escapee is signalled only when a fresh snapshot shows the same
457
+ pid still owned by the same start time, so a recycled pid is never
458
+ killed by mistake.
459
+ - The spec-name guess now tries the underscored require name of a dashed
460
+ path: `lib/acts-as-taggable-on/x.rb` finds
461
+ `spec/acts_as_taggable_on/x_spec.rb` (and the `spec/lib/` form) instead
462
+ of stopping on an unmatched file (#383). The literal dashed name still
463
+ wins when both exist.
464
+
465
+ - Factory-block subjects resolve to the right owner (#352). A class factory
466
+ assigned inside `class << self` binds its constant on the singleton
467
+ class, so re-evaluation now resolves a factory constant after a sclass
468
+ scope there instead of raising "cannot resolve" for every mutant of its
469
+ methods. And a constant or keyword class inside a factory block binds on
470
+ the enclosing lexical scope: `FactoryProbe::Nested::RULES` and
471
+ `M::HELPER` are now ordinary subjects under their lexical owner instead
472
+ of a phantom `FactoryProbe::Outer::Nested::RULES` skipped as defined
473
+ inside a block.
474
+ - Constants assigned inside `class << self` are now named with their real
475
+ owner, the singleton class (`Host.singleton_class::RULES`), instead of the
476
+ attached class (#380, #390). A same-named constant in the class body no
477
+ longer collides with it or triggers a false "assigned more than once"
478
+ refusal. Read matching follows: reads inside `class << self` still count
479
+ toward the attached class's constant through singleton ancestry, and a
480
+ singleton-class write shadows the attached class's constant for reads
481
+ below it.
482
+
483
+ - A spec file that calls `exit` at load now refuses the run with a
484
+ clear diagnostic instead of terminating the loader mid-require (#411).
485
+
486
+ ### Changed
487
+
488
+ - The dirty-tree warning splits its count (#309, #316): changes inside
489
+ the mutation scope (the Ruby files the configured include and
490
+ exclude select; source operands narrow the run after the warning),
491
+ outside it, and saboteur setup files (the config file the run reads, a
492
+ `BUNDLE_GEMFILE` trial Gemfile and its lock), each as tracked and
493
+ untracked, naming up to five scope and setup paths. The groups add up
494
+ to the count. A tree of untracked notes or trial setup files no longer
495
+ reads like application edits about to be mutated.
496
+ - A broken `.rb` symlink under `include` (dangling, a loop, or through a
497
+ regular file) is skipped instead of ending the run with an internal
498
+ error: it has no real path, so it is not mutation source (#503).
499
+ - The `each`/`map` method swap is no longer emitted where the call stands
500
+ as a non-last statement (#399): its return value is discarded there, and
501
+ both names run the same block, so the swap is treated as equivalent.
502
+ The last statement of a body and calls nested inside an
503
+ expression stay swappable, as does any call on a lazy receiver:
504
+ lazy.map defers its block, so the swap is not equivalent there (#431).
505
+ Only a `.lazy` in the receiver chain is detected: a lazy enumerator held
506
+ in a variable, parameter or instance variable is not, and its swaps are
507
+ suppressed.
508
+ The discard analysis now recurses through branch tails: an each/map call
509
+ that is the last expression of a discarded if/elsif/else/unless/case/when
510
+ branch, begin/rescue body, or while/until body is suppressed too (#440).
511
+ - A `--since` run where only some changed files have a guessed spec now
512
+ runs the mapped subset with a loud warning per unmapped file instead of
513
+ stopping with "so nothing ran"; the stop message and the warning name
514
+ same-basename specs found under `spec/**`, so the file can be passed to
515
+ `--test` without hunting; a basename containing glob metacharacters is
516
+ matched literally, never as a pattern. A run with no guesses at all
517
+ still stops, as
518
+ do full runs, `--subject` runs, and any run with a `--threshold` or a
519
+ `--max-*` gate, since the gate would judge a score the unmapped file
520
+ never joined (#370).
521
+
522
+ - A `--since` run now stops when every changed source file sits outside the
523
+ include scope (#483), naming the hidden files and the ways out (extend
524
+ `include`, or `--test` the specs). The old fallback ran the whole suite
525
+ against zero subjects - the slow full run the `pr` preset exists to
526
+ avoid - and its message read as if the diff had touched no source.
527
+
528
+ - The rerun and replay commands in the Next steps footer and the agent
529
+ protocol leave out an empty `--operators` or `--exclude-operators` list
530
+ that was not typed on the command line (#317): the config the command
531
+ reads again supplies the same empty list. A typed empty list stays, since
532
+ it may clear a configured value, and non-empty lists stay as before.
533
+ - `--since` no longer treats a line whose only change is layout as
534
+ changed (#313). Wrapping existing statements in a block used to put
535
+ every re-indented statement in scope; now only the new block lines and
536
+ real edits get line-level mutants. A line is layout-only when
537
+ `git diff -b` pairs it with a base line and Prism lexes both to the
538
+ same tokens, so whitespace inside strings, heredocs and regexps, and a
539
+ moved `__LINE__`, still count as changes.
540
+ - `--since` runs every spec file the diff adds or changes next to the
541
+ inferred specs (#307). A request or controller spec whose name maps to no
542
+ changed file used to be left out, so a mutant only it killed was reported
543
+ as a survivor. A spec the diff adds beyond inference runs only when the
544
+ configured RSpec suite loads it (its pattern, exclude pattern and default
545
+ path) and it is a regular file; inferred specs run as before. A changed
546
+ `*_spec.rb` that does not run, or that the diff deletes, is named with
547
+ the reason, also before a stop on an unmatched file. `--test` and
548
+ `--all` are unchanged.
549
+
550
+ - **Breaking change:** the pr preset no longer accepts a budget-stopped
551
+ run by default. It exits 4; pass `--max-duration-ok` explicitly to
552
+ accept the ordinary verdict. The terminal prints how many planned
553
+ mutants did not run, or says the count is unknown when planning did not
554
+ finish. (#654)
555
+
556
+
557
+ ### Fixed
558
+
559
+ - A non-Rails suite that connects ActiveRecord to sqlite `:memory:` and
560
+ loads its schema at require time no longer gets a bare "Could not find
561
+ table" baseline failure (#387): every forked child reconnects to a fresh,
562
+ empty in-memory database, so the child now detects that shape and the
563
+ refusal names the cause and the remedies - move schema setup to
564
+ `before(:suite)`, or load it in an `after_fork` hook (README recipe
565
+ added). The Rails integration's per-child snapshot is unchanged.
566
+ - `saboteur run --test a_spec.rb b_spec.rb` refused: spec files in the source scope now fail as a
567
+ usage error pointing at repeating `--test`, instead of a green run that mutates nothing; the
568
+ `--since` "no subjects covering" warning names the misuse when every file is a spec file (#371).
569
+ - A mutant's test that daemonizes - forks an intermediate child that calls
570
+ `setsid` and exits at once - no longer leaves a sleeping grandchild the
571
+ supervisor never kills (#365). The tree poll can miss a descendant that
572
+ leaves the process group and loses its parent between two polls, so
573
+ every child now carries a unique marker in its environment (which
574
+ reaches exec'd descendants) and in its comm via `PR_SET_NAME` (which
575
+ reaches fork-only ones), and the end-of-run kill also sweeps the process
576
+ table for the marker. Linux only for now; a self-renaming daemon still
577
+ needs the CPU backstop.
578
+
579
+ - The Landlock ABI probe no longer flakes fail-closed with "Success -
580
+ landlock_create_ruleset probe" on runners where the first errno read
581
+ gets clobbered between the syscall and the read: the errno reader is
582
+ built before the probe, and a failure return paired with errno 0
583
+ (impossible from a real kernel answer) is probed once more. Genuinely
584
+ unknown errors still fail closed.
585
+ - `rspec_args` that hold only options and their values (such as
586
+ `["--require", "spec_helper"]`) no longer leave the suite with no
587
+ files under `exe/saboteur`: RSpec adds its default path only when `$0`
588
+ is `rspec`, and the value was taken for a path. saboteur now adds the
589
+ default path whenever RSpec's own parse finds no file or directory to
590
+ run, and a `--default-path` from `.rspec` wins over `spec`.
591
+ - `script/preflight` no longer loses why a git probe died: a probe
592
+ whose child never ran git (the spawn failed, or a signal killed
593
+ the forked child, as macOS does pre-exec under memory pressure)
594
+ is retried once and, when it still fails, the error names the
595
+ cause instead of printing an empty detail; a failure git
596
+ reported itself is raised at once with git's own words (#319).
597
+
598
+ - The whole-suite empty-coverage guard no longer passes when the map holds
599
+ spec-file lines only: the recorder maps the suite's spec files alongside
600
+ the included files, and the guard checked whole-map emptiness, so a suite
601
+ exercising none of the included files still recorded its own spec code
602
+ and the run exited 0 instead of 3. The guard now checks each included
603
+ file.
604
+
605
+ - Coverage-guided selection no longer drops a mutant's killing tests when a
606
+ `case` has a `when X` arm whose body is bare `nil`: Ruby spans that arm
607
+ from the `when` to the case's end, containing its sibling arms, and the
608
+ recorder stored the span as-is, so edits inside the sibling arms were
609
+ narrowed to the nil-arm's takers. Sibling arms are disjoint by
610
+ definition; a span containing one of them is now rejected when the map
611
+ is recorded (coverage map format 9).
612
+
613
+ - `script/preflight` full mode no longer rejects its own RSpec report: the
614
+ fd3 shim now attaches a dedicated JSON formatter to the pipe instead of
615
+ redirecting RSpec's output stream, so a project's progress formatter
616
+ (`.rspec` `--format progress`) and spec output stay on stdout and cannot
617
+ corrupt the report.
618
+
619
+ - `--incremental` no longer reuses a verdict when a file the covering
620
+ tests touched changed: the dependency files from the run's coverage map
621
+ join the verdict fingerprint (#197). The same holds for a dependency
622
+ with only method coverage (an endless def), a file that ran while the
623
+ app loaded, a loaded source the coverage map never observed, and a
624
+ helper required lazily inside a test outside the include list, which
625
+ the coverage build now records in the map so its changes invalidate
626
+ verdicts too (#344, #353; coverage map format 10).
627
+ - `--test-command` survivors no longer carry the `missing_test`
628
+ classification: the run's only recorded evidence is the pseudo-test id,
629
+ which is not real coverage (#305).
630
+ - `--test-command` on macOS no longer refuses every tree containing an
631
+ internal symlink: the disposable copy root is canonicalized before
632
+ symlink targets are judged against it (/var vs /private/var) (#305).
633
+
634
+ - Suites that turn warnings into errors (`--warnings` plus a raising
635
+ `Warning.warn` or `Warning.process` handler, dry-rb/hanami-style) no
636
+ longer error every mutant: the re-evaluation's own "method redefined"
637
+ notice is silenced while the mutant is installed, the same guard the
638
+ whole-file strategy already had (#603).
639
+
640
+ - A mutant reported Survived when the suite itself replaced its
641
+ definition mid-run: a `before(:each)` that re-loads the mutated source
642
+ file (rolify-style `load "lib/..."`, #597) puts the original back
643
+ before any test calls it, so every selected test passing proved
644
+ nothing. The child now records which definition the mutant's
645
+ evaluation installed and compares it with what is in place after the
646
+ tests; a positively identified different def marks the mutant
647
+ Unreachable (out of the score), never Survived, only when no test ever
648
+ invoked the mutant, and the warning names the re-load site. A run
649
+ where every mutant ends Unreachable exits 3 instead of going silently
650
+ green. (#662)
651
+
652
+ - A scoped (`--since`) run that skips a subject for no coverage now
653
+ names the loaded specs (or says none) and points at `--test`. Coverage
654
+ in sibling spec files may not have been loaded; the existing exit-3
655
+ gate is unchanged. (#636)
656
+
657
+
658
+ ### Added
659
+
660
+ - guess-tests now tries the flattened spec root and a `spec/unit/` prefix
661
+ after the mirrored guesses: `lib/pundit/policy_finder.rb` finds
662
+ `spec/policy_finder_spec.rb` and `lib/aasm/base.rb` finds
663
+ `spec/unit/base_spec.rb`, so `--subject` and `--since` stop hard-stopping
664
+ on pundit, aasm and shoulda-matchers style layouts (#394, #370, #393).
665
+ - Methods defined in the four core class factories (`X = Data.define(...)`,
666
+ `Struct.new`, `Class.new`, `Module.new` with a block) are mutation
667
+ subjects instead of skipped subjects (#290): the finder names the
668
+ generated class as the owner, and the mutant is re-evaluated through
669
+ `X.class_eval`, leaving the generated class itself untouched. Defs in
670
+ any other block stay skipped as "defined inside a block".
671
+ - `define_method(:m) { ... }` calls with a literal name and a block are
672
+ mutation subjects (#194): the block body is mutated and the whole call is
673
+ re-evaluated on the enclosing owner. A computed name, an explicit
674
+ receiver, or a lambda argument stays unsupported, and a call in a block
675
+ whose owner is unknown keeps the "defined inside a block" skip reason.
676
+
677
+ - A fault-injection e2e probe now pins cache-dir removal during the
678
+ baseline window (#300): deleting `.saboteur` while the baseline holds
679
+ the suite open changes neither the exit code nor the run evidence -
680
+ later phases recreate the directory and the report holds the same
681
+ verdicts; only the in-flight baseline log is lost. Other windows are
682
+ not pinned to recover (a missing mutant log fails the run by design),
683
+ so stop the run before deleting the cache, as `saboteur help` says.
684
+
685
+ - `saboteur doctor` now documents its version scope (#318): it certifies no
686
+ versions of Rails, databases or application tools saboteur never invokes.
687
+ A mismatched tool the suite loads still surfaces through the boot check's
688
+ LoadError or the baseline refusal with the tool's own output; a narrower
689
+ doctor check first needs the tool name, the expected and actual versions
690
+ and the failure output, recorded on the issue.
691
+ - `--test-command CMD` / `c.test_command`: run an external command per
692
+ mutant and take its exit status as the verdict - for apps that cannot
693
+ load inside saboteur's Ruby. Serial, no coverage, unsandboxed. Mutants
694
+ are applied in a fresh disposable copy of the tree per execution - the
695
+ runner's own writes never touch the real files or follow symlinks - and
696
+ the command's process group is terminated on every exit path. One
697
+ budget-bounded neutral preflight checks the command on an untouched
698
+ copy before any mutant exists; the shell's 126/127 read as setup
699
+ errors, blank and non-string commands are rejected, and nested
700
+ saboteur runs are refused (#140, #305). The preflight runs under an
701
+ absolute deadline admitted after the tree copy, and a spent budget
702
+ stops the run as budget-exhausted rather than a command failure.
703
+ Internal symlinks are rebuilt as relative links inside the copy, so an
704
+ alias sees the mutant while nothing can link out; escaping or looping
705
+ links refuse the run. Copy and write failures become error outcomes
706
+ with bounded context instead of crashing the run. Output capture is a
707
+ bounded in-memory ring buffer, scrubbed to valid UTF-8 for reports,
708
+ and its drain is time-bounded so a session-escaped descendant holding
709
+ the pipe cannot stall the run. Subjects outside the project root are
710
+ refused.
711
+ - Survivor classification labels in the JSON report (#192): each survivor
712
+ carries `classification` - `likely_equivalent` when the mutant compiles
713
+ to the original's instructions, `arid` when every line an edit touches
714
+ matches an arid pattern, `missing_test` when a test covered the line
715
+ and passed. The HTML report badges each survivor with the same label
716
+ and the agent JSON carries it on each shown finding. Labels are
717
+ heuristics for triage order only, never a diagnosis: scores and the
718
+ exit code never change.
719
+ - `--since` mutates changed entries of constant hash and array literals:
720
+ the assignments join the diff scope as subjects, `pair_removal` edits hash
721
+ entries, the new default-on `element_removal` operator edits array
722
+ elements (in methods too, so array literals grow mutant totals the way
723
+ hashes do), and the tests are the specs covering a read of the constant.
724
+ Unsupported forms (namespaced targets, `||=`/`&&=`, non-literal values,
725
+ condition-guarded writes, or a constant written more than once in one
726
+ file counting every write) are named in the diagnostics instead of being
727
+ mutated or reported survived (#306).
728
+ - Constant replay vets the whole file: `--since` replays a constant's file
729
+ only when its load-time code is limited to requires, pure defs,
730
+ class/module nesting and literal assignments and trips no hazard denylist
731
+ entry; anything else needs the constant listed in `c.whole_file` (the
732
+ existing whole-file opt-in) or the literal moved into its own file.
733
+ Constants whose only reads capture the value at load time elsewhere -
734
+ including a capture inside a boot-time block - are dropped with a
735
+ diagnostic. Spec files count as consumers: the coverage map records spec
736
+ line hits, directory test paths and the default spec tree feed the
737
+ consumer scan, `element_removal` honors the arid policy, arid patterns
738
+ screen constant entries too, and constant mutation respects `c.sources`
739
+ (no `builtin`, no constant mutants) (#306, #319).
740
+ - Constant replay hardening from the #319 lens review: `require` and
741
+ `require_relative` count as vetted load code only with a plain
742
+ literal-string argument and no block, so `require(build_path)` no longer
743
+ reruns helper code on every mutant; rooted constant writes (`::RULES`,
744
+ `::Shop::RULES`) share one identity with the bare write, and an
745
+ unrooted qualified write (`Shop::RULES`) resolves through the lexical
746
+ scope, so it demotes every literal on its scope chain it may overwrite
747
+ (`Shop::RULES` inside `module Outer; module Shop` overwrites
748
+ `Outer::Shop::RULES`); either way a re-assigning write demotes the
749
+ first literal to unsupported instead of leaving it scored; the --all consumer scan reads
750
+ the configured integration's loaded spec files instead of guessing the
751
+ conventional spec tree; and an arid pattern that cannot judge an entry's
752
+ bytes (binary source against a UTF-8 pattern) keeps the mutant instead
753
+ of raising (#319).
754
+ - Fault-injection e2e specs pin exit 3 with a clear, test-blame-free message
755
+ when the cache dir is read-only, is occupied by a regular file, or holds an
756
+ unwritable baseline log (#251).
757
+ - A dropped mutation that does not parse is now named in the warnings with
758
+ its subject and operator instead of only counting toward invalid
759
+ mutations (#116).
760
+ - `docs/stability.md`: the agent JSON payload and the exit codes are stable
761
+ public APIs with a deprecation process, pinned by a new contract spec; a
762
+ governance note in CONTRIBUTING covers maintainer availability (#249).
763
+ - `saboteur doctor` says plainly on macOS that mutants run unsandboxed,
764
+ advising a container for filesystem containment and network denial or
765
+ stubbing for real-API risk, and the README carries a short per-platform
766
+ sandbox and network statement (#247).
767
+ - Ignore-list entries accept a triage `state:` - `accepted_equivalent`
768
+ (the default) and `wont_fix` suppress the survivor's exit failure (it
769
+ still counts in the score), `todo` records the decision to fix and keeps
770
+ the survivor counting as an unignored survivor. The HTML report badges the
771
+ state; in the JSON report `accepted_equivalent` keeps the plain
772
+ `ignored: <reason>` while `wont_fix` and `todo` are labelled
773
+ (`ignored: <reason> (wont_fix)`, `todo: <reason>`), and all three keep
774
+ status `Survived` (#155).
775
+
776
+ ### Removed
777
+ - The stale self-deprecating benchmark claims in the README ('rerun
778
+ pending'): speed and memory numbers now live only with the harness and raw
779
+ data that reproduce them, or are framed as dated history (#243).
780
+
9
781
  ## [0.7.0] - 2026-10-16
10
782
 
11
783
  ### Upgrade notes