mutineer 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50113a063a85737ad99476f85e78cb2ac015f267018d19ba6350ba36ff4ac6df
4
- data.tar.gz: c12343a00d2df6d495859644c326bc650e33d013ddf009f2648c06a199aceb86
3
+ metadata.gz: eeac45268c75073f522daa5d26dd65fe425ae66b604cfb7543c0b529ab71f2e6
4
+ data.tar.gz: 834755a3b106f02013ef75b9515f18e692d1c6ac219bee42b837da8a6f696fc7
5
5
  SHA512:
6
- metadata.gz: a170b15c6883a4dcec6a2798c3b102a5f9c9ded1f46c166df98d0e5bdf5b891ca5b24892efe06245680b45a94da38638adc36ca9a48a48d2ac1c07b29126f173
7
- data.tar.gz: a30272ff6afb48dd666d6945c6532d987ef335e0e78dc422420cf26cdbec639ee1c850118a10ba25e10a426a2644b3926e39698126eaad0646707a4f2be2e719
6
+ metadata.gz: '092252eef7f756e5fa117784b7c3696777127b0f1f1b96825c38d99a9f8d362a23416ad2351685e62bd780563a2b7fa2eab0e926c10bee3e293293ff88e0d28d'
7
+ data.tar.gz: 02344ad3f678a639a37f0272577e50e5f189e585a9246be0b0f1451202a03c63cb57e013181e349ab7b4db04f36fb2333e2de7d864063c4da92d4394f5dce6b3
data/CHANGELOG.md CHANGED
@@ -6,6 +6,230 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.6.0] - 2026-10-08
10
+
11
+ ### Added
12
+
13
+ - **An `unplaceable` mutant status.** Under `--strategy redefine`, a mutant in
14
+ a method whose class or module has no constant name is not run. It is left
15
+ out of the score and, unlike `uncapturable`, out of `no_verdict`, so it does
16
+ not fail `--threshold`. The human report gives it an `Unplaceable:` row, the
17
+ HTML report a count, and the JSON report `summary.unplaceable` and an
18
+ `unplaceable[]` list; `schema_version` is `1.7`.
19
+ - **The agent skill installs with `gh skill` and `npx skills` (#125).** It
20
+ moved from `docs/skill.md` to `skills/mutineer/SKILL.md`, the layout of the
21
+ Agent Skills specification, so `gh skill install davidteren/mutineer
22
+ mutineer` finds it and `npx skills add davidteren/mutineer --skill mutineer`
23
+ installs one `SKILL.md` instead of the whole `docs/` site. The site build
24
+ copies that file to `skill.md`, so its URL still works. The skill's
25
+ description now says when to use it, and the README shows how to install it.
26
+ - **A `ran_at_load` mutant status** (#187). A mutant on a method-body line
27
+ that ran while the app booted or its class loaded gets this status when it
28
+ survives, or when only the load ran the line. A kill stays a kill. It is
29
+ left out of the score and out of `no_verdict`, so it does not fail
30
+ `--threshold`. The human report gives it a `Ran at load:` row that points
31
+ to `--test-command` to verify these mutants, the HTML report a count, and
32
+ the JSON report `summary.ran_at_load` and a `ran_at_load[]` list, within
33
+ schema `1.7`.
34
+
35
+ ### Changed
36
+
37
+ - **Mutants no longer run under Coverage with `--boot` or `--rails` (#228).** Coverage
38
+ starts before the app boots so the coverage map can be built, and it used to
39
+ keep running in every mutant fork after that, though nothing read it. It is
40
+ now suspended once the map is built, from a fresh capture or from the cache.
41
+ Coverage that a host process started before the run is left running.
42
+ On a small app whose test calls tiny methods in a loop, the CPU time of a
43
+ whole in-process `--boot` run fell from 0.76s to 0.57s (median of five). On
44
+ the Rails fixture app the run is mostly boot, and the change is within noise
45
+ (1.61s before, 1.60s after). `--daemon` runs its mutants in worker daemons
46
+ that never start Coverage, so its time does not change (3.48s before, 3.44s
47
+ after); the map-building daemon now suspends Coverage after the map too.
48
+ - **A mutant runs its paired test files first, then the cheapest (#203).** The
49
+ covering test files used to run in the order the coverage map stored them,
50
+ so a mutant that one fast file kills could still score `timeout` when slow
51
+ files ran first. Now the files that pairing finds for the source run first,
52
+ then the rest from fastest to slowest by the time each file took during
53
+ coverage capture, less the fastest file's time (the startup cost each
54
+ capture pays). The order is fixed for a given cache. Costs compare in
55
+ doubling buckets, so files of close cost keep their path order when a
56
+ rebuilt cache measures them again; a file near a bucket edge can still move,
57
+ so keep `.mutineer/` between CI runs for the most stable `--baseline`. The
58
+ order applies to Minitest (after the seeded class shuffle, with
59
+ `parallelize_me!` classes still last) and to RSpec. `--daemon` runs every
60
+ covering test with no stop, so the order does not change its verdicts. The
61
+ coverage cache now saves these timings, so a cache from an earlier version
62
+ rebuilds once. A complete run gives the same verdicts and `--matrix` rows,
63
+ but the new order can change which mutants time out, so a `--baseline` gate
64
+ can see a one-time change on upgrade.
65
+ - **Job selection moved from `Mutineer::Runner` to a new `Mutineer::JobPlan`
66
+ module (#75).** The in-process runner, the `--daemon` backend and
67
+ `--dry-run` share it, so each file now requires what it uses and no
68
+ `require_relative` cycle exists in `lib/mutineer`. The moved methods are
69
+ `collect_jobs`, `filter_since`, `coverage_selection`, `ran_at_load?`,
70
+ `load_verdict`, `abort_if_unclean!`, `test_load_roots`, `source_dirs`,
71
+ `sweep_orphans`, `suppress_map`, `suppressed?` and `result_keys`. These were
72
+ internal methods, so `Runner` keeps no copies. Runs, reports and caches do
73
+ not change.
74
+
75
+ ### Fixed
76
+
77
+ - **An endless method that only the tests call is no longer `no_coverage`
78
+ under `--rails`, `--boot` or `--daemon` (#209).** Ruby counts no line when
79
+ an endless method such as `def half(x) = x / 2` runs, only its `def` line
80
+ when it is defined. The boot defines it before any test runs, so the forked
81
+ coverage capture never saw that line run. Coverage now also
82
+ counts methods (`Coverage.start(lines: true, methods: true)`), and a test
83
+ that calls such a method covers its `def` line. Coverage caches from earlier
84
+ versions rebuild once.
85
+ - **`module_function :name` in a reopened module inside `class << self`
86
+ promotes the method from the earlier opening (#216).** Ruby treats both
87
+ openings as one module, but Mutineer matched them only within one body, so
88
+ it named the method `#<Class:App>::M#c` instead of `#<Class:App>::M.c`.
89
+ The two openings now match by the module's full name. A module whose name
90
+ Mutineer cannot be sure of still matches only within its own body: a path
91
+ such as `self::X` or `Foo::X` written directly inside `class << self`, a
92
+ name under an anonymous class, or a module in a `class << self` inside a
93
+ method or a `class_eval` block. A `module self::X` in a block, or a
94
+ `self::X = ...` in a `class_eval` block, matches only openings in the same
95
+ block. A `self::X` like these now also stays apart from a
96
+ module of the same name outside `class << self`, where a `module_function`
97
+ in that module used to promote its methods.
98
+ - **A `module self::X` or `class self::X` in a block is `unplaceable` under
99
+ `--strategy redefine` (#229).** In a block, `self` is not always the
100
+ enclosing class: in `Other.class_eval do` it is `Other`, and in
101
+ `Foo = Class.new do` it is the new class. Mutineer gave these modules a
102
+ known owner on the enclosing namespace, so redefine loaded the mutant onto
103
+ a module the tests never use, and the mutant could falsely survive or
104
+ score `error`. Such a module, everything nested in it, and a
105
+ `self::X = ...` in a block that does not build a class now have
106
+ their owner unknown: redefine reports their mutants as `unplaceable`, and
107
+ `--strategy reload` still runs them. In a `Data.define`, `Struct.new`,
108
+ `Class.new` or `Module.new` block, the module is now named under the
109
+ builder's constant (`Outer::Foo::X#c`, was `Outer::X#c`), so the subject
110
+ names and mutant ids of those methods change. `self::X` directly in a class
111
+ or module body is unchanged.
112
+
113
+ - **`--daemon` worker databases start as a copy of the test database
114
+ (#222).** Each worker slot's SQLite database used to start from
115
+ `db/schema.rb` alone. Rows the daemon wrote while it booted, in
116
+ initializers or `--require` files, were only in the base test database, so
117
+ a test that read them passed in-process but failed under `--daemon`. On its
118
+ first use, a worker database is now a copy of the base test database (schema
119
+ and rows, made with the SQLite online backup API). The worker still loads
120
+ `db/schema.rb` when the copy's schema differs from it (another schema
121
+ version, or another or no `schema_sha1` checksum in `ar_internal_metadata`),
122
+ for example when the test database is empty, out of date, or set up with a
123
+ plain `load "db/schema.rb"`. That load drops the
124
+ copied rows of the tables it defines.
125
+ - **Standalone coverage capture loads the `--require` files (#217).** A run
126
+ without `--boot` requires the sources and then each `--require` (or
127
+ `require:`) file before it forks the mutants, but the coverage capture
128
+ loaded only the sources. A source method that a `--require` file called
129
+ while it loaded was then not seen as run at load, so its mutant was a false
130
+ `survived` instead of `ran_at_load`. The capture and the clean-suite check
131
+ now load the `--require` files after the sources. With or without
132
+ `--boot`, the coverage cache rebuilds when one of them changes, is added,
133
+ is removed, or moves in the load order.
134
+ - **`--daemon` loads the `--require` files (#220).** The daemon booted the app
135
+ but never loaded the `--require` (or `require:`) files, so a test that
136
+ needs one failed under `--daemon` and passed in-process. A source method
137
+ that such a file called while it loaded was also not seen as run at load,
138
+ so its mutant could be a false `survived` instead of `ran_at_load`. The
139
+ daemon now loads the files after the app boots, as an in-process run does,
140
+ and the daemon's coverage cache rebuilds when one of them changes.
141
+ - **The release workflow publishes only stable tags** (#161). Its trigger,
142
+ `v*.*.*`, also matches a prerelease tag such as `v1.3.1-rc1`, and the only
143
+ guard checked that the tag matched `Mutineer::VERSION`. A prerelease tag
144
+ could then publish the gem and move the floating major tag (`v1`) that
145
+ Action users pin. The workflow now stops with a clear error before any
146
+ publish step unless the tag is a stable `vMAJOR.MINOR.PATCH`.
147
+ - **Coverage capture keeps to `capture_timeout` from start to finish.** Under
148
+ `--boot` and `--daemon`, a hung test file ignored the timeout and blocked
149
+ the run; it is now skipped as "timed out", like a standalone capture. A
150
+ process that a test started and left running could keep the result pipe
151
+ open and make a boot-mode capture wait for it to exit. Each capture now runs
152
+ in its own process group, and a timeout stops the whole group, not only the
153
+ test process. A capture whose test finished just before the deadline could
154
+ also lose its valid result and report "invalid coverage output"; it now
155
+ gets a short grace period to read the result. (#101, #129)
156
+ - **`--daemon` no longer waits forever on a daemon that stops answering**
157
+ (#101). A mutant whose verdict has not arrived 30 seconds after its own
158
+ timeout is scored `error`, and the daemon is killed and respawned, as after
159
+ a crash. A boot that has not finished after 600 seconds ends the run with a
160
+ message that says so (it is not retried by a second daemon), and shutdown no longer waits on a stuck daemon.
161
+
162
+ - **A method in a `Data.define`, `Struct.new`, `Class.new` or `Module.new`
163
+ block belongs to the class or module the block builds.** In
164
+ `class App; Argo = Data.define(:url) do def self.load = ...; end; end` the
165
+ subject is now `App::Argo.load`, not `App.load`; at the top level it was
166
+ `.load`, with no owner. Under `--strategy redefine` (the `--rails` default)
167
+ the mutated method was loaded into the wrong class, so the real method never
168
+ changed and every such mutant falsely survived. The class or module is
169
+ named after the constant it is assigned to with `=`, `||=` or `&&=`, through
170
+ parentheses or `begin`, resolved as Ruby resolves the assignment: `X` in the
171
+ current namespace, `::X` and any path at the top level from `Object`,
172
+ `self::X` under the current class. When it cannot be named statically (no
173
+ constant, a relative path such as `User::Permission` inside a namespace, or
174
+ a constant assigned in `class << self`), redefine reports its mutants with
175
+ a new status, `unplaceable`, rather than load them onto the wrong class;
176
+ reload still runs them. `module_function :name` in a `Module.new` block
177
+ promotes that module's method. Mutant ids for these subjects change:
178
+ regenerate `ignore:` entries and baselines that name them.
179
+ - **The automated release PR gets its required CI checks without a token
180
+ secret (#85).** `release-pr.yml` pushes the release branch with the default
181
+ `GITHUB_TOKEN`, which starts no other workflow, so the four required checks
182
+ never ran and the PR stayed blocked until a person closed and reopened it.
183
+ The job now dispatches `ci.yml` on the release branch (CI gained a
184
+ `workflow_dispatch` trigger), and those runs report the required checks on
185
+ the PR's head commit. With a `RELEASE_PR_TOKEN` secret set, the push
186
+ triggers CI as before and no dispatch is made.
187
+ - **Code that runs while the app boots or a class loads no longer gives a false
188
+ `survived` or `no_coverage`** (#187). A class body such as
189
+ `ALL = [price(3)].freeze` runs `price` before any mutant is applied. Under
190
+ `--strategy redefine` (the `--rails` default) the load never runs again, so
191
+ a test that checks `ALL` saw the original value and the mutant falsely
192
+ survived. Under `--rails` or `--boot` no test was credited with lines that
193
+ ran during boot, so those mutants were falsely `no_coverage`. Mutineer now
194
+ reads the lines that ran at load (from the boot, or from requiring the
195
+ sources in standalone capture) and reports these mutants as `ran_at_load`,
196
+ under both strategies and with `--daemon`: reload runs the file's class body
197
+ again but not an initializer or another file's code, so a survivor there is
198
+ not trusted either. Under `--boot` with redefine, a
199
+ lazily loaded class (Zeitwerk, `autoload`) is loaded before the boot lines
200
+ are read, so its class body counts as load too. A baseline survivor that is
201
+ now `ran_at_load` is listed as fixed, not as a regression. Standalone
202
+ coverage caches from earlier versions rebuild once. A one-line or endless
203
+ `def` keeps its body on the `def` line, which Ruby counts when the method is
204
+ defined, so Mutineer reads the method's call count at load for it instead
205
+ (#209).
206
+ - **A boot file that prints to stdout no longer breaks the daemon handshake
207
+ (#102).** Output from `puts`, `STDOUT`, `$stdout`, a direct fd 1 write, or a
208
+ subprocess reached the JSON channel before the ready message, so the daemon
209
+ failed with "daemon exited before the handshake". The daemon now keeps a
210
+ private copy of its original stdout for the protocol and points the app's
211
+ stdout at stderr, where that output stays visible.
212
+ - **A class or module opened inside `class << self` belongs to the singleton
213
+ class (#208).** In `class App; class << self; class Q; def q1 ...` the
214
+ subject was `App::Q.q1`, a class method of a constant Ruby never defines:
215
+ `Q` is `App.singleton_class::Q`, and `q1` is an instance method. It is now
216
+ `#<Class:App>::Q#q1` with no known owner, as is a class or module opened in
217
+ a builder block there, and anything nested in either. Inside a builder
218
+ block, `class << self` opens the built class's singleton class, so a class
219
+ or constant there is named under it, for example `#<Class:App::P>::W`. Under
220
+ `--strategy redefine` loading the mutant raised `NameError` and scored
221
+ `error`; redefine now reports these mutants as `unplaceable`, and reload
222
+ still runs them. A compact `class Foo::X` and a top-level `class ::X` or
223
+ `::X = Class.new do` there are named as written (`::X` as `X`, now with
224
+ instance methods) and are owner-unknown too: redefine reopens them without
225
+ the singleton class, so constants their bodies look up through it would not
226
+ resolve. `module_function :name` in a body whose owner is unknown now
227
+ promotes that body's own methods, wherever it is: a `Foo::X = Module.new`
228
+ block's `c` is `Foo::X.c`, not `Foo::X#c`, and a def in an anonymous
229
+ `Class.new` block is no longer promoted by a `module_function` of the module
230
+ around it. Mutant ids for these subjects change: regenerate `ignore:`
231
+ entries and baselines that name them.
232
+
9
233
  ## [1.5.0] - 2026-10-06
10
234
 
11
235
  ### Added
@@ -926,6 +1150,7 @@ Rails hardening + CI batch (issues #8–#13), all verified Rails-free.
926
1150
  - `.mutineer.yml` configuration (CLI > config > default precedence).
927
1151
  - Byte-correct source handling for multibyte (UTF-8) sources.
928
1152
 
1153
+ [1.6.0]: https://github.com/davidteren/mutineer/releases/tag/v1.6.0
929
1154
  [1.5.0]: https://github.com/davidteren/mutineer/releases/tag/v1.5.0
930
1155
  [1.4.0]: https://github.com/davidteren/mutineer/releases/tag/v1.4.0
931
1156
  [1.3.0]: https://github.com/davidteren/mutineer/releases/tag/v1.3.0
data/README.md CHANGED
@@ -21,7 +21,9 @@ for how the two tools differ.
21
21
  - **Coverage-guided** — each mutant runs only the test files that cover its line.
22
22
  - **Stops at the first failing test** — in-process runs (not `--daemon` or
23
23
  `--test-command`) stop a mutant's test run at the first failure, unless
24
- `--matrix` asks for every covering test.
24
+ `--matrix` asks for every covering test. The source's paired test files run
25
+ first, then the rest from fastest to slowest in doubling steps (under 1 s,
26
+ 1 to 3 s, 3 to 7 s, and so on); files in one step run in path order.
25
27
 
26
28
  📖 **[mutineer.github.io →](https://davidteren.github.io/mutineer/)** — overview, operators, and usage.
27
29
 
@@ -119,6 +121,18 @@ different entry point. Boot mode requires at least one `--test` file and is
119
121
  coverage-guided — each mutant runs only the test files that exercise its line
120
122
  (coverage is captured by forking the booted app, then cached).
121
123
 
124
+ Some code runs while the app boots or a class loads, for example a class body
125
+ that calls a method to build a constant, a `to_prepare` initializer, or a
126
+ `--require` file that calls a source method. That run
127
+ happens before the mutant is applied, and the forked test does not repeat it.
128
+ A mutant on such a line is `ran_at_load`, not `survived` or `no_coverage`. It is
129
+ left out of the score and does not fail `--threshold`; a kill still counts.
130
+ This holds under both strategies: `reload` runs the mutated file's class body
131
+ again, but not an initializer or another file's code, so a survivor on such a
132
+ line is not trusted there either.
133
+ Run those mutants with `--test-command`, which boots a fresh process per mutant,
134
+ to get a verdict.
135
+
122
136
  Add Mutineer to your Gemfile's test group:
123
137
 
124
138
  ```ruby
@@ -144,7 +158,14 @@ RAILS_ENV=test bundle exec mutineer run \
144
158
  a mutant on an uncovered line is `no_coverage`, so the score stays comparable to
145
159
  the in-process `--rails` score.
146
160
  - **Safe `--jobs N`** — each worker routes to its own copy of the test database, so
147
- parallel verdicts equal serial (no fixture cross-talk).
161
+ parallel verdicts equal serial (no fixture cross-talk). On first use, a
162
+ worker's database is a copy of the test database after the app boots, so rows
163
+ written by initializers or `--require` files are there, as in-process. When
164
+ the copy's schema differs from `db/schema.rb` (another schema version or
165
+ `schema_sha1` checksum, or no stored checksum, as in an empty or out-of-date
166
+ test database, or one set up with a plain `load "db/schema.rb"`), the
167
+ worker loads `db/schema.rb` over it, which drops the copied rows of the
168
+ tables it defines.
148
169
  - **One backend at a time** — `--daemon` can't be combined with `--test-command`
149
170
  (choose one), and it needs an app to boot (`--rails` or `--boot`).
150
171
 
@@ -227,7 +248,7 @@ Tradeoffs — this path is correct but not free:
227
248
 
228
249
  The mutation score is `killed / (killed + survived)`. A mutant whose tests run
229
250
  past `--timeout` is a `timeout`. It is neither killed nor survived, so it is
230
- left out of the score, like `no_coverage`, `uncapturable`, `errored`, skipped
251
+ left out of the score, like `no_coverage`, `uncapturable`, `unplaceable`, `ran_at_load`, `errored`, skipped
231
252
  and ignored mutants. A timeout is not counted as a kill, because a hang the
232
253
  mutant caused and a suite that is just slow look the same. The human report
233
254
  shows the count in its `Timeout:` row, and the JSON report in `summary.timeout`.
@@ -435,6 +456,10 @@ structured exit codes, and diff-scoped runs. See:
435
456
  [source](https://davidteren.github.io/mutineer/json-schema.md)
436
457
  - **Ruby API (YARD)** — class reference for the shipped gem:
437
458
  [https://davidteren.github.io/mutineer/api/](https://davidteren.github.io/mutineer/api/)
459
+ - **Agent skill** ([`skills/mutineer/SKILL.md`](https://github.com/davidteren/mutineer/blob/main/skills/mutineer/SKILL.md)): a short
460
+ card for coding agents with install, the agent loop, and exit codes. Install it
461
+ with `gh skill install davidteren/mutineer mutineer` or
462
+ `npx skills add davidteren/mutineer --skill mutineer`.
438
463
 
439
464
  ## Configuration
440
465
 
data/lib/mutineer/cli.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "project"
9
9
  require_relative "pairing"
10
10
  require_relative "changed_lines"
11
11
  require_relative "runner"
12
+ require_relative "job_plan"
12
13
  require_relative "reporter"
13
14
  require_relative "kill_matrix"
14
15
  require_relative "baseline"
@@ -639,20 +640,20 @@ module Mutineer
639
640
  "it runs this mutineer version or later."
640
641
  end
641
642
 
642
- # Runs dry-run mode. Reuses Runner.collect_jobs (+ filter_since) so the
643
+ # Runs dry-run mode. Reuses JobPlan.collect_jobs (+ filter_since) so the
643
644
  # candidate list cannot drift from a real run's job selection.
644
645
  #
645
646
  # @param config [Mutineer::Config] run configuration.
646
647
  # @return [void]
647
648
  def self.dry_run(config)
648
649
  operator_classes = MutatorRegistry.resolve(config.operators || MutatorRegistry::DEFAULT_NAMES)
649
- jobs, ignored_results, source_map, extras = Runner.collect_jobs(config, operator_classes)
650
+ jobs, ignored_results, source_map, extras = JobPlan.collect_jobs(config, operator_classes)
650
651
  warn_legacy_ignore_matches(extras[:legacy_ignore_matches])
651
652
  # Narrow jobs and ignored the same way so the summary matches the printed list.
652
653
  if config.since
653
- jobs = Runner.filter_since(jobs, source_map, config)
654
+ jobs = JobPlan.filter_since(jobs, source_map, config)
654
655
  ignored_jobs = ignored_results.map { |r| [r.subject, r.mutation, r.id] }
655
- ignored = Runner.filter_since(ignored_jobs, source_map, config).size
656
+ ignored = JobPlan.filter_since(ignored_jobs, source_map, config).size
656
657
  else
657
658
  ignored = ignored_results.size
658
659
  end