saboteur 0.5.0 → 0.7.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +399 -0
  3. data/README.md +202 -42
  4. data/exe/saboteur +34 -7
  5. data/lib/saboteur/baseline/comparison.rb +1 -0
  6. data/lib/saboteur/baseline/evidence.rb +11 -1
  7. data/lib/saboteur/baseline/run_evidence.rb +1 -1
  8. data/lib/saboteur/baseline/workflow.rb +2 -1
  9. data/lib/saboteur/baseline_health.rb +40 -6
  10. data/lib/saboteur/cli/exit_status.rb +49 -4
  11. data/lib/saboteur/cli/help_options.rb +16 -0
  12. data/lib/saboteur/cli/presets.rb +36 -0
  13. data/lib/saboteur/cli/run_budget_options.rb +37 -0
  14. data/lib/saboteur/cli/run_reporting.rb +75 -6
  15. data/lib/saboteur/cli.rb +80 -48
  16. data/lib/saboteur/config.rb +88 -13
  17. data/lib/saboteur/crash_report.rb +154 -0
  18. data/lib/saboteur/custom_map.rb +3 -1
  19. data/lib/saboteur/doctor.rb +64 -2
  20. data/lib/saboteur/engine/baseline_guard.rb +79 -5
  21. data/lib/saboteur/engine/boot.rb +12 -1
  22. data/lib/saboteur/engine/canary.rb +41 -10
  23. data/lib/saboteur/engine/candidate_selection.rb +9 -1
  24. data/lib/saboteur/engine/candidates.rb +4 -1
  25. data/lib/saboteur/engine/coverage_guard.rb +29 -0
  26. data/lib/saboteur/engine/coverage_store.rb +48 -6
  27. data/lib/saboteur/engine/diagnostics.rb +23 -2
  28. data/lib/saboteur/engine/diff_scope.rb +24 -5
  29. data/lib/saboteur/engine/distribution.rb +124 -8
  30. data/lib/saboteur/engine/early_stop_recording.rb +46 -0
  31. data/lib/saboteur/engine/result_cache.rb +2 -1
  32. data/lib/saboteur/engine/run_budget.rb +63 -0
  33. data/lib/saboteur/engine/run_fingerprint.rb +5 -1
  34. data/lib/saboteur/engine/sandbox_policy.rb +18 -11
  35. data/lib/saboteur/engine/scheduler.rb +114 -18
  36. data/lib/saboteur/engine/subject_selection.rb +84 -5
  37. data/lib/saboteur/engine/suite_files.rb +3 -1
  38. data/lib/saboteur/engine/test_guess.rb +183 -23
  39. data/lib/saboteur/engine/thread_warnings.rb +53 -0
  40. data/lib/saboteur/engine/unscoped_suite_warning.rb +41 -0
  41. data/lib/saboteur/engine/verdict_check.rb +54 -18
  42. data/lib/saboteur/engine.rb +175 -96
  43. data/lib/saboteur/execution/protocol.rb +2 -1
  44. data/lib/saboteur/execution/runner.rb +179 -17
  45. data/lib/saboteur/execution/sandbox.rb +6 -1
  46. data/lib/saboteur/execution/supervisor.rb +18 -3
  47. data/lib/saboteur/execution/worker_pool.rb +87 -7
  48. data/lib/saboteur/frameworks/database_hosts.rb +30 -0
  49. data/lib/saboteur/frameworks/rails.rb +8 -10
  50. data/lib/saboteur/help.rb +55 -10
  51. data/lib/saboteur/ignore_list.rb +11 -1
  52. data/lib/saboteur/install.rb +4 -3
  53. data/lib/saboteur/integration/base.rb +7 -2
  54. data/lib/saboteur/integration/rspec.rb +40 -4
  55. data/lib/saboteur/mutation_source/builtin.rb +22 -13
  56. data/lib/saboteur/mutation_source/hazard_guard.rb +21 -3
  57. data/lib/saboteur/mutation_source/interpolation_removal.rb +41 -0
  58. data/lib/saboteur/mutation_source/method_swap.rb +45 -0
  59. data/lib/saboteur/mutation_source/mutation.rb +5 -3
  60. data/lib/saboteur/mutation_source/operand_replacement.rb +54 -0
  61. data/lib/saboteur/mutation_source/operator_examples.rb +38 -0
  62. data/lib/saboteur/mutation_source/operator_registry.rb +36 -8
  63. data/lib/saboteur/mutation_source/operator_visit.rb +39 -0
  64. data/lib/saboteur/preview.rb +12 -2
  65. data/lib/saboteur/reporter/agent_commands.rb +49 -16
  66. data/lib/saboteur/reporter/agent_incomplete.rb +27 -2
  67. data/lib/saboteur/reporter/agent_summary.rb +1 -1
  68. data/lib/saboteur/reporter/history_policy.rb +35 -0
  69. data/lib/saboteur/reporter/history_record.rb +4 -5
  70. data/lib/saboteur/reporter/next_steps.rb +14 -4
  71. data/lib/saboteur/reporter/score_history.rb +19 -3
  72. data/lib/saboteur/reporter/stryker.rb +19 -8
  73. data/lib/saboteur/reporter/survivor_hints.rb +3 -0
  74. data/lib/saboteur/reporter/terminal.rb +43 -10
  75. data/lib/saboteur/result.rb +25 -7
  76. data/lib/saboteur/selection/coverage_map.rb +2 -1
  77. data/lib/saboteur/selection/git_diff.rb +15 -5
  78. data/lib/saboteur/selection/untracked_changes.rb +126 -0
  79. data/lib/saboteur/subject.rb +11 -0
  80. data/lib/saboteur/text.rb +42 -0
  81. data/lib/saboteur/version.rb +1 -1
  82. data/lib/saboteur.rb +16 -1
  83. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9d8e69906c1a27f726dea87fb72eae3a92a6dfc4e3d10a5fbe903c0f8cd4e38
4
- data.tar.gz: f6c85551dd523d8926c3ac05313f1dc0edea86aa84d321a8b4e05d7f84ca4706
3
+ metadata.gz: c6e75e9cdd3caa4691a569db12d33348c09b05076865e1f62a96ce9664333b88
4
+ data.tar.gz: dc677720684aa27b4c24120695339961a7cc23327e67f187948abbde77b93e16
5
5
  SHA512:
6
- metadata.gz: 8b4bddc7c4b7b621082a6ad986e49e3e4a113ae1100f4cfb18f6a649d6ac8f852da486f110bcb61da0af3d8f588c172970e1526454d857d7ca6fb1411bde2c48
7
- data.tar.gz: 4b8ab1f9f45dd75506b7e5a388a85af5a2fa46cbe6b70bc885ef1dc615988e4ef01ca4815fe67b2f8fd62cb5aa07511bf733da8e4f21775e6325080f478f2f06
6
+ metadata.gz: 183c1c0f548c72bb6d6535eb35c5456a74fa0bf02dbd66dd689d5f87424b91cd048e319acfea75532a50568821ea47168508831e1fd034dc67850c3e8c1bdbfe
7
+ data.tar.gz: c9f52334868d552cdde9e0a404cc0b45fde089dfe77dbe30f0b252e8084cb291e121a6728f267fec98437587321d5f9fff36463bb27b19f0281f3c36e3e69fc1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,405 @@ All notable changes are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-10-16
10
+
11
+ ### Upgrade notes
12
+ - Inferred test selection is now the default. Use `--all` or
13
+ `c.test_selection = :all` to keep the 0.6.0 whole-suite behavior; use
14
+ `--test` to name specs explicitly. Missing inferred specs stop before
15
+ boot with exit 2. `--guess-tests` is now a deprecated alias for the
16
+ default; `--no-guess-tests` warns and is ignored.
17
+ - Three new default-on operators change the mutation set, runtime and
18
+ scores. Review survivor baselines and CI thresholds after upgrading;
19
+ use `--exclude-operators` to omit operators you do not want.
20
+ - Harness integrations should handle budget-exhausted runs (exit 4),
21
+ unverified mutants outside the score, and explicit JSON report write
22
+ failures (exit 3). In agent mode, exit codes remain verdict-driven;
23
+ budget exhaustion appears in `incomplete[]` and `earlyStop` instead.
24
+ `--max-duration-ok` does not certify an unscored run.
25
+
26
+ ### Changed
27
+ - Breaking: inferred test selection is now the default. Without `--test`,
28
+ a run picks the spec named after each mutated file and runs only those
29
+ for the baseline passes, the coverage pass and every mutant, as `--test`
30
+ would; with `--since` only the changed files count. A file that needs a
31
+ spec and has none now stops the run before it boots (exit 2) and names
32
+ both ways out, instead of silently running the whole suite. Use `--all`
33
+ or `c.test_selection = :all` to keep the old whole-suite behavior.
34
+ `--guess-tests` is a deprecated alias for the default and
35
+ `--no-guess-tests` warns and is ignored; both flags disappear in a
36
+ future release (#270). A `--subject` run infers only from the named
37
+ subjects' files, so an unrelated spec-less file no longer stops a
38
+ one-method run. The unmatched-file guidance names the expected specs
39
+ and tells you to re-run the same command with `--test` followed by your
40
+ existing spec paths, or to append `--all`, instead of printing a
41
+ reconstructed command. `--test` may stand alone (the configured include
42
+ names the sources), so agent rerun/replay commands stay executable; a
43
+ whole-suite `--all` run is recorded in the agent provenance only when
44
+ no tests resolved, so a configured `:all` combined with named tests
45
+ still produces runnable follow-up commands. Baseline evidence is
46
+ fingerprinted from the declared config before inference narrows the
47
+ test list, so `baseline propose` and `baseline check` agree again
48
+ (#275). Two stale ignore-list entries for
49
+ `Saboteur::CLI#define_execution_options` are gone: full runs no longer
50
+ generate those description-string mutants, so the entries could never
51
+ match again.
52
+ A `--subject` run combined with `--since` now infers specs only for
53
+ named subjects inside the diff scope: an unchanged subject file, one
54
+ whose changes sit in other methods, or one with a comment-only change
55
+ no longer stops the run before boot. Agent provenance records tests
56
+ defined in the config file, so rerun and replay commands keep their
57
+ scope instead of falling back to inference.
58
+ - Report-artifact write semantics (#296): a failed HTML report or score
59
+ history write now warns and lets the run exit by its results, so a
60
+ read-only tree (a sandboxed child holding the project read-only) no
61
+ longer turns a good run into a tool failure. An explicitly requested
62
+ JSON report (`--json` / `c.json_report`) instead fails the run with
63
+ exit 3 - it is the completion evidence an outer harness trusts, and a
64
+ warning could leave a stale or partial file presented as this run's.
65
+ The default `.saboteur/report.json` stays best-effort. Score-history
66
+ soft failures are narrowed to the write's own IO errors; validation,
67
+ integrity, record-size and lock failures stay hard errors.
68
+ - Crash-log fallback hardening (#296): when the cache dir cannot hold
69
+ `internal-error.log`, the fallback in the system temp dir is created
70
+ privately (`O_EXCL | O_NOFOLLOW`, mode 0600) instead of following or
71
+ overwriting a pre-existing path. The pid-keyed fallback path is only
72
+ computed after the primary write fails. Every crash log is now written private (0600) through a
73
+ no-follow, non-blocking open - a planted symlink cannot redirect the
74
+ write and a planted FIFO cannot block it - and the runner reads it
75
+ back the same way, accepting only a regular file owned by the same uid
76
+ with no group/other bits. Where the platform lacks `O_NOFOLLOW` or
77
+ `O_NONBLOCK`, the primary log and the excerpt are omitted rather than
78
+ opened unsafely; the `O_EXCL`-created temp fallback stays safe and can
79
+ still be written. The appended
80
+ excerpt is scrubbed to valid UTF-8 and labelled as freshness-matched,
81
+ not certainly that run's.
82
+
83
+ ### Added
84
+ - Dogfood/mutation diagnostics (#296): a rejected kill now reports why the
85
+ killing test failed with the method unchanged - the exception class, its
86
+ message and up to five backtrace frames, bounded to 2000 chars -
87
+ instead of only the first line ("expected: 0"). Filtering frames whose
88
+ paths contain `/gems/rspec` prioritizes project frames; the message and
89
+ frame limits do not guarantee that the failing spec line survives.
90
+ The terminal report
91
+ prints the detail as indented continuation lines and the JSON report's
92
+ `statusReason` keeps every line; one-line reasons are unchanged. An `unverified` verdict also keeps the runner's own
93
+ reason (for example "the --max-duration budget is spent") rather than a
94
+ generic one.
95
+ - Nested-run crash surfacing (#296): a spec that runs saboteur itself (the
96
+ dogfood's own cli_spec) crashes the nested CLI as exit 3, and the crash
97
+ lands in the run's internal-error.log. A child whose tests wrote a fresh
98
+ log appends its first lines to the failure detail, so a verdict check's
99
+ rejection includes crash context instead of only the exit-code diff.
100
+ The excerpt is freshness-matched and may be from another run; it is
101
+ not proof that this nested run wrote the log.
102
+ - `--max-duration` budget-exhaustion semantics (#296): a verdict check or
103
+ timeout calibration the budget cannot admit is skipped rather than run
104
+ with a zero-length timeout, so genuine kills are no longer misclassified
105
+ as errors and cached. Admission is per check and per sequence: a check
106
+ admitted with time left that hits its budget-shortened cap mid-check is
107
+ `unverified`, not a rejected kill, later sequences never fork once the
108
+ budget is spent, and unverified verdicts are never cached. Affected
109
+ mutants report as `unverified` - outside the score - the early stop
110
+ lists their subjects (`unverifiedSubjects` in the JSON report), and the
111
+ CLI run exits 4 unless `--max-duration-ok` is set. Agent mode remains
112
+ verdict-driven and records the budget in `incomplete[]` and `earlyStop`.
113
+ Baseline checks and the coverage map build are now capped
114
+ at the time left in the budget, and the early-stop reason comes from the
115
+ actual truncation state, so mid-subject budget stops report
116
+ `max_duration` even with `--fail-fast`. Agent output names unverified
117
+ mutants in `incomplete[]`, and `--max-duration-ok` only passes a run
118
+ whose verified verdicts (kills, survivors, timeouts) can carry a score.
119
+ - `saboteur pr [REF]` and `saboteur audit` presets: opinionated bundles of
120
+ existing flags - the PR loop (changed lines since REF, one baseline
121
+ check, a ten-minute budget with `--max-duration-ok`) and the audit
122
+ preset (`--all`, all operators, two baseline checks, JSON and HTML
123
+ reports). The PR preset opts out of the budget-specific exit 4, not
124
+ normal or incomplete verdict failures. The audit keeps configured
125
+ `since`, subjects, exclusions and budgets; explicit tests replace its
126
+ injected `--all`. The run prints the expansion up front and explicit
127
+ flags override it (#244).
128
+ - `--max-duration` (config `max_duration`, e.g. `10m`) budgets the whole
129
+ run, baselines included: past the budget the run stops before the next
130
+ subject, reports which selected subjects never ran, marks the JSON and
131
+ agent output partial, and exits 4 in ordinary CLI mode;
132
+ `--max-duration-ok` leaves the exit to the plain verdict (which can
133
+ still fail the run). Agent exits remain verdict-driven, with budget
134
+ exhaustion in `incomplete[]` and `earlyStop`. Works with
135
+ `--jobs` by halting the worker pool (#245).
136
+ - `baseline_passes` (config and `--baseline-passes`) sets how many times the
137
+ unchanged suite runs before mutation (default 2). `1` is the fast PR
138
+ loop; the run announces that it gives up the order-stability
139
+ comparison between passes, and the whole-suite cost message counts
140
+ the configured checks (#55).
141
+ - Usability fixes from 0.5.0 field feedback: an unscoped whole-suite run
142
+ announces the example count and the real number of full passes before
143
+ the first one (a cached map counts as reused only when it loads clean)
144
+ and estimates the remaining baseline and coverage time once the first
145
+ baseline pass is timed; past 1,000 examples it also names `--test` as
146
+ the way to scope (#260). The Next steps footer prints the complete,
147
+ shell-safe replay command carrying the run's own selection options
148
+ and the CLI-only switches that change candidate availability or boot,
149
+ in full however long it gets, instead of asking you to reassemble
150
+ them (#261). `--verbose` prints one line per finished subject with its
151
+ result marks, so a survivor is attributable to a method before the run
152
+ ends (#262). The threads-alive-at-fork warning says why the two known
153
+ threads are expected harmless, and under which conditions, and what an
154
+ unknown thread can break (#263). A `chain_removal` survivor's hint
155
+ appends a conditional equivalence note: if the shorter chain proves
156
+ equivalent across the intended inputs, simplify or ignore (#264).
157
+ - Three new default-on operators: `method_swap` swaps a method with its
158
+ semantic opposite (`min`/`max`, `any?`/`all?`, `map`/`each`; a receiver
159
+ is required, so bare `max(a, b)` is untouched) (#237),
160
+ `operand_replacement` replaces an arithmetic or comparison operation
161
+ with each operand in turn (`a + b` yields `a` and `b`; boolean
162
+ connectors stay with `boolean_logic`) (#238), and
163
+ `interpolation_removal` removes each `#{expr}` and each literal segment
164
+ of an interpolated string (#239). The hazard guard now also recognizes
165
+ `send`, `public_send` and `__send__` with a literal symbol or string
166
+ selector and applies the same checks as for the direct call to the
167
+ resolved selector, with the arguments shifted past it: a destructive
168
+ method name on any receiver (`helper.send(:rmtree, path)`), the
169
+ configured hazard patterns, `Kernel#system` / `Process#spawn`, and
170
+ `Kernel#open` with a "| command" argument all stay suppressed. Dynamic
171
+ selectors stay out of scope as documented (#240).
172
+ - `--since` warns when a changed Ruby file in scope has no subjects
173
+ covering the changed lines, so nothing is mutated from it (#232). A
174
+ silent zero-subject scope used to pass as a clean run. Comment-only or
175
+ formatting-only changes within discovered methods are skipped without
176
+ this warning; the run reports those skipped methods. `--since` also
177
+ warns when a changed Ruby file is covered by an include pattern but
178
+ removed by `c.exclude`, naming up to five such files, so a too-wide
179
+ exclude can no longer dogfood as a clean empty scope (#282).
180
+
181
+ ### Fixed
182
+ - `--since` no longer skips untracked files silently (#295): a never-committed
183
+ source file is invisible to `git diff`, so it used to contribute no changed
184
+ lines and every subject in it was skipped without a word - even the
185
+ changed-files-without-subjects warning could not name it. Untracked files
186
+ now union into the changed-lines map as fully changed, so their subjects
187
+ are selected, and one without subjects triggers the warning like any other
188
+ changed file. Ignored (`.gitignore`), binary and empty files stay out, and
189
+ only files inside the configured `include` set are considered, so a large
190
+ unrelated untracked file is never opened. Each unioned file is opened
191
+ `O_NOFOLLOW`/`O_NONBLOCK` and validated on that descriptor (a swapped-in
192
+ symlink or FIFO is rejected and reported as unreadable), the binary sniff reads only the first
193
+ few kilobytes, and the map holds a range of lines instead of one entry per
194
+ line, keeping memory bounded. On platforms without those open flags the
195
+ union is skipped with a warning.
196
+ - `--since` no longer drops an include-eligible untracked file quietly when
197
+ it cannot be read (vanished, unreadable, or replaced by a link or special
198
+ file between the listing and the open): the run names it in a warning and
199
+ records it as a discovery failure, so an agent run over mixed tested and
200
+ omitted source reports `partial`/`incomplete` with the `unreadable` reason
201
+ instead of a clean result. Files that do not parse (`invalid_file_syntax`)
202
+ surface the same way instead of only warning (#297).
203
+ - Include membership for untracked files is lexical first: an included path
204
+ swapped to a symlink is attempted, rejected by the safe open and reported,
205
+ instead of being filtered out silently on its resolved target. A symlinked
206
+ repository root stays in scope through the canonical fallback (#297).
207
+ - Test inference under `--since` no longer enumerates a whole-file change
208
+ range per method when deciding whether a changed file needs a spec; it
209
+ answers ranges by interval, like the run's own subject selection (#297).
210
+ - The excluded-changes warning now also names untracked files in excluded
211
+ territory: they never appear in the diff, so an exclude that removes one
212
+ used to hide the removal entirely (#297).
213
+ - Distance and overlap checks over the whole-file ranges the untracked
214
+ union produces are now geometric, so subject selection, test guessing and
215
+ survivor ranking answer in O(1) per subject instead of scanning one entry
216
+ per line of a large new file (#297).
217
+ - A baseline check or coverage map build that hit only its
218
+ budget-shortened timeout failed the run as a suite error (exit 3) with
219
+ no partial report; it now reports budget exhaustion with every selected
220
+ subject unreached and exits 4 in ordinary CLI mode unless
221
+ `--max-duration-ok` is set; agent exits remain verdict-driven (#296).
222
+ - The unscoped-suite warning pluralizes zero baseline checks correctly
223
+ ("0 baseline checks", not "0 baseline check") (#296).
224
+ - Review hardening for the 0.5.0 loop flags (#245): the `--max-duration`
225
+ clock starts when the run starts (boot, baselines and the coverage map
226
+ all spend the budget) and is checked before dispatching every subject,
227
+ between mutants inside a subject, and between baseline passes, in
228
+ serial and `--jobs` runs alike; a parallel budget halt stops handing
229
+ out new work and lets in-flight subjects finish and report, so their
230
+ completed outcomes are kept instead of abandoned as nil holes
231
+ mislabeled "never ran". `--max-duration-ok`
232
+ no longer exits 0 when the budget stopped the run before any mutant
233
+ was tested - there is no score to judge, so the run is incomplete
234
+ (exit 3), in agent mode too. Agent output records `failFast` only for
235
+ a real `--fail-fast` stop, `--baseline-passes 1` no longer certifies
236
+ the suite stable in the baseline evidence, and the rerun/replay
237
+ commands keep `--max-duration`, `--max-duration-ok` and
238
+ `--baseline-passes`. `saboteur audit` bundles the `--operators all
239
+ --baseline-passes 2` its help advertises, an explicit `--test`
240
+ replaces its injected `--all`, and `--no-max-duration-ok` overrides
241
+ the pr preset's budget pass.
242
+ - macOS CI flakes: the supervisor `on_tick` cleanup spec waits for a fully
243
+ written pid before triggering instead of racing the file's first bytes
244
+ (#277), fixture repositories disable Git background auto-maintenance so
245
+ teardown no longer races its lock deletion (#271), and a baseline child
246
+ that exits without a result now has its exit status or signal in the
247
+ error and its dying exception on stderr, so the next flake names its
248
+ cause (#255). The same error carries a bounded tail of the child's
249
+ redirected log, the dying-child warning names its stage ("baseline
250
+ child died during setup/run", "coverage child died during the map
251
+ build"), and a broken child stderr can no longer swallow the exit
252
+ (#289).
253
+ - `Saboteur::Engine#call` restores the host process's Coverage state when
254
+ it returns or raises: an embedded engine (a spec, the dogfood's own
255
+ coverage-map build) no longer leaves Coverage suspended for everything
256
+ the host runs afterwards, and a resume that fails because the host
257
+ disturbed Coverage mid-run warns instead of masking the run outcome,
258
+ with worker cleanup running either way (#254). A scoped run (`--since`) no longer
259
+ calls an ignore entry stale when its mutation exists but sits on an
260
+ unchanged line and was scoped out; staleness is judged against each
261
+ examined subject's full, pre-scope candidate digests (#284). A worker
262
+ start-failure storm while the pool is already draining no longer raises
263
+ "workers keep failing to start" over the tool errors that carry the
264
+ real abort cause (#286).
265
+ - The canary no longer blames a method's last mutant for deleted files:
266
+ a cheap per-mutant probe (snapshot stats only, about 1.3 ms for this
267
+ repo's 692 watched files against a full inventory's 5 ms) runs after
268
+ every mutant, so the abort names the mutant whose run just ended when
269
+ the loss was detected as the likely cause - with the per-method check
270
+ as the backstop for losses outside a mutant's window (#235).
271
+ - Parallel runs aborted by a wiped log dir now name the culprit: a
272
+ worker that only noticed the damage could report "an unknown mutant"
273
+ before the worker whose mutant did it, and the first report won the
274
+ abort. Tool-error reports now carry whether they name a mutant, the
275
+ pool stops handing out new work but lets in-flight workers finish
276
+ and report, and the abort ranks the reports by attribution quality:
277
+ a report that names its mutant first, then a canary trip, then the
278
+ earliest bare report. A canary trip from the end-of-subject check
279
+ folds into the same choice instead of overriding collected reports,
280
+ and the in-flight drain is bounded (a 30-second default drain
281
+ window, followed by worker cleanup), so a wedged worker cannot
282
+ stall the abort (#235).
283
+ - `saboteur doctor` prints the coverage line once instead of twice (it is
284
+ still verified before and after target boot), and reports the sandbox
285
+ status as a kernel capability probe, not verified isolation: the
286
+ Landlock ABI when the kernel can deliver it, the gap when it cannot -
287
+ told apart as a `c.sandbox = "strict"` refusal versus the permitted
288
+ degradation of a non-strict run - or off when `c.sandbox = false`. A
289
+ probe the kernel refuses (EPERM under a seccomp policy) is an
290
+ actionable tool error with exit 3, not an internal-error crash report,
291
+ and doctor writes nothing (#236). The sandbox policy
292
+ no longer creates the project's `tmp/` directory to grant it: the
293
+ grant applies when `tmp/` already exists and only `c.sandbox_writable`
294
+ entries are created (#236).
295
+ - The mutant sandbox no longer grants the cache dir: a mutant child
296
+ cannot delete the baseline, the reports or other children's logs,
297
+ and its own log is opened before the fork and inherited as a
298
+ descriptor, with stdio kept explicitly through the sandbox sweep.
299
+ A child log that cannot be opened, or that vanishes mid-run with
300
+ the sandbox off or degraded, aborts the run with exit 3 - the
301
+ canary's hazard report names the culprit when tracked files
302
+ vanished too, otherwise the tool error does - never a mutant
303
+ verdict such as "killing test also fails with the method
304
+ unchanged"; in parallel runs the error aborts the whole run
305
+ instead of degrading into per-mutant errors. The grants are
306
+ additive, so a cache dir placed inside
307
+ `tmp/`, the system temp dir or a `c.sandbox_writable` entry stays
308
+ writable. Note the sandbox prevents the damage, not the verdict:
309
+ with `--allow-hazardous`, a destructive mutant whose test swallows
310
+ the denial (EACCES) can still read as a survivor; by default the
311
+ hazard guard suppresses such mutants and reports the hazard (#234).
312
+ - An extensionless `hooks` path now boots: it resolves its `.rb` at load
313
+ time, the way `require` does and matching what doctor accepts (#267).
314
+ - Fixed two encoding failures in mutant children running under a non-UTF-8
315
+ locale such as `LANG=C` (seen on CI, where the Landlock sandbox engages):
316
+ the inherited-fd sweep compared a locale-tagged descriptor target against
317
+ grant patterns with string semantics, raising
318
+ `Encoding::CompatibilityError` before any test ran when an open file's
319
+ path carried non-ASCII bytes; and an exception message embedding such a
320
+ path made failure reporting raise the same way, turning a killed mutant
321
+ into an errored run. The sweep now compares raw bytes, and failure
322
+ messages are scrubbed before their first line is extracted; if the
323
+ scrub itself raises - it is mutable code, and the mutant under test
324
+ may have broken it - an escaped fallback keeps the failure a clean
325
+ kill instead of an errored run (#272).
326
+ - Fixed `--guess-tests` falling back to the whole suite when a changed
327
+ file has no mutation subjects on its changed lines, such as a deletion
328
+ whose remaining context is blank or comment lines or a comment-only
329
+ change: such files no longer require a guessed spec. A changed file
330
+ that does need a spec but has none now stops before boot with exit 2,
331
+ matching the default-inference policy above (#275).
332
+ Guesses come only from files with a changed mutation subject, and when
333
+ no changed file has one the run reports that there is nothing to mutate
334
+ (fixes #268).
335
+ - Fixed failures under non-UTF-8 locales such as `LANG=C` when project
336
+ paths contain non-ASCII characters. Git output, coverage maps, result
337
+ caches, baseline logs and worker messages are now decoded as UTF-8
338
+ regardless of the process locale. This prevents
339
+ `Encoding::CompatibilityError` in the canary, baseline failure excerpts
340
+ and worker results, and fixes `--since` silently skipping changed files
341
+ with non-ASCII names. Paths are identity, not display text: the canary
342
+ watch, `--since` diff parsing, source discovery and the run fingerprint
343
+ retag locale-tagged bytes losslessly, so a tracked file whose name is
344
+ not valid UTF-8 stays watched (its deletion still trips the canary) and
345
+ keeps its exact bytes through selection. The canary and diff parser
346
+ preserve path bytes rather than replacing invalid UTF-8 bytes; the
347
+ cache, coverage-map and worker-message boundaries reject invalid UTF-8
348
+ instead of rewriting it.
349
+ - An unexpected internal error now prints `saboteur: internal error (...)
350
+ - please report it` and exits 3, instead of escaping as a bare stack
351
+ trace with an accidental exit 1. The crash message is normalized to one
352
+ bounded line first, and agent mode reports the same failure as agent
353
+ JSON. The full backtrace is written to
354
+ `<cache_dir>/internal-error.log` (default `.saboteur/`), which the
355
+ message names, and printed to stderr with `--verbose`; an unset
356
+ `cache_dir` or a failed write leaves the one-liner standing alone.
357
+ The executable loads the crash reporter before the CLI,
358
+ so a failure while it is loading, being built, or running gets
359
+ the full report. Coverage setup and the reporter's own load happen before
360
+ it, so those failures get only the bare normalized one-liner plus
361
+ exit 3. When agent mode was requested but the agent reporter never
362
+ loaded, a minimal standalone failure JSON in the agent reporter's
363
+ schema (status `error`, verdict `tool_failure`, exit code 3)
364
+ answers instead (#233).
365
+ - The install spec reads the README as UTF-8, so the suite also passes
366
+ under `LANG=C`.
367
+ - Tool and test reads of files that can carry non-ASCII text are decoded
368
+ as UTF-8 regardless of the process locale: the baseline manifest and
369
+ preview receipt, `.rspec` option files, the custom-map duplicate scan,
370
+ the installer's Gemfile and `.gitignore` probes (`.gitignore` is
371
+ rewritten losslessly), and the JSON reads in the test helpers.
372
+ - CI now runs the suite on Ubuntu Ruby 3.4 with `LANG=C` and `LC_ALL=C`
373
+ to catch locale regressions exercised by the tests. The panel gate's
374
+ required-check policy includes this job (#281).
375
+
376
+ ## [0.6.0] - 2026-10-14
377
+
378
+ ### Fixed
379
+ - `saboteur doctor`'s boot-file check accepts `require` and `hooks`
380
+ paths with or without the `.rb` extension, so the README's
381
+ `c.require = ["./config/environment"]` quickstart no longer fails
382
+ the check (#257).
383
+
384
+ ### Added
385
+ - `allow_db_hosts` config (#258): hosts you assert are disposable test
386
+ databases. The Rails integration's local-database check accepts them,
387
+ and its refusal names the setting. `saboteur doctor` surfaces the same
388
+ refusal, naming the remote hosts, and after a successful boot prints
389
+ each test database's host. `0.0.0.0` now counts as a local host: compose
390
+ setups write it into database URLs, and connecting to it reaches this
391
+ machine.
392
+ - `--guess-tests` now maps files in packwerk packs (any directory with a
393
+ `package.yml`) to the spec inside the same pack, and maps
394
+ `app/controllers` files to `spec/requests` specs (`users_spec.rb` or
395
+ `users_controller_spec.rb`). It still names the changed files it could
396
+ not match (#259).
397
+ - `script/preflight`: a local pre-review check on one exact base and head
398
+ (#210). It runs the protected-path scope check, sign-off, SPDX headers,
399
+ whitespace, RuboCop and the specs (fast or full mode), writes every harness
400
+ output through a symlink-safe writer under `tmp/preflight/`, and takes
401
+ check reports through harness-owned pipes rather than checkout paths.
402
+ It is not CI and a pass is not an approval.
403
+
404
+ ### Changed
405
+ - All built-in operators now run by default, including `range`, `operator_assignment` and `condition_negation`, which were opt-in in 0.5.0. Use `--exclude-operators` (or `c.exclude_operators`) to omit operators, or `--operators` (or `c.operators`) to select a smaller set. Exclusions win; each CLI list replaces its matching config list. This changes the default mutation set: counts, runtime, survivors and scores may change. Review CI thresholds and survivor baselines before adopting the new results. To retain the 0.5.0 default operator set during migration: `bundle exec saboteur run --exclude-operators range,operator_assignment,condition_negation`. To clear an exclusion set in config from the CLI: `bundle exec saboteur run --exclude-operators ''`.
406
+ - Breaking selection policy change: old `core`/`extended` tier names are refused with migration guidance. Policy version 2 binds effective operators and exclusions to cache/verdict identity, reports, score-history v2 and baseline evidence. Old baselines are incompatible and need a fresh reviewed proposal; unchanged mutation IDs stay stable.
407
+
9
408
  ## [0.5.0] - 2026-10-14
10
409
 
11
410
  ### Added