evilution 1.1.0 → 1.2.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +20 -0
  3. data/CHANGELOG.md +39 -0
  4. data/README.md +141 -11
  5. data/docs/architecture.md +20 -5
  6. data/docs/isolation.md +3 -4
  7. data/exe/evil +8 -1
  8. data/exe/evilution +8 -1
  9. data/lib/evilution/ast/local_reads.rb +43 -0
  10. data/lib/evilution/cli/exit_guard.rb +59 -0
  11. data/lib/evilution/cli/parser/options_builder.rb +1 -0
  12. data/lib/evilution/cli.rb +1 -0
  13. data/lib/evilution/config.rb +3 -2
  14. data/lib/evilution/diagnostic.rb +22 -0
  15. data/lib/evilution/integration/loading/concern_state_cleaner.rb +20 -4
  16. data/lib/evilution/integration/loading/reeval_warning_filter.rb +72 -0
  17. data/lib/evilution/integration/loading/source_evaluator.rb +4 -1
  18. data/lib/evilution/integration/minitest.rb +2 -1
  19. data/lib/evilution/integration/rspec/crash_detector_lifecycle.rb +9 -1
  20. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +4 -1
  21. data/lib/evilution/integration/rspec/unresolved_spec_warner.rb +2 -1
  22. data/lib/evilution/integration/rspec.rb +48 -2
  23. data/lib/evilution/integration/test_unit/test_file_resolver.rb +2 -1
  24. data/lib/evilution/isolation/fork.rb +29 -8
  25. data/lib/evilution/mutator/operator/block_destructuring_expansion.rb +85 -0
  26. data/lib/evilution/mutator/operator/block_parameter_drop.rb +96 -0
  27. data/lib/evilution/mutator/operator/forwarding_super_to_explicit.rb +71 -0
  28. data/lib/evilution/mutator/operator/method_body_replacement.rb +10 -1
  29. data/lib/evilution/mutator/operator/method_body_to_raise.rb +59 -0
  30. data/lib/evilution/mutator/operator/method_body_to_super.rb +150 -0
  31. data/lib/evilution/mutator/operator/optional_default_injection.rb +71 -0
  32. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +41 -0
  33. data/lib/evilution/mutator/operator/typed_default_return.rb +84 -0
  34. data/lib/evilution/mutator/registry.rb +8 -0
  35. data/lib/evilution/process_supervisor.rb +20 -8
  36. data/lib/evilution/reporter/cli/item_formatters/neutral_group.rb +23 -0
  37. data/lib/evilution/reporter/cli/item_formatters/subject_score.rb +42 -0
  38. data/lib/evilution/reporter/cli/item_formatters/subject_score_group.rb +16 -0
  39. data/lib/evilution/reporter/cli/line_formatters/infra_retry_notice.rb +19 -0
  40. data/lib/evilution/reporter/cli/line_formatters/result_line.rb +27 -3
  41. data/lib/evilution/reporter/cli/line_formatters/score.rb +19 -1
  42. data/lib/evilution/reporter/cli/line_formatters/unresolved_targets.rb +35 -0
  43. data/lib/evilution/reporter/cli/metrics_block.rb +4 -0
  44. data/lib/evilution/reporter/cli/trailer.rb +11 -7
  45. data/lib/evilution/reporter/cli.rb +20 -4
  46. data/lib/evilution/reporter/json/subjects.rb +29 -0
  47. data/lib/evilution/reporter/json.rb +23 -1
  48. data/lib/evilution/result/mutation_result.rb +3 -2
  49. data/lib/evilution/result/neutral_reason.rb +35 -0
  50. data/lib/evilution/result/subject_score.rb +28 -0
  51. data/lib/evilution/result/subject_scorer.rb +37 -0
  52. data/lib/evilution/result/summary.rb +44 -2
  53. data/lib/evilution/runner/canary.rb +52 -5
  54. data/lib/evilution/runner/mutation_executor/infra_retry.rb +54 -0
  55. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +9 -3
  56. data/lib/evilution/runner/mutation_executor/neutralizer/infra_error.rb +18 -1
  57. data/lib/evilution/runner/mutation_executor/result_cache.rb +11 -0
  58. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +19 -1
  59. data/lib/evilution/runner/mutation_executor.rb +32 -4
  60. data/lib/evilution/runner/report_publisher.rb +31 -9
  61. data/lib/evilution/runner/target_spec_audit.rb +43 -0
  62. data/lib/evilution/runner.rb +10 -1
  63. data/lib/evilution/version.rb +1 -1
  64. data/lib/evilution.rb +12 -0
  65. metadata +25 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71f26de1d4437f62a01f5d5784a3a67ffdc914418ae952cb279de392ec401ecb
4
- data.tar.gz: 92d18647ec19836335c5eaa3156ce6da7db1857b285b66f49efc2ed66de49ed0
3
+ metadata.gz: 4fe2a5694f081965211f6471734e4f1b11d4e02d2a33617b19a0e6fb3b3c685f
4
+ data.tar.gz: 3e2637aa9ebb40904cb8def655dc2b5df7e9e0727f22add31267e8183553fe79
5
5
  SHA512:
6
- metadata.gz: 0174eb2e10182274ed8cbda806172a8d4956caeff7af844c409daa6f3de711b709a450be89cf4dc3a08b36e7df84ca0c0473a66d21909d8752a83bce4309aab2
7
- data.tar.gz: 8bf2f8cfce0633f537186cd08aaf6abd66c06869d56308647f0be34352d6d94c06912b8c1c0e8128ca19b45acc60536b5a6f35028996ce25ca2a0501bdc1b508
6
+ metadata.gz: 530e85eaa20e6d6fa18a6b633d4fdca79c5b55a6ca5515f6a0e79951ece9582b077a14921fc9217df3d96567974a256df9b27deb46d014da263fc0cee253da1a
7
+ data.tar.gz: 0a4ba7c748b0fa7f7c664cd0a25e7ed68f3d3c59e51536185fbc869c81b20b2d77f6391fe6b6aabe3ae3a848d6ffa3a70ef5d07760d28e520426b871daa97100
@@ -469,3 +469,23 @@
469
469
  {"id":"int-a9687ce8","kind":"field_change","created_at":"2026-08-23T06:22:47.016572566Z","actor":"Denis Kiselev","issue_id":"EV-170m.9","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
470
470
  {"id":"int-6979f453","kind":"field_change","created_at":"2026-08-23T06:22:50.346314707Z","actor":"Denis Kiselev","issue_id":"EV-170m.13","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
471
471
  {"id":"int-c0c4ce61","kind":"field_change","created_at":"2026-08-23T06:22:53.658409885Z","actor":"Denis Kiselev","issue_id":"EV-170m.17","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
472
+ {"id":"int-9fe2b310","kind":"field_change","created_at":"2026-08-23T15:26:14.173757324Z","actor":"Denis Kiselev","issue_id":"EV-secd","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
473
+ {"id":"int-e3df2816","kind":"field_change","created_at":"2026-08-23T15:26:14.758723969Z","actor":"Denis Kiselev","issue_id":"EV-v2rc","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
474
+ {"id":"int-4964c024","kind":"field_change","created_at":"2026-08-23T16:53:26.159738103Z","actor":"Denis Kiselev","issue_id":"EV-df7u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
475
+ {"id":"int-09aacdba","kind":"field_change","created_at":"2026-08-23T17:31:02.425168724Z","actor":"Denis Kiselev","issue_id":"EV-65nf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
476
+ {"id":"int-f248abda","kind":"field_change","created_at":"2026-09-19T02:33:35.442608624Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
477
+ {"id":"int-2295820f","kind":"field_change","created_at":"2026-09-19T05:36:52.469484387Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
478
+ {"id":"int-c34dface","kind":"field_change","created_at":"2026-09-19T06:40:49.582010237Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
479
+ {"id":"int-b54a1b1b","kind":"field_change","created_at":"2026-09-19T09:16:07.408034725Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.7","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
480
+ {"id":"int-d07f50fb","kind":"field_change","created_at":"2026-09-20T05:54:55.207588665Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
481
+ {"id":"int-8c521e96","kind":"field_change","created_at":"2026-09-20T07:27:33.837283926Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
482
+ {"id":"int-0082d40c","kind":"field_change","created_at":"2026-09-20T11:10:19.819992902Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.6","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
483
+ {"id":"int-b1fbb9d9","kind":"field_change","created_at":"2026-09-20T12:09:22.863657064Z","actor":"Denis Kiselev","issue_id":"EV-0y0p.8","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Closed"}}
484
+ {"id":"int-0341459f","kind":"field_change","created_at":"2026-09-20T15:42:35.81801432Z","actor":"Denis Kiselev","issue_id":"EV-p4sm","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
485
+ {"id":"int-37d6d94c","kind":"field_change","created_at":"2026-09-20T16:28:47.475124108Z","actor":"Denis Kiselev","issue_id":"EV-39t1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
486
+ {"id":"int-3c77e0d2","kind":"field_change","created_at":"2026-09-20T17:08:50.170870939Z","actor":"Denis Kiselev","issue_id":"EV-j0bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
487
+ {"id":"int-9a5aa97e","kind":"field_change","created_at":"2026-09-21T05:03:01.265493211Z","actor":"Denis Kiselev","issue_id":"EV-g8ya","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
488
+ {"id":"int-2b83bb6b","kind":"field_change","created_at":"2026-09-21T06:59:17.164808817Z","actor":"Denis Kiselev","issue_id":"EV-nlx1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
489
+ {"id":"int-9a7df3ab","kind":"field_change","created_at":"2026-09-21T07:43:13.101312655Z","actor":"Denis Kiselev","issue_id":"EV-5pob","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
490
+ {"id":"int-6c08ef8f","kind":"field_change","created_at":"2026-09-21T08:47:57.19015326Z","actor":"Denis Kiselev","issue_id":"EV-f8h3","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
491
+ {"id":"int-41e28161","kind":"field_change","created_at":"2026-09-21T09:13:10.822154641Z","actor":"Denis Kiselev","issue_id":"EV-vk1f","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}}
data/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  Versioning policy: see [docs/versioning.md](docs/versioning.md).
4
4
 
5
+ ## [1.2.0] - 2026-09-21
6
+
7
+ Reporting honesty release. A run now says what it did **not** measure instead of letting the headline score speak for the whole target set: files that resolved to no spec fail the run, subjects nothing reached are named, neutral results record why, and the printed verdict and the exit code finally share one threshold. The `default` profile also grows from 80 to 88 operators, so mutation scores will move — every new operator produces mutants your suite has never been measured against. Pin the gem version and the operator profile if you need a stable score across runs.
8
+
9
+ Most of this release comes from a field report on 1.1.0 ([discussion #1584](https://github.com/marinazzio/evilution/discussions/1584)): eight months of side-by-side runs against `mutant` on a private Rails application, filed as GH #1603–#1608.
10
+
11
+ ### Added
12
+
13
+ - **Eight new mutation operators for method definitions (`default` profile: 80 -> 88)** — the operator set mutated method bodies only to `nil`, `self` or `super`, and never touched parameter lists (GH #1419):
14
+ - **`method_body_to_raise`** — whole body to a bare `raise`. Where body-to-nil asks whether the return value matters, this asks the prior question of whether the method is called at all on a path the suite asserts; a method whose result is discarded survives the nil mutation but not this one (PR #1609, GH #1447)
15
+ - **`method_body_to_super`** — whole body to a bare `super`, for plain overrides. `method_body_replacement` emits the same replacement only when the body *already* calls super, so the case worth probing was never reached. Emitted only where a super target exists: an explicit superclass, or `include`/`prepend` for instance methods and `extend` for singleton ones (PR #1610, GH #1448)
16
+ - **`typed_default_return`** — single-expression body to the empty value of the type its trailing call returns (`users.map(&:name)` -> `[]`, `users.count` -> `0`). A survivor means the suite asserts the shape of what comes back but never its content (PR #1611, GH #1449)
17
+ - **`block_parameter_drop`** — drops a block's single parameter: `users.each { |u| touch(u) }` -> `users.each { touch(u) }`. A survivor means the block never ran on an asserted path (PR #1612, GH #1453)
18
+ - **`optional_parameter_to_required`** — `def f(a = 1)` -> `def f(a)`. A survivor means no example calls the method without that argument, so the default is never exercised (PR #1613, GH #1450)
19
+ - **`optional_default_injection`** — overwrites an optional parameter with its own default at the top of the body. The mirror of the above: it asks whether any value *other* than the default is ever asserted (PR #1614, GH #1451)
20
+ - **`block_destructuring_expansion`** — `|(k, v), i|` -> `|k, v, i|`, emitted only where the group has a sibling parameter, since a lone group and a flat list bind identically for a single yielded array (PR #1615, GH #1452)
21
+ - **`forwarding_super_to_explicit`** — bare `super` to `super()`. Bare super forwards the current arguments, `super()` forwards none, so a survivor means the forwarded arguments never reach an assertion (PR #1616, GH #1454)
22
+ - **Per-subject score breakdown** — the run's score is computed per file, which says nothing about a method inside it that no example reaches; a well-tested file that gains untested methods still reported 100%. Every report now scores each subject separately. The text report names the subjects the file-level score does not speak for, and JSON carries every subject under `subjects` with `killed`, `verified`, `score` and `reached` (PR #1621, GH #1605)
23
+ - **Neutral results record why they are neutral** — `neutral` covered both "the spec was already red" and "the test process died on infrastructure", which want opposite responses. Each neutral now carries a reason; the text report groups by it and names the failing spec or the crash class, and JSON entries carry `neutral_reason` as `{ kind, detail }` (PR #1622, GH #1606)
24
+ - **`--output FILE`** — writes the report to a file instead of stdout, for projects whose preloaded spec helper writes to stdout on exit (PR #1620, GH #1608)
25
+
26
+ ### Changed
27
+
28
+ - **A target file that resolves to no spec now fails the run** — previously it contributed a handful of `unresolved` mutations and disappeared behind the other files' score: adding one well-tested file to the same command turned a 0% file into a `PASS`. Such files are now named, and the run exits non-zero whatever the score. `--fallback-full-suite` suppresses this, since nothing goes untested there. JSON carries `summary.unresolved_target_files` (PR #1617, GH #1603)
29
+ - **The printed verdict and the exit code share one threshold** — `Result:` was rendered against a hard-coded 80% while the exit code used `min_score`, whose default is `0.0`, so a run could print `FAIL (score 0.00% < 80.00%)` and exit 0. The reporter now uses the run's own `min_score`, and with no gate configured it prints the score instead of a verdict: `Result: 66.67% (no minimum score set)`. Exit-code semantics are unchanged (PR #1618, GH #1604)
30
+ - **The score line says how much of the run it covers** — `Score: 100.00% (10/10 verified of 17 mutations, 7 neutral)` whenever mutations were left out of the denominator. A run that measured everything still prints the plain pair (PR #1622, GH #1606)
31
+ - **Evilution owns its exit status and, in JSON mode, stdout** — `--preload` loads the project's spec helper into the parent process, and an at-exit hook it installs (SimpleCov calls `exit` with its own status, and prints its coverage report) replaced evilution's exit code and appended text after the JSON document. The executable now installs a guard that has the final word on the status, and JSON mode points stdout at stderr once the document is written (PR #1620, GH #1608)
32
+
33
+ ### Fixed
34
+
35
+ - **Survivors are confirmed against the whole spec file** — per-mutation targeting selects examples by matching the enclosing method's name against example bodies, which keys on incidental identifier text: on evilution's own code a local variable named `row` selected two examples and hid six kills, reporting survivors the suite actually covered. A survivor is now re-run against the whole resolved file before it is reported. Only survivors pay for it, and only where the subset was narrower than the file (PR #1629, GH #1624)
36
+ - **Neutral counts no longer move with `--jobs`** — parallel workers contending on a shared database crash the test process, and those crashes are demoted to `:neutral` so they cannot inflate the kill count (GH #814). Since the contention exists only while the pool runs, the same workload scored 24 killed / 0 neutral at `-j 1` and 6 killed / 18 neutral at `-j 4`. Mutations neutralised that way are now re-run one at a time once the pool is done, and the run reports how many it had to redo. Such crashes are also never written to the `--incremental` cache, where a cached `:killed` would have hidden a real survivor on the next run (PR #1619, GH #1607)
37
+ - **`--format json` is parseable under `in_process` isolation** — RSpec redirects its own output only when the stream its configuration holds is the current `$stdout`; in-process isolation swaps `$stdout` for a null IO, and `--preload` builds the configuration before that swap, so RSpec kept writing to the real stdout ahead of the document. The run now claims RSpec's streams outright (PR #1631, GH #1627)
38
+ - **`method_body_replacement` no longer emits a bare `super` for a method that has no parent to call** — a `super` inside a *nested* def made the enclosing method eligible, producing a mutant that raises `NoMethodError` on contact: a kill that proves nothing (PR #1630, GH #1625)
39
+ - **Canary failures name the underlying error** — the proof-of-life check reported that it failed without saying why (PR #1592)
40
+ - **Advisory warnings bypass `Kernel#warn`** — a project whose `Warning` handler raises could turn an evilution advisory into a fatal error mid-run (PR #1590)
41
+ - **`ConcernStateCleaner` handles ActiveSupport deprecation proxies** — sending messages to a module that intercepts them left concern state uncleared (PR #1587)
42
+ - **Empty results from a child report exit status and signal** — a worker that died without producing a result said only "empty result from child" (PR #1585, GH #1580)
43
+
5
44
  ## [1.1.0] - 2026-08-23
6
45
 
7
46
  Control-flow and pattern-matching operator expansion: the `default` profile grows from 74 to 80 operators, and three existing operators gain mutations they were silently missing. Adding operators to `default` is a MINOR change under [docs/versioning.md](docs/versioning.md), and mutation scores will move — every new operator produces mutants your suite has never been measured against. Pin the gem version and the operator profile if you need a stable score across runs.
data/README.md CHANGED
@@ -104,6 +104,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
104
104
  | `-t`, `--timeout N` | Integer | 30 | Per-mutation timeout in seconds. |
105
105
  | `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
106
106
  | `--target EXPR` | String | _(none)_ | Only mutate matching methods. Supports method name (`Foo::Bar#calculate`), class (`Foo`), namespace wildcards (`Foo::Bar*`), method-type selectors (`Foo#`, `Foo.`), descendants (`descendants:Foo`), and source globs (`source:lib/**/*.rb`). |
107
+ | `--output FILE` | String | _(stdout)_ | Write the report to FILE instead of stdout. Useful when a preloaded spec helper writes to stdout on exit. |
107
108
  | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
108
109
  | `--spec FILES` | Array | _(none)_ | Spec files to run (comma-separated). Defaults to auto-detection via `SpecResolver`, which also resolves non-mirrored (`spec/unit`, `test/unit`), dir-grouped (`test/unit/<class>/*_test.rb`), and flat `test_`-prefixed (`test/test_connection_pool_timed_stack.rb`) layouts. |
109
110
  | `--spec-dir DIR` | String | _(none)_ | Include all `*_spec.rb` files in DIR recursively. Composable with `--spec`. |
@@ -157,7 +158,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
157
158
 
158
159
  Two profiles ship out of the box:
159
160
 
160
- - **`default`** — the 80 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
161
+ - **`default`** — the 88 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
161
162
  - **`strict`** — adds extra truthiness mutators on top of `default`. Currently `PredicateToNil` (replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values). Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
162
163
 
163
164
  Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
@@ -167,9 +168,23 @@ Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.e
167
168
  | Code | Meaning | Agent action |
168
169
  |------|-----------------------------------------------|---------------------------------------|
169
170
  | 0 | Mutation score meets or exceeds `--min-score` | Success. No action needed. |
170
- | 1 | Mutation score below `--min-score` | Parse output, fix surviving mutants. |
171
+ | 1 | Mutation score below `--min-score`, or a target file resolved to no spec | Parse output, fix surviving mutants. |
171
172
  | 2 | Tool error (bad config, parse failure, etc.) | Check stderr, fix invocation. |
172
173
 
174
+ `min_score` defaults to `0.0`, so **no score gate is armed unless you set one**. The `Result:` line says so rather than implying a threshold nobody configured:
175
+
176
+ ```
177
+ $ evilution run lib/half_tested.rb # no gate
178
+ Result: 66.67% (no minimum score set) # exit 0
179
+
180
+ $ evilution run lib/half_tested.rb --min-score 0.8
181
+ Result: FAIL (score 66.67% < 80.00%) # exit 1
182
+ ```
183
+
184
+ Evilution owns the exit status: `--preload` loads the project's own spec helper into the parent process, and an at-exit hook it installs (SimpleCov calls `exit` with its own status when coverage is below the minimum) would otherwise replace the status evilution computed (GH #1608).
185
+
186
+ The printed verdict and the exit code always use the same threshold. Previously the line was printed against a hard-coded 80% that the exit code did not share, so a failing-looking run still exited 0 (GH #1604).
187
+
173
188
  ## Configuration
174
189
 
175
190
  Generate default config: `bundle exec evilution init`
@@ -224,6 +239,7 @@ All keys recognised under `schema_version: 1`:
224
239
  | `timeout` | Integer | `30` | Per-mutation timeout in seconds. |
225
240
  | `format` | String | `text` | Output format: `text`, `json`, `html`. |
226
241
  | `target` | String / null | `null` | Filter expression: method (`Foo#bar`), class (`Foo`), namespace (`Foo*`), descendants (`descendants:Foo`), source glob (`source:**/*.rb`). |
242
+ | `output` | String | _(stdout)_ | Write the report to this file instead of stdout. |
227
243
  | `min_score` | Float | `0.0` | Minimum mutation score (0.0–1.0) for exit code 0. |
228
244
  | `integration` | String | `rspec` | Test framework: `rspec`, `minitest`, or `test_unit`. |
229
245
  | `verbose` | Boolean | `false` | Verbose output (RSS/GC stats per phase, error details for errored mutations). |
@@ -299,6 +315,8 @@ Schema:
299
315
  "neutral": "integer — mutations whose tests already failed before mutation (baseline failure)",
300
316
  "equivalent": "integer — mutations proven to have identical behavior to the original",
301
317
  "unresolved": "integer — mutations where no spec file resolved (coverage gap, not a failure)",
318
+ "unresolved_target_files": "array of strings (optional) — target files that resolved to no spec at all; present only when non-empty, and the run fails when it is",
319
+ "infra_retried": "integer (optional) — mutations a parallel pass could not judge because the test process crashed on infrastructure, re-run serially afterwards; present only when non-zero",
302
320
  "unparseable": "integer — mutations whose mutated source did not parse (short-circuited, never executed)",
303
321
  "score": "float — killed / (total - errors - neutral - equivalent - unresolved - unparseable), range 0.0-1.0, rounded to 4 decimals",
304
322
  "duration": "float — total wall-clock seconds, rounded to 4 decimals",
@@ -313,7 +331,20 @@ Schema:
313
331
  "duration": "float — seconds this mutation took, rounded to 4 decimals",
314
332
  "diff": "string — legacy +/- diff snippet",
315
333
  "unified_diff": "string (optional, survived only) — git-style unified diff with `--- a/file`, `+++ b/file`, `@@` hunk header and sdiff body; omitted when source slices are unavailable",
316
- "suggestion": "string — actionable hint for surviving mutants (survived only)"
334
+ "suggestion": "string — actionable hint for surviving mutants (survived only)",
335
+ "neutral_reason": "object (optional, neutral only) — { kind: 'baseline_failure' | 'infra_error', detail: string|null — the failing spec file or the crash class; null when the run was given explicit --spec files and no single spec can be named }"
336
+ }
337
+ ],
338
+ "subjects": [
339
+ {
340
+ "name": "string — subject name (e.g. 'Foo#bar')",
341
+ "file": "string — relative path to source file",
342
+ "total": "integer — mutations generated for this subject",
343
+ "killed": "integer — mutations detected",
344
+ "verified": "integer — mutations that got a verdict (killed + survived + timed out)",
345
+ "survived": "integer — mutations that went undetected",
346
+ "score": "float — killed / verified, 0.0 when nothing was verified, rounded to 4 decimals",
347
+ "reached": "boolean — false when no mutation of this subject got a verdict at all"
317
348
  }
318
349
  ],
319
350
  "coverage_gaps": [
@@ -361,6 +392,10 @@ Sessions saved by `--save-session` (under `.evilution/results/*.json`) and consu
361
392
 
362
393
  Saved sessions also omit the per-status arrays (`killed`, `neutral`, `equivalent`, `unresolved`, `unparseable`, `timed_out`, `errors`) — only `survived` and `coverage_gaps` are persisted. The score, totals, and timestamps are stable for diff/compare consumers.
363
394
 
395
+ #### stdout in JSON mode
396
+
397
+ With `--format json`, stdout carries the JSON document and nothing else. Each mutation's test run writes to buffers evilution owns, whatever the isolation mode: under `in_process` the framework's configuration can outlive a single run — `--preload` builds it before isolation swaps `$stdout` — so the run claims RSpec's output and error streams outright rather than relying on RSpec to redirect them (GH #1627). Once the document is written, stdout is pointed at stderr, so anything a preloaded spec helper prints on the way out — SimpleCov's coverage report, for example — lands on stderr instead of after the document where it would leave `JSON.parse` with nothing to work with. `--output FILE` writes the document to a file and leaves stdout alone entirely.
398
+
364
399
  #### Schema versioning
365
400
 
366
401
  Every session and stdout JSON document carries a top-level `schema_version` integer (currently `1`). On read:
@@ -383,14 +418,70 @@ Compatibility policy for the `1.x` gem line:
383
418
  | `survived` | No test failed — gap in coverage | denominator only |
384
419
  | `timeout` | Test run exceeded `--timeout` — treated like survived for scoring | denominator only |
385
420
  | `error` | Mutation caused an unexpected error (syntax error, boot failure, etc.) | excluded from denominator |
386
- | `neutral` | Baseline tests already failed before mutation — not a meaningful signal | excluded |
421
+ | `neutral` | Baseline tests already failed before mutation, or the test process crashed on infrastructure (DB lock, statement timeout) rather than on the mutation. Every neutral records which of the two, and the report groups them by it | excluded |
387
422
  | `equivalent` | Mutation is provably identical to the original (e.g. no-op replacement) | excluded |
388
423
  | `unresolved` | No spec file resolved for the mutated source — **coverage gap, not a failure**. Use `--fallback-full-suite` to run the full suite instead. | excluded |
389
424
  | `unparseable` | Mutated source failed to parse (e.g. dangling heredoc opener after `method_body_replacement`). Short-circuited — never executed. | excluded |
390
425
 
391
426
  Unresolved mutations indicate a missing test mapping — the file has no corresponding test file that the resolver could find (for example, an RSpec `_spec.rb` file or a Minitest `_test.rb` file, depending on configuration). The resolver searches the `lib/`-mirrored path, common non-mirrored buckets (`spec/unit`, `spec/lib`, `test/unit`, `test/lib`), and the flat `test_`-prefixed Minitest/Test::Unit convention (`test/test_connection_pool_timed_stack.rb`), so a high unresolved rate usually means a genuinely missing or unconventionally-placed test; a run that leaves many mutations unresolved prints an unresolved-rate warning with a best-guess spec path per source file. They are reported separately so you can act on them (add a test, adjust test naming, pass `--spec`, or opt in to the full-suite fallback) without inflating the error count.
392
427
 
393
- ## Mutation Operators (80 total)
428
+ A *target file* that resolves to no test at all is a stronger condition than an individual unresolved mutation, and is reported on its own terms: evilution names the file and **fails the run**, whatever the mutations it did measure scored. Without this, adding one well-tested file to the command dilutes the unresolved rate and the untested file disappears behind a `PASS` (GH #1603):
429
+
430
+ ```
431
+ $ evilution run app/services/untested.rb app/services/well_tested.rb
432
+ Mutations: 98 total, 82 killed, 0 survived, 0 timed out, 2 neutral, 14 unresolved
433
+ Score: 100.00% (82/82)
434
+ ! 1 of 2 target files has no resolvable spec — it was never tested:
435
+ app/services/untested.rb
436
+ Result: FAIL (1 target file has no resolvable spec)
437
+ $ echo $?
438
+ 1
439
+ ```
440
+
441
+ The check covers files evilution found something to mutate in; a file it produced no mutations for (a constants-only file, say) is not reported. `--fallback-full-suite` runs such a file against the whole suite instead, so nothing goes untested and nothing is reported. The file list is also in JSON output under `summary.unresolved_target_files`.
442
+
443
+ ### Survivor Confirmation
444
+
445
+ Per-mutation targeting runs a subset of a spec file's examples, chosen by matching the enclosing method's name against example bodies. That match keys on identifier text, so it can miss the example that would have caught a mutation — and the mutation is then reported as a survivor the suite actually covers. A survivor nobody can reproduce is worse than a missed kill: it sends the reader to write a test that is already there (GH #1624).
446
+
447
+ Before any survivor is reported, it is therefore re-run against the whole resolved spec file, and that run is the one reported. Only survivors pay for the extra run, and only where the targeted subset was narrower than the file; a mutation the targeted examples already killed is never re-run.
448
+
449
+ On evilution's own `lib/evilution/reporter/json/subjects.rb` this moved the reported score from 74.19% with 8 survivors to 93.55% with 2 — the six that disappeared were killed by an example in the same file all along, and the run now agrees with `--no-example-targeting` instead of contradicting it.
450
+
451
+ ### Neutral Mutations
452
+
453
+ Neutral covers two unrelated situations that want opposite responses: a spec file that was already red before any mutation ran, and a test process that died on infrastructure rather than on the mutation. Each neutral records which, and the report groups by it, naming the spec or the error class (GH #1606):
454
+
455
+ ```
456
+ Score: 100.00% (10/10 verified of 17 mutations, 7 neutral)
457
+
458
+ Neutral mutations (7, not verified):
459
+ baseline already failing (spec/tally_spec.rb):
460
+ arithmetic_replacement: lib/tally.rb:9
461
+ integer_literal: lib/tally.rb:9
462
+ ```
463
+
464
+ The score line names the remainder whenever the run left mutations out of the denominator, because full marks over a fraction of a run otherwise reads as a verdict on all of it. A clean run still prints the plain `Score: 100.00% (17/17)`.
465
+
466
+ Those seven mutations were survivors until the spec file went red — a neutral of this kind is a hidden coverage gap, not a clean bill of health. JSON output carries `neutral_reason` as `{ kind, detail }` on neutral entries that have one; `detail` is null where no single spec can be named (an explicit `--spec` run), and the field is absent on a result recorded without a reason, which the text report shows as `reason not recorded`.
467
+
468
+ ### Per-Subject Scores
469
+
470
+ The run's score is computed per file, and test selection resolves per file too, so a well-tested file that gains new untested methods still reports 100% — the number only ever describes what the resolved spec reaches (GH #1605). Every report therefore breaks the run down per subject, and the text report names the subjects the file-level score does not speak for:
471
+
472
+ ```
473
+ Mutations: 33 total, 7 killed, 0 survived, 0 timed out, 26 unresolved
474
+ Score: 100.00% (7/7)
475
+
476
+ Subjects needing attention (2 subjects in 1 file):
477
+ lib/helper.rb
478
+ #summary_for 0.00% (0/17) nothing reached this subject
479
+ #total_for 0.00% (0/9) nothing reached this subject
480
+ ```
481
+
482
+ A subject is listed when something survived, or when nothing reached it at all — zero verdicts, every mutation unresolved or neutral. Fully-killed subjects are not listed, so the section stays actionable. JSON output carries every subject under `subjects`, whether or not it needs attention, so a CI step can assert on `reached` or on a per-subject `score`.
483
+
484
+ ## Mutation Operators (88 total)
394
485
 
395
486
  Each operator name is stable and appears in JSON output under `survived[].operator`.
396
487
 
@@ -464,6 +555,14 @@ Each operator name is stable and appears in JSON output under `survived[].operat
464
555
  | `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
465
556
  | `loop_flip` | Swap while/until loops | `while cond` -> `until cond` |
466
557
  | `loop_body_to_raise` | Replace a loop body with `raise` | `while c; body; end` -> `while c; raise; end` |
558
+ | `method_body_to_raise` | Replace a whole method body with `raise` | `def foo; body; end` -> `def foo; raise; end` |
559
+ | `method_body_to_super` | Replace a method body with bare `super` where a super target exists | `def foo; body; end` -> `def foo; super; end` |
560
+ | `typed_default_return` | Replace a single-expression body with the empty value of its inferred type | `def names(u); u.map(&:name); end` -> `def names(u); []; end` |
561
+ | `block_parameter_drop` | Drop a block's single parameter | `users.each { |u| touch(u) }` -> `users.each { touch(u) }` |
562
+ | `optional_parameter_to_required` | Drop an optional positional parameter's default | `def f(a = 1)` -> `def f(a)` |
563
+ | `optional_default_injection` | Overwrite an optional parameter with its own default at the top of the body | `def f(a = 1); body; end` -> `def f(a = 1); a = 1; body; end` |
564
+ | `block_destructuring_expansion` | Flatten a destructuring group in a block's parameters | `pairs.each_with_index { |(k, v), i| use(k, v, i) }` -> `pairs.each_with_index { |k, v, i| use(k, v, i) }` |
565
+ | `forwarding_super_to_explicit` | Give a forwarding `super` an empty argument list | `def f(a); super; end` -> `def f(a); super(); end` |
467
566
  | `string_interpolation` | Replace interpolation content with nil | `"hello #{name}"` -> `"hello #{nil}"` |
468
567
  | `retry_removal` | Remove retry statements | `retry` -> `nil` |
469
568
  | `case_when` | Remove/replace case/when branches | Remove `when` branch, drop one condition from `when a, b`, body -> `nil`, empty body -> `raise`, remove `else` |
@@ -520,6 +619,12 @@ The `evilution-mutate` tool accepts a `verbosity` parameter to control response
520
619
 
521
620
  Use `minimal` when context window budget is tight and you only need to see what survived. The trimmed `errors` sample (each entry: `error_message`, `error_class`, location, plus the first 5 backtrace lines) is added so a partly-broken run is still self-diagnosable without escalating verbosity. Use `full` when you need to inspect killed/neutral/equivalent entries for debugging.
522
621
 
622
+ What survives trimming matters when you are deciding whether to trust a score:
623
+
624
+ - `neutral` entries — and with them each `neutral_reason` — are dropped at `summary` and `minimal`. Use `full` to see why mutations were neutralised.
625
+ - `subjects` is kept at `full` and `summary`, and dropped at `minimal`, which keeps only `summary` and `survived`.
626
+ - Everything inside `summary` survives at every level, including `unresolved_target_files`, `infra_retried` and the `neutral` count — so even a `minimal` response still says whether a target file went untested and how much of the run the score covers.
627
+
523
628
  ### Enriched Survived Entries
524
629
 
525
630
  Unlike `evilution --format json`, every survived entry returned by `evilution-mutate` carries extra fields so the agent can act without a second round-trip:
@@ -605,7 +710,7 @@ Per-tool placement:
605
710
  - **`evilution-session` `list`** — `{ "schema_version": Integer, "sessions": Array<{ file, timestamp, total, killed, survived, score, duration }> }`. Sessions are reverse-chronological; the array is filtered by `limit` when provided.
606
711
  - **`evilution-session` `show`** — the parsed session JSON document, exactly as written under `.evilution/results/*.json`. Field reference: see [Session JSON files](#session-json-files).
607
712
  - **`evilution-session` `diff`** — `{ "schema_version": Integer, "summary": { base_score, head_score, score_delta, base_survived, head_survived, base_total, head_total, base_killed, head_killed }, "fixed": Array, "new_survivors": Array, "persistent": Array }`. The mutation arrays carry the same per-mutation fields the session `survived` list uses (`operator`, `file`, `line`, `subject`, `diff`).
608
- - **`evilution-info` `subjects`** — `{ "schema_version": Integer, "subjects": Array<{ name, file, line, mutations }>, "total_subjects": Integer, "total_mutations": Integer }`.
713
+ - **`evilution-info` `subjects`** — `{ "schema_version": Integer, "subjects": Array<{ name, file, line, mutations }>, "total_subjects": Integer, "total_mutations": Integer }`. Discovery only: this lists what *can* be mutated. The `subjects` array in a run's report is a different shape, carrying what each subject scored.
609
714
  - **`evilution-info` `tests`** — `{ "schema_version": Integer, "specs": Array<{ source, spec }>, "unresolved": Array<String>, "total_sources": Integer, "total_specs": Integer }`.
610
715
  - **`evilution-info` `environment`** — `{ "schema_version": Integer, "version": String, "ruby": String, "config_file": String|null, ... }` mirroring the effective `Evilution::Config`.
611
716
  - **`evilution-info` `statuses`** — `{ "schema_version": Integer, "statuses": Array<{ name, meaning, in_score }> }`.
@@ -636,7 +741,7 @@ When a parameter, action, or output field on the public MCP contract is deprecat
636
741
  bundle exec evilution run lib/ --format json --min-score 0.8
637
742
  ```
638
743
 
639
- Parse JSON output. Exit code 0 = pass, 1 = surviving mutants to address.
744
+ Parse JSON output. Exit code 0 = pass, 1 = fail — either the score is below `--min-score`, or a target file resolved to no spec and was never tested (`summary.unresolved_target_files` names them). Without `--min-score` no score gate is armed; the run still fails on an untested target file.
640
745
 
641
746
  ### 2. PR / changed-lines scan (fast feedback)
642
747
 
@@ -691,8 +796,23 @@ bundle exec evilution run lib/models/user.rb lib/models/account.rb lib/models/or
691
796
 
692
797
  Pass multiple file paths on a single invocation to amortise startup cost. The framework (Rails, Sorbet, etc.) and the `preload` chain (`spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`) load **once** in the parent process. When `--isolation=fork` is selected (the default `--isolation=auto` resolves to `fork` on Rails projects and packaged gems), every subsequent mutation across all files forks from that warmed parent — materially faster than scripting a `for f in ...; do bundle exec evilution run "$f"; done` loop, which pays the bootstrap per file. With `--isolation=in_process` (default for non-Rails, non-gem projects under `auto`), there is no per-mutation fork, but the parent-process boot still runs once instead of N times. Per-file paths and line numbers are preserved in the report (`survived[].file`, HTML grouping by source file).
693
798
 
799
+ ### What to read before acting on a score
800
+
801
+ A score describes only the mutations that got a verdict. Four fields say what it leaves out, and each points at a different action:
802
+
803
+ | Field | Meaning | What to do |
804
+ |---|---|---|
805
+ | `summary.unresolved_target_files` | A file you named resolved to no spec and was never tested; the run fails on this alone | Write a spec, pass `--spec`, or map it in `spec_mappings` — do not trust the score until this is empty |
806
+ | `subjects[].reached == false` | Mutations were generated for that method but none got a verdict | The method is untested even where its file scores well; start here rather than with `survived[]` |
807
+ | `neutral[].neutral_reason.kind == "baseline_failure"` | The spec file was already red before any mutation ran; `detail` names it | Fix that spec first — nothing about these mutations is measurable until it is green |
808
+ | `neutral[].neutral_reason.kind == "infra_error"` | The test process crashed on infrastructure (DB lock, timeout); `detail` names the class | Not a coverage gap. Give parallel workers their own database, or run `-j 1` |
809
+
810
+ `summary.infra_retried` reports how many mutations had to be re-run serially because of the last case; a large number means the parallel run was fighting shared infrastructure rather than measuring your suite.
811
+
694
812
  ### 6. Fixing surviving mutants
695
813
 
814
+ Every entry in `survived[]` has already been re-run against the whole resolved spec file, so it is a gap the suite genuinely does not cover rather than an artefact of per-mutation example targeting.
815
+
696
816
  For each entry in `survived[]`:
697
817
  1. Read `file` at `line` to understand the code context
698
818
  2. Read `operator` to understand what was changed
@@ -733,7 +853,7 @@ RUBYOPT="-Itest" bundle exec evilution mutate lib/<file>.rb \
733
853
  --spec test/<dir>/<file>_test.rb
734
854
  ```
735
855
 
736
- `-j 4` parallelises across workers, `-t 10` caps any mutation that pathologically loops at 10 s. Expect the run to print progress only when stderr is a TTY (use `bundle exec evilution mutate ... 2>&1 | tee log` to get progress while still saving output). The historical "Minitest fork hangs on liquid" report (EV-blnq / GH #1211) turned out to be a slow run + silent UX, not an actual deadlock — the worker logs show steady forward progress when captured via `--quiet-children --quiet-children-dir DIR`.
856
+ `-j 4` parallelises across workers, `-t 10` caps any mutation that pathologically loops at 10 s. Expect the run to print progress only when stderr is a TTY (use `bundle exec evilution mutate ... 2>&1 | tee log` to get progress while still saving output). The historical "Minitest fork hangs on liquid" report (GH #1211) turned out to be a slow run + silent UX, not an actual deadlock — the worker logs show steady forward progress when captured via `--quiet-children --quiet-children-dir DIR`.
737
857
 
738
858
  ### 8. CI gate
739
859
 
@@ -742,7 +862,9 @@ bundle exec evilution run lib/ --format json --min-score 0.8 --quiet
742
862
  # Exit code 0 = pass, 1 = fail, 2 = error
743
863
  ```
744
864
 
745
- Note: `--quiet` suppresses all stdout output (including JSON). Use it in CI only when you care about the exit code and do not need JSON output.
865
+ Exit 1 covers two conditions: the score missed `--min-score`, and a target file that resolved to no spec. The second fails the run whether or not a score gate is set, so a CI step that names files explicitly cannot silently stop testing one of them.
866
+
867
+ Note: `--quiet` suppresses all stdout output (including JSON). Use it in CI only when you care about the exit code and do not need JSON output. `--output FILE` is the alternative when a preloaded spec helper (SimpleCov, for example) writes to stdout on exit.
746
868
 
747
869
  ### 9. Regression tracking across runs (`compare`)
748
870
 
@@ -771,7 +893,15 @@ Use in CI to gate merges on `reintroduced` being empty, or to surface `new` surv
771
893
 
772
894
  ## Parallel Runs with SQLite
773
895
 
774
- Running with `-j N` forks worker processes. If your Rails app uses SQLite, every worker opens the same `db/test.sqlite3` file, and concurrent writers collide on the database-level lock. Symptoms: `ActiveRecord::StatementTimeout`, `SQLite3::BusyException`, and slow runs. Evilution classifies these crashes as `:neutral` (see [EV-toid / #814](https://github.com/taxdome/evilution/issues/814)) so the mutation score is not polluted, but the wall-clock penalty remains.
896
+ Running with `-j N` forks worker processes. If your Rails app uses SQLite, every worker opens the same `db/test.sqlite3` file, and concurrent writers collide on the database-level lock. Symptoms: `ActiveRecord::StatementTimeout`, `SQLite3::BusyException`, and slow runs. Evilution classifies these crashes as `:neutral` (see [GH #814](https://github.com/marinazzio/evilution/issues/814)) so the mutation score is not polluted, but the wall-clock penalty remains.
897
+
898
+ Because that contention only exists while several workers are running, those mutations are re-run one at a time once the pool is done, and the verdict from the quiet re-run is the one reported. Without it the neutral bucket moved with `-j` on identical input — the same files scoring 24 killed / 0 neutral at `-j 1` and 6 killed / 18 neutral at `-j 4` (GH #1607). The run says how much it had to redo:
899
+
900
+ ```
901
+ ! 18 mutations hit infrastructure errors under parallel workers; re-ran them serially.
902
+ ```
903
+
904
+ The count is in JSON output as `summary.infra_retried`. Such a crash is also never written to the `--incremental` cache: the cache keeps no error class, so a cached `:killed` would be indistinguishable from a real one on the next run and would short-circuit both the demotion and the retry. A neutral from a failing baseline is never re-run — that is a real statement about the spec, not a missed verdict. The retry costs wall-clock time in proportion to the contention, which is another reason to give each worker its own database file:
775
905
 
776
906
  Evilution follows the [`parallel_tests`](https://github.com/grosser/parallel_tests) convention: each worker receives a `TEST_ENV_NUMBER` environment variable (`""` for worker 1, `"2"` for worker 2, `"3"` for worker 3, …). Interpolate it into `config/database.yml` so each worker gets its own SQLite file:
777
907
 
@@ -816,7 +946,7 @@ points — see [docs/architecture.md](docs/architecture.md).
816
946
  1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
817
947
  2. **Extract** — Methods are identified as mutation subjects
818
948
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
819
- 4. **Mutate** — 80 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing); heredoc literal text is skipped by default. Identical byte-mutations from different operators are deduplicated by `(file_path, mutated_source)` so the count is not inflated by overlap
949
+ 4. **Mutate** — 88 operators produce text replacements at precise byte offsets (source-level surgery, no AST unparsing); heredoc literal text is skipped by default. Identical byte-mutations from different operators are deduplicated by `(file_path, mutated_source)` so the count is not inflated by overlap
820
950
  5. **Isolate** — Mutations are applied to temporary file copies (never modifying originals); load-path redirection ensures `require` resolves the mutated copy. Default isolation is in-process for plain Ruby projects (no gemspec) and fork for Rails projects and packaged gems (auto-detected); `--isolation fork` forces forked child processes. Both sequential and parallel (`--jobs N`) modes respect the configured isolation strategy
821
951
  6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
822
952
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
data/docs/architecture.md CHANGED
@@ -48,13 +48,14 @@ Everything lives under `lib/evilution/`.
48
48
  | `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
49
49
  | `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
50
50
  | `AST`, `Subject` | Prism parse, find method subjects, source surgery, pattern matching, heredoc spans. | `ast/parser.rb`, `ast/source_surgeon.rb`, `subject.rb` |
51
- | `Mutator`, `Mutator::Operator::*` | 80 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
51
+ | `Mutator`, `Mutator::Operator::*` | 88 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
52
52
  | `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
53
53
  | `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
54
54
  | `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
55
55
  | `Integration::{RSpec,Minitest,TestUnit}` | Apply a mutation and run the configured test framework; report the raw outcome. | `integration/base.rb`, `integration/rspec.rb` |
56
56
  | `Parallel::{Pool,WorkQueue}` | Fan mutations across worker processes for `jobs > 1`. | `parallel/pool.rb`, `parallel/work_queue.rb` |
57
57
  | `Result::{MutationResult,Summary}` | Per-mutation result + aggregated, scored summary. | `result/mutation_result.rb`, `result/summary.rb` |
58
+ | `Result::{SubjectScore,SubjectScorer,NeutralReason}` | Score each subject on its own, and record why a neutral is neutral — what the file-level score does not speak for. | `result/subject_scorer.rb`, `result/neutral_reason.rb` |
58
59
  | `Reporter::{CLI,JSON,HTML,Suggestion}` | Render a `Summary` to text / JSON / HTML. | `reporter/*` |
59
60
  | `Session`, `Compare` | Persist runs to `.evilution/results/*.json`; diff two sessions. | `session/store.rb`, `compare.rb` |
60
61
  | `Coverage`, `Equivalent`, `Baseline`, `Cache`, `Hooks`, `MCP` | Coverage-based example targeting, equivalent-mutation detection, baseline capture, incremental cache, lifecycle hooks, MCP server. | respective dirs |
@@ -87,7 +88,10 @@ class that owns it.
87
88
  5. **Spec resolution** — per subject, `SpecSelector#call(source_path)` picks specs
88
89
  (explicit `spec_files` → `spec_mappings` → `SpecResolver#resolve_specs` layout
89
90
  heuristics). Example-level targeting narrows to examples that reference the
90
- mutated token (`ExampleFilter` / `CoverageExampleFilter`).
91
+ mutated token (`ExampleFilter` / `CoverageExampleFilter`). Separately,
92
+ `Runner::TargetSpecAudit` asks the same selector once per target file in the
93
+ parent, so a file that resolves to no spec at all is a fact the summary
94
+ carries rather than a handful of `unresolved` mutations.
91
95
  6. **Execute** — `Runner::MutationExecutor#call` picks a strategy by `config.jobs`:
92
96
  `Strategy::Sequential` for `jobs == 1`, `Strategy::Parallel` (via
93
97
  `Parallel::Pool` / `WorkQueue`) for `jobs > 1`. Either way each mutation reaches
@@ -103,13 +107,24 @@ class that owns it.
103
107
  guard / unresolved); the final symbol is chosen by
104
108
  `Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
105
109
  `:unresolved` → `:error` → `:survived` (tests passed) → default `:killed`.
106
- A `NeutralizationPipeline` can reclassify results whose covering spec already
107
- failed at baseline into `:neutral`.
110
+ A `NeutralizationPipeline` can reclassify results into `:neutral` — either
111
+ because the covering spec already failed at baseline (`Neutralizer::BaselineFailed`)
112
+ or because the test process crashed on infrastructure rather than on the
113
+ mutation (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`,
114
+ since the two want opposite responses. A survivor is re-run against its whole
115
+ spec file before it is reported (`Integration::RSpec#confirm_survivor?`), so a
116
+ narrowed example set cannot invent one; after a parallel pass,
117
+ `MutationExecutor::InfraRetry` re-runs the infrastructure-neutralised
118
+ mutations serially, once the contention that caused them is gone.
108
119
  9. **Aggregate + report** — `Result::Summary` counts each status and computes
109
120
  `score = killed / score_denominator` (total minus error/neutral/equivalent/
110
121
  unresolved/unparseable). `Runner::ReportPublisher#publish` selects a reporter by
111
122
  `config.format` and writes it; `Session::Store` optionally persists the run.
112
- `Commands::Run` maps `summary.success?(min_score:)` to exit code `0`/`1` (`2` on error).
123
+ `Summary#success?` is also false when a target file resolved to no spec, whatever
124
+ the score. `Commands::Run` maps `summary.success?(min_score:)` to exit code
125
+ `0`/`1` (`2` on error), and `CLI::ExitGuard` — installed by the executable before
126
+ anything can preload the project — has the final word on the process status, so a
127
+ preloaded spec helper's at-exit hook cannot replace it.
113
128
 
114
129
  ## How to add a new mutator
115
130
 
data/docs/isolation.md CHANGED
@@ -48,7 +48,7 @@ framework must be loaded in the parent before forking — see [Automatic
48
48
  preload](#automatic-preload) — and `in_process` cannot preload them without
49
49
  polluting the host process. A non-Rails gem run under the old `in_process`
50
50
  default therefore produced 0 examples / 100% errors out of the box; defaulting
51
- gems to `fork` lets auto-preload fire (EV-z03y, PR #1375). A plain non-Rails,
51
+ gems to `fork` lets auto-preload fire (PR #1375). A plain non-Rails,
52
52
  non-gem project (no gemspec) still defaults to `in_process`.
53
53
 
54
54
  The same hazard applies to any Ruby code that uses
@@ -92,12 +92,11 @@ order, falling back to the gem's library entry point (`lib/<gem>.rb`):
92
92
  When a gem is detected but none of those helpers exist, evilution prints a
93
93
  warning naming the locations it looked in and pointing at `--preload`, so a
94
94
  non-standard test layout reads as a fixable configuration issue rather than a
95
- silent 0% (EV-z03y, PR #1375).
95
+ silent 0% (PR #1375).
96
96
 
97
97
  Minitest/Test::Unit helpers that `require "test_helper"` (or any non-relative
98
98
  `require "support/..."`) work without `-Itest`: evilution puts the test root
99
- on `$LOAD_PATH` for the preload just as the test runner would (EV-5hk5, PR
100
- #1373).
99
+ on `$LOAD_PATH` for the preload just as the test runner would (PR #1373).
101
100
 
102
101
  No configuration needed.
103
102
 
data/exe/evil CHANGED
@@ -3,4 +3,11 @@
3
3
 
4
4
  require "evilution"
5
5
 
6
- exit Evilution::CLI.new(ARGV).call
6
+ # Installed before anything loads the project's spec helper, so this hook is the
7
+ # last one standing and decides the process status — a helper's own at_exit hook
8
+ # (SimpleCov calls exit with its own status) cannot replace it.
9
+ # EV-g8ya / GH #1608.
10
+ guard = Evilution::CLI::ExitGuard.new.install
11
+ guard.status = Evilution::CLI.new(ARGV).call
12
+
13
+ exit guard.status
data/exe/evilution CHANGED
@@ -3,4 +3,11 @@
3
3
 
4
4
  require "evilution"
5
5
 
6
- exit Evilution::CLI.new(ARGV).call
6
+ # Installed before anything loads the project's spec helper, so this hook is the
7
+ # last one standing and decides the process status — a helper's own at_exit hook
8
+ # (SimpleCov calls exit with its own status) cannot replace it.
9
+ # EV-g8ya / GH #1608.
10
+ guard = Evilution::CLI::ExitGuard.new.install
11
+ guard.status = Evilution::CLI.new(ARGV).call
12
+
13
+ exit guard.status
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "../ast"
6
+
7
+ # Answers whether a scope body ever reads a given local name — the question an
8
+ # operator asks before changing a parameter, since a parameter nothing reads
9
+ # makes most such mutations behaviour-preserving.
10
+ #
11
+ # Scope-aware in the ways that matter here. A block shares the scope it is
12
+ # written in, so a read inside one counts — unless the block binds the same name
13
+ # itself, as a parameter or a block-local, in which case the read is of the
14
+ # block's own variable. A nested def opens a scope of its own, where a local of
15
+ # the same name is unrelated. A write is not a read.
16
+ #
17
+ # Shadowing is settled by Prism's own answer rather than by inspecting each
18
+ # block's parameter list: a LocalVariableReadNode carries the number of scopes
19
+ # between the read and the variable's declaration, so a read refers to the local
20
+ # in question exactly when that depth matches the number of block scopes
21
+ # descended to reach it.
22
+ class Evilution::AST::LocalReads
23
+ def call(node, name, depth = 0)
24
+ return false if node.nil?
25
+ return true if reads?(node, name, depth)
26
+
27
+ node.compact_child_nodes.any? do |child|
28
+ next false if child.is_a?(Prism::DefNode)
29
+
30
+ call(child, name, depth + (scope?(child) ? 1 : 0))
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def reads?(node, name, depth)
37
+ node.is_a?(Prism::LocalVariableReadNode) && node.name.to_s == name && node.depth == depth
38
+ end
39
+
40
+ def scope?(node)
41
+ node.is_a?(Prism::BlockNode) || node.is_a?(Prism::LambdaNode)
42
+ end
43
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gives evilution the final word on the process exit status.
4
+ #
5
+ # `--preload` loads the project's own spec helper into the parent process, and
6
+ # whatever that helper installs comes along with it. SimpleCov's at_exit hook
7
+ # calls `exit` with its own status when coverage is below the configured
8
+ # minimum, which replaces the status evilution computed and breaks a CI step
9
+ # reading it (EV-g8ya / GH #1608).
10
+ #
11
+ # at_exit hooks run last-registered-first, so a hook installed before the
12
+ # preload is the last one standing. It exits with `exit!`, which is immediate
13
+ # and cannot be overridden by anything left in the queue — by then every other
14
+ # hook, evilution's own temp-directory cleanup included, has already run.
15
+ #
16
+ # With no status recorded, evilution did not finish normally: an exception is on
17
+ # its way out, and Ruby's own handling decides the status instead.
18
+ #
19
+ # The hook belongs to the process that installed it. Evilution forks workers,
20
+ # and a fork inherits its parent's at_exit hooks; a worker running this one
21
+ # would exit with the parent's status and skip its own ending. The guard
22
+ # therefore remembers its process and stands down anywhere else.
23
+ class Evilution::CLI::ExitGuard
24
+ attr_accessor :status
25
+
26
+ def initialize(register: nil, exiter: nil, pid_source: nil, streams: nil)
27
+ @register = register || ->(&hook) { at_exit(&hook) }
28
+ @exiter = exiter || ->(code) { exit!(code) }
29
+ @streams = streams || -> { [$stdout, $stderr] }
30
+ @pid_source = pid_source || -> { Process.pid }
31
+ @status = nil
32
+ end
33
+
34
+ def install
35
+ @pid = @pid_source.call
36
+ @register.call { fire }
37
+ self
38
+ end
39
+
40
+ private
41
+
42
+ def fire
43
+ return if @status.nil?
44
+ return unless @pid_source.call == @pid
45
+
46
+ flush_streams
47
+ @exiter.call(@status)
48
+ end
49
+
50
+ # `exit!` leaves buffered output where it stands, so anything written by the
51
+ # report, or by a hook that ran before this one, has to be pushed out first.
52
+ def flush_streams
53
+ @streams.call.each do |stream|
54
+ stream.flush
55
+ rescue IOError, Errno::EBADF
56
+ nil
57
+ end
58
+ end
59
+ end