evilution 1.2.0 → 1.4.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 (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
data/README.md CHANGED
@@ -21,9 +21,15 @@ Add to `Gemfile`:
21
21
  gem "evilution", group: :test
22
22
  ```
23
23
 
24
- Then: `bundle install`
24
+ Then:
25
+ ```shell
26
+ bundle install
27
+ ```
25
28
 
26
- Or standalone: `gem install evilution`
29
+ Or standalone:
30
+ ```shell
31
+ gem install evilution
32
+ ```
27
33
 
28
34
  Requires `prism >= 1.5, < 2`. Older Rails apps (e.g. Rails 7.1 pins `prism 0.19`) must upgrade prism — the gemspec constraint forces bundler to resolve a compatible 1.x version. If your app pins `prism 2.x`, bundler will reject the install until evilution widens its upper bound.
29
35
 
@@ -88,7 +94,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
88
94
  | `init` | Generate `.evilution.yml` config file | |
89
95
  | `version` | Print version string | |
90
96
  | `subjects [files]` | List mutation subjects with locations and counts | |
91
- | `tests list [files]` | List spec files mapped to source files | |
97
+ | `tests list [files]` | List the spec files `run` would use for each source (layout, `spec_mappings`, `spec_pattern`) | |
92
98
  | `session list` | List saved session results | |
93
99
  | `session show FILE` | Display detailed session results | |
94
100
  | `session diff A B` | Compare two sessions (fixed/new/persistent) | |
@@ -103,7 +109,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
103
109
  |------------------------------|---------|--------------|---------------------------------------------------|
104
110
  | `-t`, `--timeout N` | Integer | 30 | Per-mutation timeout in seconds. |
105
111
  | `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
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`). |
112
+ | `--target EXPR` | String | _(none)_ | Only mutate matching subjects. Supports method name (`Foo::Bar#calculate`), class (`Foo`, which also matches a value-object constant named `Foo`), namespace wildcards (`Foo::Bar*`), method-type selectors (`Foo#`, `Foo.`), descendants (`descendants:Foo`), and source globs (`source:lib/**/*.rb`). |
107
113
  | `--output FILE` | String | _(stdout)_ | Write the report to FILE instead of stdout. Useful when a preloaded spec helper writes to stdout on exit. |
108
114
  | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
109
115
  | `--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. |
@@ -113,7 +119,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
113
119
  | `--example-targeting MODE` | String | `lexical` | How targeting picks examples: `lexical` (name-grep example bodies for the mutated method/class), `coverage` (run only the examples that actually execute the mutated line, from a cached line-coverage map), or `full_file` (run all resolved examples). |
114
120
  | `--example-targeting-fallback MODE` | String | `full_file` | Behavior when no example matches: `full_file` (run the whole spec file) or `unresolved` (skip the mutation as `:unresolved`). |
115
121
  | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
116
- | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures and marks those mutations as `neutral`. |
122
+ | `--no-baseline` | Boolean | _(enabled)_ | Skip baseline test suite check. By default, a baseline run detects pre-existing failures, so that a mutation whose tests fail only on those is recorded `neutral` rather than killed. |
117
123
  | `--[no-]canary` | Boolean | _(enabled)_ | Run a proof-of-life synthetic mutation at session start; abort the run if the pipeline misreports it. Catches misconfigured isolation, broken autoload, and reporter-plugin eviction before any real score is produced. Pass `--no-canary` to skip (e.g. CI speed, or when the canary itself is the thing under test). |
118
124
  | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
119
125
  | `-v`, `--verbose` | Boolean | false | Verbose output with RSS memory and GC stats per phase and per mutation; also prints error class, message, and first 5 backtrace lines for errored mutations. |
@@ -129,13 +135,14 @@ Every command, subcommand, and flag listed in this section is part of evilution'
129
135
  | `--isolation MODE` | String | `auto` | Isolation strategy: `auto`, `fork`, or `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`), `in_process` otherwise. See [docs/isolation.md](docs/isolation.md). |
130
136
  | `--preload FILE` | String | _(auto)_ | File to require in parent before forking workers. Auto-detect chain for Rails projects: `spec/rails_helper.rb` → `spec/spec_helper.rb` → `test/test_helper.rb`. For non-Rails gems: `spec/spec_helper.rb` → `test/test_helper.rb` → `test/helper.rb`, falling back to the gem entry `lib/<gem>.rb` (with a warning naming the searched paths). Pass `--no-preload` to opt out. |
131
137
  | `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
138
+ | `--warmup NAME` | String | `none` | After preload, warm lazily-initialised framework state once in the parent: `none` or `rails`. See [Warming up Rails before forking](#warming-up-rails-before-forking). |
132
139
  | `--skip-heredoc-literals` | Boolean | false | Skip all string literal mutations inside heredocs. |
133
140
  | `--show-disabled` | Boolean | false | Report mutations skipped by `# evilution:disable` comments. |
134
141
  | `--fallback-full-suite` | Boolean | false | When no matching spec/test resolves for a mutation, run the whole test suite instead of marking it `:unresolved` and skipping. |
135
142
  | `--related-specs-heuristic` | Boolean | false | When a mutation removes an `includes(...)` call, also run matching specs from `spec/{requests,integration,features,system}` (Rails-style domain match on the source file's basename). Trades extra spec runs for higher kill rate on ORM mutations. |
136
143
  | `--baseline-session PATH` | String | _(none)_ | Saved session file for HTML report comparison. |
137
144
  | `-e CODE`, `--eval CODE` | String | _(none)_ | Inline Ruby code for `util mutation` command. |
138
- | `--profile NAME` | String | `default` | Operator profile: `default` or `strict`. `strict` adds aggressive truthiness mutators (e.g. replaces `x.predicate?` with `nil`) intended for pre-merge audits. |
145
+ | `--profile NAME` | String | `default` | Operator profile: `default` or `strict`. `strict` adds aggressive mutators (e.g. replaces `x.predicate?` with `nil`, or appends `rescue nil` to a raising call) intended for pre-merge audits. |
139
146
  | `--strict` | Boolean | false | Shortcut for `--profile=strict`. |
140
147
 
141
148
  ### Options (for `session` subcommands)
@@ -158,8 +165,14 @@ Every command, subcommand, and flag listed in this section is part of evilution'
158
165
 
159
166
  Two profiles ship out of the box:
160
167
 
161
- - **`default`** — the 88 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
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.
168
+ - **`default`** — the 136 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
169
+ - **`strict`** — adds extra aggressive mutators on top of `default`:
170
+ - `PredicateToNil` replaces every `x.predicate?` call with `nil` to surface tests that only assert truthiness rather than exact return values.
171
+ - `ExceptionSwallow` appends `rescue nil` to a statement that raises by convention — a bang method, `fetch` without a default, `Integer` / `Float` / `Rational` — to surface tests that never make it fail and check the error comes out (`record.save!` -> `record.save! rescue nil`). It skips Ruby core in-place bangs (`uniq!`, `sort_by!`, …), `exit!`, statements already under a rescue, and `raise`; project bangs that mutate rather than raise will still show up as survivors.
172
+
173
+ - `StatementReorder` swaps two adjacent commands (`charge(card); send_receipt(user)` -> `send_receipt(user); charge(card)`) to surface side effects whose order is never asserted. It only swaps statements that both act — call a method, append, write an index, yield, call super — and leaves alone pairs that share a variable, contain control flow or a heredoc, involve a local or instance variable assignment, write different literal keys of the same receiver, or move the last statement of a body. Expect survivors where the order only shows up in help text or in the order a list is built.
174
+
175
+ Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
163
176
 
164
177
  Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
165
178
 
@@ -202,6 +215,7 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
202
215
  # isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems)
203
216
  # canary: true # proof-of-life synthetic mutation at session start (false to skip)
204
217
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails + gems
218
+ # warmup: none # rails: warm I18n, routes, assets and templates once after preload (fork isolation)
205
219
  # skip_heredoc_literals: false # skip string literal mutations inside heredocs (recommended for Rails: heredoc SQL/templates rarely have test coverage)
206
220
  # show_disabled: false # report mutations skipped by disable comments
207
221
  # baseline_session: null # path to session file for HTML comparison
@@ -246,7 +260,7 @@ All keys recognised under `schema_version: 1`:
246
260
  | `quiet` | Boolean | `false` | Suppress output. |
247
261
  | `jobs` | Integer | `1` | Number of parallel workers. |
248
262
  | `fail_fast` | Integer / null | `null` | Stop after N surviving mutants. `null` = disabled. |
249
- | `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (marked `:neutral`). |
263
+ | `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (a mutation failing only on those is `:neutral`). |
250
264
  | `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`). |
251
265
  | `incremental` | Boolean | `false` | Cache killed/timeout results across runs. |
252
266
  | `suggest_tests` | Boolean | `false` | Generate concrete test code in survivor suggestions (matches `integration`). |
@@ -261,6 +275,7 @@ All keys recognised under `schema_version: 1`:
261
275
  | `related_specs_heuristic` | Boolean | `false` | Append related request/integration/feature/system specs for `includes(...)` mutations. |
262
276
  | `fallback_to_full_suite` | Boolean | `false` | When no matching spec resolves, run the entire suite instead of marking the mutation `:unresolved`. |
263
277
  | `preload` | String / Boolean / null | `null` | File to preload in parent before forking. `false` to disable. `null` to auto-detect for Rails projects and packaged gems. |
278
+ | `warmup` | String | `none` | `rails` warms lazily-initialised Rails state once after preload so forks do not each pay it. `none` / `false` to skip. |
264
279
  | `spec_mappings` | Hash&lt;String, String/Array&gt; | `{}` | Custom mapping from source path to spec path(s). |
265
280
  | `spec_pattern` | String / null | `null` | Glob restricting resolved spec candidates. |
266
281
  | `example_targeting` | Boolean | `true` | Per-mutation example-level targeting. |
@@ -312,11 +327,14 @@ Schema:
312
327
  "survived": "integer — mutations NOT detected (test passed = gap in coverage)",
313
328
  "timed_out": "integer — mutations that exceeded timeout",
314
329
  "errors": "integer — mutations that caused unexpected errors",
315
- "neutral": "integer — mutations whose tests already failed before mutation (baseline failure)",
330
+ "neutral": "integer — mutations with no verdict: their tests failed only on examples that were already failing in the baseline, or the test process crashed on infrastructure",
316
331
  "equivalent": "integer — mutations proven to have identical behavior to the original",
317
332
  "unresolved": "integer — mutations where no spec file resolved (coverage gap, not a failure)",
318
333
  "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
334
  "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",
335
+ "uncovered_code": "array (optional) — targeted lines that hold code outside every subject, so no mutation was generated for them: [{ file, lines: ['2-4', '9'] }]; checked for a line range as given, and for a whole file only when it has no subject at all; present only when non-empty",
336
+ "baseline_neutralized": "integer (optional) — kills not counted because only examples already failing in the baseline failed; those mutations are recorded neutral; present only when non-zero",
337
+ "baseline_failures": "array (optional) — one entry per spec file that was red in the baseline: { spec_file, error: string|null — what stopped the file outside any example (load error, runner exception, timeout, a baseline process that died), failing_examples: integer — every failing example, examples: [{ id, description, message }] — the first few of them }; present only when non-empty",
320
338
  "unparseable": "integer — mutations whose mutated source did not parse (short-circuited, never executed)",
321
339
  "score": "float — killed / (total - errors - neutral - equivalent - unresolved - unparseable), range 0.0-1.0, rounded to 4 decimals",
322
340
  "duration": "float — total wall-clock seconds, rounded to 4 decimals",
@@ -418,7 +436,7 @@ Compatibility policy for the `1.x` gem line:
418
436
  | `survived` | No test failed — gap in coverage | denominator only |
419
437
  | `timeout` | Test run exceeded `--timeout` — treated like survived for scoring | denominator only |
420
438
  | `error` | Mutation caused an unexpected error (syntax error, boot failure, etc.) | excluded from denominator |
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 |
439
+ | `neutral` | No verdict: the tests failed only on examples that were already failing in the baseline, 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 |
422
440
  | `equivalent` | Mutation is provably identical to the original (e.g. no-op replacement) | excluded |
423
441
  | `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 |
424
442
  | `unparseable` | Mutated source failed to parse (e.g. dangling heredoc opener after `method_body_replacement`). Short-circuited — never executed. | excluded |
@@ -450,10 +468,15 @@ On evilution's own `lib/evilution/reporter/json/subjects.rb` this moved the repo
450
468
 
451
469
  ### Neutral Mutations
452
470
 
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):
471
+ Neutral means the run has no verdict on a mutation. It covers two unrelated situations that want opposite responses, and each neutral records which; the report groups by it, naming the spec or the error class (GH #1606):
472
+
473
+ - **The tests failed, but only on examples that were already failing before any mutation ran.** Such an example fails whatever the mutation is, so its failure is not a kill. Nothing that was passing caught the mutation either, but the red example might have, once fixed.
474
+ - **The test process died on infrastructure** rather than on the mutation.
454
475
 
455
476
  ```
456
477
  Score: 100.00% (10/10 verified of 17 mutations, 7 neutral)
478
+ ! 7 kills not counted for spec/tally_spec.rb: only examples already failing in the baseline failed, so those mutations have no verdict.
479
+ ./spec/tally_spec.rb[1:3] Tally adds -- NameError: undefined local variable or method 'tally'
457
480
 
458
481
  Neutral mutations (7, not verified):
459
482
  baseline already failing (spec/tally_spec.rb):
@@ -461,9 +484,17 @@ Neutral mutations (7, not verified):
461
484
  integer_literal: lib/tally.rb:9
462
485
  ```
463
486
 
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)`.
487
+ 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)`. The line under it says how many kills were not counted, for which spec file, and why the baseline was red for it — the failing examples with their first error line, or the error that stopped the file before any example ran. The same detail is printed to stderr once per red spec file as soon as the baseline finishes, and is in JSON output as `summary.baseline_neutralized` and `summary.baseline_failures`.
488
+
489
+ What a red baseline does not do:
465
490
 
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`.
491
+ - **It does not touch a kill in which something that was passing failed too.** That is a kill like any other.
492
+ - **It does not touch survivors.** A mutation whose tests passed is a survivor whatever the baseline did — including when the baseline's failure did not happen again in the mutation run (a flaky example, a failure of the baseline process itself). Earlier versions recorded every survivor covered by a red spec file as neutral, which hid exactly those gaps.
493
+ - **It does not second-guess a baseline failure with no failing example to name** — a file that did not load, a timeout, a baseline process that died. A mutation run that hits the same problem reports `error` or `timeout` itself.
494
+
495
+ Recognising an already-failing example needs the test framework to name the examples that failed, the same way in the baseline and in a mutation run. RSpec examples are named by spec file and position, Minitest tests as `Class#test`, Test::Unit tests as `test(Class)`. Two Minitest test classes without a name that share a test method name cannot be told apart.
496
+
497
+ 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
498
 
468
499
  ### Per-Subject Scores
469
500
 
@@ -481,7 +512,7 @@ Subjects needing attention (2 subjects in 1 file):
481
512
 
482
513
  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
514
 
484
- ## Mutation Operators (88 total)
515
+ ## Mutation Operators (136 total)
485
516
 
486
517
  Each operator name is stable and appears in JSON output under `survived[].operator`.
487
518
 
@@ -508,12 +539,57 @@ Each operator name is stable and appears in JSON output under `survived[].operat
508
539
  | `method_body_replacement` | Replace entire method body | Method body -> `nil`, `self`, `super` |
509
540
  | `negation_insertion` | Negate predicate methods | `x.empty?` -> `!x.empty?` |
510
541
  | `return_value_removal` | Strip return values | `return x` -> `return` |
542
+ | `return_keyword_removal` | Drop the `return` keyword and keep its value, so control flow continues past a guard clause or early return (several values become an array; skips returns in tail position, where the value is the result anyway, and bare `return`) | `return :neg if x.negative?` -> `:neg if x.negative?` |
511
543
  | `collection_replacement` | Swap collection methods | `map` -> `each`, `select` <-> `reject` |
512
544
  | `collection_return` | Replace collection return values | `return [1]` -> `return []` |
513
545
  | `scalar_return` | Replace scalar return values | `return 42` -> `return 0` |
514
546
  | `method_call_removal` | Remove method calls, keep receiver | `obj.foo(x)` -> `obj` |
515
547
  | `argument_removal` | Remove individual arguments | `foo(a, b)` -> `foo(b)` |
516
548
  | `argument_nil_substitution` | Replace arguments with `nil` | `foo(a, b)` -> `foo(nil, b)` |
549
+ | `call_to_nil` | Replace a method call with `nil` (skips void statements, receivers of another call, and attribute or index writes) | `user.name` -> `nil` |
550
+ | `safe_navigation_removal` | Replace `&.` with a plain call (skips `self` and literal receivers) | `user&.name` -> `user.name` |
551
+ | `attribute_write_to_read` | Replace an attribute or index write with the matching read (skips writes `statement_deletion` already removes) | `a.foo = b` -> `a.foo`, `a[i] = b` -> `a[i]` |
552
+ | `argument_propagation` | Replace a call with its only positional argument (skips operator methods, attribute writes and void statements) | `normalize(value)` -> `value` |
553
+ | `argument_list_removal` | Drop a call's whole argument list, keeping any block (skips operator methods and attribute writes) | `foo(a, b)` -> `foo`, `foo(a, &b)` -> `foo(&b)` |
554
+ | `symbol_to_proc_replacement` | Apply the `send_mutation` and `collection_replacement` selector tables to a symbol block-pass (skips alias swaps) | `map(&:to_s)` -> `map(&:to_i)` |
555
+ | `dynamic_dispatch_resolution` | Resolve `send` / `__send__` / `public_send` with a literal symbol into a direct call, only where visibility checks can differ | `obj.send(:m, a)` -> `obj.m(a)` |
556
+ | `keyword_argument_removal` | Drop one `key: value` argument at a call site (keeps `**opts`; skips a lone keyword) | `f(a: 1, b: 2)` -> `f(b: 2)` |
557
+ | `double_negation_removal` | Replace a double negation with its operand | `!!x` -> `x` |
558
+ | `regexp_anchor_to_predicate` | Rewrite a match against an anchored literal regexp as a prefix / suffix predicate (skips `match?` with `\A` / `\z`, an exact equivalent) | `x =~ /^foo/` -> `x.start_with?("foo")` |
559
+ | `reduce_to_sum` | Rewrite a `+` reduction (`reduce` / `inject` with `:+` or `&:+`) into `sum` | `xs.reduce(:+)` -> `xs.sum`, `xs.inject(0, :+)` -> `xs.sum(0)` |
560
+ | `array_coercion_to_literal` | Replace an `Array()` coercion with an array literal | `Array(x)` -> `[x]` |
561
+ | `to_i_to_integer` | Replace lenient `to_i` with strict `Integer()` (skips numeric literal receivers) | `x.to_i` -> `Integer(x)`, `x.to_i(16)` -> `Integer(x, 16)` |
562
+ | `coercion_emptying` | Replace a conversion with an empty value of its type (`to_a` / `to_ary`, `to_h` / `to_hash`, `to_s` / `to_str`) | `x.to_s` -> `""`, `x.to_h` -> `{}` |
563
+ | `const_get_to_constant_path` | Resolve `const_get` with a literal symbol into a constant path | `X.const_get(:Y)` -> `X::Y` |
564
+ | `proc_to_lambda` | Turn `proc { }` / `Proc.new { }` into `lambda { }` (arity checks, local `return`) | `proc { \|x\| x }` -> `lambda { \|x\| x }` |
565
+ | `dig_to_fetch_chain` | Make the first step of a multi-key `dig` strict | `x.dig(a, b)` -> `x.fetch(a).dig(b)` |
566
+ | `inequality_to_negated_identity` | Rewrite `!=` with the stricter `eql?` / `equal?` (skips nil, boolean and symbol literals) | `a != b` -> `!a.eql?(b)`, `!a.equal?(b)` |
567
+ | `binary_operand_promotion` | Replace an arithmetic or bitwise expression with one of its operands (skips integer identities and void statements) | `a + b` -> `a`, `b` |
568
+ | `receiver_constructor_swap` | Swap `Date` / `DateTime` / `Time.parse` for a stricter sibling constructor on the same receiver | `Date.parse(s)` -> `Date.iso8601(s)`, `Date.strptime(s)`, ... |
569
+ | `block_body_to_nil` | Replace a block body with `nil`, keeping the iteration (skips `loop` and endless `cycle` / `cycle(nil)`, which would hang) | `xs.each { \|x\| log(x) }` -> `xs.each { \|x\| nil }` |
570
+ | `block_body_to_raise` | Replace a block body with `raise` to prove the block is invoked (skips bodies with a `rescue` clause, which would swallow it) | `xs.each { \|x\| log(x) }` -> `xs.each { \|x\| raise }` |
571
+ | `block_body_promotion` | Replace a call with its parameter-less block body, run once (skips blocks with parameters or a `rescue` / `ensure` clause) | `Base.transaction { save! }` -> `save!`, `3.times { poll }` -> `poll` |
572
+ | `data_struct_member` | Drop a member, or swap adjacent members, of a `Data.define` / `Struct.new` definition (a definition outside any method — `Point = Data.define(...)`, `class Coord < Struct.new(...)` — is its own subject, named after the constant; skips single-member and splatted lists) | `Data.define(:a, :b)` -> `Data.define(:b)`, `Data.define(:b, :a)` |
573
+ | `pin_operator_removal` | Drop the pin of a pattern variable so it captures instead of comparing (local variables only; skips pins inside alternative patterns, where a capture is not allowed) | `in ^expected` -> `in expected` |
574
+ | `rightward_assignment` | Turn a rightward pattern match into a pattern predicate, so a mismatch returns `false` instead of raising (skips a bare capture, which cannot fail) | `value => [a, b]` -> `value in [a, b]` |
575
+ | `numbered_parameter_swap` | Swap neighbouring numbered block parameters that the body reads (skips blocks reading a single one; `it` has nothing to swap with) | `pairs.map { _1 - _2 }` -> `pairs.map { _2 - _1 }` |
576
+ | `forwarded_argument_drop` | Stop forwarding one part of the arguments a method passes through unnamed; a `...` signature is spelled out so a single part can be left out (skips a lone anonymous argument and named splats) | `g(*, **, &)` -> `g(**, &)`, `def f(...) = g(...)` -> `def f(*, **, &) = g(*, **)` |
577
+ | `pattern_wildcard_widening` | Widen a pattern so it accepts shapes it rejects: append a rest to an array pattern, wildcard or drop a hash-pattern pair, remove `**nil` (skips pairs that bind a variable and patterns that already have a rest) | `in [a, b]` -> `in [a, b, *]`, `in { age: Integer }` -> `in { age: _ }` |
578
+ | `no_matching_pattern_else` | Add an empty `else` to a `case/in` that has none, so an unmatched value yields `nil` instead of raising (skips a `case/in` with a bare capture clause, which matches everything) | `case v; in Integer then 1; end` -> `case v; in Integer then 1; else; end` |
579
+ | `argument_order_permutation` | Swap neighbouring positional arguments of a call, `super` or `yield` (keeps keywords, block and splats in place; skips identical neighbours, `raise` / `fail`, `format` / `sprintf` / `printf`, index writes, `start_with?` / `end_with?` and `Set[...]`). Calls that accept arguments in any order, such as `OptionParser#on`, can be silenced with `ignore_patterns`, e.g. `call{name=on, receiver=local_variable_read{name=opts}}` | `compute(a, b)` -> `compute(b, a)` |
580
+ | `keyword_value_swap` | Swap the values of neighbouring keyword arguments, keeping the keys (keeps positional arguments, `**splat` and block in place; skips identical values and braced hash arguments; spells out shorthand keys) | `compute(x: a, y: b)` -> `compute(x: b, y: a)` |
581
+ | `comparison_operand_swap` | Swap the operands of a spaceship comparison, reversing a sort block or a custom ordering (skips identical operands and safe navigation; `between?` / `clamp` bounds are swapped by `argument_order_permutation`) | `x.age <=> y.age` -> `y.age <=> x.age` |
582
+ | `integer_division_to_fdiv` | Turn a division into `fdiv`, so integer division keeps its remainder (groups a compound dividend; skips float literals, `/=` and the explicit `./(...)` form) | `a / b` -> `a.fdiv(b)` |
583
+ | `off_by_one_boundary` | Lower a count held in a variable by one: the receiver of `times`, the argument of `upto` / `downto` / `take` / `first` / `last` / `drop` / `each_slice` / `each_cons` (skips literal counts, which `integer_literal` shifts, and safe navigation) | `n.times` -> `(n - 1).times`, `items.first(n)` -> `items.first(n - 1)` |
584
+ | `format_specifier_swap` | Loosen one conversion specifier of a literal format string in `format` / `sprintf` / `printf` / `String#%`: drop flags, width and precision, or render a non-integer number as `%s` (skips `%%`, `%{name}`, `*` widths, `%c`, `%p` and interpolated or heredoc strings) | `format("%05d", n)` -> `format("%d", n)`, `"%.2f"` -> `"%f"`, `"%s"` |
585
+ | `alias_removal` | Drop an `alias` or `alias_method` declaration from a class, module or `class << self` body, attributed to the first method of that scope (skips aliases inside methods, `alias_method` sent to another receiver and global-variable aliases) | `alias length size` -> _(removed)_ |
586
+ | `regexp_character_type_complement` | Flip a regexp character type or word boundary to its complement, one at a time (leaves `\X`, `\R` and Unicode properties alone) | `/\d+/` -> `/\D+/`, `/\bword/` -> `/\Bword/` |
587
+ | `regexp_anchor_promotion` | Promote a regexp line anchor to a string anchor, so the pattern stops matching around newlines (`^` -> `\A`, `$` and `\Z` -> `\z`) | `/^admin$/` -> `/\Aadmin$/`, `/^admin\z/` |
588
+ | `regexp_alternation_branch_deletion` | Delete one branch of a regexp alternation at a time (skips alternations of more than ten branches, which are lookup tables, and deletions that would break a reference inside the pattern) | `/cat\|dog/` -> `/dog/`, `/cat/` |
589
+ | `regexp_quantifier_minimum_swap` | Swap a regexp quantifier between zero-or-more and one-or-more, keeping it lazy or possessive, so only the empty case changes | `/\A\d*\z/` -> `/\A\d+\z/`, `/a+?/` -> `/a*?/` |
590
+ | `regexp_capture_to_passive` | Turn a regexp capture group into a passive group, so `$1`, `m[1]` or `\1` in a replacement loses its value (skips patterns passed to `match?` / `!~`, patterns with named groups, and patterns with numbered references) | `/id=(\d+)/` -> `/id=(?:\d+)/` |
591
+ | `regexp_named_group_rename` | Rename a named regexp group, with its references inside the pattern, so `m[:name]` and the local bound by `/(?<name>…)/ =~ s` no longer find it (skips patterns passed to `match?` / `!~`) | `/(?<user>\w+)@/` -> `/(?<_user>\w+)@/` |
592
+ | `regexp_option_removal` | Drop the `i` or `m` option of a regexp literal where it changes something: `i` with a cased letter in the pattern, `m` with a `.` outside a character class | `/admin/i` -> `/admin/`, `/a.b/m` -> `/a.b/` |
517
593
  | `keyword_argument` | Remove keyword defaults/params | `def foo(bar: 42)` -> `def foo(bar:)` |
518
594
  | `multiple_assignment` | Remove targets or swap order | `a, b = 1, 2` -> `b, a = 1, 2` |
519
595
  | `block_removal` | Remove blocks from method calls | `items.map { \|x\| x * 2 }` -> `items.map` |
@@ -522,7 +598,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
522
598
  | `regexp_mutation` | Replace regexp with always/never matching | `/pat/` -> `/a\A/` |
523
599
  | `regex_simplification` | Simplify regex quantifiers, anchors, ranges | `/\d+/` -> `/\d/`, `/[a-z]/` -> `/[az]/` |
524
600
  | `receiver_replacement` | Drop explicit `self` receiver | `self.foo` -> `foo` |
525
- | `send_mutation` | Swap semantically related methods | `detect` -> `find`, `map` -> `flat_map` |
601
+ | `send_mutation` | Swap semantically related methods | `detect` -> `find`, `map` -> `flat_map`, `round` -> `floor` / `ceil` |
526
602
  | `compound_assignment` | Swap compound assignment operators | `+=` -> `-=`, `&&=` -> `\|\|=` |
527
603
  | `local_variable_assignment` | Replace variable assignment with `nil` | `x = expr` -> `x = nil` |
528
604
  | `instance_variable_write` | Replace ivar assignment with `nil` | `@x = expr` -> `@x = nil` |
@@ -532,6 +608,9 @@ Each operator name is stable and appears in JSON output under `survived[].operat
532
608
  | `superclass_removal` | Remove class inheritance | `class Foo < Bar` -> `class Foo` |
533
609
  | `rescue_removal` | Remove rescue clauses | Deletes rescue block |
534
610
  | `rescue_body_replacement` | Replace rescue body with `nil` | Rescue body -> `nil` |
611
+ | `rescue_handler_promotion` | Run a rescue handler instead of the code it protects, so the happy path never runs: each handler of a `begin` / method / block rescue in turn, and the fallback of a rescue modifier (drops `else`, keeps `ensure`; skips handlers that read the exception, re-raise or retry) | `begin; fetch(id); rescue NotFound; default; end` -> `begin; default; end`, `fetch(id) rescue default` -> `default` |
612
+ | `rescue_handler_concatenation` | Run a rescue handler after the protected code as well, keeping the rescue, so a successful run also does what the handler does (skips empty and value-only handlers, handlers that read the exception, re-raise or retry, and rescue modifiers) | `begin; fetch(id); rescue NotFound; rollback; end` -> `begin; fetch(id); rollback; rescue NotFound; rollback; end` |
613
+ | `rescue_else_concatenation` | Move the `else` body of a rescue into the protected code, so an error it raises is now rescued (only under a bare `rescue` or one naming `StandardError` / `Exception`; keeps the rescue and `ensure`) | `begin; a; rescue; b; else; c; end` -> `begin; a; c; rescue; b; end` |
535
614
  | `inline_rescue` | Remove inline rescue fallback | `expr rescue val` -> `expr` |
536
615
  | `ensure_removal` | Remove ensure blocks | Deletes ensure block |
537
616
  | `break_statement` | Remove break statements | `break` -> removed |
@@ -550,7 +629,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
550
629
  | `pattern_matching_alternative` | Remove/reorder alternatives | `pat1 \| pat2` -> `pat1` |
551
630
  | `pattern_matching_array` | Remove/wildcard array elements | `[a, b]` -> `[a, _]` |
552
631
  | `yield_statement` | Remove yield or its arguments | `yield(x)` -> `yield` |
553
- | `splat_operator` | Remove splat/double-splat | `foo(*args)` -> `foo(args)` |
632
+ | `splat_operator` | Remove splat/double-splat (skips `**` in a hash literal, `**` after a keyword argument or another `**`, and the rest of a pattern) | `foo(*args)` -> `foo(args)` |
554
633
  | `defined_check` | Replace `defined?` with `true` | `defined?(x)` -> `true` |
555
634
  | `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
556
635
  | `loop_flip` | Swap while/until loops | `while cond` -> `until cond` |
@@ -559,7 +638,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
559
638
  | `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
639
  | `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
640
  | `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)` |
641
+ | `optional_parameter_to_required` | Drop an optional positional parameter's default; of several, only the ones Ruby lets become required: the first, and the last unless a `*rest` parameter follows | `def f(a = 1)` -> `def f(a)` |
563
642
  | `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
643
  | `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
644
  | `forwarding_super_to_explicit` | Give a forwarding `super` an empty argument list | `def f(a); super; end` -> `def f(a); super(); end` |
@@ -589,15 +668,15 @@ Create a `.mcp.json` file in your project root:
589
668
  "mcpServers": {
590
669
  "evilution": {
591
670
  "type": "stdio",
592
- "command": "evilution",
593
- "args": ["mcp"],
671
+ "command": "bundle",
672
+ "args": ["exec", "evilution", "mcp"],
594
673
  "env": {}
595
674
  }
596
675
  }
597
676
  }
598
677
  ```
599
678
 
600
- If using Bundler, set the command to `bundle` and args to `["exec", "evilution", "mcp"]`.
679
+ Run the server through `bundle exec` so the project's `Gemfile.lock` decides which versions of evilution, your test framework and their shared dependencies get loaded. Started outside Bundler, RubyGems picks the newest installed copy of each gem, which your test framework may refuse — the run then stops at the proof-of-life canary with a gem activation conflict. Projects without a Gemfile can use `"command": "evilution"` and `"args": ["mcp"]`.
601
680
 
602
681
  The server exposes the following tools:
603
682
 
@@ -605,7 +684,7 @@ The server exposes the following tools:
605
684
  |---|---|
606
685
  | `evilution-mutate` | Run mutation testing on target files with structured JSON results |
607
686
  | `evilution-session` | Inspect mutation testing history — `action: list` browses saved sessions, `action: show` displays one, `action: diff` compares two (fixed/new/persistent survivors, score delta) |
608
- | `evilution-info` | Discovery before mutation — `action: subjects` lists mutatable methods with mutation counts, `action: tests` resolves which specs cover given sources, `action: environment` dumps the effective config, `action: statuses` returns the mutation-result status glossary, `action: feedback` returns the public Discussions URL plus consent + privacy guidance for posting feedback |
687
+ | `evilution-info` | Discovery before mutation — `action: subjects` lists the mutation subjects (methods, and class-body subjects such as scopes and callback declarations) with mutation counts, `action: tests` resolves which specs cover given sources, `action: environment` dumps the effective config, `action: statuses` returns the mutation-result status glossary, `action: feedback` returns the public Discussions URL plus consent + privacy guidance for posting feedback |
609
688
 
610
689
  ### Verbosity Control
611
690
 
@@ -623,7 +702,7 @@ What survives trimming matters when you are deciding whether to trust a score:
623
702
 
624
703
  - `neutral` entries — and with them each `neutral_reason` — are dropped at `summary` and `minimal`. Use `full` to see why mutations were neutralised.
625
704
  - `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.
705
+ - Everything inside `summary` survives at every level, including `unresolved_target_files`, `uncovered_code`, `infra_retried`, `baseline_neutralized`, `baseline_failures` 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
706
 
628
707
  ### Enriched Survived Entries
629
708
 
@@ -772,6 +851,14 @@ Methods whose body overlaps the requested range are included. Mix targeted and w
772
851
  evilution run lib/foo.rb:15-30 lib/bar.rb --format json
773
852
  ```
774
853
 
854
+ A range is matched against subjects, and not every line belongs to one. Methods are subjects, and so are scopes, AASM guards and callbacks, callback and validation declarations, and `Data.define` / `Struct.new` definitions (see step 2 under [Internals](#internals-for-context-not-for-direct-use)). Other class-body code — a constant list, a serializer attribute, a DSL call evilution does not know — is not. When the range holds such code, the run says so on stderr and in `summary.uncovered_code`:
855
+
856
+ ```
857
+ [evilution] app/models/order.rb:12-14 holds code outside every subject (class-body code such as DSL calls and constants is not mutated); no mutations target those lines.
858
+ ```
859
+
860
+ Read `0 mutations` next to that message as "not measured", not as "nothing to test".
861
+
775
862
  ### 4. Method-name targeted scan
776
863
 
777
864
  ```bash
@@ -798,13 +885,14 @@ Pass multiple file paths on a single invocation to amortise startup cost. The fr
798
885
 
799
886
  ### What to read before acting on a score
800
887
 
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:
888
+ A score describes only the mutations that got a verdict. Five fields say what it leaves out, and each points at a different action:
802
889
 
803
890
  | Field | Meaning | What to do |
804
891
  |---|---|---|
805
892
  | `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
893
  | `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 |
894
+ | `summary.uncovered_code` | Lines you targeted hold code outside every subject — class-body code such as constant lists or DSL calls evilution does not treat as a subject — so no mutation was generated for them; the same is printed to stderr | A run that reports `0 mutations` for those lines has not shown they are tested. Target the methods that code calls, or cover it with ordinary tests |
895
+ | `neutral[].neutral_reason.kind == "baseline_failure"` | The mutation's tests failed only on examples that were already failing before any mutation ran; `detail` names the red spec file | Read `summary.baseline_failures` for why it was red, and fix that first — these mutations have no verdict until it is green |
808
896
  | `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
897
 
810
898
  `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.
@@ -840,6 +928,17 @@ bundle exec evilution mutate lib/aasm/base.rb --spec spec/unit/event_spec.rb,spe
840
928
  bundle exec evilution mutate lib/aasm/base.rb --fallback-full-suite
841
929
  ```
842
930
 
931
+ To make the mapping permanent, declare it once in `.evilution.yml` under `spec_mappings`; the run, its baseline and `tests list` all honour it. `evilution tests list <file>` prints exactly the spec files a run would use, so it is the quick way to check a mapping before spending a run on it:
932
+
933
+ ```yaml
934
+ spec_mappings:
935
+ lib/aasm/base.rb:
936
+ - spec/unit/event_spec.rb
937
+ - spec/unit/callbacks_spec.rb
938
+ ```
939
+
940
+ Without `--fallback-full-suite` an unresolved source is also left out of the baseline, which does not run the whole test directory for it.
941
+
843
942
  ### Long Minitest fork runs — not a hang
844
943
 
845
944
  Minitest projects under `--isolation=fork` re-bootstrap the test environment (`test_helper.rb`, plugins, runnable state) once per mutation. On constant-heavy files (e.g. Shopify/liquid's `lib/liquid/lexer.rb`, ~270 mutations) the wall-clock cost is dominated by that per-fork bootstrap and any mutations that hit a `--timeout` rather than killing the test fast. A single-worker run (`-j 1`) on a few hundred mutations can take 4+ minutes; combined with `--no-progress` and a non-TTY stderr (CI, redirected logs) the run looks silent the entire time.
@@ -853,7 +952,7 @@ RUBYOPT="-Itest" bundle exec evilution mutate lib/<file>.rb \
853
952
  --spec test/<dir>/<file>_test.rb
854
953
  ```
855
954
 
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`.
955
+ `-j 4` parallelises across workers, `-t 10` caps any mutation that pathologically loops at 10 s. On a Rails app, add `--warmup rails` so each fork does not re-initialise I18n, routes and templates on its first request (see [Warming up Rails before forking](#warming-up-rails-before-forking)). 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`.
857
956
 
858
957
  ### 8. CI gate
859
958
 
@@ -891,6 +990,21 @@ Output buckets:
891
990
 
892
991
  Use in CI to gate merges on `reintroduced` being empty, or to surface `new` survivors for reviewer attention without failing the build on `persistent` debt.
893
992
 
993
+ ## Warming up Rails before forking
994
+
995
+ Under `isolation: fork` the parent preloads once and every mutation runs in a fresh fork, which inherits only what the parent had already initialised. Rails initialises a lot lazily on a process's first request: loading locale files, building route helpers, resolving asset paths, compiling templates. Without a warm-up, every forked mutation whose tests make a request pays that cold start again, often several hundred milliseconds each.
996
+
997
+ `warmup: rails` (or `--warmup rails`) does that initialisation once in the parent, right after the preload:
998
+
999
+ - `I18n.eager_load!`
1000
+ - the route set's `url_helpers` (and `eager_load!` for lazily drawn routes on Rails 8)
1001
+ - an asset path lookup through the controller helpers
1002
+ - template precompilation, only if the [`actionview_precompiler`](https://github.com/jhawthorn/actionview_precompiler) gem is installed
1003
+
1004
+ Each step is skipped if its library is not loaded, and skipped with a one-line note if it raises, so an app that does not fit a step still runs normally.
1005
+
1006
+ Do not warm up by sending a real request from your preload (`Rails.application.call(...)`). Controller callbacks change process-global state that every fork then inherits: `I18n.locale`, gem request stores such as PaperTrail's, a session row in the database. Tests that depend on that state then fail in every fork, and evilution reports their mutations as killed. The steps above make no request and leave that state untouched.
1007
+
894
1008
  ## Parallel Runs with SQLite
895
1009
 
896
1010
  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.
@@ -944,9 +1058,9 @@ For the full contributor architecture — module map, data flow, and extension
944
1058
  points — see [docs/architecture.md](docs/architecture.md).
945
1059
 
946
1060
  1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
947
- 2. **Extract** — Methods are identified as mutation subjects
1061
+ 2. **Extract** — Methods are identified as mutation subjects, and so are value-object definitions outside any method (`Point = Data.define(:x, :y)`, `class Coord < Struct.new(:lat, :lng)`), which only the operators that apply to them mutate, and ActiveRecord scopes declared with a literal body in a class body (`scope :recent, -> { where(recent: true) }`), named after the class method they define (`Order.recent`) and mutated like a method body — also when declared in a concern's `included do ... end` block, where they are named after the concern (`Publishable.published`); likewise the guards and callbacks written out as lambdas or blocks inside an AASM `event` or `state` declaration (`event :ship, guard: -> { address? }`), named after the method that declaration defines (`Order#ship`, `Order#paid?`), or after the concern when the machine is declared in its `included do ... end` block (`Shippable#ship`); and the conditions and bodies written out in callback and validation declarations of a class body or of a concern's `included do ... end` block (`validate :credit_limit, if: -> { paid? }`, `before_save { ... }`), named the way the declaration reads (`Order.validate(:credit_limit)`, `Order.before_save`, `Publishable.validates(:title)`)
948
1062
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
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
1063
+ 4. **Mutate** — 136 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
950
1064
  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
951
1065
  6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
952
1066
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention
data/docs/architecture.md CHANGED
@@ -12,7 +12,10 @@ For deeper dives on two subsystems that have their own docs, see
12
12
  ## The one-paragraph version
13
13
 
14
14
  Evilution parses each target file with [Prism](https://github.com/ruby/prism)
15
- into an AST with exact byte offsets. Every method becomes a *subject*. Each
15
+ into an AST with exact byte offsets. Every method becomes a *subject*, and so
16
+ does every value-object definition outside a method (`Point = Data.define(:x)`)
17
+ and every scope declared with a literal body (`scope :recent, -> { ... }`),
18
+ and every guard or callback written out inside an AASM `event` or `state`. Each
16
19
  mutation *operator* walks a subject's AST and emits *mutations* — byte-range
17
20
  edits applied by source-level surgery (no AST unparsing). For every mutation,
18
21
  evilution copies the file, applies the edit, runs the covering specs in an
@@ -48,7 +51,7 @@ Everything lives under `lib/evilution/`.
48
51
  | `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
49
52
  | `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
50
53
  | `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::*` | 88 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
54
+ | `Mutator`, `Mutator::Operator::*` | 136 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
52
55
  | `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
53
56
  | `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
54
57
  | `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
@@ -60,6 +63,40 @@ Everything lives under `lib/evilution/`.
60
63
  | `Session`, `Compare` | Persist runs to `.evilution/results/*.json`; diff two sessions. | `session/store.rb`, `compare.rb` |
61
64
  | `Coverage`, `Equivalent`, `Baseline`, `Cache`, `Hooks`, `MCP` | Coverage-based example targeting, equivalent-mutation detection, baseline capture, incremental cache, lifecycle hooks, MCP server. | respective dirs |
62
65
 
66
+ ## Require conventions
67
+
68
+ Most directories under `lib/evilution/` have a parent file of the same name
69
+ (`runner.rb` next to `runner/`) that declares the namespace. The rules:
70
+
71
+ - **Every child requires its parent.** A file in `runner/` starts with
72
+ `require_relative "../runner"`, a file in `cli/commands/` with
73
+ `require_relative "../commands"`, and so on down the tree.
74
+ - **A parent declares its namespace before requiring its children.** Usually
75
+ the class or module is defined first and children are required at the
76
+ bottom. Where the parent's own body needs its children loaded first (for
77
+ example a constant built from them), it opens with an empty declaration —
78
+ `class Evilution::CLI; end` — then requires the children, then defines the
79
+ rest. `cli.rb` and `integration/rspec/state_guard.rb` do this.
80
+ - **Top-level files (`lib/evilution/*.rb`) are the exception.** Their parent is
81
+ `lib/evilution.rb`, the gem's entry point, which loads everything. A
82
+ top-level file that only needs the root module requires `version.rb`, which
83
+ defines `module Evilution` and nothing else. Only a file that genuinely needs
84
+ the whole gem requires `../evilution` — `runner.rb`, whose collaborators
85
+ reach every operator through `Mutator::Registry`.
86
+
87
+ The parent require and the parent's own `require_relative` of its children
88
+ form a cycle (`runner.rb` → `runner/canary.rb` → `runner.rb`). It is inert:
89
+ Ruby does not load a file twice, so a re-entrant require of a file already
90
+ being loaded returns immediately, and the namespace already exists because the
91
+ parent declared it first. Reviews flagging it as a circular require can be
92
+ answered with this section.
93
+
94
+ Loading an arbitrary file on its own (`require "evilution/cli/command"` in a
95
+ fresh process) is not a goal. The parent requires make most files work that
96
+ way, but not all: a base class whose subclasses are required by the parent
97
+ (`cli/command.rb`) cannot finish defining itself before those subclasses load.
98
+ Load the gem through `require "evilution"`.
99
+
63
100
  ## Data flow, source → result
64
101
 
65
102
  Driven entirely by `Evilution::Runner#call` (`runner.rb:24`). Each step names the
@@ -71,13 +108,34 @@ class that owns it.
71
108
  2. **Subjects** — `Runner::SubjectPipeline#call` resolves target files (explicit,
72
109
  `source:<glob>`, or `Git::ChangedFiles`), then `AST::Parser#call` runs
73
110
  `Prism.parse` and `AST::SubjectFinder` (a `Prism::Visitor`) emits one
74
- `Evilution::Subject` per `def` node. Optional descendant/target/line-range filters follow.
111
+ `Evilution::Subject` per `def` node (kind `:method`), plus one per
112
+ `Data.define` / `Struct.new` assigned to a constant or used as a superclass
113
+ outside any method (kind `:constant`, named after the constant), plus one per
114
+ `scope :name, -> { }` / `lambda { }` / `proc { }` written directly in a class
115
+ body (kind `:scope`, named `Class.name` after the class method it defines;
116
+ it spans the declaration and its node is the body; a scope declared in a
117
+ concern's `included do ... end` is named after the concern), plus one per literal
118
+ callable inside an `event` or `state` of a class-body `aasm do ... end` --
119
+ keyword values (`guard: -> { }`, also inside arrays), those of the event's
120
+ `transitions`, and callback blocks (`before { }`) -- kind `:aasm`, named
121
+ `Class#event` / `Class#state?` after the method the declaration defines, so
122
+ one event may contribute several subjects of the same name (a machine in a
123
+ concern's `included do ... end` is found too, and named after the concern);
124
+ and one per
125
+ literal callable of a class-body callback or validation declaration
126
+ (`validate`, `validates`, `validates_*`, `before_*`, `after_*`, `around_*`)
127
+ -- an `if:` / `unless:` condition, or the callback itself as a block or
128
+ lambda -- kind `:callback`, named as the declaration reads
129
+ (`Order.validate(:credit_limit)`, `Order.before_save`; one in a concern's
130
+ `included do ... end` is named after the concern). Optional
131
+ descendant/target/line-range filters follow.
75
132
  3. **Baseline** — `Runner::BaselineRunner#call` builds the integration from
76
133
  `Runner::INTEGRATIONS` (`rspec`/`minitest`/`test_unit`) and records spec files
77
134
  that already fail *before* any mutation, so their mutations aren't miscounted.
78
135
  An optional `Runner::Canary` proves the pipeline can observe a known mutation.
79
136
  4. **Mutations** — `Runner::MutationPlanner#call` flat-maps subjects through
80
- `Mutator::Registry#mutations_for`. The registry instantiates each operator and
137
+ `Mutator::Registry#mutations_for`. The registry instantiates each operator whose
138
+ `subject_kinds` include the subject's kind (`:method`, `:scope`, `:aasm` and `:callback` by default) and
81
139
  runs `operator.call(subject, filter:)`; each operator subclasses
82
140
  `Mutator::Base` and calls `add_mutation`, which runs `AST::SourceSurgeon` and
83
141
  builds an immutable `Evilution::Mutation`. The planner then **deduplicates**
@@ -108,11 +166,20 @@ class that owns it.
108
166
  `Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
109
167
  `:unresolved` → `:error` → `:survived` (tests passed) → default `:killed`.
110
168
  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
169
+ because the tests failed on nothing but examples that were already failing
170
+ at baseline (`Neutralizer::BaselineFailed`) or because the test process
171
+ crashed on infrastructure rather than on the mutation
172
+ (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`, since
173
+ the two want opposite responses. For the first, the baseline reports the
174
+ ids of its failing examples (`Integration::RSpec::ExampleIds`, absolute path
175
+ plus position, so they compare across working directories;
176
+ `Integration::Minitest::TestIds`, `Class#test`;
177
+ `Integration::TestUnit::TestIds`, `test(Class)`), the integration is built
178
+ with them (`known_failures:`, held as `Integration::KnownFailures`), and a
179
+ failed run whose failed examples are all among them comes back flagged
180
+ `known_failures_only`; a survivor is never neutralized. A survivor is re-run
181
+ against its whole spec file before it is reported
182
+ (`Integration::RSpec#confirm?`), as is a targeted run flagged that way, so a
116
183
  narrowed example set cannot invent one; after a parallel pass,
117
184
  `MutationExecutor::InfraRetry` re-runs the infrastructure-neutralised
118
185
  mutations serially, once the contention that caused them is gone.
@@ -159,6 +226,53 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
159
226
  - The operator's registered name is auto-derived from the class name
160
227
  (`MyThing` → `my_thing`); that string is the `operator` field in JSON output
161
228
  and is part of the public contract, so name it deliberately.
229
+ - Operators see method, scope, aasm and callback subjects. One that also applies to value-object
230
+ definitions outside a method overrides `self.subject_kinds` to return
231
+ `%i[method constant]`; a constant subject's node is the definition's
232
+ `CallNode`.
233
+ - A scope subject's mutation only takes effect if the mutated file re-runs
234
+ its `scope` call, which `BodyCallNeutralizer` would otherwise blank: the
235
+ eval source keeps that one call (`keep_offset`). Its `restore_source` is
236
+ the original file with the same call kept; `Integration::Base#call`
237
+ evaluates it after the tests, since in-process runs restore methods only
238
+ by re-evaluating the next mutation's source, where scopes are blanked.
239
+ - A scope in a concern's `included` block has two audiences. The kept
240
+ `included` call registers the mutated block for classes that include the
241
+ concern later (`ConcernStateCleaner` clears the old one first, on apply
242
+ and on restore: a concern ignores a block handed to it twice from the
243
+ same place). For classes that include it already, the eval source guards
244
+ every other call of the block with `ConcernRedeclaration.skipping?` and
245
+ follows the block with `ConcernRedeclaration.call(self)`, which runs the
246
+ block on each of them with the guard up, so only the scope is declared
247
+ again. Both additions go on existing lines; no line number moves.
248
+ - A callback subject's declaration is kept too, wrapped in
249
+ `CallbackRedeclaration.call(self, __FILE__, lines) { ... }`. Callbacks do
250
+ not replace on re-declaration: a symbol callback moves to the end of its
251
+ chain, a block or validator is added beside the first. The helper reads
252
+ the chains, runs the declaration and rebuilds them through
253
+ `__update_callbacks`, so the new callbacks sit where the ones this
254
+ declaration registered before were. Those are found by the source
255
+ location of their procs the first time (`lines` is the declaration's
256
+ range in the file as loaded) and from the helper's own record afterwards,
257
+ since a mutation may add or remove lines. In a concern's `included` block
258
+ the declaration is wrapped the same way inside the kept block, whose
259
+ other calls are guarded: run on a class that includes the concern already
260
+ it replaces that class's callbacks in place, run by a class including it
261
+ later it simply declares. `BodyCallNeutralizer` leaves a blanked call's
262
+ line breaks behind, so the evaluated source keeps the file's line numbers
263
+ and a class loaded during one mutation can be matched by location in the
264
+ next.
265
+ - An aasm subject works the same way, one level down: the `aasm` call is
266
+ kept, and inside its block every declaration but the `event` or `state`
267
+ holding the mutation is blanked. AASM stores what an event or state holds
268
+ by value, so re-running one replaces it; the machine's own callbacks
269
+ (`after_all_transitions`) accumulate, which is why the rest of the block
270
+ must not run again and why they get no subjects. For a machine inside a
271
+ concern's `included` block the two mechanisms combine: the block is kept
272
+ and followed by `ConcernRedeclaration.call(self)`, and both its other
273
+ calls and the machine's other declarations are guarded with
274
+ `ConcernRedeclaration.skipping?` instead of blanked -- a class that
275
+ includes the concern later needs the whole machine.
162
276
 
163
277
  2. **Require it** in `lib/evilution.rb` alongside the other
164
278
  `require_relative "evilution/mutator/operator/..."` lines.