mutineer 1.7.0 → 1.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +432 -0
  3. data/README.md +157 -747
  4. data/lib/mutineer/baseline.rb +1 -1
  5. data/lib/mutineer/changed_lines.rb +2 -1
  6. data/lib/mutineer/cli.rb +119 -27
  7. data/lib/mutineer/concern_blocks.rb +168 -5
  8. data/lib/mutineer/config.rb +6 -0
  9. data/lib/mutineer/coverage_map.rb +92 -17
  10. data/lib/mutineer/daemon_backend.rb +75 -32
  11. data/lib/mutineer/daemon_client.rb +50 -17
  12. data/lib/mutineer/daemon_server.rb +92 -40
  13. data/lib/mutineer/external_backend.rb +150 -17
  14. data/lib/mutineer/isolation.rb +67 -25
  15. data/lib/mutineer/job_plan.rb +117 -33
  16. data/lib/mutineer/kill_channel.rb +24 -14
  17. data/lib/mutineer/migrate.rb +10 -8
  18. data/lib/mutineer/minitest_integration/in_test_database_error.rb +184 -0
  19. data/lib/mutineer/minitest_integration/kill_recorder.rb +25 -17
  20. data/lib/mutineer/minitest_integration.rb +44 -14
  21. data/lib/mutineer/mutators/array_literal.rb +4 -2
  22. data/lib/mutineer/mutators/base.rb +22 -0
  23. data/lib/mutineer/mutators/call_to_receiver.rb +20 -7
  24. data/lib/mutineer/mutators/chain_link.rb +6 -6
  25. data/lib/mutineer/mutators/condition_forcing.rb +6 -3
  26. data/lib/mutineer/mutators/operand_removal.rb +4 -2
  27. data/lib/mutineer/mutators/range_literal.rb +105 -4
  28. data/lib/mutineer/mutators/return_nil.rb +1 -0
  29. data/lib/mutineer/orphan_guard.rb +18 -6
  30. data/lib/mutineer/pairing.rb +62 -1
  31. data/lib/mutineer/project.rb +226 -12
  32. data/lib/mutineer/rails_worker_db.rb +200 -16
  33. data/lib/mutineer/redaction.rb +112 -11
  34. data/lib/mutineer/reporter.rb +109 -21
  35. data/lib/mutineer/result.rb +33 -5
  36. data/lib/mutineer/runner.rb +126 -18
  37. data/lib/mutineer/statement_lines.rb +14 -8
  38. data/lib/mutineer/subject.rb +13 -5
  39. data/lib/mutineer/test_runners/minitest.rb +2 -1
  40. data/lib/mutineer/test_runners.rb +4 -2
  41. data/lib/mutineer/triage.rb +1 -1
  42. data/lib/mutineer/version.rb +1 -1
  43. data/lib/mutineer/worker_pool.rb +2 -1
  44. data/skills/mutineer/SKILL.md +84 -0
  45. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2afb9e81b16cbdf3b7ecd0d074365ee05eb58bdb0045ff6a73643a68893a4b1a
4
- data.tar.gz: 949369679de39fbc53025fc470896bbef0c5310fc59c11cb928ad80aa748cc22
3
+ metadata.gz: 86c3963ad7e14aed11afda2b474e0465f5eeec91d4bac3aea68c8aaa8d405391
4
+ data.tar.gz: d8d632c8cde79fccda2e29583ac213cff95460f9f430afed56e31675170c27ba
5
5
  SHA512:
6
- metadata.gz: d78d0145b257b25321b7a9f05ebebc51e1818459eacecd26917953ef2dd0296af34fcb755a3a782102ef5453a9f27d90cb3fb2d8261fd8760ab557f273b2d866
7
- data.tar.gz: 3d844218d9e05bc1c09bc7c1bfa7617c7fa79b49c943bb1a96af20ab68fa85312baf08ed9fa37d4f9929bc07c027c943717e2ce4344e15639509feeae66ff560
6
+ metadata.gz: ffd90e04f7af0a47b630e7083873d2dfa2442b386900a03ee4ae12e0ef08709f126b1cedfa3e9397e68448b49c51795012b7430264717aed6975f1c69ee74bca
7
+ data.tar.gz: d5dadd48db5f383cae7220cc6eda44f6fe1c53b5e6348eda92f97be16a176af239c2ce5556f605b980141e54d5805b62a33a96f2254fdfdbe590a5c7f1c78d51
data/CHANGELOG.md CHANGED
@@ -6,6 +6,437 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.8.0] - 2026-10-29
10
+
11
+ ### Highlights
12
+
13
+ Read these before you upgrade. Details are in the sections below.
14
+
15
+ - **Scores can change after the upgrade.** Under `--strategy reload`, most methods
16
+ in a concern's `included` or `prepended` block are now scored (#297). Runs
17
+ that list `call_to_receiver` get more mutants (#274). Runs that list
18
+ `range` get new mutants for endless ranges (#299). A mutant that two
19
+ operators make now counts once (#275). These can change counts, scores
20
+ and survivors. If you gate on `--baseline`, write a new full baseline.
21
+ - **Delete `ignore:` entries for `range` mutants (#299).** A new `range`
22
+ mutant can take an old id, so an old entry can hide a real survivor.
23
+ - **Fewer false kills.** Under `--test-command`, a non-zero exit other than 1
24
+ scores `error`, and so does an exit 1 with no failing test in the summary
25
+ line (#292). An Active Record connection or lock error inside a Minitest
26
+ test scores `error` too (#296). Before, these scored `killed`.
27
+ - **Exit codes match STABILITY.md (#288).** A boot, source or `require:`
28
+ file that raises while it loads exits 1. A missing boot or `require:`
29
+ file exits 2.
30
+ - **`--threshold` compares the exact score (#280).** A run with 1999 of
31
+ 2000 mutants killed shows 100.0% but no longer passes `--threshold 100`.
32
+ - **New report data.** The JSON report adds `summary.declined` and
33
+ `summary.declined_by_operator` (schema `1.9`), and stderr warns when many
34
+ mutants could not run (#291).
35
+ - **Parallel `--daemon` with unused test databases (#316).** List them under
36
+ `shared_databases` to run more than one worker. Needs Rails 7.1 or later.
37
+ - **The gem ships the agent skill (#315).**
38
+ - **Releases wait for a maintainer.** Nothing publishes on a schedule or on
39
+ a tag push.
40
+
41
+ ### Added
42
+
43
+ - **`shared_databases` config key** (#316). An app with a second test
44
+ database ran one worker under `--daemon`. List the databases your tests
45
+ never use (`shared_databases: [cable]`), and the workers share them and run
46
+ in parallel. After each mutant, a parallel worker checks those databases.
47
+ The mutant scores `error` when a pool for one is still connected after the
48
+ tests, or ran SQL during them. A replica in the same `connects_to` counts
49
+ as its database. Listing the primary database stops the run with exit 2.
50
+ A name that matches no test database warns and is not checked. The check
51
+ needs Rails 7.1 or later. Three safety changes come with it for
52
+ `--rails --daemon`: a test database with `database_tasks: false` now
53
+ counts toward the one-worker limit, and the run keeps one worker when the
54
+ app's databases cannot be listed or the first daemon does not boot
55
+ (before, each case could start several workers). See
56
+ [More than one database](https://github.com/davidteren/mutineer/blob/main/docs/rails-setup.md#more-than-one-database)
57
+ for the transactional-tests caveat.
58
+ - **Counts of mutants that were not scored (#291).** Operators now count the
59
+ candidates they decline: a heredoc they cannot mutate safely, or an
60
+ equivalent skip such as an endless range or a skipped chain-link name. The
61
+ dry run prints the count. The human report prints it when the run has
62
+ mutants; when every candidate was declined, the empty-run warning names the
63
+ count instead (that warning is off under `--allow-empty`). The JSON report
64
+ adds `summary.declined` and `summary.declined_by_operator` (schema `1.9`).
65
+ A new stderr warning fires when unplaceable and skipped-invalid mutants are
66
+ more than one and more than 10% of the mutants a run tried to score. The
67
+ warning does not change the exit code. Measured on commit caa9e1a (this
68
+ change merged with main) with every operator: the dry run on `lib/`
69
+ declines 195 candidates beside 17,374 mutations (call_to_receiver 122,
70
+ chain_link 45, range 12, statement_removal 10, return_nil 6); the dry run
71
+ on the Rails fixture app declines 0 beside 69 mutations. A
72
+ `--strategy redefine --rails` run on the fixture app models (order,
73
+ price_list, billing/fee) has 0 unplaceable and 0 skipped-invalid of 12
74
+ attempted, so no warning. The fixture has no concerns, so it cannot tune
75
+ the 10% limit.
76
+ - **Included and prepended block methods under reload.** After you upgrade,
77
+ follow the [full baseline refresh recipe](https://davidteren.github.io/mutineer/agentic-coding.html#ci-gate)
78
+ with `--no-since --format json --output .mutineer/refreshed-baseline.json` and your
79
+ existing source and test options. Review the report and replace the stored
80
+ baseline. This applies only to runs that use `--strategy reload`: under
81
+ redefine these methods stay `unplaceable`. GitHub Action users on reload,
82
+ the Action's default (a Rails app without a daemon uses redefine), must
83
+ replace the file passed to the `baseline` input too. A check against an older
84
+ report lists the new survivors as regressions.
85
+ Under `--strategy reload`, in-process and on the daemon, a method in a
86
+ concern's `included` or `prepended` block is now mutated and scored (#297).
87
+ Mutineer loads the mutated method into each class that already has it. A
88
+ prepended wrapper that calls `super` reaches that mutated method too. A
89
+ concern included into an object's singleton class is supported. A class
90
+ that includes the concern later gets it from the reloaded block. The
91
+ rest of the block does not run again, so validators and callbacks are not
92
+ added twice. These mutants used to be `unplaceable`, so a reload score can go
93
+ down and a `--threshold` gate can start to fail. Some methods stay
94
+ `unplaceable`: a method in a class, module or `Struct.new` block inside the
95
+ block, a method inside another method or block, a method with an explicit
96
+ receiver other than `self` (such as `def base.tag` in `included do |base|`),
97
+ a hook-block method whose source line conflicts with another `def` or an
98
+ explicit Ruby method creator (the full rule is in `docs/json-schema.md`),
99
+ every method in a block that uses `alias` or `alias_method`, a method the
100
+ block itself calls, and every method in a block whose `included do` call is
101
+ not a plain line of the module
102
+ body (for example inside `if`, `unless`, or `x = included do`). The full list
103
+ is in `docs/json-schema.md`. If a class aliases a method that Mutineer does
104
+ run (one not in that list) outside the block, the mutant scores `error`,
105
+ because the alias keeps the original method. This also applies when a
106
+ wrapper replaces the original name and calls the saved alias. A retained
107
+ `Method` or `UnboundMethod` for the original also makes the mutant score
108
+ `error`: a wrapper can call that handle without testing the mutated method.
109
+ `--strategy redefine` still
110
+ reports all of these methods `unplaceable`.
111
+ - **The gem ships the agent skill.** `skills/mutineer/SKILL.md` is now
112
+ packaged in the gem, and the gemspec opts in as a
113
+ [Rails Hyperdrive](https://github.com/rails-hyperdrive/rails-hyperdrive)
114
+ companion (`hyperdrive_targets: "*"`, `hyperdrive_artifacts: "skill"`). In a
115
+ Rails app that bundles mutineer, `bin/rails hyperdrive:init` installs the
116
+ skill to `.claude/skills/mutineer/SKILL.md`. A test in the default suite
117
+ fails if the gemspec stops shipping the skill or drops the opt-in (#315).
118
+
119
+ ### Fixed
120
+
121
+ - **`--daemon` stops on MySQL older than 8.0 (#298).** The support matrix said
122
+ MySQL 5.7 is not supported, but `--daemon` ran on it. Mutineer now reads the
123
+ server version before it copies any worker database. MySQL older than 8.0
124
+ stops the run with `could not provision <base>: MySQL <version> is not
125
+ supported`. Pass `--no-daemon` (or set `daemon: false`) to stay serial.
126
+ MariaDB, MySQL 9 or later, and a server version Mutineer cannot read print
127
+ one warning and run.
128
+ - **An in-test database error scores `error`, not `killed` (#296).** When
129
+ the first failing test of a Minitest mutant run failed with an Active
130
+ Record connection or lock error (`ConnectionNotEstablished`, including pool
131
+ timeouts, `ConnectionFailed`, `Deadlocked`, `LockWaitTimeout`), the mutant
132
+ now scores `error` on `--daemon` and in-process. This includes an error in
133
+ `setup`. Errors are out of the score, so a lost database connection during
134
+ a run no longer raises the score. A stderr line names the error. A mutant
135
+ that really causes a deadlock, or that breaks connection-handling code, now
136
+ also scores `error`. More `error` verdicts can fail a `--threshold` run
137
+ through the existing no-verdict gate. Other database errors, such as a bad
138
+ SQL statement, still score `killed`. So does `ConnectionNotDefined`, which
139
+ Rails 8 raises for an unknown role or shard. Rails 7.2 raises a plain
140
+ `ConnectionNotEstablished` there, so such a mutant scores `error` on 7.2.
141
+ With `--matrix`, a database-failed test is never a killer and leaves its
142
+ row incomplete, even if an earlier assertion already killed the mutant.
143
+ - **An old temp file of the other mode no longer stops the boot (#287).**
144
+ Before the app boots, each mode sweeps the source directories. In 1.7.0
145
+ an in-process run removed only old `mutineer_mutant*.rb` files and the
146
+ daemon removed only old `mutineer_daemon*.rb` files, so a file the other
147
+ mode left made Zeitwerk raise. Now both boot sweeps remove an orphan of
148
+ either mode, and the daemon tells Zeitwerk to skip both names. You no
149
+ longer delete such a file by hand. A file of your own whose name only
150
+ starts with `mutineer_mutant` or `mutineer_daemon`, such as
151
+ `mutineer_mutant_helper.rb`, is no longer removed: a sweep now matches
152
+ only the temp file shape. An unlocked old file is removed even when its
153
+ creator's pid has been reused; a held file lock still protects it.
154
+ Do not run Mutineer older than 1.7.0
155
+ at the same time in the same checkout: it still writes temp files
156
+ without the dot, and a newer run's mutants error when Zeitwerk finds
157
+ one.
158
+ - **A second in-process run no longer deletes a live mutant file (#295).**
159
+ Each in-process run sweeps the source directories before its boot and
160
+ after its run. That sweep treated every `.mutineer_mutant*.rb` file as an
161
+ orphan, so it could delete another run's file between the write and the
162
+ load, and that mutant scored `errored`. Now the file is locked while it
163
+ loads, as the daemon's file is, and a sweep keeps a file whose creating
164
+ process still runs.
165
+ - **The `range` operator swaps endless ranges outside slices.** Before, it
166
+ skipped every endless range (`1..`, `1..nil`) as equivalent. But `(1..)`
167
+ and `(1...)` differ on `==`, `hash`, `exclude_end?` and `to_s`, so a test
168
+ can kill that mutant. Now the operator skips an endless range only when it
169
+ is a direct index argument of `[]`, `[]=`, `slice`, `slice!`, `byteslice`,
170
+ `bytesplice`, `values_at` or an index write, and the receiver is not a
171
+ class (`Array[1..]` is swapped). A Hash, Proc, or custom `[]`/`slice` can
172
+ still tell `1..` from `1...`, so those skips can hide a killable mutant. See
173
+ [Equivalent skips](https://davidteren.github.io/mutineer/equivalent-skips.html#endless-range).
174
+ A run that names `range` can get new mutants, new survivors and new mutant
175
+ ids. A new mutant can take the id of an older `range` mutant in the same
176
+ method, so an old `ignore:` id can name a different mutant. If you use
177
+ `range`:
178
+ - Delete the `ignore:` entries for `range` mutants.
179
+ - Save a new report with the same operators you gate with:
180
+ `mutineer run <paths> --operators <list> --no-since --format json --output <file>`.
181
+ Use it as your new baseline.
182
+ - Print new entries with
183
+ `mutineer triage <file> --reason TEXT (--id ID ... | --all)` and paste
184
+ them under `ignore:`. The command does not edit files.
185
+
186
+ Some new mutants are equivalent, for example a range used only with
187
+ `include?`, or a range in an Active Record query such as
188
+ `where(created_at: time..)`. Suppress them with `# mutineer:disable-line`
189
+ or an `ignore:` entry. (#299)
190
+ - **A test file given as a source after `--test` now exits 2 in more layouts
191
+ (#293).** The check missed Minitest `test_*.rb` files under `test/` or
192
+ `spec/`, and `*_test.rb` or `*_spec.rb` files in the same directory as a
193
+ `--test` file, such as the project root. Mutineer then mutated that file as
194
+ a source and never ran it as a test. With `--format json`, the stray file's
195
+ Minitest output also made the report invalid JSON. A support file with a
196
+ `test_` name under the top-level `test/` or `spec/` (other than
197
+ `test_helper.rb`) now also exits 2 when given as a source with `--test`.
198
+ - **A mutant on a later line of a call inside a one-line block can be killed
199
+ (#289).** A call can span several lines, such as
200
+ `users.map { |u| format("%s",` with arguments on the next lines. Mutineer
201
+ used to pick no tests for a mutant on a later line, so it was always
202
+ `no_coverage`. Now, when the lines of the statement give no tests, Mutineer
203
+ retries. It runs the tests that ran the first or last line of the
204
+ statement, never the `def` line. A kill counts. A survivor stays
205
+ `no_coverage` (or `ran_at_load`), because those tests may not have run it. A
206
+ score can rise with no test change. Such a mutant can now also end as
207
+ `error` or `timeout`. That counts toward the `--threshold` completeness
208
+ check.
209
+ - Mutineer reads its files and pipes as UTF-8 when `LANG` and `LC_ALL` are
210
+ unset (#303). Before, a non-ASCII character in a source file, a baseline, a
211
+ coverage cache, a triage report, `.mutineer.yml` (for `migrate`),
212
+ `db/schema.rb`, a `--since` diff, test-command output, or a daemon message
213
+ stopped the run with "invalid byte sequence in US-ASCII" (exit 2) or a
214
+ backtrace (exit 1). An invalid byte in test-command output now shows as
215
+ `\xNN`. Non-ASCII file and folder names are not covered yet (#346).
216
+ - **Upgrade note for `--test-command` with a custom failure exit code (#292).**
217
+ An RSpec setup with `--failure-exit-code 3` now scores every kill as
218
+ `error`. Use the default exit code, or a wrapper that maps a failure to
219
+ exit 1. An `error` verdict leaves the score, so the score can drop, and more
220
+ `error` verdicts can fail a `--threshold` run through the existing no-verdict
221
+ check. A mutant that only trips a SimpleCov `minimum_coverage` gate now
222
+ scores `survived` when the output has SimpleCov's gate message and a
223
+ passing Minitest or RSpec summary line. Without a summary line that
224
+ Mutineer recognizes, the gate's exit scores `error`, which leaves the
225
+ score. Either way the old false kill is gone, so the score can drop. A `--baseline` made before
226
+ this change can report new survivors and errors: make a new baseline after
227
+ the upgrade.
228
+ - **A `--test-command` exit other than 1 no longer counts as a kill (#292).**
229
+ Only exit 1 scores `killed`, the same rule as the in-process backend. One
230
+ exception: a SimpleCov gate exit whose summary reports a failing test also
231
+ scores `killed`. Any other non-zero exit scores `error`, such as a wrapper's
232
+ 137 after an out-of-memory kill.
233
+ - **A load crash under `--test-command` scores `error` (#292).** When the clean
234
+ run prints a Minitest or RSpec summary line, an exit 1 also needs a failing
235
+ test in that line. Code under test that calls `exit 1` or `abort` while the
236
+ default Minitest reporter runs its tests still scores `killed`, as in
237
+ process. Under RSpec or minitest-reporters, that case now scores `error`.
238
+ When a SimpleCov `minimum_coverage` gate replaces the exit code, the summary
239
+ line decides: a failing test is a kill, and a passing run is `survived`, as
240
+ in process. When the clean run
241
+ prints no summary line, Mutineer warns once and keeps the exit-code rule.
242
+
243
+ - **`--output .mutineer/run.json` on a fresh checkout.** The documented
244
+ report path no longer exits 2 when `.mutineer/` does not exist yet. The
245
+ output check now creates the cache directory (`cache_dir`) when it is the
246
+ missing parent. Any other missing directory still exits 2. This is a CLI
247
+ fix: the GitHub Action's `output` input still needs an existing directory
248
+ (#270, #340).
249
+
250
+ - **`--output` can no longer replace its own baseline.** When `--output`
251
+ and `--baseline` or `baseline:` in `.mutineer.yml` name the same file
252
+ (also through a symlink or hard link), a real `mutineer run` exits 2
253
+ before the run, with the GitHub Action's error adapted for the CLI. A dry run
254
+ still works because it writes no report. Before, a
255
+ failing run wrote its report over the baseline, and the next run passed
256
+ against it (#281).
257
+
258
+ - **`--threshold` compares the exact score, not the rounded one (#280).** The
259
+ score is rounded to one decimal for display, and the gate used to compare that
260
+ rounded value. A run with 1999 of 2000 mutants killed showed 100.0% and passed
261
+ `--threshold 100` with a survivor. The exit code, the Action, and the PASSED or
262
+ FAILED line now use the exact counts. When the rounded score points the other
263
+ way, the PASSED or FAILED line gives the counts and says that the score is
264
+ rounded.
265
+
266
+ - **The Minitest clean check now runs the tests in the order the mutant runs
267
+ use (#282).** Since 1.1.0, each mutant run used seed `1`, but coverage
268
+ capture, the clean checks and the `--daemon` mutant runs used a random
269
+ seed. An order-dependent suite could pass the clean check and then fail in
270
+ every mutant run, so each mutant scored a false `killed`. Every Minitest
271
+ run that Mutineer starts itself (not `--test-command`, see #338) now uses seed `1`,
272
+ or `SEED` when the environment sets it. Such a suite now stops the run
273
+ with "the unmutated suite is not green". The message names the configured
274
+ seed or default and notes that test helpers can change it. Capture, clean
275
+ checks and mutant runs all read `SEED` after loading the tests and helpers.
276
+ Check the listed test files with the same seed and helpers to investigate
277
+ the order-dependent test. A coverage cache from another
278
+ `SEED` in Mutineer's environment or an older version is captured again. A
279
+ `--coverage-from` capture from another `SEED` or an older version is
280
+ rejected with exit 2: capture it again with this version. The internal constant
281
+ `MinitestIntegration::STOP_AT_FIRST_FAILURE_SEED` is now `PINNED_SEED`.
282
+ One gap stays: when a mutant runs several covering test files, their test
283
+ classes can still run in a different order than in the combined clean
284
+ check. RSpec does not change.
285
+
286
+ - **`--daemon` SQLite workers load the schema file the app names (#317).**
287
+ The daemon always loaded `db/schema.rb` over an out-of-date worker copy,
288
+ even when `config/database.yml` sets `schema_dump:` to another file. An
289
+ app that keeps its SQLite schema in `db/schema_sqlite.rb` beside a MySQL
290
+ `db/schema.rb` errored every mutant. The booted daemon now asks Rails for
291
+ the file: `SCHEMA` when it is set, else `schema_dump:`, else
292
+ `db/schema.rb`. With `structure.sql`, a missing file, or
293
+ `schema_dump: false` (unless `SCHEMA` is set) it loads no schema, even
294
+ when a `db/schema.rb` exists: the worker keeps its copy of the test
295
+ database. A Postgres worker is a copy of the test database, so it was not
296
+ affected. MySQL already asked Rails for the file. It now also reads the
297
+ database's own `schema_format:` (Rails 8.1) and a string format such as
298
+ `"sql"`, the same as SQLite.
299
+
300
+ - **No `DATABASE_URL` password in load errors and mutineer's own messages (#288).**
301
+ A load error in the tool process (an
302
+ in-process `--boot` or `--rails` run, a source file, or a `require:`
303
+ file) no longer prints the raw password or a backtrace. The run prints a
304
+ `mutineer: cannot load <path>: ...` message (a `SyntaxError` keeps its code
305
+ excerpt) and exits 1. By default, a load error adds a hint to re-run with `--verbose`;
306
+ verbose output gives a redacted backtrace and cause chain. CLI error messages, the
307
+ fork child's error line, the worker crash text in the report, the daemon's
308
+ stderr lines (except bare passwords known only to the app after boot),
309
+ the failing test report and any load error from the
310
+ clean-suite and coverage checks, and the `--verbose` test command output
311
+ are redacted too. Output that app code or a test writes straight to
312
+ stderr in a fork or subprocess is not filtered (#341 tracks this and the
313
+ other known gaps). A `password=` query parameter in DATABASE_URL is now
314
+ hidden too.
315
+ Daemon stderr is now copied line by line as UTF-8, so an invalid byte
316
+ shows as U+FFFD. Output without a newline waits for a newline or EOF, so
317
+ credentials split across writes can be redacted together. A password that
318
+ equals the username no longer blanks the same text elsewhere (for example
319
+ `role "postgres"`). Each password form of 3 characters or fewer is hidden
320
+ only when no ASCII letter, digit or underscore touches either edge.
321
+ The limit applies separately to raw, decoded and escaped forms: `%61%62`
322
+ is still replaced inside longer text, while its decoded `ab` is matched
323
+ only as a whole token. A longer form is hidden wherever it appears,
324
+ and the password inside a URL is always
325
+ hidden.
326
+
327
+ - **`--strategy redefine` and path-relative methods.** Redefine now loads
328
+ the mutated method from a hidden file beside the source, as reload does.
329
+ It used the system temp directory before, so `__dir__` and
330
+ `require_relative` in the method pointed there, and every mutant of such
331
+ a method was falsely killed (#279). Redefine is also the default under
332
+ `--rails` without `--daemon`. When a source directory is not writable,
333
+ redefine still uses the system temp directory there, as before, and
334
+ the run warns that path-relative methods there can be falsely killed
335
+ (tracked in #339).
336
+
337
+ - `--test-command` now sets `DISABLE_BOOTSNAP_COMPILE_CACHE=1` for the test
338
+ command. Mutineer writes each mutant over the source file, and Bootsnap keys
339
+ its compile cache on path, size and whole-second mtime. Two same-size mutants
340
+ written in the same second could load the first one's cached code, so a
341
+ mutant could be scored on code that was not the mutant. Each mutant boot now
342
+ compiles the app again, which is slower. The variable works through
343
+ `require "bootsnap/setup"` on Bootsnap 1.7.0 or later; an app that calls
344
+ `Bootsnap.setup` itself must pass
345
+ `compile_cache_iseq: ENV["DISABLE_BOOTSNAP_COMPILE_CACHE"].nil?`, so only
346
+ mutant runs lose the cache. The `--daemon` path is unchanged (#285).
347
+
348
+ - **A mutant that two operators make is scored once** (#275).
349
+ `literal_mutation` and `string_literal` both turn `"hi"` into `""`, and
350
+ `chain_link` and `call_to_receiver` both turn `node.name.name` into
351
+ `node.name`. Each copy had its own id, so one surviving edit counted twice.
352
+ Now one copy runs: the copy of the operator that comes first in
353
+ `mutineer --list-operators`, whatever the `--operators` order. Only the
354
+ same text counts: a single-quoted `'hi'` still gives two mutants (`""` and
355
+ `''`). Both edits must also start on the same line: in a chain with one
356
+ call per line, the two copies start on different lines, so both are kept,
357
+ and `--since` filters each copy by its own start line.
358
+ - Every kept mutant keeps its id. The dropped copy's id no longer appears
359
+ in reports. Runs that use only one of these operators keep the same
360
+ mutants and ids.
361
+ - If you combine these operators, the mutant count and the score can move
362
+ with no code change, so a `--threshold` or `--baseline` gate can change
363
+ its result. Regenerate a `--baseline` file after upgrading, from a full
364
+ `--format json` run with no `--since`, `--only` or `--fail-fast` (pass
365
+ `--no-since` and `--no-fail-fast` when `.mutineer.yml` sets them). A
366
+ scoped report is refused as a baseline, and a partial one misses
367
+ mutants. In a full (unscoped) comparison, a dropped copy that survived
368
+ is listed as fixed.
369
+ - An ignore entry for one copy's id still ignores only that copy, as for
370
+ the #159 copies. So if you ignore the copy a report shows, the other copy
371
+ then runs and is reported, and it needs its own entry. Keep both entries:
372
+ a run with one operator alone still uses its entry, and
373
+ `mutineer migrate` keeps both as current ids. This also fixes the #159
374
+ copies: an `ignore:` list that names both copies of a repeated edit no
375
+ longer makes `mutineer migrate` exit 1.
376
+ - An old-format ignore entry that matches only a dropped copy gets the
377
+ old-id-format warning and counts in `summary.legacy_id_matches.ignore`,
378
+ so `mutineer migrate` updates it. For the #159 copies this is new: such
379
+ an entry was not counted before, so with one operator the count can go
380
+ up, and the warning can name an id that no report shows.
381
+ - Within a method, mutants now run and list in the `--list-operators`
382
+ order. This applies to any run whose `--operators` or `operators:` list
383
+ is in another order, so `--dry-run` lists and `--fail-fast` can differ.
384
+ - **A nested method's mutant under `--strategy redefine`.** Redefine now
385
+ also loads the mutated method into each method that encloses it. Before,
386
+ a call to the outer method defined the original inner method again, so
387
+ the mutant could falsely survive (#290). For a method nested in other
388
+ methods, redefine now gives the same verdict as reload when a test calls
389
+ an enclosing method by its own name. Mutant ids do not change, so
390
+ baselines and `ignore:` entries stay valid. Scores under redefine can
391
+ rise, and an `ignore:` entry added only to hide such a survivor can now
392
+ be removed. A method defined inside a non-builder block (for example
393
+ inside `define_method`), inside a `module_function` method, inside
394
+ `class << self` in a method, or called through an alias of the outer
395
+ method can still falsely survive under redefine. An enclosing method
396
+ that a memoizer or decorator wraps after its `def` loses that wrapper
397
+ when redefine loads it, so its verdict can differ from reload. Use
398
+ `--strategy reload` for those cases.
399
+
400
+ ### Changed
401
+
402
+ - **A release waits for a maintainer.** The Monday release-PR schedule is
403
+ gone, and pushing a `vX.Y.Z` tag leaves the gem unpublished. A human
404
+ collaborator with maintain or admin prepares a release PR by hand, tags by
405
+ hand, then runs the Release workflow on that tag with `confirm=publish`.
406
+ A required reviewer approves the `rubygems` GitHub environment before the
407
+ gem is pushed. A new Release run refuses to start the publish when that
408
+ environment is missing, has no required reviewers, or lets admins bypass
409
+ them. Re-running only the publish job of an earlier run does not repeat
410
+ that check. The publish job builds the commit that was dispatched, and it
411
+ stops before `gem push` if the tag moved while the job waited for
412
+ approval. The job no longer moves the floating major tag (`v1`). When the
413
+ release is the newest stable tag in its line, the job prints the command
414
+ and a maintainer runs it, so a ruleset can keep that tag for maintainers.
415
+ For a patch on an older line, it prints no command.
416
+ - **Load failures now use the runtime-error exit code (#288).** An
417
+ `ArgumentError` or `SystemCallError` raised while a boot file, a source
418
+ file or a `.mutineer.yml` `require:` file loads now exits 1 (runtime
419
+ error), instead of 2 (usage error). A missing boot or `require:` file now
420
+ exits 2 (usage error) with a one-line message. Before, it crashed with a
421
+ raw `LoadError` and a backtrace, and exited 1. With `--daemon` it is
422
+ checked before the daemon starts, so it exits 2 there too. Both moves bring
423
+ the code in line with the exit-code table in STABILITY.md, which already
424
+ lists a bad path as 2 and a runtime error as 1.
425
+ - **`call_to_receiver` now mutates last-call conversions such as `to_s` and
426
+ `to_i`.** `m[1].to_i` now gives the mutant `m[1]`. Before, the operator skipped the
427
+ same names as `chain_link`. Now it skips only `new`, `dup`, `clone`,
428
+ `freeze`, `itself` and `to_set`. `chain_link` does not change. Only runs
429
+ that list `call_to_receiver` in `--operators` (or `operators:`) change: they
430
+ get more mutants (113 more on Mutineer's own `lib/`). If you gate such a run
431
+ with `--threshold` or `--baseline`, expect new survivors: write a new
432
+ full-scope baseline with `mutineer run ... --no-since --no-fail-fast
433
+ --format json`, with no `--only`. A `to_s` mutant on a value that is already
434
+ a String is equivalent: suppress it with
435
+ `# mutineer:disable-line call_to_receiver` or an `ignore:` entry. (#274)
436
+ - Explain mutation testing before setup, with a runnable beginner guide and a
437
+ first run on one file. Simplify wording across the website and clarify Rails
438
+ setup, CI baselines, and reasons for ignoring a mutant.
439
+
9
440
  ## [1.7.0] - 2026-10-23
10
441
 
11
442
  ### Added
@@ -1397,6 +1828,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
1397
1828
  - `.mutineer.yml` configuration (CLI > config > default precedence).
1398
1829
  - Byte-correct source handling for multibyte (UTF-8) sources.
1399
1830
 
1831
+ [1.8.0]: https://github.com/davidteren/mutineer/releases/tag/v1.8.0
1400
1832
  [1.7.0]: https://github.com/davidteren/mutineer/releases/tag/v1.7.0
1401
1833
  [1.6.0]: https://github.com/davidteren/mutineer/releases/tag/v1.6.0
1402
1834
  [1.5.0]: https://github.com/davidteren/mutineer/releases/tag/v1.5.0