saboteur 0.6.0 → 0.8.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/README.md +199 -952
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/workflow.rb +2 -1
  6. data/lib/saboteur/baseline_health.rb +40 -6
  7. data/lib/saboteur/cli/exit_status.rb +80 -4
  8. data/lib/saboteur/cli/help_options.rb +17 -0
  9. data/lib/saboteur/cli/history_command.rb +135 -0
  10. data/lib/saboteur/cli/presets.rb +40 -0
  11. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  12. data/lib/saboteur/cli/run_reporting.rb +74 -5
  13. data/lib/saboteur/cli/selection_options.rb +31 -0
  14. data/lib/saboteur/cli/shard_options.rb +17 -0
  15. data/lib/saboteur/cli/source_set.rb +53 -0
  16. data/lib/saboteur/cli/survivors_command.rb +157 -0
  17. data/lib/saboteur/cli.rb +136 -98
  18. data/lib/saboteur/config.rb +163 -15
  19. data/lib/saboteur/config_file.rb +8 -0
  20. data/lib/saboteur/constant_reads.rb +182 -0
  21. data/lib/saboteur/constant_subject.rb +70 -0
  22. data/lib/saboteur/constant_subject_finder.rb +281 -0
  23. data/lib/saboteur/coverage_reads.rb +29 -0
  24. data/lib/saboteur/crash_report.rb +154 -0
  25. data/lib/saboteur/custom_map.rb +3 -1
  26. data/lib/saboteur/dirty_tree_warning.rb +128 -0
  27. data/lib/saboteur/doctor.rb +49 -1
  28. data/lib/saboteur/engine/baseline_guard.rb +136 -14
  29. data/lib/saboteur/engine/boot.rb +16 -1
  30. data/lib/saboteur/engine/canary.rb +41 -10
  31. data/lib/saboteur/engine/candidate_selection.rb +28 -1
  32. data/lib/saboteur/engine/candidates.rb +43 -3
  33. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  34. data/lib/saboteur/engine/coverage_store.rb +79 -15
  35. data/lib/saboteur/engine/defined_order_prefix.rb +43 -0
  36. data/lib/saboteur/engine/diagnostics.rb +41 -2
  37. data/lib/saboteur/engine/diff_scope.rb +24 -5
  38. data/lib/saboteur/engine/distribution.rb +134 -8
  39. data/lib/saboteur/engine/early_stop_recording.rb +47 -0
  40. data/lib/saboteur/engine/gated_run.rb +35 -0
  41. data/lib/saboteur/engine/load_coverage.rb +45 -0
  42. data/lib/saboteur/engine/out_of_scope_guard.rb +21 -0
  43. data/lib/saboteur/engine/pipelines.rb +107 -0
  44. data/lib/saboteur/engine/result_cache.rb +67 -9
  45. data/lib/saboteur/engine/run_budget.rb +90 -0
  46. data/lib/saboteur/engine/run_fingerprint.rb +4 -0
  47. data/lib/saboteur/engine/sandbox_policy.rb +56 -11
  48. data/lib/saboteur/engine/scheduler.rb +222 -20
  49. data/lib/saboteur/engine/selection_announcement.rb +54 -0
  50. data/lib/saboteur/engine/shard.rb +69 -0
  51. data/lib/saboteur/engine/subject_selection.rb +373 -19
  52. data/lib/saboteur/engine/suite_files.rb +3 -1
  53. data/lib/saboteur/engine/test_guess.rb +438 -25
  54. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  55. data/lib/saboteur/engine/unchanged_syntax.rb +17 -2
  56. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  57. data/lib/saboteur/engine/verdict_check.rb +54 -18
  58. data/lib/saboteur/engine.rb +202 -158
  59. data/lib/saboteur/execution/external_runner.rb +471 -0
  60. data/lib/saboteur/execution/marker_sweep.rb +79 -0
  61. data/lib/saboteur/execution/peak_memory.rb +28 -0
  62. data/lib/saboteur/execution/process_table.rb +12 -0
  63. data/lib/saboteur/execution/process_tree.rb +65 -5
  64. data/lib/saboteur/execution/protocol.rb +2 -1
  65. data/lib/saboteur/execution/reevaluator.rb +68 -12
  66. data/lib/saboteur/execution/runner.rb +225 -24
  67. data/lib/saboteur/execution/sandbox.rb +21 -3
  68. data/lib/saboteur/execution/seatbelt.rb +226 -0
  69. data/lib/saboteur/execution/supervisor.rb +45 -13
  70. data/lib/saboteur/execution/worker_pool.rb +110 -8
  71. data/lib/saboteur/frameworks/sqlite_memory_database.rb +49 -0
  72. data/lib/saboteur/gates.rb +43 -2
  73. data/lib/saboteur/help.rb +108 -13
  74. data/lib/saboteur/ignore_list.rb +40 -7
  75. data/lib/saboteur/install.rb +4 -3
  76. data/lib/saboteur/integration/base.rb +7 -2
  77. data/lib/saboteur/integration/rspec.rb +175 -9
  78. data/lib/saboteur/mutation_source/argument_removal.rb +1 -1
  79. data/lib/saboteur/mutation_source/builtin.rb +44 -14
  80. data/lib/saboteur/mutation_source/chain_removal.rb +1 -1
  81. data/lib/saboteur/mutation_source/condition_negation.rb +1 -1
  82. data/lib/saboteur/mutation_source/constant_literals.rb +66 -0
  83. data/lib/saboteur/mutation_source/element_removal.rb +73 -0
  84. data/lib/saboteur/mutation_source/endpoint_replacement.rb +1 -1
  85. data/lib/saboteur/mutation_source/entry_edits.rb +44 -0
  86. data/lib/saboteur/mutation_source/filter_replacement.rb +1 -1
  87. data/lib/saboteur/mutation_source/hazard_guard.rb +129 -3
  88. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  89. data/lib/saboteur/mutation_source/literal_replacement.rb +1 -1
  90. data/lib/saboteur/mutation_source/method_swap.rb +193 -0
  91. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  92. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  93. data/lib/saboteur/mutation_source/operator_assignment.rb +1 -1
  94. data/lib/saboteur/mutation_source/operator_examples.rb +6 -1
  95. data/lib/saboteur/mutation_source/operator_registry.rb +7 -2
  96. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  97. data/lib/saboteur/mutation_source/pair_removal.rb +13 -31
  98. data/lib/saboteur/mutation_source/range_replacement.rb +1 -1
  99. data/lib/saboteur/mutation_source/regex_replacement.rb +93 -0
  100. data/lib/saboteur/preview.rb +122 -13
  101. data/lib/saboteur/refinement_activations.rb +60 -0
  102. data/lib/saboteur/reporter/agent.rb +9 -1
  103. data/lib/saboteur/reporter/agent_commands.rb +47 -15
  104. data/lib/saboteur/reporter/agent_incomplete.rb +38 -2
  105. data/lib/saboteur/reporter/agent_summary.rb +4 -3
  106. data/lib/saboteur/reporter/findings.rb +5 -2
  107. data/lib/saboteur/reporter/history_policy.rb +11 -0
  108. data/lib/saboteur/reporter/history_reader.rb +47 -0
  109. data/lib/saboteur/reporter/html.rb +26 -4
  110. data/lib/saboteur/reporter/mutation_text.rb +6 -0
  111. data/lib/saboteur/reporter/next_steps.rb +14 -4
  112. data/lib/saboteur/reporter/score_history.rb +5 -1
  113. data/lib/saboteur/reporter/stryker.rb +40 -7
  114. data/lib/saboteur/reporter/survivor_classification.rb +101 -0
  115. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  116. data/lib/saboteur/reporter/survivor_list.rb +15 -9
  117. data/lib/saboteur/reporter/terminal.rb +74 -17
  118. data/lib/saboteur/result.rb +38 -7
  119. data/lib/saboteur/selection/coverage_map.rb +107 -11
  120. data/lib/saboteur/selection/dependency_shield.rb +80 -0
  121. data/lib/saboteur/selection/external_test_map.rb +27 -0
  122. data/lib/saboteur/selection/git_diff.rb +105 -13
  123. data/lib/saboteur/selection/layout_only_lines.rb +80 -0
  124. data/lib/saboteur/selection/recorder.rb +22 -4
  125. data/lib/saboteur/selection/source_scope.rb +22 -0
  126. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  127. data/lib/saboteur/subject.rb +53 -7
  128. data/lib/saboteur/subject_finder.rb +173 -29
  129. data/lib/saboteur/text.rb +42 -0
  130. data/lib/saboteur/version.rb +1 -1
  131. data/lib/saboteur/working_tree.rb +38 -15
  132. data/lib/saboteur.rb +29 -1
  133. metadata +50 -2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,1030 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-10-18
10
+
11
+ ### Added
12
+
13
+ - `--test-command` runs a mutation-visibility check before the first
14
+ mutant (#441). The command runs once with the first subject's file
15
+ replaced by a load-time error; if it still passes, the command cannot
16
+ see mutants in that file - a `RUBYLIB`/`-I` path or an installed gem
17
+ anchors it to the real tree, or the suite never loads the file - and
18
+ the run warns loudly that the score would read 0.0 % instead of
19
+ silently reporting every mutant Survived.
20
+
21
+
22
+ - `--shard K/N` (#470): run one deterministic slice of the planned candidate
23
+ inventory - `K` is the 1-based shard and `N` the number of shards. Each
24
+ planned mutant is assigned by a SHA-256 of its candidate identity, never by
25
+ position, so the split stays stable when an earlier subject gains or loses a
26
+ mutant. The terminal, JSON and agent outputs name the shard, the inventory
27
+ digest and the selected/planned counts, and a shard run is always reported
28
+ partial. See docs/guides/sharding.md for the CI matrix setup.
29
+
30
+ - `saboteur survivors <report.json>` (#475): a read-only terminal browser
31
+ for the Stryker JSON report - list and filter survivors (with the
32
+ ranked-findings order available via `--ranked`), and `--show DIGEST`
33
+ prints the diff, the exact replay command and an ignore-file entry
34
+ template. It never boots the project or edits the ignore file.
35
+ - `--flaky-check survivors|decisive` (#476): one reordered confirmation
36
+ rerun per new survivor (and per fresh kill in `decisive` mode). A
37
+ verdict the confirmation disagrees with becomes `unverified` - named
38
+ with its decisive tests, outside the score, never retried until it
39
+ passes.
40
+ - `saboteur badge` writes Shields endpoint JSON from the opt-in score
41
+ history and `saboteur trend` prints the covered-score delta against the
42
+ last comparable record (#474). A README badge needs no hosted service:
43
+ commit the written JSON and point a Shields endpoint badge at its raw
44
+ URL. An incomplete run is refused as badge evidence unless
45
+ `--mark-partial` labels the badge as partial, and the trend never
46
+ compares records with different selection, inventory, schema version
47
+ or operator policy.
48
+ - `c.sandbox = "seatbelt"`, an opt-in macOS sandbox for mutant children
49
+ (#436): each child applies a Seatbelt profile (writes denied except the
50
+ granted dirs, the system temp dir, and its own inherited logs) via
51
+ `sandbox_init`, after a forked probe proves a deny-default profile
52
+ really blocks a write. A macOS without Seatbelt degrades to the hazard
53
+ guard and canary with a one-time note; an errored probe or a profile
54
+ that does not enforce refuses the run (the Landlock probe's pinned
55
+ semantics, #178), and a child whose profile refuses to apply fails
56
+ closed instead of running the mutant.
57
+ On Linux the value behaves like `true`. Opt-in because Apple deprecates
58
+ `sandbox_init` without a published replacement; #436 tracks the
59
+ ecosystem's response.
60
+ - `saboteur run --dry-run --format json` prints a versioned machine-readable
61
+ mutant inventory without booting the suite (#469): schema version, run
62
+ metadata, subjects, every planned mutant with its stable digest, operator,
63
+ file, line and byte-range edits, and every skipped candidate with its
64
+ reason (inline suppression, hazard refusal or the per-method cap).
65
+ `--dry-run --agent` now names this flag instead of only refusing.
66
+ - A `regex_replacement` operator (#135): drops each regex anchor
67
+ (`\A`, `\z`, `\Z`, `^`, `$`) in turn and swaps each bare `+` or `*`
68
+ quantifier, so a validation like `/\A\d+\z/` finally gets mutants.
69
+ Escaped characters, character classes and lazy or possessive stacks stay
70
+ untouched; interpolated regexes stay out. The operator policy version
71
+ moves to 3: histories stamped with an older policy stay readable but
72
+ never trend-compare against new runs.
73
+ - Methods defined by a non-interpolated `class_eval`/`module_eval` heredoc
74
+ are mutation subjects (#544): the heredoc body is re-parsed against the
75
+ real file's bytes, so mutants inside it are generated and judged like
76
+ ordinary defs (the faraday shape that used to produce 0 subjects). An
77
+ interpolated heredoc still yields no subjects and keeps the
78
+ code-in-strings warning (#467).
79
+ - Per-path score gate config (#473, slice 1): `score_gate "app/payments/**/*",
80
+ min_score: 95` in `saboteur.rb` declares a per-subtree score floor,
81
+ validated at load (min_score 0-100, pattern must match an included file).
82
+ Enforcement lands with slice 2 (#570).
83
+ - Per-path score gates are enforced (#473, slice 2): each `score_gate`
84
+ subtree from slice 1 (#565) is scored with the run's own formula. A gate under its floor fails the run like the
85
+ count gates (`scoreGate` cause in the JSON report); a gate with nothing
86
+ executed under it passes vacuously and says so. Score gates cannot combine
87
+ with `--agent`, like `--threshold`.
88
+ - Peak-memory measurement (#202, slice 1): each worker reports its kernel
89
+ peak RSS (VmHWM) when it retires, and the JSON report records it under
90
+ `saboteur.memory` as per-worker `peakMb` values plus the supervisor's own
91
+ peak. No total is printed: fork copy-on-write makes per-process RSS
92
+ non-additive. The section is omitted where procfs is unavailable.
93
+
94
+ ### Changed
95
+
96
+ - Any `--since` run whose resolved range has no changes now refuses with
97
+ exit 2 instead of passing with 0 subjects (#643) - scheduled or no-op
98
+ runs included. The common hit is `saboteur pr <merge_sha>` on a
99
+ checkout AT the merge commit, which diffed the merge against itself;
100
+ the refusal names the base and the resolved range and says to fetch
101
+ the merge's parent or point `--since` at the change's base.
102
+ - A run whose effective include set selects no Ruby file now refuses with
103
+ exit 2 instead of passing with 0 subjects (#447). The default include
104
+ targets plain gems, so a first run on a Rails-shaped tree used to be a
105
+ green gate that mutated nothing on every CI run; the refusal names the
106
+ stale pattern and points at the install generator.
107
+ - A `--subject` pattern that matches no subject now fails the run (exit 2)
108
+ instead of passing silently with zero subjects (#379). When some patterns
109
+ match, the run keeps them and the summary names the missing pattern and
110
+ the matched count.
111
+
112
+ ### Fixed
113
+
114
+ - A single `using` anywhere in a file used to skip every subject in the
115
+ file. Refinement skips are now scope-granular (#634): a bare `using`
116
+ skips only subjects after it in its own class/module body (or the
117
+ rest of the file at the top level), so one module's refinement no
118
+ longer skips a sibling module's methods. A `using` inside a method
119
+ or block body still skips the whole file.
120
+
121
+ - `scope :name, -> { ... }` and `define_singleton_method` bodies are now
122
+ subjects (#194): the human-essentials dogfood showed a scope's lambda
123
+ going unmutated. The call is a singleton subject of the enclosing owner;
124
+ the lambda or block body mutates, and re-evaluating the whole call
125
+ re-registers the method.
126
+ - An arid statement (a logging or instrumentation call) whose block carries
127
+ real work is now mutated inside the block (#464). The walk used to skip
128
+ the whole statement, so a method like one wrapped in
129
+ `Notifications.instrument ... do |payload| ... end` produced only the
130
+ coarse `body_raise` and no statement_removal, boolean_logic or other
131
+ fine mutants on the changed lines. The block body is walked like any
132
+ other, and such a statement also keeps the method out of the
133
+ nothing-to-remove coarse check.
134
+ - A loaded class defining a singleton `hash` that does not return an
135
+ Integer (forem's `ReactionCategory`) no longer crashes the boot as an
136
+ internal TypeError: every `Coverage.result` read now refuses with a
137
+ diagnostic explaining the violated `#hash` contract and blaming the
138
+ application's code (#564).
139
+ - A `--since` diff that deletes a method no longer reports the deletion as
140
+ "differ only in comments or formatting" and no longer names the method the
141
+ deleted lines drifted onto. The run says what was deleted instead: `N
142
+ changed subject(s) were deleted, nothing to mutate: ...` (#466).
143
+ - 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).
144
+ - Preflight no longer refuses a precompiled Ruby (mise, ruby-build) whose
145
+ binary tops the 50 MB evidence-document cap (#364): tool identity
146
+ digests stream with an executable-sized bound instead. The cap for JSON
147
+ evidence documents is unchanged.
148
+ - A missing `rspec-core` no longer crashes saboteur into the internal-error
149
+ report with a bare `LoadError`: the RSpec integration refuses with
150
+ "rspec is not in the bundle" and names the remedy, adding the gem to the
151
+ test group or pointing `c.integration` at the suite's framework (#482).
152
+ Only the framework's own `LoadError` converts; a `LoadError` raised by
153
+ the app's own files, or for a different feature such as `rspec/core_ext`,
154
+ keeps the crash route.
155
+ - A `--since` run whose changed methods were all skipped - refinements,
156
+ no coverage, or any other skip reason - used to exit 0 with `score n/a`,
157
+ a green run that tested nothing. It now exits 3 and names each skipped
158
+ subject with its reason, and a partially skipped diff announces each
159
+ skip up front instead of only in the end-of-run summary (#574).
160
+
161
+ - `saboteur badge` escapes incomplete-run reasons with `.inspect` in its
162
+ refusal message, so a reason carrying newlines or control characters
163
+ cannot spoof extra output lines (#474).
164
+ - `c.sandbox = "seatbelt"` grants now match real paths on macOS: the
165
+ kernel resolves `/var` to `/private/var` when it checks a write
166
+ against a Seatbelt subpath grant, so a granted dir under a symlinked
167
+ parent (like `Dir.mktmpdir` under `/var/folders`) silently matched
168
+ nothing and the child saw its own writable dir denied. Granted dirs
169
+ are now resolved with `File.realpath` before the profile is built,
170
+ the same view `fcntl(F_GETPATH)` reports back.
171
+
172
+ - The artifact dir now ignores itself (#426): a run writes logs,
173
+ coverage maps and reports under the cache dir, and the marker
174
+ `.gitignore` keeps a `git add -A` from committing them. An existing
175
+ `.gitignore` there is left alone.
176
+ - Rejected kills no longer surface as generic errors: the terminal
177
+ lists them in a `Rejected kills` section and annotates the error count,
178
+ and report.json flags each rejected mutant with `rejectedKill: true`
179
+ instead of carrying only the `rejectedKills` count (#375).
180
+
181
+ - A changed file the diff only deletes from no longer hard-stops a
182
+ --since run for want of a spec (#484): a deletion-only file yields no
183
+ subject and no fine-grained mutant, so test inference treats it as
184
+ subjectless instead of demanding a spec, and the run goes ahead.
185
+ - The pr preset no longer hard-stops when a changed file's coverage
186
+ lives in a consolidated spec the diff itself touches: a spec added
187
+ or changed in the diff that reads a constant the uncovered file
188
+ defines covers that file and runs, instead of exit 2 (#538). The
189
+ stop remains for a mutable change no changed spec exercises.
190
+ - Dependency trees are never treated as changed source on a `--since`
191
+ run (#504): a vendored bundle or `node_modules`, tracked or
192
+ untracked, git-ignored or not, is rejected at enumeration, so the
193
+ diff scope never lists, reads or warns on it. The `.bundle/config`
194
+ file itself is project configuration and stays visible.
195
+ - The project-local bundler path (`.bundle/config`'s `BUNDLE_PATH`) now
196
+ joins the dependency shield in its quoted and non-canonical forms
197
+ (#567): matching quotes are stripped, an inline `#` comment is ignored
198
+ the way Bundler::YAMLSerializer ignores it, a relative path is
199
+ normalized against the project root, and an absolute path (the system
200
+ bundle) stays outside the shield. A config that is not a regular file
201
+ or cannot be parsed - including a NUL byte in the value - means no
202
+ shield rather than a crash or a hang.
203
+
204
+ - A `--max-duration` budget that cannot cover the cold coverage map build
205
+ now stops the run before the build starts, instead of spending the
206
+ whole budget mid-build and keeping nothing - the trap where every
207
+ `--incremental` resume redid the baselines and died at the same point.
208
+ Once the baseline passes are timed, a build costing about one more
209
+ full pass than the budget holds is refused up front, with the
210
+ shortfall and the remedies (a higher budget, fewer
211
+ `--baseline-passes`, or one unbudgeted run to warm the coverage
212
+ cache) in the message (#427).
213
+
214
+ - The first-pass ETA no longer reads like a second baseline check when
215
+ the baseline is done (#465): with a cold coverage cache the remaining
216
+ pass is the coverage map build, and the message now names the coverage
217
+ build and any remaining baseline passes separately.
218
+ - Def-time macro mutants - a `deprecate`-style helper, a `before_action`
219
+ block, any DSL whose only caller is a class body - no longer report as
220
+ survived: the closure captured while the app loads keeps the pristine
221
+ definition, so replacing the method later cannot judge them. They now
222
+ report as `ran at load`, outside the score (#409).
223
+ - A mutant on the continuation line of a multi-line interpolated
224
+ statement is no longer misclassified missing_test (#498): attribution
225
+ now spans the whole enclosing statement, because an interpolation's own
226
+ statements are eager and Ruby's per-line events on continuation lines
227
+ are not reliable per example.
228
+ - `--incremental` now sees dependency-declaration edits on projects
229
+ that commit no lockfile (the library convention): the verdict
230
+ fingerprint hashed only `Gemfile.lock` / `gems.locked`, so the digest
231
+ was a constant and every cached verdict was reused across a `Gemfile`,
232
+ `gems.rb` or `*.gemspec` edit (#408). Manifests and gemspecs anywhere
233
+ under the project root are hashed always.
234
+
235
+ - A `--since` change inside a constant literal saboteur refuses to mutate
236
+ (unvettable load-time code, or an unsupported literal form) no longer
237
+ reads like an unexplained miss in the umbrella "no subjects covering the
238
+ changed lines" warning: the warning now says how many of the files sit
239
+ in a constant literal that is not mutated, named by the constant
240
+ warnings above it (#537).
241
+
242
+ - ERB generator templates (`.rb` files with ERB interpolation, as Rails
243
+ generators carry under `lib/generators/**/templates/`) no longer print a
244
+ per-file "does not parse" warning on every discovery pass: they can
245
+ never parse as plain Ruby, so they are skipped with one summarized note
246
+ per run. A real syntax error in a plain Ruby file still warns per file
247
+ (#372).
248
+ - A `c.sources` entry that looks like a source glob now fails with a
249
+ pointer to `c.include` (#488): `c.sources` registers custom operator
250
+ sources (`builtin` or `path.rb:ClassName`), not the files to mutate.
251
+ - A baseline stopped by a containment limit (process count or memory)
252
+ now names the knob and its current value in the refusal (#459): a
253
+ suite that forks heavily by design, or legitimately needs more
254
+ memory, is pointed at `c.max_processes` / `c.max_memory_mb` instead
255
+ of reading as a suite failure.
256
+
257
+ - A baseline that passed but leaked child processes no longer reads as a
258
+ suite failure quoting an empty "Failures:" section (#486): the refusal
259
+ says the suite passed, names each leaked process (pid and command,
260
+ captured before the supervisor's kill) and skips the log excerpt, which
261
+ only holds RSpec output.
262
+ - Engine setup load failures are reported clearly instead of as a bare
263
+ quit (#378): when RSpec dies while loading spec files, the error names
264
+ the files that failed to load, parsed from RSpec's own load-failure
265
+ output, and suggests the remedy - exclude the broken subtrees with
266
+ `--exclude-pattern` (for a Rails engine's embedded spec/dummy) or name
267
+ the suite with `--test`.
268
+
269
+ - A spec file whose later examples rely on state left by earlier ones no
270
+ longer turns its mutants into errors (#557). When a covering example
271
+ fails even with the method unchanged, saboteur retries once with the
272
+ file's examples in defined order, from the start through the last
273
+ covering example. The kill stands only if that run passes unchanged.
274
+
275
+ - Spec and test files vendored under `lib/` (capybara ships its driver
276
+ suite at `lib/capybara/spec/**`) are no longer swept into the default
277
+ mutation scope (#504): the default exclude now shields
278
+ `lib/**/spec/**`, `lib/**/test/**`, `lib/**/tests/**` and the
279
+ `*_spec.rb` / `*_test.rb` / `test_*.rb` shapes under `lib/`. A config's
280
+ own `c.exclude` replaces the shields wholesale, and the shields never
281
+ trip the stale-pattern warning.
282
+
283
+ - A --since run whose changed lines sit inside a heredoc now says so
284
+ (#467). Code embedded in a string - a class_eval/module_eval argument or
285
+ a template constant - evaluates methods the static walk cannot see, so
286
+ the generic "may not parse, or every change may sit outside a method"
287
+ hint misdiagnosed it; the warning now names the files and the
288
+ code-in-a-string blind spot.
289
+
290
+ - The "nothing to mutate" note on an empty selection now prints only
291
+ once (#468). An empty-selection run already ends with the
292
+ "0 subjects, 0 mutants" summary, and announcing the note again as a
293
+ report warning at the end of the run read like a second failure; it
294
+ is now a one-time inform when nothing has a subject.
295
+
296
+ - `--incremental` no longer reuses a stored verdict after the project source
297
+ set changes: a file added after the coverage map was built (a conditional
298
+ require, a Zeitwerk autoload) can load into a mutant run unseen, and the
299
+ reused verdict read as a false green. The set is paths only; content edits
300
+ to other files stay the documented cross-file tradeoff (#396).
301
+ - A factory assigned inside `class << self` now keeps its own scope in the
302
+ owner path, so a constant defined there is named after the factory's
303
+ singleton: `Host::W.singleton_class::V#n`. An outer factory with no
304
+ `class << self` of its own still opens no scope, even when a `class <<
305
+ self` encloses it (#438).
306
+
307
+ - A class factory assigned inside `class << self` no longer shares its
308
+ method ids with a same-named factory in the class body (#406). The two
309
+ unrelated classes each keep their own subjects: the singleton-side one is
310
+ now named `Host.singleton_class::Widget#doubled`, so reports, `--subject`
311
+ filters, ignore entries and the unchanged-syntax comparison can name it.
312
+ - `--test-command` keeps env-exported bundler settings the child needs:
313
+ `Bundler.with_unbundled_env` strips every `BUNDLE_*` key, so a
314
+ shell-exported `BUNDLE_PATH` (common in CI without a `.bundle/config`)
315
+ left the child's bundler unable to find the app's gems and the run
316
+ failed at baseline (#449). Values present in the pre-bundler
317
+ environment are re-added; bundler-activated values and anchor keys
318
+ stay dropped.
319
+ - `--test-command` no longer inherits bundler anchors (`BUNDLE_GEMFILE`,
320
+ `BUNDLE_BIN_PATH`, `BUNDLER_SETUP`, ...) from a `bundle exec` parent or
321
+ an exported shell environment (#435). The anchored child resolved the
322
+ project gem from the real tree, never exercised the mutated copy, and
323
+ reported every mutant Survived; the child now gets the unbundled
324
+ environment and resolves its Gemfile from the mutated copy.
325
+ - Test selection consults `config/saboteur_custom_map.rb` again (#369).
326
+ The map is the documented escape hatch for layouts the naming guess
327
+ cannot match, but guess-tests never called it, so a mapped file still
328
+ stopped the run as unmatched. A matching entry now wins over the naming
329
+ convention, its specs run as the inferred ones, and an empty entry marks
330
+ the file intentionally untested so it needs no inferred spec. The map is
331
+ now documented in the README. Mapped spec values that are globs are
332
+ expanded against the project root (the literal glob string never
333
+ reached RSpec), a winning entry whose specs match no file stops the
334
+ run with a config error naming the entry, and an empty entry's file
335
+ is dropped from mutation with a named diagnostic instead of running
336
+ the whole suite (#369 review).
337
+
338
+ - The marker sweep no longer kills a marked process it cannot identify
339
+ (#389). A process that started before the run's root carries the marker
340
+ only through an exec chain the run did not spawn, so it is left alone,
341
+ and an escapee is signalled only when a fresh snapshot shows the same
342
+ pid still owned by the same start time, so a recycled pid is never
343
+ killed by mistake.
344
+ - The spec-name guess now tries the underscored require name of a dashed
345
+ path: `lib/acts-as-taggable-on/x.rb` finds
346
+ `spec/acts_as_taggable_on/x_spec.rb` (and the `spec/lib/` form) instead
347
+ of stopping on an unmatched file (#383). The literal dashed name still
348
+ wins when both exist.
349
+
350
+ - Factory-block subjects resolve to the right owner (#352). A class factory
351
+ assigned inside `class << self` binds its constant on the singleton
352
+ class, so re-evaluation now resolves a factory constant after a sclass
353
+ scope there instead of raising "cannot resolve" for every mutant of its
354
+ methods. And a constant or keyword class inside a factory block binds on
355
+ the enclosing lexical scope: `FactoryProbe::Nested::RULES` and
356
+ `M::HELPER` are now ordinary subjects under their lexical owner instead
357
+ of a phantom `FactoryProbe::Outer::Nested::RULES` skipped as defined
358
+ inside a block.
359
+ - Constants assigned inside `class << self` are now named with their real
360
+ owner, the singleton class (`Host.singleton_class::RULES`), instead of the
361
+ attached class (#380, #390). A same-named constant in the class body no
362
+ longer collides with it or triggers a false "assigned more than once"
363
+ refusal. Read matching follows: reads inside `class << self` still count
364
+ toward the attached class's constant through singleton ancestry, and a
365
+ singleton-class write shadows the attached class's constant for reads
366
+ below it.
367
+
368
+ - A spec file that calls `exit` at load now refuses the run with a
369
+ clear diagnostic instead of terminating the loader mid-require (#411).
370
+
371
+ ### Changed
372
+
373
+ - The dirty-tree warning splits its count (#309, #316): changes inside
374
+ the mutation scope (the Ruby files the configured include and
375
+ exclude select; source operands narrow the run after the warning),
376
+ outside it, and saboteur setup files (the config file the run reads, a
377
+ `BUNDLE_GEMFILE` trial Gemfile and its lock), each as tracked and
378
+ untracked, naming up to five scope and setup paths. The groups add up
379
+ to the count. A tree of untracked notes or trial setup files no longer
380
+ reads like application edits about to be mutated.
381
+ - A broken `.rb` symlink under `include` (dangling, a loop, or through a
382
+ regular file) is skipped instead of ending the run with an internal
383
+ error: it has no real path, so it is not mutation source (#503).
384
+ - The `each`/`map` method swap is no longer emitted where the call stands
385
+ as a non-last statement (#399): its return value is discarded there, and
386
+ both names run the same block, so the swap is treated as equivalent.
387
+ The last statement of a body and calls nested inside an
388
+ expression stay swappable, as does any call on a lazy receiver:
389
+ lazy.map defers its block, so the swap is not equivalent there (#431).
390
+ Only a `.lazy` in the receiver chain is detected: a lazy enumerator held
391
+ in a variable, parameter or instance variable is not, and its swaps are
392
+ suppressed.
393
+ The discard analysis now recurses through branch tails: an each/map call
394
+ that is the last expression of a discarded if/elsif/else/unless/case/when
395
+ branch, begin/rescue body, or while/until body is suppressed too (#440).
396
+ - A `--since` run where only some changed files have a guessed spec now
397
+ runs the mapped subset with a loud warning per unmapped file instead of
398
+ stopping with "so nothing ran"; the stop message and the warning name
399
+ same-basename specs found under `spec/**`, so the file can be passed to
400
+ `--test` without hunting; a basename containing glob metacharacters is
401
+ matched literally, never as a pattern. A run with no guesses at all
402
+ still stops, as
403
+ do full runs, `--subject` runs, and any run with a `--threshold` or a
404
+ `--max-*` gate, since the gate would judge a score the unmapped file
405
+ never joined (#370).
406
+
407
+ - A `--since` run now stops when every changed source file sits outside the
408
+ include scope (#483), naming the hidden files and the ways out (extend
409
+ `include`, or `--test` the specs). The old fallback ran the whole suite
410
+ against zero subjects - the slow full run the `pr` preset exists to
411
+ avoid - and its message read as if the diff had touched no source.
412
+
413
+ - The rerun and replay commands in the Next steps footer and the agent
414
+ protocol leave out an empty `--operators` or `--exclude-operators` list
415
+ that was not typed on the command line (#317): the config the command
416
+ reads again supplies the same empty list. A typed empty list stays, since
417
+ it may clear a configured value, and non-empty lists stay as before.
418
+ - `--since` no longer treats a line whose only change is layout as
419
+ changed (#313). Wrapping existing statements in a block used to put
420
+ every re-indented statement in scope; now only the new block lines and
421
+ real edits get line-level mutants. A line is layout-only when
422
+ `git diff -b` pairs it with a base line and Prism lexes both to the
423
+ same tokens, so whitespace inside strings, heredocs and regexps, and a
424
+ moved `__LINE__`, still count as changes.
425
+ - `--since` runs every spec file the diff adds or changes next to the
426
+ inferred specs (#307). A request or controller spec whose name maps to no
427
+ changed file used to be left out, so a mutant only it killed was reported
428
+ as a survivor. A spec the diff adds beyond inference runs only when the
429
+ configured RSpec suite loads it (its pattern, exclude pattern and default
430
+ path) and it is a regular file; inferred specs run as before. A changed
431
+ `*_spec.rb` that does not run, or that the diff deletes, is named with
432
+ the reason, also before a stop on an unmatched file. `--test` and
433
+ `--all` are unchanged.
434
+
435
+ - **Breaking change:** the pr preset no longer accepts a budget-stopped
436
+ run by default. It exits 4; pass `--max-duration-ok` explicitly to
437
+ accept the ordinary verdict. The terminal prints how many planned
438
+ mutants did not run, or says the count is unknown when planning did not
439
+ finish. (#654)
440
+
441
+
442
+ ### Fixed
443
+
444
+ - A non-Rails suite that connects ActiveRecord to sqlite `:memory:` and
445
+ loads its schema at require time no longer gets a bare "Could not find
446
+ table" baseline failure (#387): every forked child reconnects to a fresh,
447
+ empty in-memory database, so the child now detects that shape and the
448
+ refusal names the cause and the remedies - move schema setup to
449
+ `before(:suite)`, or load it in an `after_fork` hook (README recipe
450
+ added). The Rails integration's per-child snapshot is unchanged.
451
+ - `saboteur run --test a_spec.rb b_spec.rb` refused: spec files in the source scope now fail as a
452
+ usage error pointing at repeating `--test`, instead of a green run that mutates nothing; the
453
+ `--since` "no subjects covering" warning names the misuse when every file is a spec file (#371).
454
+ - A mutant's test that daemonizes - forks an intermediate child that calls
455
+ `setsid` and exits at once - no longer leaves a sleeping grandchild the
456
+ supervisor never kills (#365). The tree poll can miss a descendant that
457
+ leaves the process group and loses its parent between two polls, so
458
+ every child now carries a unique marker in its environment (which
459
+ reaches exec'd descendants) and in its comm via `PR_SET_NAME` (which
460
+ reaches fork-only ones), and the end-of-run kill also sweeps the process
461
+ table for the marker. Linux only for now; a self-renaming daemon still
462
+ needs the CPU backstop.
463
+
464
+ - The Landlock ABI probe no longer flakes fail-closed with "Success -
465
+ landlock_create_ruleset probe" on runners where the first errno read
466
+ gets clobbered between the syscall and the read: the errno reader is
467
+ built before the probe, and a failure return paired with errno 0
468
+ (impossible from a real kernel answer) is probed once more. Genuinely
469
+ unknown errors still fail closed.
470
+ - `rspec_args` that hold only options and their values (such as
471
+ `["--require", "spec_helper"]`) no longer leave the suite with no
472
+ files under `exe/saboteur`: RSpec adds its default path only when `$0`
473
+ is `rspec`, and the value was taken for a path. saboteur now adds the
474
+ default path whenever RSpec's own parse finds no file or directory to
475
+ run, and a `--default-path` from `.rspec` wins over `spec`.
476
+ - `script/preflight` no longer loses why a git probe died: a probe
477
+ whose child never ran git (the spawn failed, or a signal killed
478
+ the forked child, as macOS does pre-exec under memory pressure)
479
+ is retried once and, when it still fails, the error names the
480
+ cause instead of printing an empty detail; a failure git
481
+ reported itself is raised at once with git's own words (#319).
482
+
483
+ - The whole-suite empty-coverage guard no longer passes when the map holds
484
+ spec-file lines only: the recorder maps the suite's spec files alongside
485
+ the included files, and the guard checked whole-map emptiness, so a suite
486
+ exercising none of the included files still recorded its own spec code
487
+ and the run exited 0 instead of 3. The guard now checks each included
488
+ file.
489
+
490
+ - Coverage-guided selection no longer drops a mutant's killing tests when a
491
+ `case` has a `when X` arm whose body is bare `nil`: Ruby spans that arm
492
+ from the `when` to the case's end, containing its sibling arms, and the
493
+ recorder stored the span as-is, so edits inside the sibling arms were
494
+ narrowed to the nil-arm's takers. Sibling arms are disjoint by
495
+ definition; a span containing one of them is now rejected when the map
496
+ is recorded (coverage map format 9).
497
+
498
+ - `script/preflight` full mode no longer rejects its own RSpec report: the
499
+ fd3 shim now attaches a dedicated JSON formatter to the pipe instead of
500
+ redirecting RSpec's output stream, so a project's progress formatter
501
+ (`.rspec` `--format progress`) and spec output stay on stdout and cannot
502
+ corrupt the report.
503
+
504
+ - `--incremental` no longer reuses a verdict when a file the covering
505
+ tests touched changed: the dependency files from the run's coverage map
506
+ join the verdict fingerprint (#197). The same holds for a dependency
507
+ with only method coverage (an endless def), a file that ran while the
508
+ app loaded, a loaded source the coverage map never observed, and a
509
+ helper required lazily inside a test outside the include list, which
510
+ the coverage build now records in the map so its changes invalidate
511
+ verdicts too (#344, #353; coverage map format 10).
512
+ - `--test-command` survivors no longer carry the `missing_test`
513
+ classification: the run's only recorded evidence is the pseudo-test id,
514
+ which is not real coverage (#305).
515
+ - `--test-command` on macOS no longer refuses every tree containing an
516
+ internal symlink: the disposable copy root is canonicalized before
517
+ symlink targets are judged against it (/var vs /private/var) (#305).
518
+
519
+ - Suites that turn warnings into errors (`--warnings` plus a raising
520
+ `Warning.warn` or `Warning.process` handler, dry-rb/hanami-style) no
521
+ longer error every mutant: the re-evaluation's own "method redefined"
522
+ notice is silenced while the mutant is installed, the same guard the
523
+ whole-file strategy already had (#603).
524
+
525
+ - A mutant reported Survived when the suite itself replaced its
526
+ definition mid-run: a `before(:each)` that re-loads the mutated source
527
+ file (rolify-style `load "lib/..."`, #597) puts the original back
528
+ before any test calls it, so every selected test passing proved
529
+ nothing. The child now records which definition the mutant's
530
+ evaluation installed and compares it with what is in place after the
531
+ tests; a positively identified different def marks the mutant
532
+ Unreachable (out of the score), never Survived, only when no test ever
533
+ invoked the mutant, and the warning names the re-load site. A run
534
+ where every mutant ends Unreachable exits 3 instead of going silently
535
+ green. (#662)
536
+
537
+ - A scoped (`--since`) run that skips a subject for no coverage now
538
+ names the loaded specs (or says none) and points at `--test`. Coverage
539
+ in sibling spec files may not have been loaded; the existing exit-3
540
+ gate is unchanged. (#636)
541
+
542
+
543
+ ### Added
544
+
545
+ - guess-tests now tries the flattened spec root and a `spec/unit/` prefix
546
+ after the mirrored guesses: `lib/pundit/policy_finder.rb` finds
547
+ `spec/policy_finder_spec.rb` and `lib/aasm/base.rb` finds
548
+ `spec/unit/base_spec.rb`, so `--subject` and `--since` stop hard-stopping
549
+ on pundit, aasm and shoulda-matchers style layouts (#394, #370, #393).
550
+ - Methods defined in the four core class factories (`X = Data.define(...)`,
551
+ `Struct.new`, `Class.new`, `Module.new` with a block) are mutation
552
+ subjects instead of skipped subjects (#290): the finder names the
553
+ generated class as the owner, and the mutant is re-evaluated through
554
+ `X.class_eval`, leaving the generated class itself untouched. Defs in
555
+ any other block stay skipped as "defined inside a block".
556
+ - `define_method(:m) { ... }` calls with a literal name and a block are
557
+ mutation subjects (#194): the block body is mutated and the whole call is
558
+ re-evaluated on the enclosing owner. A computed name, an explicit
559
+ receiver, or a lambda argument stays unsupported, and a call in a block
560
+ whose owner is unknown keeps the "defined inside a block" skip reason.
561
+
562
+ - A fault-injection e2e probe now pins cache-dir removal during the
563
+ baseline window (#300): deleting `.saboteur` while the baseline holds
564
+ the suite open changes neither the exit code nor the run evidence -
565
+ later phases recreate the directory and the report holds the same
566
+ verdicts; only the in-flight baseline log is lost. Other windows are
567
+ not pinned to recover (a missing mutant log fails the run by design),
568
+ so stop the run before deleting the cache, as `saboteur help` says.
569
+
570
+ - `saboteur doctor` now documents its version scope (#318): it certifies no
571
+ versions of Rails, databases or application tools saboteur never invokes.
572
+ A mismatched tool the suite loads still surfaces through the boot check's
573
+ LoadError or the baseline refusal with the tool's own output; a narrower
574
+ doctor check first needs the tool name, the expected and actual versions
575
+ and the failure output, recorded on the issue.
576
+ - `--test-command CMD` / `c.test_command`: run an external command per
577
+ mutant and take its exit status as the verdict - for apps that cannot
578
+ load inside saboteur's Ruby. Serial, no coverage, unsandboxed. Mutants
579
+ are applied in a fresh disposable copy of the tree per execution - the
580
+ runner's own writes never touch the real files or follow symlinks - and
581
+ the command's process group is terminated on every exit path. One
582
+ budget-bounded neutral preflight checks the command on an untouched
583
+ copy before any mutant exists; the shell's 126/127 read as setup
584
+ errors, blank and non-string commands are rejected, and nested
585
+ saboteur runs are refused (#140, #305). The preflight runs under an
586
+ absolute deadline admitted after the tree copy, and a spent budget
587
+ stops the run as budget-exhausted rather than a command failure.
588
+ Internal symlinks are rebuilt as relative links inside the copy, so an
589
+ alias sees the mutant while nothing can link out; escaping or looping
590
+ links refuse the run. Copy and write failures become error outcomes
591
+ with bounded context instead of crashing the run. Output capture is a
592
+ bounded in-memory ring buffer, scrubbed to valid UTF-8 for reports,
593
+ and its drain is time-bounded so a session-escaped descendant holding
594
+ the pipe cannot stall the run. Subjects outside the project root are
595
+ refused.
596
+ - Survivor classification labels in the JSON report (#192): each survivor
597
+ carries `classification` - `likely_equivalent` when the mutant compiles
598
+ to the original's instructions, `arid` when every line an edit touches
599
+ matches an arid pattern, `missing_test` when a test covered the line
600
+ and passed. The HTML report badges each survivor with the same label
601
+ and the agent JSON carries it on each shown finding. Labels are
602
+ heuristics for triage order only, never a diagnosis: scores and the
603
+ exit code never change.
604
+ - `--since` mutates changed entries of constant hash and array literals:
605
+ the assignments join the diff scope as subjects, `pair_removal` edits hash
606
+ entries, the new default-on `element_removal` operator edits array
607
+ elements (in methods too, so array literals grow mutant totals the way
608
+ hashes do), and the tests are the specs covering a read of the constant.
609
+ Unsupported forms (namespaced targets, `||=`/`&&=`, non-literal values,
610
+ condition-guarded writes, or a constant written more than once in one
611
+ file counting every write) are named in the diagnostics instead of being
612
+ mutated or reported survived (#306).
613
+ - Constant replay vets the whole file: `--since` replays a constant's file
614
+ only when its load-time code is limited to requires, pure defs,
615
+ class/module nesting and literal assignments and trips no hazard denylist
616
+ entry; anything else needs the constant listed in `c.whole_file` (the
617
+ existing whole-file opt-in) or the literal moved into its own file.
618
+ Constants whose only reads capture the value at load time elsewhere -
619
+ including a capture inside a boot-time block - are dropped with a
620
+ diagnostic. Spec files count as consumers: the coverage map records spec
621
+ line hits, directory test paths and the default spec tree feed the
622
+ consumer scan, `element_removal` honors the arid policy, arid patterns
623
+ screen constant entries too, and constant mutation respects `c.sources`
624
+ (no `builtin`, no constant mutants) (#306, #319).
625
+ - Constant replay hardening from the #319 lens review: `require` and
626
+ `require_relative` count as vetted load code only with a plain
627
+ literal-string argument and no block, so `require(build_path)` no longer
628
+ reruns helper code on every mutant; rooted constant writes (`::RULES`,
629
+ `::Shop::RULES`) share one identity with the bare write, and an
630
+ unrooted qualified write (`Shop::RULES`) resolves through the lexical
631
+ scope, so it demotes every literal on its scope chain it may overwrite
632
+ (`Shop::RULES` inside `module Outer; module Shop` overwrites
633
+ `Outer::Shop::RULES`); either way a re-assigning write demotes the
634
+ first literal to unsupported instead of leaving it scored; the --all consumer scan reads
635
+ the configured integration's loaded spec files instead of guessing the
636
+ conventional spec tree; and an arid pattern that cannot judge an entry's
637
+ bytes (binary source against a UTF-8 pattern) keeps the mutant instead
638
+ of raising (#319).
639
+ - Fault-injection e2e specs pin exit 3 with a clear, test-blame-free message
640
+ when the cache dir is read-only, is occupied by a regular file, or holds an
641
+ unwritable baseline log (#251).
642
+ - A dropped mutation that does not parse is now named in the warnings with
643
+ its subject and operator instead of only counting toward invalid
644
+ mutations (#116).
645
+ - `docs/stability.md`: the agent JSON payload and the exit codes are stable
646
+ public APIs with a deprecation process, pinned by a new contract spec; a
647
+ governance note in CONTRIBUTING covers maintainer availability (#249).
648
+ - `saboteur doctor` says plainly on macOS that mutants run unsandboxed,
649
+ advising a container for filesystem containment and network denial or
650
+ stubbing for real-API risk, and the README carries a short per-platform
651
+ sandbox and network statement (#247).
652
+ - Ignore-list entries accept a triage `state:` - `accepted_equivalent`
653
+ (the default) and `wont_fix` suppress the survivor's exit failure (it
654
+ still counts in the score), `todo` records the decision to fix and keeps
655
+ the survivor counting as an unignored survivor. The HTML report badges the
656
+ state; in the JSON report `accepted_equivalent` keeps the plain
657
+ `ignored: <reason>` while `wont_fix` and `todo` are labelled
658
+ (`ignored: <reason> (wont_fix)`, `todo: <reason>`), and all three keep
659
+ status `Survived` (#155).
660
+
661
+ ### Removed
662
+ - The stale self-deprecating benchmark claims in the README ('rerun
663
+ pending'): speed and memory numbers now live only with the harness and raw
664
+ data that reproduce them, or are framed as dated history (#243).
665
+
666
+ ## [0.7.0] - 2026-10-16
667
+
668
+ ### Upgrade notes
669
+ - Inferred test selection is now the default. Use `--all` or
670
+ `c.test_selection = :all` to keep the 0.6.0 whole-suite behavior; use
671
+ `--test` to name specs explicitly. Missing inferred specs stop before
672
+ boot with exit 2. `--guess-tests` is now a deprecated alias for the
673
+ default; `--no-guess-tests` warns and is ignored.
674
+ - Three new default-on operators change the mutation set, runtime and
675
+ scores. Review survivor baselines and CI thresholds after upgrading;
676
+ use `--exclude-operators` to omit operators you do not want.
677
+ - Harness integrations should handle budget-exhausted runs (exit 4),
678
+ unverified mutants outside the score, and explicit JSON report write
679
+ failures (exit 3). In agent mode, exit codes remain verdict-driven;
680
+ budget exhaustion appears in `incomplete[]` and `earlyStop` instead.
681
+ `--max-duration-ok` does not certify an unscored run.
682
+
683
+ ### Changed
684
+ - Breaking: inferred test selection is now the default. Without `--test`,
685
+ a run picks the spec named after each mutated file and runs only those
686
+ for the baseline passes, the coverage pass and every mutant, as `--test`
687
+ would; with `--since` only the changed files count. A file that needs a
688
+ spec and has none now stops the run before it boots (exit 2) and names
689
+ both ways out, instead of silently running the whole suite. Use `--all`
690
+ or `c.test_selection = :all` to keep the old whole-suite behavior.
691
+ `--guess-tests` is a deprecated alias for the default and
692
+ `--no-guess-tests` warns and is ignored; both flags disappear in a
693
+ future release (#270). A `--subject` run infers only from the named
694
+ subjects' files, so an unrelated spec-less file no longer stops a
695
+ one-method run. The unmatched-file guidance names the expected specs
696
+ and tells you to re-run the same command with `--test` followed by your
697
+ existing spec paths, or to append `--all`, instead of printing a
698
+ reconstructed command. `--test` may stand alone (the configured include
699
+ names the sources), so agent rerun/replay commands stay executable; a
700
+ whole-suite `--all` run is recorded in the agent provenance only when
701
+ no tests resolved, so a configured `:all` combined with named tests
702
+ still produces runnable follow-up commands. Baseline evidence is
703
+ fingerprinted from the declared config before inference narrows the
704
+ test list, so `baseline propose` and `baseline check` agree again
705
+ (#275). Two stale ignore-list entries for
706
+ `Saboteur::CLI#define_execution_options` are gone: full runs no longer
707
+ generate those description-string mutants, so the entries could never
708
+ match again.
709
+ A `--subject` run combined with `--since` now infers specs only for
710
+ named subjects inside the diff scope: an unchanged subject file, one
711
+ whose changes sit in other methods, or one with a comment-only change
712
+ no longer stops the run before boot. Agent provenance records tests
713
+ defined in the config file, so rerun and replay commands keep their
714
+ scope instead of falling back to inference.
715
+ - Report-artifact write semantics (#296): a failed HTML report or score
716
+ history write now warns and lets the run exit by its results, so a
717
+ read-only tree (a sandboxed child holding the project read-only) no
718
+ longer turns a good run into a tool failure. An explicitly requested
719
+ JSON report (`--json` / `c.json_report`) instead fails the run with
720
+ exit 3 - it is the completion evidence an outer harness trusts, and a
721
+ warning could leave a stale or partial file presented as this run's.
722
+ The default `.saboteur/report.json` stays best-effort. Score-history
723
+ soft failures are narrowed to the write's own IO errors; validation,
724
+ integrity, record-size and lock failures stay hard errors.
725
+ - Crash-log fallback hardening (#296): when the cache dir cannot hold
726
+ `internal-error.log`, the fallback in the system temp dir is created
727
+ privately (`O_EXCL | O_NOFOLLOW`, mode 0600) instead of following or
728
+ overwriting a pre-existing path. The pid-keyed fallback path is only
729
+ computed after the primary write fails. Every crash log is now written private (0600) through a
730
+ no-follow, non-blocking open - a planted symlink cannot redirect the
731
+ write and a planted FIFO cannot block it - and the runner reads it
732
+ back the same way, accepting only a regular file owned by the same uid
733
+ with no group/other bits. Where the platform lacks `O_NOFOLLOW` or
734
+ `O_NONBLOCK`, the primary log and the excerpt are omitted rather than
735
+ opened unsafely; the `O_EXCL`-created temp fallback stays safe and can
736
+ still be written. The appended
737
+ excerpt is scrubbed to valid UTF-8 and labelled as freshness-matched,
738
+ not certainly that run's.
739
+
740
+ ### Added
741
+ - Dogfood/mutation diagnostics (#296): a rejected kill now reports why the
742
+ killing test failed with the method unchanged - the exception class, its
743
+ message and up to five backtrace frames, bounded to 2000 chars -
744
+ instead of only the first line ("expected: 0"). Filtering frames whose
745
+ paths contain `/gems/rspec` prioritizes project frames; the message and
746
+ frame limits do not guarantee that the failing spec line survives.
747
+ The terminal report
748
+ prints the detail as indented continuation lines and the JSON report's
749
+ `statusReason` keeps every line; one-line reasons are unchanged. An `unverified` verdict also keeps the runner's own
750
+ reason (for example "the --max-duration budget is spent") rather than a
751
+ generic one.
752
+ - Nested-run crash surfacing (#296): a spec that runs saboteur itself (the
753
+ dogfood's own cli_spec) crashes the nested CLI as exit 3, and the crash
754
+ lands in the run's internal-error.log. A child whose tests wrote a fresh
755
+ log appends its first lines to the failure detail, so a verdict check's
756
+ rejection includes crash context instead of only the exit-code diff.
757
+ The excerpt is freshness-matched and may be from another run; it is
758
+ not proof that this nested run wrote the log.
759
+ - `--max-duration` budget-exhaustion semantics (#296): a verdict check or
760
+ timeout calibration the budget cannot admit is skipped rather than run
761
+ with a zero-length timeout, so genuine kills are no longer misclassified
762
+ as errors and cached. Admission is per check and per sequence: a check
763
+ admitted with time left that hits its budget-shortened cap mid-check is
764
+ `unverified`, not a rejected kill, later sequences never fork once the
765
+ budget is spent, and unverified verdicts are never cached. Affected
766
+ mutants report as `unverified` - outside the score - the early stop
767
+ lists their subjects (`unverifiedSubjects` in the JSON report), and the
768
+ CLI run exits 4 unless `--max-duration-ok` is set. Agent mode remains
769
+ verdict-driven and records the budget in `incomplete[]` and `earlyStop`.
770
+ Baseline checks and the coverage map build are now capped
771
+ at the time left in the budget, and the early-stop reason comes from the
772
+ actual truncation state, so mid-subject budget stops report
773
+ `max_duration` even with `--fail-fast`. Agent output names unverified
774
+ mutants in `incomplete[]`, and `--max-duration-ok` only passes a run
775
+ whose verified verdicts (kills, survivors, timeouts) can carry a score.
776
+ - `saboteur pr [REF]` and `saboteur audit` presets: opinionated bundles of
777
+ existing flags - the PR loop (changed lines since REF, one baseline
778
+ check, a ten-minute budget with `--max-duration-ok`) and the audit
779
+ preset (`--all`, all operators, two baseline checks, JSON and HTML
780
+ reports). The PR preset opts out of the budget-specific exit 4, not
781
+ normal or incomplete verdict failures. The audit keeps configured
782
+ `since`, subjects, exclusions and budgets; explicit tests replace its
783
+ injected `--all`. The run prints the expansion up front and explicit
784
+ flags override it (#244).
785
+ - `--max-duration` (config `max_duration`, e.g. `10m`) budgets the whole
786
+ run, baselines included: past the budget the run stops before the next
787
+ subject, reports which selected subjects never ran, marks the JSON and
788
+ agent output partial, and exits 4 in ordinary CLI mode;
789
+ `--max-duration-ok` leaves the exit to the plain verdict (which can
790
+ still fail the run). Agent exits remain verdict-driven, with budget
791
+ exhaustion in `incomplete[]` and `earlyStop`. Works with
792
+ `--jobs` by halting the worker pool (#245).
793
+ - `baseline_passes` (config and `--baseline-passes`) sets how many times the
794
+ unchanged suite runs before mutation (default 2). `1` is the fast PR
795
+ loop; the run announces that it gives up the order-stability
796
+ comparison between passes, and the whole-suite cost message counts
797
+ the configured checks (#55).
798
+ - Usability fixes from 0.5.0 field feedback: an unscoped whole-suite run
799
+ announces the example count and the real number of full passes before
800
+ the first one (a cached map counts as reused only when it loads clean)
801
+ and estimates the remaining baseline and coverage time once the first
802
+ baseline pass is timed; past 1,000 examples it also names `--test` as
803
+ the way to scope (#260). The Next steps footer prints the complete,
804
+ shell-safe replay command carrying the run's own selection options
805
+ and the CLI-only switches that change candidate availability or boot,
806
+ in full however long it gets, instead of asking you to reassemble
807
+ them (#261). `--verbose` prints one line per finished subject with its
808
+ result marks, so a survivor is attributable to a method before the run
809
+ ends (#262). The threads-alive-at-fork warning says why the two known
810
+ threads are expected harmless, and under which conditions, and what an
811
+ unknown thread can break (#263). A `chain_removal` survivor's hint
812
+ appends a conditional equivalence note: if the shorter chain proves
813
+ equivalent across the intended inputs, simplify or ignore (#264).
814
+ - Three new default-on operators: `method_swap` swaps a method with its
815
+ semantic opposite (`min`/`max`, `any?`/`all?`, `map`/`each`; a receiver
816
+ is required, so bare `max(a, b)` is untouched) (#237),
817
+ `operand_replacement` replaces an arithmetic or comparison operation
818
+ with each operand in turn (`a + b` yields `a` and `b`; boolean
819
+ connectors stay with `boolean_logic`) (#238), and
820
+ `interpolation_removal` removes each `#{expr}` and each literal segment
821
+ of an interpolated string (#239). The hazard guard now also recognizes
822
+ `send`, `public_send` and `__send__` with a literal symbol or string
823
+ selector and applies the same checks as for the direct call to the
824
+ resolved selector, with the arguments shifted past it: a destructive
825
+ method name on any receiver (`helper.send(:rmtree, path)`), the
826
+ configured hazard patterns, `Kernel#system` / `Process#spawn`, and
827
+ `Kernel#open` with a "| command" argument all stay suppressed. Dynamic
828
+ selectors stay out of scope as documented (#240).
829
+ - `--since` warns when a changed Ruby file in scope has no subjects
830
+ covering the changed lines, so nothing is mutated from it (#232). A
831
+ silent zero-subject scope used to pass as a clean run. Comment-only or
832
+ formatting-only changes within discovered methods are skipped without
833
+ this warning; the run reports those skipped methods. `--since` also
834
+ warns when a changed Ruby file is covered by an include pattern but
835
+ removed by `c.exclude`, naming up to five such files, so a too-wide
836
+ exclude can no longer dogfood as a clean empty scope (#282).
837
+
838
+ ### Fixed
839
+ - `--since` no longer skips untracked files silently (#295): a never-committed
840
+ source file is invisible to `git diff`, so it used to contribute no changed
841
+ lines and every subject in it was skipped without a word - even the
842
+ changed-files-without-subjects warning could not name it. Untracked files
843
+ now union into the changed-lines map as fully changed, so their subjects
844
+ are selected, and one without subjects triggers the warning like any other
845
+ changed file. Ignored (`.gitignore`), binary and empty files stay out, and
846
+ only files inside the configured `include` set are considered, so a large
847
+ unrelated untracked file is never opened. Each unioned file is opened
848
+ `O_NOFOLLOW`/`O_NONBLOCK` and validated on that descriptor (a swapped-in
849
+ symlink or FIFO is rejected and reported as unreadable), the binary sniff reads only the first
850
+ few kilobytes, and the map holds a range of lines instead of one entry per
851
+ line, keeping memory bounded. On platforms without those open flags the
852
+ union is skipped with a warning.
853
+ - `--since` no longer drops an include-eligible untracked file quietly when
854
+ it cannot be read (vanished, unreadable, or replaced by a link or special
855
+ file between the listing and the open): the run names it in a warning and
856
+ records it as a discovery failure, so an agent run over mixed tested and
857
+ omitted source reports `partial`/`incomplete` with the `unreadable` reason
858
+ instead of a clean result. Files that do not parse (`invalid_file_syntax`)
859
+ surface the same way instead of only warning (#297).
860
+ - Include membership for untracked files is lexical first: an included path
861
+ swapped to a symlink is attempted, rejected by the safe open and reported,
862
+ instead of being filtered out silently on its resolved target. A symlinked
863
+ repository root stays in scope through the canonical fallback (#297).
864
+ - Test inference under `--since` no longer enumerates a whole-file change
865
+ range per method when deciding whether a changed file needs a spec; it
866
+ answers ranges by interval, like the run's own subject selection (#297).
867
+ - The excluded-changes warning now also names untracked files in excluded
868
+ territory: they never appear in the diff, so an exclude that removes one
869
+ used to hide the removal entirely (#297).
870
+ - Distance and overlap checks over the whole-file ranges the untracked
871
+ union produces are now geometric, so subject selection, test guessing and
872
+ survivor ranking answer in O(1) per subject instead of scanning one entry
873
+ per line of a large new file (#297).
874
+ - A baseline check or coverage map build that hit only its
875
+ budget-shortened timeout failed the run as a suite error (exit 3) with
876
+ no partial report; it now reports budget exhaustion with every selected
877
+ subject unreached and exits 4 in ordinary CLI mode unless
878
+ `--max-duration-ok` is set; agent exits remain verdict-driven (#296).
879
+ - The unscoped-suite warning pluralizes zero baseline checks correctly
880
+ ("0 baseline checks", not "0 baseline check") (#296).
881
+ - Review hardening for the 0.5.0 loop flags (#245): the `--max-duration`
882
+ clock starts when the run starts (boot, baselines and the coverage map
883
+ all spend the budget) and is checked before dispatching every subject,
884
+ between mutants inside a subject, and between baseline passes, in
885
+ serial and `--jobs` runs alike; a parallel budget halt stops handing
886
+ out new work and lets in-flight subjects finish and report, so their
887
+ completed outcomes are kept instead of abandoned as nil holes
888
+ mislabeled "never ran". `--max-duration-ok`
889
+ no longer exits 0 when the budget stopped the run before any mutant
890
+ was tested - there is no score to judge, so the run is incomplete
891
+ (exit 3), in agent mode too. Agent output records `failFast` only for
892
+ a real `--fail-fast` stop, `--baseline-passes 1` no longer certifies
893
+ the suite stable in the baseline evidence, and the rerun/replay
894
+ commands keep `--max-duration`, `--max-duration-ok` and
895
+ `--baseline-passes`. `saboteur audit` bundles the `--operators all
896
+ --baseline-passes 2` its help advertises, an explicit `--test`
897
+ replaces its injected `--all`, and `--no-max-duration-ok` overrides
898
+ the pr preset's budget pass.
899
+ - macOS CI flakes: the supervisor `on_tick` cleanup spec waits for a fully
900
+ written pid before triggering instead of racing the file's first bytes
901
+ (#277), fixture repositories disable Git background auto-maintenance so
902
+ teardown no longer races its lock deletion (#271), and a baseline child
903
+ that exits without a result now has its exit status or signal in the
904
+ error and its dying exception on stderr, so the next flake names its
905
+ cause (#255). The same error carries a bounded tail of the child's
906
+ redirected log, the dying-child warning names its stage ("baseline
907
+ child died during setup/run", "coverage child died during the map
908
+ build"), and a broken child stderr can no longer swallow the exit
909
+ (#289).
910
+ - `Saboteur::Engine#call` restores the host process's Coverage state when
911
+ it returns or raises: an embedded engine (a spec, the dogfood's own
912
+ coverage-map build) no longer leaves Coverage suspended for everything
913
+ the host runs afterwards, and a resume that fails because the host
914
+ disturbed Coverage mid-run warns instead of masking the run outcome,
915
+ with worker cleanup running either way (#254). A scoped run (`--since`) no longer
916
+ calls an ignore entry stale when its mutation exists but sits on an
917
+ unchanged line and was scoped out; staleness is judged against each
918
+ examined subject's full, pre-scope candidate digests (#284). A worker
919
+ start-failure storm while the pool is already draining no longer raises
920
+ "workers keep failing to start" over the tool errors that carry the
921
+ real abort cause (#286).
922
+ - The canary no longer blames a method's last mutant for deleted files:
923
+ a cheap per-mutant probe (snapshot stats only, about 1.3 ms for this
924
+ repo's 692 watched files against a full inventory's 5 ms) runs after
925
+ every mutant, so the abort names the mutant whose run just ended when
926
+ the loss was detected as the likely cause - with the per-method check
927
+ as the backstop for losses outside a mutant's window (#235).
928
+ - Parallel runs aborted by a wiped log dir now name the culprit: a
929
+ worker that only noticed the damage could report "an unknown mutant"
930
+ before the worker whose mutant did it, and the first report won the
931
+ abort. Tool-error reports now carry whether they name a mutant, the
932
+ pool stops handing out new work but lets in-flight workers finish
933
+ and report, and the abort ranks the reports by attribution quality:
934
+ a report that names its mutant first, then a canary trip, then the
935
+ earliest bare report. A canary trip from the end-of-subject check
936
+ folds into the same choice instead of overriding collected reports,
937
+ and the in-flight drain is bounded (a 30-second default drain
938
+ window, followed by worker cleanup), so a wedged worker cannot
939
+ stall the abort (#235).
940
+ - `saboteur doctor` prints the coverage line once instead of twice (it is
941
+ still verified before and after target boot), and reports the sandbox
942
+ status as a kernel capability probe, not verified isolation: the
943
+ Landlock ABI when the kernel can deliver it, the gap when it cannot -
944
+ told apart as a `c.sandbox = "strict"` refusal versus the permitted
945
+ degradation of a non-strict run - or off when `c.sandbox = false`. A
946
+ probe the kernel refuses (EPERM under a seccomp policy) is an
947
+ actionable tool error with exit 3, not an internal-error crash report,
948
+ and doctor writes nothing (#236). The sandbox policy
949
+ no longer creates the project's `tmp/` directory to grant it: the
950
+ grant applies when `tmp/` already exists and only `c.sandbox_writable`
951
+ entries are created (#236).
952
+ - The mutant sandbox no longer grants the cache dir: a mutant child
953
+ cannot delete the baseline, the reports or other children's logs,
954
+ and its own log is opened before the fork and inherited as a
955
+ descriptor, with stdio kept explicitly through the sandbox sweep.
956
+ A child log that cannot be opened, or that vanishes mid-run with
957
+ the sandbox off or degraded, aborts the run with exit 3 - the
958
+ canary's hazard report names the culprit when tracked files
959
+ vanished too, otherwise the tool error does - never a mutant
960
+ verdict such as "killing test also fails with the method
961
+ unchanged"; in parallel runs the error aborts the whole run
962
+ instead of degrading into per-mutant errors. The grants are
963
+ additive, so a cache dir placed inside
964
+ `tmp/`, the system temp dir or a `c.sandbox_writable` entry stays
965
+ writable. Note the sandbox prevents the damage, not the verdict:
966
+ with `--allow-hazardous`, a destructive mutant whose test swallows
967
+ the denial (EACCES) can still read as a survivor; by default the
968
+ hazard guard suppresses such mutants and reports the hazard (#234).
969
+ - An extensionless `hooks` path now boots: it resolves its `.rb` at load
970
+ time, the way `require` does and matching what doctor accepts (#267).
971
+ - Fixed two encoding failures in mutant children running under a non-UTF-8
972
+ locale such as `LANG=C` (seen on CI, where the Landlock sandbox engages):
973
+ the inherited-fd sweep compared a locale-tagged descriptor target against
974
+ grant patterns with string semantics, raising
975
+ `Encoding::CompatibilityError` before any test ran when an open file's
976
+ path carried non-ASCII bytes; and an exception message embedding such a
977
+ path made failure reporting raise the same way, turning a killed mutant
978
+ into an errored run. The sweep now compares raw bytes, and failure
979
+ messages are scrubbed before their first line is extracted; if the
980
+ scrub itself raises - it is mutable code, and the mutant under test
981
+ may have broken it - an escaped fallback keeps the failure a clean
982
+ kill instead of an errored run (#272).
983
+ - Fixed `--guess-tests` falling back to the whole suite when a changed
984
+ file has no mutation subjects on its changed lines, such as a deletion
985
+ whose remaining context is blank or comment lines or a comment-only
986
+ change: such files no longer require a guessed spec. A changed file
987
+ that does need a spec but has none now stops before boot with exit 2,
988
+ matching the default-inference policy above (#275).
989
+ Guesses come only from files with a changed mutation subject, and when
990
+ no changed file has one the run reports that there is nothing to mutate
991
+ (fixes #268).
992
+ - Fixed failures under non-UTF-8 locales such as `LANG=C` when project
993
+ paths contain non-ASCII characters. Git output, coverage maps, result
994
+ caches, baseline logs and worker messages are now decoded as UTF-8
995
+ regardless of the process locale. This prevents
996
+ `Encoding::CompatibilityError` in the canary, baseline failure excerpts
997
+ and worker results, and fixes `--since` silently skipping changed files
998
+ with non-ASCII names. Paths are identity, not display text: the canary
999
+ watch, `--since` diff parsing, source discovery and the run fingerprint
1000
+ retag locale-tagged bytes losslessly, so a tracked file whose name is
1001
+ not valid UTF-8 stays watched (its deletion still trips the canary) and
1002
+ keeps its exact bytes through selection. The canary and diff parser
1003
+ preserve path bytes rather than replacing invalid UTF-8 bytes; the
1004
+ cache, coverage-map and worker-message boundaries reject invalid UTF-8
1005
+ instead of rewriting it.
1006
+ - An unexpected internal error now prints `saboteur: internal error (...)
1007
+ - please report it` and exits 3, instead of escaping as a bare stack
1008
+ trace with an accidental exit 1. The crash message is normalized to one
1009
+ bounded line first, and agent mode reports the same failure as agent
1010
+ JSON. The full backtrace is written to
1011
+ `<cache_dir>/internal-error.log` (default `.saboteur/`), which the
1012
+ message names, and printed to stderr with `--verbose`; an unset
1013
+ `cache_dir` or a failed write leaves the one-liner standing alone.
1014
+ The executable loads the crash reporter before the CLI,
1015
+ so a failure while it is loading, being built, or running gets
1016
+ the full report. Coverage setup and the reporter's own load happen before
1017
+ it, so those failures get only the bare normalized one-liner plus
1018
+ exit 3. When agent mode was requested but the agent reporter never
1019
+ loaded, a minimal standalone failure JSON in the agent reporter's
1020
+ schema (status `error`, verdict `tool_failure`, exit code 3)
1021
+ answers instead (#233).
1022
+ - The install spec reads the README as UTF-8, so the suite also passes
1023
+ under `LANG=C`.
1024
+ - Tool and test reads of files that can carry non-ASCII text are decoded
1025
+ as UTF-8 regardless of the process locale: the baseline manifest and
1026
+ preview receipt, `.rspec` option files, the custom-map duplicate scan,
1027
+ the installer's Gemfile and `.gitignore` probes (`.gitignore` is
1028
+ rewritten losslessly), and the JSON reads in the test helpers.
1029
+ - CI now runs the suite on Ubuntu Ruby 3.4 with `LANG=C` and `LC_ALL=C`
1030
+ to catch locale regressions exercised by the tests. The panel gate's
1031
+ required-check policy includes this job (#281).
1032
+
9
1033
  ## [0.6.0] - 2026-10-14
10
1034
 
11
1035
  ### Fixed