evilution 1.3.0 → 1.5.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 (187) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +132 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +127 -0
  5. data/README.md +136 -38
  6. data/docs/architecture.md +135 -11
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +121 -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/class_body.rb +101 -0
  13. data/lib/evilution/ast/included_block.rb +41 -0
  14. data/lib/evilution/ast/literal_callable.rb +21 -0
  15. data/lib/evilution/ast/never_nil.rb +36 -0
  16. data/lib/evilution/ast/parser.rb +187 -21
  17. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  18. data/lib/evilution/ast/pattern/parser.rb +14 -15
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/round_half_mode.rb +38 -0
  21. data/lib/evilution/ast/scope_declaration.rb +45 -0
  22. data/lib/evilution/ast/source_surgeon.rb +2 -3
  23. data/lib/evilution/ast/syntax_check.rb +56 -0
  24. data/lib/evilution/ast/uncovered_code.rb +88 -0
  25. data/lib/evilution/ast/value_object_definition.rb +25 -0
  26. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  27. data/lib/evilution/baseline/report.rb +66 -0
  28. data/lib/evilution/baseline/spec_failure.rb +38 -0
  29. data/lib/evilution/baseline.rb +67 -30
  30. data/lib/evilution/cli/exit_guard.rb +26 -4
  31. data/lib/evilution/cli/parser/file_args.rb +2 -1
  32. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  33. data/lib/evilution/cli.rb +3 -2
  34. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  35. data/lib/evilution/config.rb +3 -2
  36. data/lib/evilution/equivalent/detector.rb +3 -1
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  38. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  39. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  40. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  41. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  42. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  43. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  44. data/lib/evilution/example_filter.rb +45 -0
  45. data/lib/evilution/hooks/registry.rb +2 -1
  46. data/lib/evilution/integration/base.rb +11 -1
  47. data/lib/evilution/integration/known_failures.rb +26 -0
  48. data/lib/evilution/integration/loading/body_call_neutralizer.rb +177 -13
  49. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  50. data/lib/evilution/integration/loading/concern_redeclaration.rb +80 -0
  51. data/lib/evilution/integration/loading/concern_state_cleaner.rb +22 -15
  52. data/lib/evilution/integration/loading/mutation_applier.rb +44 -1
  53. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  54. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  55. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  56. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  57. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  58. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  59. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  60. data/lib/evilution/integration/minitest.rb +45 -6
  61. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  62. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  63. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  64. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  65. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  66. data/lib/evilution/integration/rspec.rb +22 -4
  67. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  68. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  69. data/lib/evilution/integration/test_unit.rb +37 -9
  70. data/lib/evilution/isolation/fork.rb +84 -11
  71. data/lib/evilution/isolation/in_process.rb +100 -8
  72. data/lib/evilution/mcp/info_tool/status_glossary.rb +15 -4
  73. data/lib/evilution/mcp/info_tool.rb +3 -2
  74. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  75. data/lib/evilution/memory/leak_check.rb +27 -3
  76. data/lib/evilution/mutation.rb +21 -9
  77. data/lib/evilution/mutator/base.rb +69 -5
  78. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  79. data/lib/evilution/mutator/operator/alias_removal.rb +56 -0
  80. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  81. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  82. data/lib/evilution/mutator/operator/argument_removal.rb +10 -2
  83. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  84. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  85. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  86. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  87. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  88. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  89. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  90. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  91. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  92. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  93. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  94. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  95. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  96. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  97. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  98. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  99. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  100. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  101. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  102. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  103. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  104. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  105. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  106. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  107. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  108. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  109. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  110. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  111. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  112. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  113. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  114. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  115. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  116. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  117. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  118. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  119. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  120. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  121. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  122. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  123. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  124. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  125. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  126. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  127. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  128. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  129. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  130. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  131. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  132. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  133. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  134. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  135. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  136. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  137. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  138. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  139. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  140. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  141. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  142. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  143. data/lib/evilution/mutator/operator/splat_operator.rb +41 -24
  144. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  145. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  146. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  147. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  148. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  149. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  150. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  151. data/lib/evilution/mutator/primitives.rb +24 -2
  152. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  153. data/lib/evilution/mutator/registry.rb +58 -2
  154. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  155. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  156. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  157. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  158. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  159. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  160. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  161. data/lib/evilution/parallel/work_queue.rb +15 -9
  162. data/lib/evilution/process_supervisor.rb +3 -2
  163. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  164. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  165. data/lib/evilution/reporter/json/baseline.rb +15 -0
  166. data/lib/evilution/reporter/json.rb +7 -2
  167. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  168. data/lib/evilution/result/mutation_result.rb +9 -1
  169. data/lib/evilution/result/summary.rb +42 -2
  170. data/lib/evilution/runner/baseline_runner.rb +9 -4
  171. data/lib/evilution/runner/canary.rb +2 -57
  172. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  173. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  174. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  175. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  176. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  177. data/lib/evilution/runner/mutation_executor.rb +1 -1
  178. data/lib/evilution/runner/mutation_planner.rb +11 -2
  179. data/lib/evilution/runner/report_publisher.rb +3 -2
  180. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  181. data/lib/evilution/runner/subject_pipeline.rb +57 -43
  182. data/lib/evilution/runner.rb +5 -2
  183. data/lib/evilution/subject.rb +25 -2
  184. data/lib/evilution/version.rb +1 -1
  185. data/lib/evilution.rb +90 -0
  186. data/script/memory_check +62 -16
  187. metadata +113 -4
data/README.md CHANGED
@@ -109,7 +109,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
109
109
  |------------------------------|---------|--------------|---------------------------------------------------|
110
110
  | `-t`, `--timeout N` | Integer | 30 | Per-mutation timeout in seconds. |
111
111
  | `-f`, `--format FORMAT` | String | `text` | Output format: `text`, `json`, or `html`. |
112
- | `--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`). |
113
113
  | `--output FILE` | String | _(stdout)_ | Write the report to FILE instead of stdout. Useful when a preloaded spec helper writes to stdout on exit. |
114
114
  | `--min-score FLOAT` | Float | 0.0 | Minimum mutation score (0.0–1.0) to pass. |
115
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. |
@@ -119,7 +119,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
119
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). |
120
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`). |
121
121
  | `-j`, `--jobs N` | Integer | 1 | Number of parallel workers. Uses demand-driven work distribution with pipe-based IPC. |
122
- | `--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. |
123
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). |
124
124
  | `--fail-fast [N]` | Integer | _(none)_ | Stop after N surviving mutants (default 1 if no value given). |
125
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. |
@@ -132,7 +132,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
132
132
  | `--no-progress` | Boolean | _(enabled)_ | Disable the TTY progress bar. |
133
133
  | `--quiet-children` | Boolean | false | Redirect each forked worker's stdout/stderr to per-pid files under `tmp/evilution_children/<pid>.{out,err}` so noisy app initializers (Datadog, Bullet, etc.) don't merge with parent output. Trade-off: live worker errors only appear in the side files, not the terminal — `tail -f tmp/evilution_children/*.err` to watch them. |
134
134
  | `--quiet-children-dir DIR` | String | `tmp/evilution_children` | Override the directory used by `--quiet-children`. |
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). |
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. Under `in_process` a mutation that takes the Ruby process down ends the run ([what `in_process` cannot contain](docs/isolation.md#what-in_process-cannot-contain)). See [docs/isolation.md](docs/isolation.md). |
136
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. |
137
137
  | `--no-preload` | Boolean | _(enabled)_ | Disable parent-process preload. |
138
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). |
@@ -142,7 +142,7 @@ Every command, subcommand, and flag listed in this section is part of evilution'
142
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. |
143
143
  | `--baseline-session PATH` | String | _(none)_ | Saved session file for HTML report comparison. |
144
144
  | `-e CODE`, `--eval CODE` | String | _(none)_ | Inline Ruby code for `util mutation` command. |
145
- | `--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. |
146
146
  | `--strict` | Boolean | false | Shortcut for `--profile=strict`. |
147
147
 
148
148
  ### Options (for `session` subcommands)
@@ -165,8 +165,15 @@ Every command, subcommand, and flag listed in this section is part of evilution'
165
165
 
166
166
  Two profiles ship out of the box:
167
167
 
168
- - **`default`** — the 111 stable operators registered in `Mutator::Registry.default`. Suitable for everyday CI runs; balances coverage signal against survivor noise.
169
- - **`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 155 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
+ - `SafeNavigationInsertion` replaces a plain call with safe navigation (`user.name` -> `user&.name`) to surface calls no test reaches with a nil receiver. With a nil receiver the mutant skips the call and gives `nil`, where the plain call raises `NoMethodError` or runs a method `nil` has (`nil.to_a`). It touches nearly every call with a receiver and survives wherever that receiver is never nil. It skips `self`, constants and literal receivers other than `nil`, and `::` calls. The opposite direction, `safe_navigation_removal`, is in the default profile.
175
+
176
+ Use for pre-merge audits where you want maximum sensitivity at the cost of more survivors.
170
177
 
171
178
  Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.evilution.yml`.
172
179
 
@@ -176,7 +183,7 @@ Set via `--profile=strict`, the `--strict` shortcut, or `profile: strict` in `.e
176
183
  |------|-----------------------------------------------|---------------------------------------|
177
184
  | 0 | Mutation score meets or exceeds `--min-score` | Success. No action needed. |
178
185
  | 1 | Mutation score below `--min-score`, or a target file resolved to no spec | Parse output, fix surviving mutants. |
179
- | 2 | Tool error (bad config, parse failure, etc.) | Check stderr, fix invocation. |
186
+ | 2 | Tool error (bad config, parse failure, a run under `--isolation in_process` that a mutation took down, etc.) | Check stderr, fix invocation. |
180
187
 
181
188
  `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:
182
189
 
@@ -188,6 +195,8 @@ $ evilution run lib/half_tested.rb --min-score 0.8
188
195
  Result: FAIL (score 66.67% < 80.00%) # exit 1
189
196
  ```
190
197
 
198
+ A run under `--isolation in_process` can end without a report: a mutation that takes the Ruby process down takes the run with it. It ends with 2, and a line on stderr names the mutation and points at `--isolation fork`. See [What `in_process` cannot contain](docs/isolation.md#what-in_process-cannot-contain).
199
+
191
200
  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).
192
201
 
193
202
  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).
@@ -206,7 +215,7 @@ schema_version: 1 # opts into strict validation (rejects unknown keys
206
215
  # integration: rspec # test framework: rspec, minitest, test_unit
207
216
  # suggest_tests: false # concrete test code in suggestions (matches integration)
208
217
  # save_session: false # persist results under .evilution/results/
209
- # isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems)
218
+ # isolation: auto # auto | fork | in_process (auto selects fork for Rails + gems, in_process otherwise; in_process cannot contain a mutation that takes the Ruby process down, see docs/isolation.md)
210
219
  # canary: true # proof-of-life synthetic mutation at session start (false to skip)
211
220
  # preload: null # path to preload before forking; false to disable; auto-detects for Rails + gems
212
221
  # warmup: none # rails: warm I18n, routes, assets and templates once after preload (fork isolation)
@@ -254,8 +263,8 @@ All keys recognised under `schema_version: 1`:
254
263
  | `quiet` | Boolean | `false` | Suppress output. |
255
264
  | `jobs` | Integer | `1` | Number of parallel workers. |
256
265
  | `fail_fast` | Integer / null | `null` | Stop after N surviving mutants. `null` = disabled. |
257
- | `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (marked `:neutral`). |
258
- | `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`). |
266
+ | `baseline` | Boolean | `true` | Run baseline test suite to detect pre-existing failures (a mutation failing only on those is `:neutral`). |
267
+ | `isolation` | String | `auto` | Isolation strategy: `auto`, `fork`, `in_process`. `auto` selects `fork` for Rails projects and packaged gems (`*.gemspec`), `in_process` otherwise. Under `in_process` a mutation that takes the Ruby process down ends the run: see [What `in_process` cannot contain](docs/isolation.md#what-in_process-cannot-contain). |
259
268
  | `incremental` | Boolean | `false` | Cache killed/timeout results across runs. |
260
269
  | `suggest_tests` | Boolean | `false` | Generate concrete test code in survivor suggestions (matches `integration`). |
261
270
  | `progress` | Boolean | `true` | TTY progress bar. |
@@ -321,11 +330,14 @@ Schema:
321
330
  "survived": "integer — mutations NOT detected (test passed = gap in coverage)",
322
331
  "timed_out": "integer — mutations that exceeded timeout",
323
332
  "errors": "integer — mutations that caused unexpected errors",
324
- "neutral": "integer — mutations whose tests already failed before mutation (baseline failure)",
333
+ "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",
325
334
  "equivalent": "integer — mutations proven to have identical behavior to the original",
326
335
  "unresolved": "integer — mutations where no spec file resolved (coverage gap, not a failure)",
327
336
  "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",
328
337
  "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",
338
+ "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",
339
+ "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",
340
+ "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",
329
341
  "unparseable": "integer — mutations whose mutated source did not parse (short-circuited, never executed)",
330
342
  "score": "float — killed / (total - errors - neutral - equivalent - unresolved - unparseable), range 0.0-1.0, rounded to 4 decimals",
331
343
  "duration": "float — total wall-clock seconds, rounded to 4 decimals",
@@ -423,11 +435,11 @@ Compatibility policy for the `1.x` gem line:
423
435
 
424
436
  | Status | Meaning | Counted in score? |
425
437
  |--------------|-----------------------------------------------------------------------|-------------------|
426
- | `killed` | A test failed when the mutation was applied — test suite caught it | numerator + denominator |
438
+ | `killed` | A test failed when the mutation was applied — test suite caught it. Also a mutation that made its own file raise while loading, before any test ran, when the original file still loads (`error_message` starts with `load failed:`). Also a mutation whose test process died on an exception no handler saw, after the mutated source loaded and while the tests ran, as a crashing test would: under fork isolation `error_message` starts with `child died` and names the exception, under in-process isolation it starts with `test run ended by` | numerator + denominator |
427
439
  | `survived` | No test failed — gap in coverage | denominator only |
428
440
  | `timeout` | Test run exceeded `--timeout` — treated like survived for scoring | denominator only |
429
- | `error` | Mutation caused an unexpected error (syntax error, boot failure, etc.) | excluded from denominator |
430
- | `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 |
441
+ | `error` | Mutation caused an unexpected error (syntax error, boot failure, a load failure the original file shares, etc.). Also a test process that died on an unhandled exception before the tests ran: under fork isolation `error_message` starts with `child died`, says `before the tests ran` and `error_class` is the exception's own class. Also a result that never arrived or could not be read, which is no verdict on the mutation (see [Diagnosing errored mutations](#7-diagnosing-errored-mutations)) | excluded from denominator |
442
+ | `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 |
431
443
  | `equivalent` | Mutation is provably identical to the original (e.g. no-op replacement) | excluded |
432
444
  | `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 |
433
445
  | `unparseable` | Mutated source failed to parse (e.g. dangling heredoc opener after `method_body_replacement`). Short-circuited — never executed. | excluded |
@@ -459,10 +471,15 @@ On evilution's own `lib/evilution/reporter/json/subjects.rb` this moved the repo
459
471
 
460
472
  ### Neutral Mutations
461
473
 
462
- 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):
474
+ 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):
475
+
476
+ - **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.
477
+ - **The test process died on infrastructure** rather than on the mutation.
463
478
 
464
479
  ```
465
480
  Score: 100.00% (10/10 verified of 17 mutations, 7 neutral)
481
+ ! 7 kills not counted for spec/tally_spec.rb: only examples already failing in the baseline failed, so those mutations have no verdict.
482
+ ./spec/tally_spec.rb[1:3] Tally adds -- NameError: undefined local variable or method 'tally'
466
483
 
467
484
  Neutral mutations (7, not verified):
468
485
  baseline already failing (spec/tally_spec.rb):
@@ -470,9 +487,17 @@ Neutral mutations (7, not verified):
470
487
  integer_literal: lib/tally.rb:9
471
488
  ```
472
489
 
473
- 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)`.
490
+ 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`.
491
+
492
+ What a red baseline does not do:
474
493
 
475
- 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`.
494
+ - **It does not touch a kill in which something that was passing failed too.** That is a kill like any other.
495
+ - **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.
496
+ - **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.
497
+
498
+ 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.
499
+
500
+ 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`.
476
501
 
477
502
  ### Per-Subject Scores
478
503
 
@@ -490,7 +515,7 @@ Subjects needing attention (2 subjects in 1 file):
490
515
 
491
516
  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`.
492
517
 
493
- ## Mutation Operators (111 total)
518
+ ## Mutation Operators (155 total)
494
519
 
495
520
  Each operator name is stable and appears in JSON output under `survived[].operator`.
496
521
 
@@ -499,16 +524,19 @@ Each operator name is stable and appears in JSON output under `survived[].operat
499
524
  | `arithmetic_replacement` | Swap arithmetic operators | `a + b` -> `a - b` |
500
525
  | `comparison_replacement` | Swap comparison operators | `a >= b` -> `a > b` |
501
526
  | `boolean_operator_replacement` | Swap `&&` / `\|\|` | `a && b` -> `a \|\| b` |
527
+ | `short_circuit_to_eager` | Evaluate both operands of `&&` / `\|\|` / `and` / `or` (an operand that would regroup is parenthesized; an operand that only jumps, such as `return`, is skipped) | `a && b` -> `a & b`, `a \|\| b` -> `a \| b` |
502
528
  | `boolean_operand_promotion` | Drop one side of `&&` / `\|\|` | `a && b` -> `a`, `b` |
503
529
  | `boolean_expression_to_nil` | Replace a whole compound boolean with `nil` | `a && b` -> `nil` |
504
530
  | `boolean_literal_replacement` | Flip boolean literals | `true` -> `false` |
505
531
  | `nil_replacement` | Replace `nil` with `true`, `false`, `0`, `""` | `nil` -> `true` |
506
- | `integer_literal` | Boundary-value integer mutations | `n` -> `0`, `1`, `n+1`, `n-1` |
507
- | `float_literal` | Boundary-value float mutations | `f` -> `0.0`, `1.0` |
508
- | `string_literal` | Empty the string | `"str"` -> `""` |
509
- | `array_literal` | Empty the array | `[a, b]` -> `[]` |
510
- | `hash_literal` | Empty the hash | `{k: v}` -> `{}` |
511
- | `symbol_literal` | Replace with sentinel symbol | `:foo` -> `:__evilution_mutated__` |
532
+ | `integer_literal` | Boundary-value integer mutations, plus one sentinel past the next integer width (int8, uint8, int16, uint16, int32, uint32, int64) above the literal's magnitude | `n` -> `0`, `1`, `n+1`, `n-1`; `42` -> `167` |
533
+ | `float_literal` | Boundary-value float mutations, plus NaN and both infinities (not inside a pattern, where they do not parse) | `f` -> `0.0`, `1.0`, `Float::NAN`, `Float::INFINITY`, `-Float::INFINITY` |
534
+ | `complex_literal` | Replace a complex literal with `0i`, `1i`, its neighbours along the imaginary axis and `nil` (a rational part such as `3ri` gets no neighbours) | `5i` -> `0i`, `1i`, `6i`, `4i`, `nil` |
535
+ | `rational_literal` | Replace a rational literal with `0r`, `1r`, its two neighbours and `nil` (the rational part of a complex literal such as `3ri` is left to `complex_literal`) | `1.5r` -> `0r`, `1r`, `2.5r`, `0.5r`, `nil` |
536
+ | `string_literal` | Empty the string or replace it with `nil`; an interpolated string is also replaced as a whole; a backtick or `%x` command is replaced with `nil` only, its text never rewritten | `"str"` -> `""`, `nil`; `"a #{x}"` -> `""`, `nil`; `` `ls` `` -> `nil` |
537
+ | `array_literal` | Empty the array, replace it with `nil`, delete each element in turn (two or more elements; a heredoc element is kept), and replace a single-element array with its element (not a splat, bare keywords, a `%w` / `%i` word or a heredoc) | `[a, b]` -> `[]`, `nil`, `[b]`, `[a]`; `[x]` -> `x` |
538
+ | `hash_literal` | Empty the hash, replace it with `nil`, delete each key/value pair in turn (two or more elements; a `**splat` and a pair holding a heredoc are kept), and rename each label key in turn (a key written with `=>` is left to `symbol_literal` / `string_literal`) | `{a: 1, b: 2}` -> `{}`, `nil`, `{b: 2}`, `{a: 1}`, `{__evilution_mutated__: 1, b: 2}` |
539
+ | `symbol_literal` | Replace with sentinel symbol or `nil`; an interpolated symbol is replaced as a whole with the empty symbol or `nil` | `:foo` -> `:__evilution_mutated__`, `nil`; `:"a_#{x}"` -> `:""`, `nil` |
512
540
  | `conditional_negation` | Replace condition with `true`/`false` | `if cond` -> `if true` |
513
541
  | `conditional_branch` | Remove if/unless/else branch | Deletes branch body |
514
542
  | `if_branch_swap` | Replace the if-branch with the else body, drop the else | `if c; x; else; y; end` -> `if c; y; end` |
@@ -517,6 +545,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
517
545
  | `method_body_replacement` | Replace entire method body | Method body -> `nil`, `self`, `super` |
518
546
  | `negation_insertion` | Negate predicate methods | `x.empty?` -> `!x.empty?` |
519
547
  | `return_value_removal` | Strip return values | `return x` -> `return` |
548
+ | `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?` |
520
549
  | `collection_replacement` | Swap collection methods | `map` -> `each`, `select` <-> `reject` |
521
550
  | `collection_return` | Replace collection return values | `return [1]` -> `return []` |
522
551
  | `scalar_return` | Replace scalar return values | `return 42` -> `return 0` |
@@ -524,6 +553,10 @@ Each operator name is stable and appears in JSON output under `survived[].operat
524
553
  | `argument_removal` | Remove individual arguments | `foo(a, b)` -> `foo(b)` |
525
554
  | `argument_nil_substitution` | Replace arguments with `nil` | `foo(a, b)` -> `foo(nil, b)` |
526
555
  | `call_to_nil` | Replace a method call with `nil` (skips void statements, receivers of another call, and attribute or index writes) | `user.name` -> `nil` |
556
+ | `constant_read_to_nil` | Replace a constant reference with `nil`, a path as a whole (skips call receivers, void statements and `rescue` exception classes) | `MAX` -> `nil`, `Config::MAX` -> `nil` |
557
+ | `variable_read_to_nil` | Replace a read of a local, instance, class or global variable, or `self`, with `nil` (skips call receivers, void statements, `if` / `unless` / `while` / `until` conditions, left operands of `&&` / `\|\|`, block-pass and double-splat arguments) | `total = count` -> `total = nil`, `build(name: @name)` -> `build(name: nil)` |
558
+ | `constant_namespace_strip` | Strip the namespace off a constant path, at each level of a longer one (skips top-level `::A` paths and operator-write targets such as `A::B ||= x`) | `A::B` -> `B`, `A::B::C` -> `C` and `B::C` |
559
+ | `constant_write_to_nil` | Assign `nil` to a constant instead of its value. A constant assignment outside a method is its own subject, named after the constant, and the literal operators mutate its value too; code that read the constant while the file loaded keeps the old value (skips a constant already `nil`; `Data.define` / `Struct.new` assignments are left to `data_struct_member`) | `LIMIT = 10` -> `LIMIT = nil` |
527
560
  | `safe_navigation_removal` | Replace `&.` with a plain call (skips `self` and literal receivers) | `user&.name` -> `user.name` |
528
561
  | `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]` |
529
562
  | `argument_propagation` | Replace a call with its only positional argument (skips operator methods, attribute writes and void statements) | `normalize(value)` -> `value` |
@@ -546,6 +579,32 @@ Each operator name is stable and appears in JSON output under `survived[].operat
546
579
  | `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 }` |
547
580
  | `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 }` |
548
581
  | `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` |
582
+ | `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)` |
583
+ | `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` |
584
+ | `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]` |
585
+ | `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 }` |
586
+ | `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(*, **)` |
587
+ | `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: _ }` |
588
+ | `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` |
589
+ | `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)` |
590
+ | `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)` |
591
+ | `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` |
592
+ | `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)` |
593
+ | `round_half_mode_swap` | Swap the tie-breaking mode of `round` for the other two, so a value sitting exactly on a tie rounds differently (literal `:up` / `:even` / `:down` only; skips a mode held in a variable; `symbol_literal` leaves these modes alone) | `amount.round(2, half: :even)` -> `half: :up`, `half: :down` |
594
+ | `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)` |
595
+ | `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"` |
596
+ | `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)_ |
597
+ | `freeze_removal` | Drop `.freeze` from a constant defined in a class or module body, attributed to the first method of that scope (skips values that are frozen anyway: symbols, numbers, `nil`, booleans, ranges, regexps, and string literals under `# frozen_string_literal: true`; a `freeze` inside a method is left to `method_call_removal`) | `LIST = %w[a b].freeze` -> `LIST = %w[a b]` |
598
+ | `visibility_removal` | Drop a `private`, `protected` or `module_function` declaration from a class, module or `class << self` body, attributed to the first method of that scope; a definition it wraps is kept (skips `public`, `private_constant`, `private_class_method`, and declarations guarded by a modifier) | `private` -> _(removed)_, `private def a; end` -> `def a; end` |
599
+ | `attr_accessor_swap` | Widen an `attr_reader` or `attr_writer` to `attr_accessor`, and remove an `attr_reader` / `attr_writer` / `attr_accessor` declaration, in a class, module or `class << self` body, attributed to the first method of that scope (skips a declaration wrapped in `private` / `protected`, or guarded by a modifier) | `attr_reader :name` -> `attr_accessor :name`, _(removed)_ |
600
+ | `sleep_to_zero` | Take the wait out of a `sleep` / `Kernel.sleep` whose duration is a variable or an expression (a literal duration is left to `integer_literal` / `float_literal`, which already emit `sleep 0`; skips a `sleep` sent to another receiver) | `sleep delay` -> `sleep 0` |
601
+ | `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/` |
602
+ | `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/` |
603
+ | `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/` |
604
+ | `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*?/` |
605
+ | `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+)/` |
606
+ | `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+)@/` |
607
+ | `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/` |
549
608
  | `keyword_argument` | Remove keyword defaults/params | `def foo(bar: 42)` -> `def foo(bar:)` |
550
609
  | `multiple_assignment` | Remove targets or swap order | `a, b = 1, 2` -> `b, a = 1, 2` |
551
610
  | `block_removal` | Remove blocks from method calls | `items.map { \|x\| x * 2 }` -> `items.map` |
@@ -554,8 +613,10 @@ Each operator name is stable and appears in JSON output under `survived[].operat
554
613
  | `regexp_mutation` | Replace regexp with always/never matching | `/pat/` -> `/a\A/` |
555
614
  | `regex_simplification` | Simplify regex quantifiers, anchors, ranges | `/\d+/` -> `/\d/`, `/[a-z]/` -> `/[az]/` |
556
615
  | `receiver_replacement` | Drop explicit `self` receiver | `self.foo` -> `foo` |
557
- | `send_mutation` | Swap semantically related methods | `detect` -> `find`, `map` -> `flat_map` |
558
- | `compound_assignment` | Swap compound assignment operators | `+=` -> `-=`, `&&=` -> `\|\|=` |
616
+ | `send_mutation` | Swap semantically related methods | `detect` -> `find`, `map` -> `flat_map`, `round` -> `floor` / `ceil` |
617
+ | `compound_assignment` | Swap compound assignment operators, or replace the whole assignment with `nil`; on variables, attributes (`obj.count += 1`, `obj.name \|\|= x`) and indexes (`counts[key] += 1`, `cache[key] \|\|= x`) and, for operator writes, on constants (`TOTAL += 1`, a subject of its own like a constant assignment) | `+=` -> `-=`, `&&=` -> `\|\|=` |
618
+ | `operator_write_receiver_promotion` | Retarget an operator write on an attribute at the instance variable it goes through (skips other receivers, logical writes and index writes) | `@account.balance += x` -> `@account += x` |
619
+ | `memoization_break` | Recompute a memoized instance variable on every call (instance variables only; a local, `@@x`, `$x` or `@cache[key] \|\|=` is left alone) | `@x \|\|= expr` -> `@x = expr` |
559
620
  | `local_variable_assignment` | Replace variable assignment with `nil` | `x = expr` -> `x = nil` |
560
621
  | `instance_variable_write` | Replace ivar assignment with `nil` | `@x = expr` -> `@x = nil` |
561
622
  | `class_variable_write` | Replace cvar assignment with `nil` | `@@x = expr` -> `@@x = nil` |
@@ -564,6 +625,10 @@ Each operator name is stable and appears in JSON output under `survived[].operat
564
625
  | `superclass_removal` | Remove class inheritance | `class Foo < Bar` -> `class Foo` |
565
626
  | `rescue_removal` | Remove rescue clauses | Deletes rescue block |
566
627
  | `rescue_body_replacement` | Replace rescue body with `nil` | Rescue body -> `nil` |
628
+ | `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` |
629
+ | `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` |
630
+ | `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` |
631
+ | `rescue_class_widening` | Widen what a rescue clause catches, so a sibling error that should escape is handled too: the listed classes become `StandardError`, then `Exception`; a bare rescue, `StandardError`, or a core class outside it (`Interrupt`, `LoadError`, ...) becomes `Exception` only (skips a clause already naming `Exception`, and rescue modifiers) | `rescue KeyError => e` -> `rescue StandardError => e`, `rescue Exception => e` |
567
632
  | `inline_rescue` | Remove inline rescue fallback | `expr rescue val` -> `expr` |
568
633
  | `ensure_removal` | Remove ensure blocks | Deletes ensure block |
569
634
  | `break_statement` | Remove break statements | `break` -> removed |
@@ -577,12 +642,16 @@ Each operator name is stable and appears in JSON output under `survived[].operat
577
642
  | `index_to_at` | Replace `[]` with `.at()` for arrays | `arr[0]` -> `arr.at(0)` |
578
643
  | `index_to_fetch` | Replace `[]` with `.fetch()` | `h[k]` -> `h.fetch(k)` |
579
644
  | `index_to_dig` | Replace `[]` chains with `.dig()` | `h[a][b]` -> `h.dig(a, b)` |
645
+ | `index_receiver_to_self` | Send an index read to `self` instead of its receiver (skips index writes and reads already on `self`) | `h[k]` -> `self[k]` |
646
+ | `index_to_key_predicate` | Replace an index read with a `key?` check (skips integer and range indexes, reads with several arguments, void statements and index writes) | `h[k]` -> `h.key?(k)` |
647
+ | `index_range_to_drop` | Replace a to-the-end range index with `drop` (also the endless `a[n..]` and `a[n...]`; skips a literal start of zero or below, ranges stopping short of the last element, void statements and index writes) | `a[n..-1]` -> `a.drop(n)` |
648
+ | `index_write_to_value` | Replace an index write with the value it assigns, dropping the store; the assignment syntax still evaluates to the same value, the `a.[]=(k, v)` / `a&.[]=(k, v)` spellings may not (skips void statements, operator writes and multiple-assignment targets) | `h[k] = v` -> `v` |
580
649
  | `index_assignment_removal` | Remove `[]=` assignments | `h[k] = v` -> removed |
581
650
  | `pattern_matching_guard` | Remove/negate pattern guards | `in x if cond` -> `in x` |
582
651
  | `pattern_matching_alternative` | Remove/reorder alternatives | `pat1 \| pat2` -> `pat1` |
583
652
  | `pattern_matching_array` | Remove/wildcard array elements | `[a, b]` -> `[a, _]` |
584
653
  | `yield_statement` | Remove yield or its arguments | `yield(x)` -> `yield` |
585
- | `splat_operator` | Remove splat/double-splat | `foo(*args)` -> `foo(args)` |
654
+ | `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)` |
586
655
  | `defined_check` | Replace `defined?` with `true` | `defined?(x)` -> `true` |
587
656
  | `regex_capture` | Swap or nil-ify capture refs | `$1` -> `$2`, `$1` -> `nil` |
588
657
  | `loop_flip` | Swap while/until loops | `while cond` -> `until cond` |
@@ -591,7 +660,7 @@ Each operator name is stable and appears in JSON output under `survived[].operat
591
660
  | `method_body_to_super` | Replace a method body with bare `super` where a super target exists | `def foo; body; end` -> `def foo; super; end` |
592
661
  | `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` |
593
662
  | `block_parameter_drop` | Drop a block's single parameter | `users.each { |u| touch(u) }` -> `users.each { touch(u) }` |
594
- | `optional_parameter_to_required` | Drop an optional positional parameter's default | `def f(a = 1)` -> `def f(a)` |
663
+ | `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)` |
595
664
  | `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` |
596
665
  | `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) }` |
597
666
  | `forwarding_super_to_explicit` | Give a forwarding `super` an empty argument list | `def f(a); super; end` -> `def f(a); super(); end` |
@@ -621,15 +690,15 @@ Create a `.mcp.json` file in your project root:
621
690
  "mcpServers": {
622
691
  "evilution": {
623
692
  "type": "stdio",
624
- "command": "evilution",
625
- "args": ["mcp"],
693
+ "command": "bundle",
694
+ "args": ["exec", "evilution", "mcp"],
626
695
  "env": {}
627
696
  }
628
697
  }
629
698
  }
630
699
  ```
631
700
 
632
- If using Bundler, set the command to `bundle` and args to `["exec", "evilution", "mcp"]`.
701
+ 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"]`.
633
702
 
634
703
  The server exposes the following tools:
635
704
 
@@ -637,7 +706,7 @@ The server exposes the following tools:
637
706
  |---|---|
638
707
  | `evilution-mutate` | Run mutation testing on target files with structured JSON results |
639
708
  | `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) |
640
- | `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 |
709
+ | `evilution-info` | Discovery before mutation — `action: subjects` lists the mutation subjects (methods, and class-body subjects such as constant assignments, 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 |
641
710
 
642
711
  ### Verbosity Control
643
712
 
@@ -655,7 +724,7 @@ What survives trimming matters when you are deciding whether to trust a score:
655
724
 
656
725
  - `neutral` entries — and with them each `neutral_reason` — are dropped at `summary` and `minimal`. Use `full` to see why mutations were neutralised.
657
726
  - `subjects` is kept at `full` and `summary`, and dropped at `minimal`, which keeps only `summary` and `survived`.
658
- - 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.
727
+ - 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.
659
728
 
660
729
  ### Enriched Survived Entries
661
730
 
@@ -804,6 +873,14 @@ Methods whose body overlaps the requested range are included. Mix targeted and w
804
873
  evilution run lib/foo.rb:15-30 lib/bar.rb --format json
805
874
  ```
806
875
 
876
+ 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, `Data.define` / `Struct.new` definitions and constant assignments (see step 2 under [Internals](#internals-for-context-not-for-direct-use)). Other class-body code — 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`:
877
+
878
+ ```
879
+ [evilution] app/models/order.rb:12-14 holds code outside every subject (class-body code such as DSL calls is not mutated); no mutations target those lines.
880
+ ```
881
+
882
+ Read `0 mutations` next to that message as "not measured", not as "nothing to test".
883
+
807
884
  ### 4. Method-name targeted scan
808
885
 
809
886
  ```bash
@@ -830,13 +907,14 @@ Pass multiple file paths on a single invocation to amortise startup cost. The fr
830
907
 
831
908
  ### What to read before acting on a score
832
909
 
833
- A score describes only the mutations that got a verdict. Four fields say what it leaves out, and each points at a different action:
910
+ A score describes only the mutations that got a verdict. Five fields say what it leaves out, and each points at a different action:
834
911
 
835
912
  | Field | Meaning | What to do |
836
913
  |---|---|---|
837
914
  | `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 |
838
915
  | `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[]` |
839
- | `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 |
916
+ | `summary.uncovered_code` | Lines you targeted hold code outside every subject — class-body code such as 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 |
917
+ | `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 |
840
918
  | `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` |
841
919
 
842
920
  `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.
@@ -854,7 +932,27 @@ For each entry in `survived[]`:
854
932
 
855
933
  ### 7. Diagnosing errored mutations
856
934
 
857
- Entries in the JSON `errors[]` array represent mutations that raised an exception (syntax error, load failure, or runtime crash) rather than producing a test outcome. Each entry includes `error_class`, `error_message`, and the first 5 `error_backtrace` lines. Use these fields to decide whether the error is a bug in the mutation operator (file an issue), a load-time problem in the mutated source (often `NoMethodError: super called outside of method` or constant-redefinition issues), or a genuine crash that the original tests should have caught. Run with `--verbose` to stream the same error details to stderr during the run.
935
+ Entries in the JSON `errors[]` array represent mutations that raised an exception (syntax error, load failure, or runtime crash) rather than producing a test outcome. A mutation that makes its own file raise while loading is not among them when the original file still loads right after it: the mutation is what broke the load, so it is reported `killed` with an `error_message` that starts with `load failed:`. A load failure stays an error when the original fails to load as well, since that says nothing about the mutation. Each entry includes `error_class`, `error_message`, and the first 5 `error_backtrace` lines. Use these fields to decide whether the error is a bug in the mutation operator (file an issue), a load-time problem in the mutated source (often `NoMethodError: super called outside of method` or constant-redefinition issues), or a genuine crash that the original tests should have caught. Run with `--verbose` to stream the same error details to stderr during the run.
936
+
937
+ A test process that dies on an exception no handler saw (a stack overflow that does not unwind, for one) reports the exception before it goes. What the mutation gets depends on when it died:
938
+
939
+ | `error_message` starts with | Isolation | Status | Meaning |
940
+ |---|---|---|---|
941
+ | `child died`, then `while running the tests` | fork | `killed` | The mutated source loaded and the tests were running: what a crashing test does. |
942
+ | `test run ended by` | in-process | `killed` | The same, in the process of the run. |
943
+ | `child died`, then `before the tests ran` | fork | `error` | Nothing was tested yet. `error_class` is the exception's class. |
944
+
945
+ Three messages are not about the mutation at all. No verdict was reached, and the entry says only why:
946
+
947
+ | `error_message` | Meaning | What to do |
948
+ |---|---|---|
949
+ | `child exited 0; its result was unreadable (...)` (the status varies) | Fork isolation. The child finished, but what arrived on its result pipe does not decode to a result; the parentheses say what was found. Every process the child forks inherits that pipe, so code under test that writes to a descriptor it did not open can put bytes on it. | Re-run that one mutation (line-range targeting). If it repeats, look for code that writes to inherited descriptors. |
950
+ | `worker's result could not be read; the worker was replaced` | Worker pool (`--jobs` above 1). The same thing on the pipe between the run and a worker: the worker was killed and replaced, and the run went on. | Re-run that one mutation, with `--jobs 1` to take the pool out of the picture. |
951
+ | `child died on SIGABRT without writing a result` | Fork isolation. The Ruby VM aborted (a `[BUG]` report, typically), so nothing could be reported from inside the child. Other signals read the same way: a segfault is `SIGSEGV`, a child the kernel killed for memory is `SIGKILL`. | Re-run that one mutation. If it repeats, the mutant crashes the interpreter: that is Ruby's to fix, and the mutation can be disabled with a comment. |
952
+
953
+ The messages in the first column of this last table are examples: the exit status, the signal and the text in parentheses vary. Search `errors[]` for `was unreadable`, `could not be read` and `without writing a result`.
954
+
955
+ Under `--isolation in_process` there is no child to lose: a mutation that takes the Ruby process down takes the run with it, and no report is written. See [Exit Codes](#exit-codes).
858
956
 
859
957
  ### Unresolved mutations — behaviour-named spec layouts
860
958
 
@@ -1002,9 +1100,9 @@ For the full contributor architecture — module map, data flow, and extension
1002
1100
  points — see [docs/architecture.md](docs/architecture.md).
1003
1101
 
1004
1102
  1. **Parse** — Prism parses Ruby files into ASTs with exact byte offsets
1005
- 2. **Extract** — Methods are identified as mutation subjects
1103
+ 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, every other constant assignment outside a method (`LIMIT = 10`, `LIMIT += 1`), named after the constant (`Config::LIMIT`); `constant_write_to_nil` mutates a plain assignment, `compound_assignment` an operator write, and the literal operators the value of either, 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` or `prepended do ... end` block, where they are named after the concern (`Publishable.published`). A method defined in a concern's `included do ... end` or `prepended do ... end` block is mutated on the classes that took the concern before the run as well as on those that take it later; 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` or `prepended 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` or `prepended 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)`)
1006
1104
  3. **Filter** — Disable comments, Sorbet `sig` blocks, and AST ignore patterns exclude mutations before execution
1007
- 4. **Mutate** — 111 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
1105
+ 4. **Mutate** — 155 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
1008
1106
  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
1009
1107
  6. **Test** — The configured test framework (RSpec, Minitest, or Test::Unit) executes against the mutated source
1010
1108
  7. **Collect** — Source strings and AST nodes are released after use to minimize memory retention