evilution 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +100 -0
  3. data/.rubocop_todo.yml +14 -0
  4. data/CHANGELOG.md +124 -0
  5. data/README.md +142 -28
  6. data/docs/architecture.md +123 -9
  7. data/docs/ast_pattern_syntax.md +50 -5
  8. data/docs/isolation.md +39 -2
  9. data/docs/migration-from-mutant.md +1 -1
  10. data/lib/evilution/ast/aasm_declaration.rb +133 -0
  11. data/lib/evilution/ast/callback_declaration.rb +91 -0
  12. data/lib/evilution/ast/included_block.rb +27 -0
  13. data/lib/evilution/ast/literal_callable.rb +21 -0
  14. data/lib/evilution/ast/parser.rb +120 -16
  15. data/lib/evilution/ast/pattern/filter.rb +1 -0
  16. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  17. data/lib/evilution/ast/pattern/parser.rb +15 -15
  18. data/lib/evilution/ast/pattern.rb +2 -0
  19. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  20. data/lib/evilution/ast/scope_declaration.rb +45 -0
  21. data/lib/evilution/ast/sorbet_sig_detector.rb +1 -0
  22. data/lib/evilution/ast/uncovered_code.rb +88 -0
  23. data/lib/evilution/ast/value_object_definition.rb +25 -0
  24. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  25. data/lib/evilution/baseline/report.rb +66 -0
  26. data/lib/evilution/baseline/spec_failure.rb +38 -0
  27. data/lib/evilution/baseline.rb +101 -42
  28. data/lib/evilution/child_output.rb +1 -1
  29. data/lib/evilution/cli/command.rb +1 -0
  30. data/lib/evilution/cli/commands/tests_list.rb +4 -3
  31. data/lib/evilution/cli/commands.rb +2 -0
  32. data/lib/evilution/cli/dispatcher.rb +2 -0
  33. data/lib/evilution/cli/exit_guard.rb +2 -0
  34. data/lib/evilution/cli/parsed_args.rb +2 -0
  35. data/lib/evilution/cli/parser/command_extractor.rb +2 -0
  36. data/lib/evilution/cli/parser/file_args.rb +4 -1
  37. data/lib/evilution/cli/parser/options_builder.rb +4 -1
  38. data/lib/evilution/cli/parser/stdin_reader.rb +1 -0
  39. data/lib/evilution/cli/parser.rb +1 -0
  40. data/lib/evilution/cli/printers/tests_list.rb +5 -4
  41. data/lib/evilution/cli/printers.rb +2 -0
  42. data/lib/evilution/cli/result.rb +2 -0
  43. data/lib/evilution/cli.rb +8 -2
  44. data/lib/evilution/config/builders.rb +2 -0
  45. data/lib/evilution/config/env_loader.rb +2 -0
  46. data/lib/evilution/config/file_loader.rb +1 -0
  47. data/lib/evilution/config/sources.rb +1 -0
  48. data/lib/evilution/config/validators/example_targeting_cache.rb +1 -0
  49. data/lib/evilution/config/validators/example_targeting_fallback.rb +1 -0
  50. data/lib/evilution/config/validators/example_targeting_strategy.rb +1 -0
  51. data/lib/evilution/config/validators/fail_fast.rb +1 -0
  52. data/lib/evilution/config/validators/hooks.rb +1 -0
  53. data/lib/evilution/config/validators/ignore_patterns.rb +1 -0
  54. data/lib/evilution/config/validators/integration.rb +1 -0
  55. data/lib/evilution/config/validators/isolation.rb +1 -0
  56. data/lib/evilution/config/validators/jobs.rb +1 -0
  57. data/lib/evilution/config/validators/preload.rb +1 -0
  58. data/lib/evilution/config/validators/profile.rb +1 -0
  59. data/lib/evilution/config/validators/spec_mappings.rb +3 -1
  60. data/lib/evilution/config/validators/spec_pattern.rb +1 -0
  61. data/lib/evilution/config/validators/warmup.rb +17 -0
  62. data/lib/evilution/config/validators.rb +2 -0
  63. data/lib/evilution/config.rb +7 -5
  64. data/lib/evilution/coverage.rb +1 -1
  65. data/lib/evilution/coverage_example_filter.rb +1 -1
  66. data/lib/evilution/diagnostic.rb +1 -1
  67. data/lib/evilution/equivalent/detector.rb +3 -1
  68. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  69. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  70. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  71. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  72. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  73. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  74. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  75. data/lib/evilution/example_filter.rb +46 -1
  76. data/lib/evilution/hooks/registry.rb +2 -1
  77. data/lib/evilution/integration/base.rb +2 -0
  78. data/lib/evilution/integration/known_failures.rb +26 -0
  79. data/lib/evilution/integration/loading/body_call_neutralizer.rb +139 -12
  80. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  81. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  82. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  83. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  84. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  85. data/lib/evilution/integration/loading/test_load_path.rb +21 -13
  86. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  87. data/lib/evilution/integration/minitest.rb +45 -6
  88. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  89. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  90. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  91. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  92. data/lib/evilution/integration/rspec/state_guard/configuration_state.rb +1 -0
  93. data/lib/evilution/integration/rspec/state_guard/configuration_streams.rb +1 -0
  94. data/lib/evilution/integration/rspec/state_guard/example_groups_constants.rb +1 -2
  95. data/lib/evilution/integration/rspec/state_guard/internals.rb +1 -2
  96. data/lib/evilution/integration/rspec/state_guard/object_space_example_groups.rb +1 -2
  97. data/lib/evilution/integration/rspec/state_guard/reporter_arrays.rb +1 -0
  98. data/lib/evilution/integration/rspec/state_guard/world_example_groups.rb +1 -0
  99. data/lib/evilution/integration/rspec/state_guard/world_filtered_examples.rb +1 -0
  100. data/lib/evilution/integration/rspec/state_guard/world_sources_by_path.rb +1 -0
  101. data/lib/evilution/integration/rspec/state_guard.rb +10 -1
  102. data/lib/evilution/integration/rspec.rb +22 -4
  103. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  104. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  105. data/lib/evilution/integration/test_unit.rb +37 -9
  106. data/lib/evilution/isolation/fork.rb +2 -1
  107. data/lib/evilution/isolation/in_process.rb +21 -4
  108. data/lib/evilution/mcp/info_tool/actions/environment.rb +1 -0
  109. data/lib/evilution/mcp/info_tool/actions/feedback.rb +1 -0
  110. data/lib/evilution/mcp/info_tool/actions/statuses.rb +1 -0
  111. data/lib/evilution/mcp/info_tool/actions/subjects.rb +1 -0
  112. data/lib/evilution/mcp/info_tool/actions/tests.rb +1 -0
  113. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  114. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  115. data/lib/evilution/memory/leak_check.rb +27 -3
  116. data/lib/evilution/mutation.rb +8 -3
  117. data/lib/evilution/mutator/base.rb +49 -5
  118. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  119. data/lib/evilution/mutator/operator/argument_list_removal.rb +52 -0
  120. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  121. data/lib/evilution/mutator/operator/argument_propagation.rb +54 -0
  122. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  123. data/lib/evilution/mutator/operator/array_coercion_to_literal.rb +34 -0
  124. data/lib/evilution/mutator/operator/attribute_write_to_read.rb +34 -0
  125. data/lib/evilution/mutator/operator/bang_method.rb +11 -3
  126. data/lib/evilution/mutator/operator/binary_operand_promotion.rb +63 -0
  127. data/lib/evilution/mutator/operator/block_body_promotion.rb +45 -0
  128. data/lib/evilution/mutator/operator/block_body_to_nil.rb +52 -0
  129. data/lib/evilution/mutator/operator/block_body_to_raise.rb +34 -0
  130. data/lib/evilution/mutator/operator/call_to_nil.rb +34 -0
  131. data/lib/evilution/mutator/operator/coercion_emptying.rb +60 -0
  132. data/lib/evilution/mutator/operator/collection_replacement.rb +19 -9
  133. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  134. data/lib/evilution/mutator/operator/comparison_replacement.rb +37 -12
  135. data/lib/evilution/mutator/operator/const_get_to_constant_path.rb +50 -0
  136. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  137. data/lib/evilution/mutator/operator/dig_to_fetch_chain.rb +45 -0
  138. data/lib/evilution/mutator/operator/double_negation_removal.rb +22 -0
  139. data/lib/evilution/mutator/operator/dynamic_dispatch_resolution.rb +79 -0
  140. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  141. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  142. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  143. data/lib/evilution/mutator/operator/inequality_to_negated_identity.rb +43 -0
  144. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  145. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  146. data/lib/evilution/mutator/operator/keyword_argument_removal.rb +47 -0
  147. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  148. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  149. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  150. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  151. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  152. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  153. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  154. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  155. data/lib/evilution/mutator/operator/proc_to_lambda.rb +43 -0
  156. data/lib/evilution/mutator/operator/receiver_constructor_swap.rb +51 -0
  157. data/lib/evilution/mutator/operator/reduce_to_sum.rb +58 -0
  158. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  159. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  160. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  161. data/lib/evilution/mutator/operator/regexp_anchor_to_predicate.rb +136 -0
  162. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  163. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  164. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  165. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  166. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  167. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  168. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  169. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  170. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  171. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  172. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +67 -0
  173. data/lib/evilution/mutator/operator/send_mutation.rb +44 -6
  174. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  175. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  176. data/lib/evilution/mutator/operator/symbol_to_proc_replacement.rb +59 -0
  177. data/lib/evilution/mutator/operator/to_i_to_integer.rb +38 -0
  178. data/lib/evilution/mutator/primitives.rb +25 -0
  179. data/lib/evilution/mutator/registry.rb +54 -2
  180. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  181. data/lib/evilution/parallel/pool.rb +1 -0
  182. data/lib/evilution/parallel_db_warning.rb +1 -1
  183. data/lib/evilution/process_supervisor.rb +3 -2
  184. data/lib/evilution/rails_warmup.rb +61 -0
  185. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  186. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  187. data/lib/evilution/reporter/html/report.rb +1 -0
  188. data/lib/evilution/reporter/json/baseline.rb +15 -0
  189. data/lib/evilution/reporter/json.rb +7 -2
  190. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  191. data/lib/evilution/result/coverage_gap_grouper.rb +1 -0
  192. data/lib/evilution/result/mutation_result.rb +9 -1
  193. data/lib/evilution/result/subject_scorer.rb +1 -0
  194. data/lib/evilution/result/summary.rb +42 -2
  195. data/lib/evilution/runner/baseline_runner.rb +26 -11
  196. data/lib/evilution/runner/canary.rb +2 -57
  197. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  198. data/lib/evilution/runner/isolation_resolver.rb +23 -2
  199. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +35 -14
  200. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  201. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  202. data/lib/evilution/runner/mutation_executor.rb +1 -1
  203. data/lib/evilution/runner/mutation_planner.rb +11 -2
  204. data/lib/evilution/runner/report_publisher.rb +3 -2
  205. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  206. data/lib/evilution/runner.rb +4 -1
  207. data/lib/evilution/source_ast_cache.rb +1 -1
  208. data/lib/evilution/spec_ast_cache.rb +1 -1
  209. data/lib/evilution/subject.rb +8 -2
  210. data/lib/evilution/version.rb +1 -1
  211. data/lib/evilution.rb +51 -0
  212. data/script/memory_check +62 -16
  213. metadata +108 -4
@@ -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,26 @@ 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
+
60
80
  ## Parent-process preload
61
81
 
62
82
  Fork isolation's one downside is that every child pays the cost of loading
@@ -132,6 +152,23 @@ the MCP server is a long-lived process that handles runs from different
132
152
  projects — preloading one project's Rails stack into a shared process would
133
153
  poison subsequent runs.
134
154
 
155
+ ## Test files under `in_process` (Minitest, Test::Unit)
156
+
157
+ Under `in_process` one process runs every mutation, and Minitest and
158
+ Test::Unit learn of a test class only when it is first defined: loading its
159
+ file a second time reopens the class and registers nothing. Each test file is
160
+ therefore loaded once per process, and the classes it registered are
161
+ dispatched again for every later mutation
162
+ (`Integration::Loading::TestClassCache`).
163
+
164
+ The consequence is that code in the body of a test file runs once. Tests
165
+ generated at load time from the code under test (`OPS.each { |op|
166
+ define_method("test_#{op}") { … } }`) keep the set built on the first load,
167
+ and state held on a test class carries over from one mutation to the next.
168
+ Under `fork` every child loads the file itself, so use `--isolation fork` for
169
+ a suite that depends on either. RSpec is unaffected: it is reset and its spec
170
+ files are loaded again for each mutation.
171
+
135
172
  ## Sandboxed working directory
136
173
 
137
174
  Every isolator runs `test_command.call` inside a per-mutation scratch
@@ -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
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+ require_relative "literal_callable"
6
+
7
+ # The callables written out in a callback or validation declaration of a
8
+ # class body:
9
+ #
10
+ # validate :credit_limit, if: -> { paid? && total.positive? }
11
+ # validates :title, presence: true, unless: [-> { draft? }, :imported?]
12
+ # before_save { self.slug = title.parameterize }
13
+ # after_commit -> { notify }, on: :create
14
+ #
15
+ # That is the condition given to `if:` / `unless:`, and the callback itself
16
+ # when it is a block or a lambda. A condition or callback named by a symbol
17
+ # is a method, and a subject of its own already.
18
+ #
19
+ # Declarations are recognised by name -- `validate`, `validates`,
20
+ # `validates_*`, `before_*`, `after_*`, `around_*` -- and only receiver-less,
21
+ # directly in a class body: that is the call the mutated file re-runs.
22
+ module Evilution::AST::CallbackDeclaration
23
+ # body: the node holding the callable's body. declaration: the call it is
24
+ # written in. label: how the declaration reads, for naming the subject.
25
+ Callable = Data.define(:body, :declaration, :label)
26
+
27
+ NAME = /\A(?:validate|validates|validates_\w+|(?:before|after|around)_\w+)\z/
28
+ CONDITIONS = %w[if unless].freeze
29
+ private_constant :NAME, :CONDITIONS
30
+
31
+ # The callables of every callback declaration among a class body's direct
32
+ # statements.
33
+ def self.in_body(body)
34
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
35
+ return [] unless statements.is_a?(Prism::StatementsNode)
36
+
37
+ statements.body.select { |node| match?(node) }.flat_map { |declaration| callables(declaration) }
38
+ end
39
+
40
+ # Whether node is a callback declaration that can be run again on its own.
41
+ # One holding a heredoc cannot be wrapped on its own lines, and is left out.
42
+ def self.match?(node)
43
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && NAME.match?(node.name.to_s) && !heredoc?(node)
44
+ end
45
+
46
+ def self.callables(declaration)
47
+ label = label_of(declaration)
48
+ bodies_of(declaration).map { |body| Callable.new(body: body, declaration: declaration, label: label) }
49
+ end
50
+
51
+ # `validate :credit_limit, if: ...` reads as `validate(:credit_limit)`; a
52
+ # declaration naming nothing, as its own name.
53
+ def self.label_of(declaration)
54
+ named = arguments_of(declaration).find { |argument| argument.is_a?(Prism::SymbolNode) }
55
+ named ? "#{declaration.name}(:#{named.unescaped})" : declaration.name.to_s
56
+ end
57
+
58
+ def self.bodies_of(declaration)
59
+ arguments = arguments_of(declaration)
60
+ bodies = arguments.filter_map { |argument| Evilution::AST::LiteralCallable.body_of(argument) }
61
+ bodies.concat(condition_bodies(arguments.last))
62
+ bodies << declaration.block if declaration.block.is_a?(Prism::BlockNode)
63
+ bodies
64
+ end
65
+
66
+ # `if: -> { }` and `unless: [-> { }, :named]` alike.
67
+ def self.condition_bodies(keywords)
68
+ return [] unless keywords.is_a?(Prism::KeywordHashNode)
69
+
70
+ keywords.elements.select { |pair| condition?(pair) }.flat_map do |pair|
71
+ values = pair.value.is_a?(Prism::ArrayNode) ? pair.value.elements : [pair.value]
72
+ values.filter_map { |value| Evilution::AST::LiteralCallable.body_of(value) }
73
+ end
74
+ end
75
+
76
+ def self.condition?(pair)
77
+ pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) && CONDITIONS.include?(pair.key.unescaped)
78
+ end
79
+
80
+ def self.arguments_of(call)
81
+ call.arguments ? call.arguments.arguments : []
82
+ end
83
+
84
+ def self.heredoc?(node)
85
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
86
+
87
+ node.compact_child_nodes.any? { |child| heredoc?(child) }
88
+ end
89
+
90
+ private_class_method :callables, :label_of, :bodies_of, :condition_bodies, :condition?, :arguments_of, :heredoc?
91
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # The block of an `ActiveSupport::Concern`'s `included do ... end`: code
7
+ # written in a module body that runs in the body of each class including the
8
+ # module. Only the receiver-less, argument-less form with a literal block
9
+ # counts -- `def self.included(base)` and `included(base) { }` are plain Ruby
10
+ # hooks with rules of their own.
11
+ module Evilution::AST::IncludedBlock
12
+ # The block node, or nil when node is not an `included` block call.
13
+ def self.of(node)
14
+ return nil unless node.is_a?(Prism::CallNode) && node.name == :included
15
+ return nil unless node.receiver.nil? && node.arguments.nil?
16
+
17
+ node.block if node.block.is_a?(Prism::BlockNode)
18
+ end
19
+
20
+ # The included blocks among a module body's direct statements.
21
+ def self.in_body(body)
22
+ statements = body.is_a?(Prism::BeginNode) ? body.statements : body
23
+ return [] unless statements.is_a?(Prism::StatementsNode)
24
+
25
+ statements.body.filter_map { |node| of(node) }
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../ast"
5
+
6
+ # A callable written out where it is used: `-> { }`, `lambda { }` or
7
+ # `proc { }`. Its body is what mutations go into. A constant, a variable or
8
+ # `method(:name)` in the same place has no body of its own to mutate.
9
+ module Evilution::AST::LiteralCallable
10
+ BLOCK_CALLS = %i[lambda proc].freeze
11
+ private_constant :BLOCK_CALLS
12
+
13
+ # The node holding the callable's body -- a LambdaNode, or the BlockNode of
14
+ # a `lambda` / `proc` call -- or nil when node is not a literal callable.
15
+ def self.body_of(node)
16
+ return node if node.is_a?(Prism::LambdaNode)
17
+ return nil unless node.is_a?(Prism::CallNode) && node.receiver.nil? && BLOCK_CALLS.include?(node.name)
18
+
19
+ node.block if node.block.is_a?(Prism::BlockNode)
20
+ end
21
+ end
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require_relative "../ast"
5
+ require_relative "value_object_definition"
6
+ require_relative "scope_declaration"
7
+ require_relative "aasm_declaration"
8
+ require_relative "included_block"
9
+ require_relative "callback_declaration"
4
10
 
5
11
  module Evilution::AST
6
12
  class Parser
@@ -39,55 +45,153 @@ module Evilution::AST
39
45
  @file_path = file_path
40
46
  @subjects = []
41
47
  @context = []
48
+ @singleton = [false]
42
49
  end
43
50
 
44
51
  def visit_module_node(node)
45
52
  @context.push(constant_name(node.constant_path))
46
- super
53
+ IncludedBlock.in_body(node.body).each do |block|
54
+ add_scope_subjects(block.body)
55
+ add_aasm_subjects(block.body)
56
+ add_callback_subjects(block.body)
57
+ end
58
+ within_scope(singleton: false) { super }
47
59
  @context.pop
48
60
  end
49
61
 
62
+ # A class whose superclass is a value-object definition
63
+ # (`class Coord < Data.define(:lat, :lng)`) makes a constant subject of
64
+ # that definition, named after the class.
50
65
  def visit_class_node(node)
51
66
  @context.push(constant_name(node.constant_path))
52
- super
67
+ superclass = node.superclass
68
+ add_subject(superclass, @context.join("::"), :constant) if ValueObjectDefinition.match?(superclass)
69
+ add_scope_subjects(node.body)
70
+ add_aasm_subjects(node.body)
71
+ add_callback_subjects(node.body)
72
+ within_scope(singleton: false) { super }
53
73
  @context.pop
54
74
  end
55
75
 
76
+ # A `def` inside `class << self` defines a method on the enclosing
77
+ # class's singleton, like `def self.name`; inside `class << Store` it
78
+ # defines one on Store.
79
+ def visit_singleton_class_node(node)
80
+ constant = constant_expression?(node.expression)
81
+ @context.push(constant_name(node.expression)) if constant
82
+ within_scope(singleton: true) { super }
83
+ @context.pop if constant
84
+ end
85
+
56
86
  def visit_def_node(node)
57
- separator = node.receiver ? "." : "#"
58
- add_subject(node, separator)
87
+ separator = node.receiver || @singleton.last ? "." : "#"
88
+ add_subject(node, "#{@context.join("::")}#{separator}#{node.name}", :method)
59
89
  super
60
90
  end
61
91
 
92
+ def visit_constant_write_node(node)
93
+ return super unless ValueObjectDefinition.match?(node.value)
94
+
95
+ within_definition(node.value, node.name.to_s) { super }
96
+ end
97
+
98
+ def visit_constant_path_write_node(node)
99
+ return super unless ValueObjectDefinition.match?(node.value)
100
+
101
+ within_definition(node.value, path_name(node.target)) { super }
102
+ end
103
+
62
104
  private
63
105
 
64
- def add_subject(node, separator)
65
- scope = @context.join("::")
66
- name = if scope.empty?
67
- "#{separator}#{node.name}"
68
- else
69
- "#{scope}#{separator}#{node.name}"
70
- end
106
+ def within_scope(singleton:)
107
+ @singleton.push(singleton)
108
+ yield
109
+ ensure
110
+ @singleton.pop
111
+ end
112
+
113
+ def constant_expression?(node)
114
+ node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
115
+ end
116
+
117
+ # A value-object definition assigned to a constant is a subject of its
118
+ # own, and a scope for what its block defines: `def area` in
119
+ # `Size = Struct.new(:w, :h) do ... end` is `Size#area`.
120
+ def within_definition(definition, name)
121
+ @context.push(name)
122
+ add_subject(definition, @context.join("::"), :constant)
123
+ yield
124
+ @context.pop
125
+ end
126
+
127
+ # `scope :recent, -> { ... }` defines the class method `recent`. The
128
+ # subject spans the whole declaration and mutates its body.
129
+ # Written in a concern's `included` block it defines that method on every
130
+ # class including the concern, and is named after the concern.
131
+ def add_scope_subjects(body)
132
+ ScopeDeclaration.in_body(body).each do |declaration|
133
+ name = "#{@context.join("::")}.#{ScopeDeclaration.scope_name(declaration)}"
134
+ add_subject(ScopeDeclaration.body_of(declaration), name, :scope, span: declaration)
135
+ end
136
+ end
137
+
138
+ # A guard or callback written out inside `aasm do ... end` runs when the
139
+ # method its event or state defines is called (`ship`, `paid?`), and is
140
+ # named after it. The subject spans that declaration and mutates the
141
+ # callable's body; one event may hold several. A machine declared in a
142
+ # concern's `included` block is named after the concern.
143
+ def add_aasm_subjects(body)
144
+ AasmDeclaration.in_body(body).each do |callable|
145
+ name = "#{@context.join("::")}##{callable.method_name}"
146
+ add_subject(callable.body, name, :aasm, span: callable.declaration)
147
+ end
148
+ end
71
149
 
72
- loc = node.location
73
- method_source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
74
- .force_encoding(@source.encoding)
150
+ # A condition or a callback written out in a declaration such as
151
+ # `validate :credit_limit, if: -> { ... }` defines no method to be named
152
+ # after, so it is named the way the declaration reads:
153
+ # `Order.validate(:credit_limit)`. The subject spans the declaration and
154
+ # mutates the callable's body. In a concern's `included` block it is named
155
+ # after the concern.
156
+ def add_callback_subjects(body)
157
+ CallbackDeclaration.in_body(body).each do |callable|
158
+ add_subject(callable.body, "#{@context.join("::")}.#{callable.label}", :callback, span: callable.declaration)
159
+ end
160
+ end
161
+
162
+ def add_subject(node, name, kind, span: node)
163
+ loc = span.location
164
+ source = @source.byteslice(loc.start_offset, loc.end_offset - loc.start_offset)
165
+ .force_encoding(@source.encoding)
75
166
 
76
167
  @subjects << Evilution::Subject.new(
77
168
  name: name,
78
169
  file_path: @file_path,
79
170
  line_number: loc.start_line,
80
- source: method_source,
81
- node: node
171
+ source: source,
172
+ node: node,
173
+ kind: kind
82
174
  )
83
175
  end
84
176
 
177
+ # `self::Unit` has no static full name; it is named within the scope it
178
+ # is written in, like a plain `Unit`.
179
+ def path_name(target)
180
+ target.full_name.delete_prefix("::")
181
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
182
+ target.name.to_s
183
+ end
184
+
185
+ # `class self::Unit` and `class << self::Unit` have no static full name
186
+ # either; like `path_name`, they are named by their own constant.
85
187
  def constant_name(node)
86
188
  if node.respond_to?(:full_name)
87
189
  node.full_name
88
190
  else
89
191
  node.name.to_s
90
192
  end
193
+ rescue Prism::ConstantPathNode::DynamicPartsInConstantPathError
194
+ node.name.to_s
91
195
  end
92
196
  end
93
197
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../pattern"
3
4
  require_relative "parser"
4
5
 
5
6
  class Evilution::AST::Pattern::Filter