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/docs/architecture.md CHANGED
@@ -12,7 +12,13 @@ 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
+ every other constant assignment outside a method (`LIMIT = 10`), every scope
18
+ declared with a literal body (`scope :recent, -> { ... }`), every guard or
19
+ callback written out inside an AASM `event` or `state`, and the conditions and
20
+ bodies of callback and validation declarations — in a class body or in a
21
+ concern's `included` / `prepended` block. Each
16
22
  mutation *operator* walks a subject's AST and emits *mutations* — byte-range
17
23
  edits applied by source-level surgery (no AST unparsing). For every mutation,
18
24
  evilution copies the file, applies the edit, runs the covering specs in an
@@ -48,7 +54,7 @@ Everything lives under `lib/evilution/`.
48
54
  | `Config`, `Config::*` | Merge `.evilution.yml` + CLI flags + env, validate, freeze. | `config.rb`, `config/sources.rb`, `config/validators/*` |
49
55
  | `Runner`, `Runner::*` | Orchestrate the whole run. Each stage is its own collaborator. | `runner.rb`, `runner/*` |
50
56
  | `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::*` | 111 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
57
+ | `Mutator`, `Mutator::Operator::*` | 155 operators (default profile) that emit byte-edits; registry + profiles. | `mutator/base.rb`, `mutator/registry.rb`, `mutator/operator/*` |
52
58
  | `Mutation` | An immutable mutation record (original/mutated sources, slice, location, parse status). | `mutation.rb` |
53
59
  | `SpecResolver`, `SpecSelector` | Map a source file to its covering spec files (layout heuristics + explicit mappings). | `spec_resolver.rb`, `spec_selector.rb` |
54
60
  | `Isolation::{Fork,InProcess}`, `ProcessSupervisor` | Run one mutation's tests in isolation; process-group lifecycle, sandboxing, TERM/KILL ladder. | `isolation/fork.rb`, `process_supervisor.rb` |
@@ -105,16 +111,43 @@ class that owns it.
105
111
  2. **Subjects** — `Runner::SubjectPipeline#call` resolves target files (explicit,
106
112
  `source:<glob>`, or `Git::ChangedFiles`), then `AST::Parser#call` runs
107
113
  `Prism.parse` and `AST::SubjectFinder` (a `Prism::Visitor`) emits one
108
- `Evilution::Subject` per `def` node. Optional descendant/target/line-range filters follow.
114
+ `Evilution::Subject` per `def` node (kind `:method`), plus one per
115
+ `Data.define` / `Struct.new` assigned to a constant or used as a superclass
116
+ outside any method (kind `:constant`, named after the constant), plus one per
117
+ `scope :name, -> { }` / `lambda { }` / `proc { }` written directly in a class
118
+ body (kind `:scope`, named `Class.name` after the class method it defines;
119
+ it spans the declaration and its node is the body; a scope declared in a
120
+ concern's `included do ... end` or `prepended do ... end` is named after the
121
+ concern), plus one per literal
122
+ callable inside an `event` or `state` of a class-body `aasm do ... end` --
123
+ keyword values (`guard: -> { }`, also inside arrays), those of the event's
124
+ `transitions`, and callback blocks (`before { }`) -- kind `:aasm`, named
125
+ `Class#event` / `Class#state?` after the method the declaration defines, so
126
+ one event may contribute several subjects of the same name (a machine in a
127
+ concern's `included do ... end` or `prepended do ... end` is found too, and
128
+ named after the concern);
129
+ and one per
130
+ literal callable of a class-body callback or validation declaration
131
+ (`validate`, `validates`, `validates_*`, `before_*`, `after_*`, `around_*`)
132
+ -- an `if:` / `unless:` condition, or the callback itself as a block or
133
+ lambda -- kind `:callback`, named as the declaration reads
134
+ (`Order.validate(:credit_limit)`, `Order.before_save`; one in a concern's
135
+ `included do ... end` or `prepended do ... end` is named after the concern).
136
+ Optional
137
+ descendant/target/line-range filters follow.
109
138
  3. **Baseline** — `Runner::BaselineRunner#call` builds the integration from
110
139
  `Runner::INTEGRATIONS` (`rspec`/`minitest`/`test_unit`) and records spec files
111
140
  that already fail *before* any mutation, so their mutations aren't miscounted.
112
141
  An optional `Runner::Canary` proves the pipeline can observe a known mutation.
113
142
  4. **Mutations** — `Runner::MutationPlanner#call` flat-maps subjects through
114
- `Mutator::Registry#mutations_for`. The registry instantiates each operator and
143
+ `Mutator::Registry#mutations_for`. The registry instantiates each operator whose
144
+ `subject_kinds` include the subject's kind (`:method`, `:scope`, `:aasm` and `:callback` by default) and
115
145
  runs `operator.call(subject, filter:)`; each operator subclasses
116
146
  `Mutator::Base` and calls `add_mutation`, which runs `AST::SourceSurgeon` and
117
- builds an immutable `Evilution::Mutation`. The planner then **deduplicates**
147
+ builds an immutable `Evilution::Mutation`. Whether the mutated source parses
148
+ is `AST::SyntaxCheck`'s call: Prism first and, on an interpreter that
149
+ compiles with parse.y (Ruby 3.3, or `--parser=parse.y`), parse.y as well,
150
+ since the two disagree on a few constructs. The planner then **deduplicates**
118
151
  (by `file_path` + mutated source), and filters **disabled** (`DisableComment`),
119
152
  **Sorbet `sig`** (`AST::SorbetSigDetector`), and **equivalent**
120
153
  (`Equivalent::Detector`) mutations. Equivalent ones become `:equivalent`
@@ -141,12 +174,25 @@ class that owns it.
141
174
  guard / unresolved); the final symbol is chosen by
142
175
  `Isolation::Fork#classify_status`: `:timeout` → `:killed` (crash) →
143
176
  `:unresolved` → `:error` → `:survived` (tests passed) → default `:killed`.
177
+ A crash is a test that raised, or a mutated source that raised while it was
178
+ loaded and whose original still loads (`MutationApplier` marks it
179
+ `load_crashed` after evaluating the original); a load failure the original
180
+ shares stays an `:error`.
144
181
  A `NeutralizationPipeline` can reclassify results into `:neutral` — either
145
- because the covering spec already failed at baseline (`Neutralizer::BaselineFailed`)
146
- or because the test process crashed on infrastructure rather than on the
147
- mutation (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`,
148
- since the two want opposite responses. A survivor is re-run against its whole
149
- spec file before it is reported (`Integration::RSpec#confirm_survivor?`), so a
182
+ because the tests failed on nothing but examples that were already failing
183
+ at baseline (`Neutralizer::BaselineFailed`) or because the test process
184
+ crashed on infrastructure rather than on the mutation
185
+ (`Neutralizer::InfraError`). Each records a `Result::NeutralReason`, since
186
+ the two want opposite responses. For the first, the baseline reports the
187
+ ids of its failing examples (`Integration::RSpec::ExampleIds`, absolute path
188
+ plus position, so they compare across working directories;
189
+ `Integration::Minitest::TestIds`, `Class#test`;
190
+ `Integration::TestUnit::TestIds`, `test(Class)`), the integration is built
191
+ with them (`known_failures:`, held as `Integration::KnownFailures`), and a
192
+ failed run whose failed examples are all among them comes back flagged
193
+ `known_failures_only`; a survivor is never neutralized. A survivor is re-run
194
+ against its whole spec file before it is reported
195
+ (`Integration::RSpec#confirm?`), as is a targeted run flagged that way, so a
150
196
  narrowed example set cannot invent one; after a parallel pass,
151
197
  `MutationExecutor::InfraRetry` re-runs the infrastructure-neutralised
152
198
  mutations serially, once the contention that caused them is gone.
@@ -158,7 +204,9 @@ class that owns it.
158
204
  the score. `Commands::Run` maps `summary.success?(min_score:)` to exit code
159
205
  `0`/`1` (`2` on error), and `CLI::ExitGuard` — installed by the executable before
160
206
  anything can preload the project — has the final word on the process status, so a
161
- preloaded spec helper's at-exit hook cannot replace it.
207
+ preloaded spec helper's at-exit hook cannot replace it. The guard also ends a run
208
+ that a mutation took down under in-process isolation with `2`, where Ruby would
209
+ end it with `1`.
162
210
 
163
211
  ## How to add a new mutator
164
212
 
@@ -193,6 +241,82 @@ A mutator is a `Prism::Visitor` subclass that emits byte-range edits.
193
241
  - The operator's registered name is auto-derived from the class name
194
242
  (`MyThing` → `my_thing`); that string is the `operator` field in JSON output
195
243
  and is part of the public contract, so name it deliberately.
244
+ - Operators see method, scope, aasm and callback subjects. One that also applies to value-object
245
+ definitions outside a method overrides `self.subject_kinds` to return
246
+ `%i[method constant]`; a constant subject's node is the definition's
247
+ `CallNode`.
248
+ - Every other assignment to a constant outside a method (`LIMIT = 10`) is a
249
+ `:constant_write` subject, whose node is the whole assignment.
250
+ An operator write (`LIMIT += 1`) is one too, under the same name.
251
+ `ConstantWriteToNil` takes no other kind and nils the value of a plain
252
+ assignment only. `CompoundAssignment` handles the operator writes, and
253
+ with the literal operators extends `Mutator::ConstantValueSubjects` to
254
+ take constant-write subjects alongside the default kinds. `SubjectPipeline` drops subjects of a kind no registered operator
255
+ accepts (`Registry#accepts?`). Re-evaluating the mutated file assigns the constant again
256
+ and the restore source puts it back; what read the constant while
257
+ the file first loaded (a class-body call, a constant in another file
258
+ computed from it) keeps the old value.
259
+ - A scope subject's mutation only takes effect if the mutated file re-runs
260
+ its `scope` call, which `BodyCallNeutralizer` would otherwise blank: the
261
+ eval source keeps that one call (`keep_offset`). Its `restore_source` is
262
+ the original file with the same call kept.
263
+ - Every mutation carries a `restore_source`, which `Integration::Base#call`
264
+ evaluates after the tests: the original file, class-body calls
265
+ neutralized. An in-process run would otherwise leave the last mutation
266
+ of a file applied while the next file is mutated, and that file's tests
267
+ would fail on its behalf. For a subject with no declaration to keep it
268
+ is one string per file, built once and shared
269
+ (`Mutator::Base.original_restore_source`).
270
+ - A scope in a concern's `included` block has two audiences. The kept
271
+ `included` call registers the mutated block for classes that include the
272
+ concern later (`ConcernStateCleaner` clears the old one first, on apply
273
+ and on restore: a concern ignores a block handed to it twice from the
274
+ same place). For classes that include it already, the eval source guards
275
+ every other call of the block with `ConcernRedeclaration.skipping?` and
276
+ follows the block with `ConcernRedeclaration.call(self)`, which runs the
277
+ block on each of them with the guard up, so only the scope is declared
278
+ again. Both additions go on existing lines; no line number moves.
279
+ - A method defined in a concern's `included` or `prepended` block, or a
280
+ constant assigned there, is handled the same way: the parser marks every
281
+ subject written in such a block (`Subject#concern_hook?`), and
282
+ `Mutator::Base` keeps the block for it as it does for a scope. The other
283
+ methods of the block and the constants it assigns are guarded like its
284
+ calls, so only the mutated one is defined again and no constant loses
285
+ what it has gathered. For a `prepended` block the eval source ends it with
286
+ `ConcernRedeclaration.call(self, :prepended)`, which runs it on the
287
+ classes that prepend the concern (it comes before them in their
288
+ ancestors) rather than on those that include it. A method the class has
289
+ defined itself over one of the block's is put back afterwards
290
+ (`Integration::Loading::OwnMethods`, which tells the two apart by the
291
+ file they were written in).
292
+ - A callback subject's declaration is kept too, wrapped in
293
+ `CallbackRedeclaration.call(self, __FILE__, lines) { ... }`. Callbacks do
294
+ not replace on re-declaration: a symbol callback moves to the end of its
295
+ chain, a block or validator is added beside the first. The helper reads
296
+ the chains, runs the declaration and rebuilds them through
297
+ `__update_callbacks`, so the new callbacks sit where the ones this
298
+ declaration registered before were. Those are found by the source
299
+ location of their procs the first time (`lines` is the declaration's
300
+ range in the file as loaded) and from the helper's own record afterwards,
301
+ since a mutation may add or remove lines. In a concern's `included` block
302
+ the declaration is wrapped the same way inside the kept block, whose
303
+ other calls are guarded: run on a class that includes the concern already
304
+ it replaces that class's callbacks in place, run by a class including it
305
+ later it simply declares. `BodyCallNeutralizer` leaves a blanked call's
306
+ line breaks behind, so the evaluated source keeps the file's line numbers
307
+ and a class loaded during one mutation can be matched by location in the
308
+ next.
309
+ - An aasm subject works the same way, one level down: the `aasm` call is
310
+ kept, and inside its block every declaration but the `event` or `state`
311
+ holding the mutation is blanked. AASM stores what an event or state holds
312
+ by value, so re-running one replaces it; the machine's own callbacks
313
+ (`after_all_transitions`) accumulate, which is why the rest of the block
314
+ must not run again and why they get no subjects. For a machine inside a
315
+ concern's `included` block the two mechanisms combine: the block is kept
316
+ and followed by `ConcernRedeclaration.call(self)`, and both its other
317
+ calls and the machine's other declarations are guarded with
318
+ `ConcernRedeclaration.skipping?` instead of blanked -- a class that
319
+ includes the concern later needs the whole machine.
196
320
 
197
321
  2. **Require it** in `lib/evilution.rb` alongside the other
198
322
  `require_relative "evilution/mutator/operator/..."` lines.
@@ -66,6 +66,29 @@ after calling `.to_s` on both sides:
66
66
  call{name=log} # matches when node.name.to_s == "log"
67
67
  ```
68
68
 
69
+ **Method names** that end in `?`, `!` or `=` are written as they are, and so are
70
+ operator methods:
71
+
72
+ ```
73
+ call{name=valid?} # record.valid?
74
+ call{name=strip_sources!} # strip_sources!
75
+ call{name=value=} # record.value = 1
76
+ call{name=<=>} # a <=> b
77
+ call{name=-@} # -a (unary minus)
78
+ ```
79
+
80
+ The bare operators are `<=>`, `===`, `==`, `=~`, `[]=`, `[]`, `<=`, `<<`, `<`,
81
+ `>=`, `>>`, `>`, `+@`, `-@`, `+`, `-`, `/`, `%`, `&`, `^` and `~`. Any other
82
+ name — `|`, `!`, `!=`, `!~`, `*`, `**`, a backtick — goes in single or double
83
+ quotes, since unquoted these characters mean alternation, negation or a wildcard.
84
+ A quoted name is matched literally:
85
+
86
+ ```
87
+ call{name='|'} # a | b, not an alternation
88
+ call{name='*'} # a * b, not a wildcard
89
+ call{name='!='} # a != b, not a negation
90
+ ```
91
+
69
92
  **Alternatives** use `|` (OR logic) within a single attribute value:
70
93
 
71
94
  ```
@@ -139,6 +162,13 @@ ignore_patterns:
139
162
 
140
163
  # Suppress mutations on constant `ENV` reads
141
164
  - "call{receiver=constant_read{name=ENV}}"
165
+
166
+ # Suppress mutations on bang and predicate calls, and on `<=>`
167
+ - "call{name=strip_sources!|eager_load!|valid?}"
168
+ - "call{name=<=>}"
169
+
170
+ # Quote names made of reserved characters
171
+ - "call{name='|'}"
142
172
  ```
143
173
 
144
174
  ## Pattern Matching Semantics
@@ -164,12 +194,21 @@ value = "!" value
164
194
  | "*"
165
195
  nested_pattern = node_type "{" attributes "}"
166
196
  alternatives = atom { "|" atom }
167
- atom = identifier | "*"
197
+ atom = name | "*"
198
+ name = identifier [ "?" | "!" | "=" ]
199
+ | operator
200
+ | "'" { any character except "'" } "'"
201
+ | '"' { any character except '"' } '"'
202
+ operator = "<=>" | "===" | "==" | "=~" | "[]=" | "[]" | "<=" | "<<" | "<"
203
+ | ">=" | ">>" | ">" | "+@" | "-@" | "+" | "-" | "/" | "%"
204
+ | "&" | "^" | "~"
168
205
  identifier = [a-zA-Z_] [a-zA-Z0-9_]*
169
206
  ```
170
207
 
171
- A bare `identifier` without `{` is parsed as a scalar value (matched via `.to_s`).
172
- An `identifier` followed by `{` is parsed as a nested pattern.
208
+ A bare `name` without `{` is parsed as a scalar value (matched via `.to_s`).
209
+ An `identifier` followed by `{` is parsed as a nested pattern. Operators are
210
+ read longest first, so `<=>` is never taken for `<=`. Node types and attribute
211
+ names are always plain identifiers. A quoted name is at least one character long.
173
212
 
174
213
  ## Examples
175
214
 
@@ -183,6 +222,9 @@ An `identifier` followed by `{` is parsed as a nested pattern.
183
222
  | `call{receiver=**}` | `foo()`, `x.foo()`, `a.b.foo()` | *(matches all)* |
184
223
  | `def{name=to_s}` | `def to_s; ... end` | `def to_str; ... end` |
185
224
  | `call{name=!log}` | `debug()`, `info()` | `log()` |
225
+ | `call{name=valid?\|save!}` | `x.valid?`, `save!` | `valid`, `save` |
226
+ | `call{name=<=>}` | `a <=> b` | `a <= b` |
227
+ | `call{name='\|'}` | `a \| b` | `a & b` |
186
228
 
187
229
  ## Design Decisions
188
230
 
@@ -191,8 +233,11 @@ An `identifier` followed by `{` is parsed as a nested pattern.
191
233
  write patterns immediately.
192
234
 
193
235
  2. **Unquoted values**: Attribute values don't require quotes. This keeps YAML clean
194
- and avoids escaping issues. The tradeoff is that values cannot contain `{`, `}`,
195
- `,`, `=`, `|`, or `!` — these are reserved syntax characters.
236
+ and avoids escaping issues. Method names ending in `?`, `!` or `=` and operators
237
+ that cannot be confused with pattern syntax are written bare too. Only names
238
+ built from reserved characters (`|`, `!`, `*`) or characters outside a method
239
+ name need quotes; there is no escaping inside them. In YAML, use one kind of
240
+ quote for the YAML string and the other inside it: `- "call{name='|'}"`.
196
241
 
197
242
  3. **Implicit wildcards for unspecified attributes**: `call{name=log}` matches
198
243
  regardless of receiver, arguments, etc. Only specified attributes constrain the
data/docs/isolation.md CHANGED
@@ -22,8 +22,8 @@ transaction block with a half-delivered exception. The mask is
22
22
  [load-bearing for transaction correctness][rails-handle-interrupt].
23
23
 
24
24
  That mask interacts badly with `Timeout.timeout`. The Timeout gem schedules a
25
- timer thread which fires `Thread#raise Timeout::Error` at the main thread when
26
- the deadline hits. Ruby receives the `raise`, inspects the interrupt mask, sees
25
+ timer thread which fires `Thread#raise` at the main thread when the deadline
26
+ hits. Ruby receives the `raise`, inspects the interrupt mask, sees
27
27
  `Exception => :never`, and **queues the exception for later delivery** — "later"
28
28
  meaning "when the masked block exits". If the mutant is stuck in an infinite
29
29
  loop inside the transaction, the block never exits, the queued exception is
@@ -57,6 +57,99 @@ The same hazard applies to any Ruby code that uses
57
57
  blocks that wrap "must complete" sections. If your target code can touch any
58
58
  of those, prefer `--isolation fork`.
59
59
 
60
+ ## How `in_process` ends a run that is out of time
61
+
62
+ The timeout raises `Evilution::Isolation::InProcess::Expired` into the run. It
63
+ is a `SignalException`, not a `Timeout::Error`, because it has to end the
64
+ whole test run. A test framework rescues nearly everything a test raises:
65
+ handed a `Timeout::Error`, it records one failed test and moves on to the
66
+ next, which may reach the same endless loop with no timer left to stop it.
67
+ Minitest, Test::Unit and RSpec all let a `SignalException` through, so the run
68
+ stops at once and the mutation is reported as timed out rather than killed.
69
+
70
+ It is not an `Interrupt` either. Tooling that handles Ctrl-C — maxitest, for
71
+ one — rescues `Interrupt` and skips every test from then on, which would turn
72
+ each later mutation in the process into a survivor.
73
+
74
+ One raise is all there is. Code under test, or a test, that rescues
75
+ `Exception` or `SignalException` and then loops again cannot be stopped from
76
+ inside its own process; use `--isolation fork` for it. The same goes for a
77
+ mutant that crashes the Ruby VM: under `fork` that costs one child, under
78
+ `in_process` the whole run.
79
+
80
+ ## What `in_process` cannot contain
81
+
82
+ A mutant runs in the same Ruby process as evilution, so whatever takes that
83
+ process down takes the run with it.
84
+
85
+ An exception outside `StandardError` that leaves the test run is contained:
86
+ a `SystemStackError` from a mutant that recurses without end is the usual
87
+ one. It is reported for that mutation and the run goes on. The mutation is
88
+ `killed` when the exception left the tests after the mutated source loaded,
89
+ as a crashing test would be, and an `error` when it was raised earlier.
90
+
91
+ One kind of stack overflow is not contained. When the recursion goes through
92
+ `const_missing` — a mutant of a `const_missing` that is installed on `Object`
93
+ does that — Ruby may run neither `rescue` nor `ensure`, or abort outright
94
+ with a `[BUG]` report. Which one depends on the stack limit of the process
95
+ (`ulimit -s`): under the usual 8 MB the machine stack gives out first and the
96
+ process is lost, under a much larger limit the overflow unwinds like any
97
+ other. The other recursions tried (plain method calls, blocks, `send`,
98
+ `method_missing`, `to_s`, a Hash default proc) unwind normally under the
99
+ usual limit, on Ruby 3.4 and 4.0.
100
+
101
+ When the process ends this way without aborting, a line on stderr names the
102
+ mutation:
103
+
104
+ ```
105
+ [evilution] the run died while testing a mutation under in-process isolation:
106
+ constant_namespace_strip: lib/foo.rb:202 (SystemStackError: stack level too deep).
107
+ In-process isolation cannot contain a mutation that takes the Ruby process down;
108
+ re-run with --isolation fork.
109
+ ```
110
+
111
+ The process then ends with status 2, the status of a tool error: nothing was
112
+ measured, and the line says what to change in the invocation. Ruby's own
113
+ status for an uncaught exception is 1, which would read as a score below
114
+ `--min-score`. The hooks the project registered with `at_exit` still run
115
+ first. Ruby's backtrace of the exception is not printed; the line carries its
116
+ class and message. A run that is interrupted (Ctrl-C) keeps the status Ruby
117
+ gives it.
118
+
119
+ After a `[BUG]` abort there is no such line and no such status: Ruby's own
120
+ crash report is all there is, and the process ends as an abort does (134 from
121
+ a shell), since no `at_exit` hook runs. In both cases no report is written
122
+ and the verdicts collected so far are lost. Re-run with `--isolation fork`,
123
+ where the same mutant costs one child process.
124
+
125
+ ## State a file leaves behind when it is evaluated again
126
+
127
+ `in_process` evaluates a source file once for every mutation of it, and once
128
+ more to put the original back. A fork child evaluates it once and ends. A file
129
+ whose evaluation adds to something, instead of replacing it, therefore behaves
130
+ differently under the two.
131
+
132
+ One such case is handled. A file that installs a module it never names,
133
+
134
+ ```ruby
135
+ handler = Module.new { def const_missing(name) = ... }
136
+ Object.extend(handler)
137
+ ```
138
+
139
+ leaves one more module on its host every time it is evaluated, and Ruby has no
140
+ way to take one off. Left alone, the mutation under test would reach the code
141
+ of every earlier mutation through `super`, fail on a predecessor's bug, and be
142
+ reported killed. Evilution remembers the anonymous modules a file's evaluation
143
+ creates and removes their methods when the file is evaluated next, so that
144
+ method lookup passes through them. Only modules are emptied, not anonymous
145
+ classes, and only the methods written in that file.
146
+
147
+ Other things a file can add to at load time are not undone: an `at_exit`
148
+ hook, a `Signal.trap`, a `TracePoint`, a callback pushed into a registry of
149
+ some library. If the file you are mutating does one of those at the top
150
+ level, compare an `in_process` run with a `--isolation fork` run before
151
+ trusting its kills.
152
+
60
153
  ## Parent-process preload
61
154
 
62
155
  Fork isolation's one downside is that every child pays the cost of loading
@@ -132,6 +225,23 @@ the MCP server is a long-lived process that handles runs from different
132
225
  projects — preloading one project's Rails stack into a shared process would
133
226
  poison subsequent runs.
134
227
 
228
+ ## Test files under `in_process` (Minitest, Test::Unit)
229
+
230
+ Under `in_process` one process runs every mutation, and Minitest and
231
+ Test::Unit learn of a test class only when it is first defined: loading its
232
+ file a second time reopens the class and registers nothing. Each test file is
233
+ therefore loaded once per process, and the classes it registered are
234
+ dispatched again for every later mutation
235
+ (`Integration::Loading::TestClassCache`).
236
+
237
+ The consequence is that code in the body of a test file runs once. Tests
238
+ generated at load time from the code under test (`OPS.each { |op|
239
+ define_method("test_#{op}") { … } }`) keep the set built on the first load,
240
+ and state held on a test class carries over from one mutation to the next.
241
+ Under `fork` every child loads the file itself, so use `--isolation fork` for
242
+ a suite that depends on either. RSpec is unaffected: it is reset and its spec
243
+ files are loaded again for each mutation.
244
+
135
245
  ## Sandboxed working directory
136
246
 
137
247
  Every isolator runs `test_command.call` inside a per-mutation scratch
@@ -169,6 +279,15 @@ single in-flight mutation is recorded as `:timeout` / `:error`, and the run
169
279
  continues with the remaining work. A single pathological mutation costs you one
170
280
  result, not the entire run.
171
281
 
282
+ The same holds for a worker whose reply cannot be trusted. The pipes between
283
+ the parent and a worker are inherited by every process the worker forks, so
284
+ code under test that writes to a descriptor it did not open can put bytes on
285
+ them. A frame that does not decode, that decodes to something other than a
286
+ result of an item the worker holds, or that stops short of the length it
287
+ announced (ten seconds are allowed for the rest) ends that worker: it is
288
+ killed and replaced, and the mutation it held is recorded as an `:error` with
289
+ the message `worker's result could not be read; the worker was replaced`.
290
+
172
291
  Each worker is also its own process-group leader, so killing a worker reaps the
173
292
  mutation subprocess and any grandchildren it spawned rather than orphaning them,
174
293
  and a terminal interrupt (Ctrl-C) is forwarded to every worker group — an
@@ -142,7 +142,7 @@ Key-by-key:
142
142
  | `matcher.ignore` | `ignore_patterns` / `# evilution:disable` | AST patterns or inline comments |
143
143
  | `fail_fast` | `fail_fast` | integer N or `null` |
144
144
  | `--score` gate (CLI; default `1.0`) | `min_score` | evilution defaults to `0.0` — set your own gate |
145
- | _(n/a)_ | `profile: strict` | opt into aggressive truthiness mutators |
145
+ | _(n/a)_ | `profile: strict` | opt into aggressive mutators (truthiness, swallowed errors, statement order) |
146
146
 
147
147
  Run `evilution init` for a fully commented template, and point your editor at
148
148
  `schema/evilution.config.schema.json` for autocomplete/validation.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # The guards and callbacks of an AASM state machine that are written out as
8
+ # literal callables:
9
+ #
10
+ # aasm do
11
+ # state :paid, before_exit: -> { ... }
12
+ # event :ship, guard: -> { ... } do
13
+ # before { ... }
14
+ # transitions from: :paid, to: :shipped, guard: -> { ... }
15
+ # end
16
+ # end
17
+ #
18
+ # Each belongs to the `event` or `state` declaration it is written in. AASM
19
+ # keeps what a declaration holds by value, so running that one declaration
20
+ # again replaces it, and a mutated body takes effect. The machine's own
21
+ # callbacks (`after_all_transitions`) accumulate instead, and are left out.
22
+ #
23
+ # Only a receiver-less `aasm` call with a block, written directly in a class
24
+ # body, counts: that is the call the mutated file re-runs.
25
+ module Evilution::AST::AasmDeclaration
26
+ # body: the node holding the callable's body. declaration: its `event` or
27
+ # `state` call. method_name: the instance method that declaration defines.
28
+ Callable = Data.define(:body, :declaration, :method_name)
29
+
30
+ DECLARATIONS = %i[event state].freeze
31
+ EVENT_CALLBACKS = %i[
32
+ before before_transaction before_success success after after_transaction after_commit error ensure
33
+ ].freeze
34
+ private_constant :DECLARATIONS, :EVENT_CALLBACKS
35
+
36
+ # The callables of every machine among a class body's direct statements.
37
+ def self.in_body(body)
38
+ statements_of(body).flat_map { |node| machine_callables(node) }
39
+ end
40
+
41
+ # The block of an `aasm` call, or nil when node is not one.
42
+ def self.machine_block(node)
43
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :aasm && node.receiver.nil?
44
+
45
+ node.block if node.block.is_a?(Prism::BlockNode)
46
+ end
47
+
48
+ # The name specs call for whatever sits on a line of a machine: the event,
49
+ # or the first state, declared there. nil outside every declaration.
50
+ def self.token_at(machine, line)
51
+ declaration = declarations_of(machine).find do |node|
52
+ line.between?(node.location.start_line, node.location.end_line)
53
+ end
54
+ declaration && declared_name(declaration)
55
+ end
56
+
57
+ def self.machine_callables(machine)
58
+ declarations_of(machine).flat_map do |declaration|
59
+ bodies_of(declaration).map do |body|
60
+ Callable.new(body: body, declaration: declaration, method_name: method_name(declaration))
61
+ end
62
+ end
63
+ end
64
+
65
+ def self.declarations_of(machine)
66
+ block = machine_block(machine)
67
+ return [] unless block
68
+
69
+ statements_of(block.body).select { |node| declaration?(node) }
70
+ end
71
+
72
+ def self.declaration?(node)
73
+ bare_call?(node) && DECLARATIONS.include?(node.name) && arguments_of(node).first.is_a?(Prism::SymbolNode)
74
+ end
75
+
76
+ def self.declared_name(declaration)
77
+ arguments_of(declaration).first.unescaped
78
+ end
79
+
80
+ # `event :ship` defines `ship`; `state :paid` defines `paid?`.
81
+ def self.method_name(declaration)
82
+ declaration.name == :event ? declared_name(declaration) : "#{declared_name(declaration)}?"
83
+ end
84
+
85
+ def self.bodies_of(declaration)
86
+ keyword_bodies(declaration) + (declaration.name == :event ? event_block_bodies(declaration) : [])
87
+ end
88
+
89
+ # What an event's block holds: its transitions, with keyword callables of
90
+ # their own, and callbacks given as blocks (`before { }`).
91
+ def self.event_block_bodies(event)
92
+ return [] unless event.block.is_a?(Prism::BlockNode)
93
+
94
+ statements_of(event.block.body).select { |node| bare_call?(node) }.flat_map do |node|
95
+ if node.name == :transitions then keyword_bodies(node)
96
+ elsif callback_block?(node) then [node.block]
97
+ else []
98
+ end
99
+ end
100
+ end
101
+
102
+ def self.callback_block?(node)
103
+ EVENT_CALLBACKS.include?(node.name) && node.arguments.nil? && node.block.is_a?(Prism::BlockNode)
104
+ end
105
+
106
+ # `guard: -> { }` and `guards: [-> { }, :named]` alike.
107
+ def self.keyword_bodies(call)
108
+ keywords = arguments_of(call).last
109
+ return [] unless keywords.is_a?(Prism::KeywordHashNode)
110
+
111
+ keywords.elements.grep(Prism::AssocNode).flat_map do |pair|
112
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
113
+ values.filter_map { |value| Evilution::AST::LiteralCallable.body_of(value) }
114
+ end
115
+ end
116
+
117
+ def self.bare_call?(node)
118
+ node.is_a?(Prism::CallNode) && node.receiver.nil?
119
+ end
120
+
121
+ def self.arguments_of(call)
122
+ call.arguments ? call.arguments.arguments : []
123
+ end
124
+
125
+ def self.statements_of(body)
126
+ body = body.statements if body.is_a?(Prism::BeginNode)
127
+ body.is_a?(Prism::StatementsNode) ? body.body : []
128
+ end
129
+
130
+ private_class_method :machine_callables, :declarations_of, :declaration?, :declared_name, :method_name,
131
+ :bodies_of, :event_block_bodies, :callback_block?, :keyword_bodies, :bare_call?,
132
+ :arguments_of, :statements_of
133
+ end