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
@@ -2,6 +2,11 @@
2
2
 
3
3
  require "prism"
4
4
  require_relative "../loading"
5
+ require_relative "../../ast/aasm_declaration"
6
+ require_relative "../../ast/included_block"
7
+ require_relative "../../ast/callback_declaration"
8
+ require_relative "callback_redeclaration"
9
+ require_relative "concern_redeclaration"
5
10
 
6
11
  # Strip non-idempotent class/module-body side-effect calls from a mutated
7
12
  # source before re-eval. Such calls (e.g. dry-monads `register_mixin`, plugin
@@ -13,7 +18,10 @@ require_relative "../loading"
13
18
  # Strategy: walk Prism tree, find CallNodes that sit directly under a class
14
19
  # or module body (not inside a def). Calls on a small allowlist of patterns
15
20
  # known to be idempotent (`include`, `attr_*`, visibility modifiers, etc.)
16
- # are preserved; everything else is replaced byte-for-byte with `nil`.
21
+ # are preserved; everything else has its source range replaced with `()`.
22
+ # A call spanning several lines leaves its line breaks behind, so the source
23
+ # keeps its line count. Edits are applied from the end of the source backwards
24
+ # to keep earlier offsets valid.
17
25
  class Evilution::Integration::Loading::BodyCallNeutralizer
18
26
  IDEMPOTENT_CALLS = %i[
19
27
  include extend prepend using
@@ -25,6 +33,17 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
25
33
  require require_relative autoload
26
34
  ].to_set.freeze
27
35
 
36
+ # `()` rather than `nil`: under -w Ruby flags a bare `nil` statement as
37
+ # "possibly useless use of nil in void context", which a project raising on
38
+ # warnings turns into an error for every mutation of the file. `()` evaluates
39
+ # to nil without the warning on every supported Ruby (`(nil)` still warns on 3.3).
40
+ REPLACEMENT = "()"
41
+
42
+ REDECLARATION = "::Evilution::Integration::Loading::ConcernRedeclaration"
43
+ GUARD = "#{REDECLARATION}.skipping? or ".freeze
44
+ REDECLARE = "; #{REDECLARATION}.call(self)".freeze
45
+ CALLBACKS = "::Evilution::Integration::Loading::CallbackRedeclaration"
46
+
28
47
  class << self
29
48
  attr_writer :preloaded_features
30
49
 
@@ -52,13 +71,33 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
52
71
  # strips method definitions that subsequent sibling statements (alias, etc.)
53
72
  # depend on, producing cascading NameError. Callers that don't pass a path
54
73
  # get the legacy always-neutralize behavior.
55
- def call(source, file_path: nil)
74
+ #
75
+ # `keep_offset` (optional) spares the one class-body call whose source
76
+ # contains that byte offset: a mutation inside a scope body only takes
77
+ # effect if the mutated file re-runs that `scope` call to replace it.
78
+ # When that call is an `aasm` machine, only the `event` or `state` holding
79
+ # the offset is spared inside its block: re-running one declaration replaces
80
+ # it, while re-running the whole machine would register its
81
+ # `after_all_transitions`-style callbacks a second time.
82
+ # When it is a concern's `included` block, the block is kept whole, since
83
+ # classes that include the concern later run all of it; every other call in
84
+ # it is guarded and the block is followed by a ConcernRedeclaration call,
85
+ # which re-declares the spared statement alone on the classes that include
86
+ # the concern already. Both are added on existing lines, so no line moves.
87
+ # An `aasm` machine inside such a block is treated the same way one level
88
+ # down: its other declarations are guarded, not blanked.
89
+ # When it is a callback declaration (`validate ... if: -> { }`), it is
90
+ # wrapped in a CallbackRedeclaration call, which puts what the re-run
91
+ # registers in the place of what the declaration registered before.
92
+ # `keep_lines` is where that was: the declaration's lines in the file as it
93
+ # was loaded.
94
+ def call(source, file_path: nil, keep_offset: nil, keep_lines: nil)
56
95
  return source if file_path && !preloaded?(file_path)
57
96
 
58
97
  result = Prism.parse(source)
59
98
  return source if result.failure?
60
99
 
61
- edits = collect_edits(result.value)
100
+ edits = collect_edits(source, result.value, keep_offset, keep_lines)
62
101
  return source if edits.empty?
63
102
 
64
103
  apply_edits(source, edits)
@@ -70,26 +109,29 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
70
109
  self.class.preloaded_features.include?(File.expand_path(file_path))
71
110
  end
72
111
 
73
- def collect_edits(tree)
112
+ def collect_edits(source, tree, keep_offset, keep_lines)
74
113
  edits = []
75
- walker = Walker.new(IDEMPOTENT_CALLS, edits)
114
+ walker = Walker.new(source, IDEMPOTENT_CALLS, edits, keep_offset, keep_lines)
76
115
  walker.visit(tree)
77
116
  edits
78
117
  end
79
118
 
80
119
  def apply_edits(source, edits)
81
120
  bytes = source.b
82
- edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
83
- bytes[start_offset, end_offset - start_offset] = "nil"
121
+ edits.sort_by!(&:first).reverse_each do |start_offset, end_offset, replacement|
122
+ bytes[start_offset, end_offset - start_offset] = replacement.b
84
123
  end
85
124
  bytes.force_encoding(source.encoding)
86
125
  end
87
126
 
88
127
  class Walker < Prism::Visitor
89
- def initialize(allowlist, edits)
128
+ def initialize(source, allowlist, edits, keep_offset, keep_lines)
90
129
  super()
130
+ @source = source
91
131
  @allowlist = allowlist
92
132
  @edits = edits
133
+ @keep_offset = keep_offset
134
+ @keep_lines = keep_lines
93
135
  end
94
136
 
95
137
  def visit_class_node(node)
@@ -118,14 +160,99 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
118
160
  return unless body_node.is_a?(Prism::StatementsNode)
119
161
 
120
162
  body_node.body.each do |stmt|
121
- next unless stmt.is_a?(Prism::CallNode)
122
- next if @allowlist.include?(stmt.name)
123
- next if stmt.receiver && !stmt.receiver.is_a?(Prism::SelfNode)
163
+ next unless neutralizable?(stmt)
164
+
165
+ edit = edit_for(stmt)
166
+ if kept?(edit)
167
+ scan_kept(stmt)
168
+ else
169
+ @edits << edit
170
+ end
171
+ end
172
+ end
173
+
174
+ def scan_kept(stmt)
175
+ scan_machine(stmt)
176
+ scan_included(stmt)
177
+ wrap_callbacks(stmt)
178
+ end
179
+
180
+ # A kept callback declaration runs inside a block handed to
181
+ # CallbackRedeclaration, opened and closed on the declaration's own lines.
182
+ def wrap_callbacks(stmt)
183
+ return unless @keep_lines && Evilution::AST::CallbackDeclaration.match?(stmt)
184
+
185
+ start_offset = stmt.location.start_offset
186
+ end_offset = stmt.location.end_offset
187
+ lines = "#{@keep_lines.first}..#{@keep_lines.last}"
188
+ @edits << [start_offset, start_offset, "#{CALLBACKS}.call(self, __FILE__, #{lines}) { "]
189
+ @edits << [end_offset, end_offset, " }"]
190
+ end
191
+
192
+ # A kept `included` block stays whole. Its other calls are guarded rather
193
+ # than blanked, and what follows it re-declares the spared one on the
194
+ # classes that include the concern already.
195
+ def scan_included(stmt)
196
+ block = Evilution::AST::IncludedBlock.of(stmt)
197
+ return unless block && block.body.is_a?(Prism::StatementsNode)
198
+
199
+ guard_others(block)
200
+ end_offset = stmt.location.end_offset
201
+ @edits << [end_offset, end_offset, REDECLARE]
202
+ end
124
203
 
125
- @edits << [stmt.location.start_offset, replacement_end_offset(stmt)]
204
+ # Guards every call of a block but the one holding the offset. When that
205
+ # one is an `aasm` machine, its own declarations are guarded the same way
206
+ # rather than blanked: a class that includes the concern later needs the
207
+ # whole machine, and one that includes it already only the mutated event.
208
+ # When it is a callback declaration, it is wrapped as it would be in a
209
+ # class body: the block runs as the including class, so that is whose
210
+ # chains CallbackRedeclaration rebuilds.
211
+ def guard_others(block)
212
+ block.body.body.grep(Prism::CallNode).each do |call|
213
+ if kept?(edit_for(call))
214
+ machine = Evilution::AST::AasmDeclaration.machine_block(call)
215
+ guard_others(machine) if machine && machine.body.is_a?(Prism::StatementsNode)
216
+ wrap_callbacks(call)
217
+ else
218
+ start_offset = call.location.start_offset
219
+ @edits << [start_offset, start_offset, GUARD]
220
+ end
126
221
  end
127
222
  end
128
223
 
224
+ def neutralizable?(stmt)
225
+ return false unless stmt.is_a?(Prism::CallNode)
226
+ return false if @allowlist.include?(stmt.name)
227
+
228
+ stmt.receiver.nil? || stmt.receiver.is_a?(Prism::SelfNode)
229
+ end
230
+
231
+ # Inside a kept `aasm` block every call is a declaration of the machine,
232
+ # so all of them but the one holding the offset go.
233
+ def scan_machine(stmt)
234
+ block = Evilution::AST::AasmDeclaration.machine_block(stmt)
235
+ return unless block && block.body.is_a?(Prism::StatementsNode)
236
+
237
+ block.body.body.grep(Prism::CallNode).each do |declaration|
238
+ edit = edit_for(declaration)
239
+ @edits << edit unless kept?(edit)
240
+ end
241
+ end
242
+
243
+ # The lines a blanked call took up are kept as empty lines: everything
244
+ # after it then sits on the line it has in the file, which is where a proc
245
+ # created by this source says it was written.
246
+ def edit_for(call)
247
+ start_offset = call.location.start_offset
248
+ end_offset = replacement_end_offset(call)
249
+ [start_offset, end_offset, REPLACEMENT + ("\n" * @source.byteslice(start_offset...end_offset).count("\n"))]
250
+ end
251
+
252
+ def kept?((start_offset, end_offset, _replacement))
253
+ @keep_offset && @keep_offset >= start_offset && @keep_offset < end_offset
254
+ end
255
+
129
256
  # Prism CallNode location ends at the close of the call syntax (e.g. the
130
257
  # closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
131
258
  # NOT include the heredoc body lines or the trailing terminator. Replacing
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Runs a callback declaration again and puts what it registers where the
6
+ # callbacks it registered before were.
7
+ #
8
+ # ActiveSupport::Callbacks does not replace on re-declaration. A callback
9
+ # named by a symbol is dropped and added again at the end of its chain; one
10
+ # that is a block or an object (a validator) is simply added, next to the
11
+ # first. Either way a mutated `if:` would run in a chain that is no longer
12
+ # the one the class was written with -- reordered, or with the unmutated
13
+ # original still answering.
14
+ #
15
+ # So the chains are read before the declaration runs and rebuilt afterwards:
16
+ # the callbacks this declaration registered last time give their place to the
17
+ # new ones, and everything else stays where it was, including what subclasses
18
+ # added themselves. See BodyCallNeutralizer for the source that calls this.
19
+ #
20
+ # "Last time" is the file's own load the first time round, and those
21
+ # callbacks are recognised by where their procs were written (or by having
22
+ # been dropped as duplicates). After that the module goes by what it
23
+ # registered itself: a mutation can add or remove lines, so a proc created by
24
+ # the mutated source may report a line that belongs to another declaration of
25
+ # the real file.
26
+ module Evilution::Integration::Loading::CallbackRedeclaration
27
+ CONDITIONS = %i[@if @unless].freeze
28
+ private_constant :CONDITIONS
29
+
30
+ # klass: the class whose body the declaration is written in. file, lines:
31
+ # where the declaration is in the file as it was loaded.
32
+ def self.call(klass, file, lines)
33
+ return yield unless klass.respond_to?(:__callbacks)
34
+
35
+ before = klass.__callbacks.transform_values(&:to_a)
36
+ result = yield
37
+ swaps = Settlement.new(klass, Origin.new(file, lines), registered).call(before)
38
+ swap_validators(klass, swaps)
39
+ result
40
+ end
41
+
42
+ # What earlier calls registered, by declaration:
43
+ # { [class, file, first line] => callbacks }.
44
+ def self.registered
45
+ @registered ||= {}
46
+ end
47
+
48
+ # `validates` also lists its validator for reflection (`Model.validators`),
49
+ # once per class; the old one is exchanged for the new there too.
50
+ def self.swap_validators(klass, swaps)
51
+ return unless klass.respond_to?(:_validators)
52
+
53
+ pairs = swaps.select { |old, new| old && new }.map { |old, new| [old.filter, new.filter] }
54
+ [klass, *descendants(klass)].each do |target|
55
+ target._validators.each_value do |validators|
56
+ pairs.each { |old, new| exchange(validators, old, new) }
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.exchange(validators, old, new)
62
+ return unless validators.any? { |validator| validator.equal?(old) }
63
+
64
+ validators.reject! { |validator| validator.equal?(new) }
65
+ validators[validators.index { |validator| validator.equal?(old) }] = new
66
+ end
67
+
68
+ def self.descendants(klass)
69
+ klass.subclasses.flat_map { |subclass| [subclass, *descendants(subclass)] }
70
+ end
71
+
72
+ private_class_method :registered, :swap_validators, :exchange, :descendants
73
+
74
+ # Callbacks are told apart by identity throughout: two that compare equal
75
+ # are still two registrations.
76
+ module Identity
77
+ def self.member?(callbacks, callback)
78
+ callbacks.any? { |candidate| candidate.equal?(callback) }
79
+ end
80
+
81
+ def self.without(callbacks, others)
82
+ callbacks.reject { |callback| member?(others, callback) }
83
+ end
84
+ end
85
+ private_constant :Identity
86
+
87
+ # Rebuilds the chains of a class and its descendants after one declaration
88
+ # has run again.
89
+ class Settlement
90
+ def initialize(klass, origin, registered)
91
+ @klass = klass
92
+ @origin = origin
93
+ @registered = registered
94
+ @key = [klass, origin.file, origin.lines.first]
95
+ end
96
+
97
+ # before: { chain name => callbacks } as they were. Returns [old, new]
98
+ # callback pairs for what changed hands.
99
+ def call(before)
100
+ previous = @registered[@key]
101
+ mine = []
102
+ swaps = before.flat_map do |name, callbacks|
103
+ stale, fresh = settle(name, callbacks, previous)
104
+ mine.concat(fresh)
105
+ stale.zip(fresh)
106
+ end
107
+ @registered[@key] = mine unless mine.empty?
108
+ swaps
109
+ end
110
+
111
+ private
112
+
113
+ def settle(name, before, previous)
114
+ after = @klass.__callbacks[name].to_a
115
+ fresh = Identity.without(after, before)
116
+ return [[], []] if fresh.empty?
117
+
118
+ stale = before.select { |callback| stale?(callback, previous) || !Identity.member?(after, callback) }
119
+ order = reorder(before, stale, fresh)
120
+ @klass.send(:__update_callbacks, name) { |target, chain| rebuild(target, name, chain, order, after) }
121
+ [stale, fresh]
122
+ end
123
+
124
+ # What this declaration registered before: what an earlier call recorded
125
+ # or, the first time, what was written on its lines -- unless an earlier
126
+ # call for another declaration put it there.
127
+ def stale?(callback, previous)
128
+ return Identity.member?(previous, callback) if previous
129
+
130
+ @origin.declared?(callback) && !Identity.member?(@registered.values.flatten, callback)
131
+ end
132
+
133
+ # The new callbacks take the place of the first old one; with no old one
134
+ # to replace they stay where the declaration put them, at the end.
135
+ def reorder(before, stale, fresh)
136
+ return before + fresh if stale.empty?
137
+
138
+ before.flat_map do |callback|
139
+ next fresh if callback.equal?(stale.first)
140
+
141
+ Identity.member?(stale, callback) ? [] : [callback]
142
+ end
143
+ end
144
+
145
+ def rebuild(target, name, chain, order, after)
146
+ own = Identity.without(chain.to_a, after)
147
+ chain.clear
148
+ (order + own).each { |callback| chain.append(callback) }
149
+ target.send(:set_callbacks, name, chain)
150
+ end
151
+ end
152
+ private_constant :Settlement
153
+
154
+ # Where a declaration is written, and whether a callback came from there.
155
+ class Origin
156
+ attr_reader :file, :lines
157
+
158
+ def initialize(file, lines)
159
+ @file = canonical(file)
160
+ @lines = lines
161
+ end
162
+
163
+ def declared?(callback)
164
+ procs_of(callback).any? { |callable| here?(callable.source_location) }
165
+ end
166
+
167
+ private
168
+
169
+ def procs_of(callback)
170
+ conditions = CONDITIONS.flat_map { |ivar| Array(callback.instance_variable_get(ivar)) }
171
+ [callback.filter, *conditions].grep(Proc)
172
+ end
173
+
174
+ def here?(location)
175
+ !location.nil? && canonical(location.first) == @file && @lines.cover?(location.last)
176
+ end
177
+
178
+ # The same file may have been loaded through a symlinked path.
179
+ def canonical(path)
180
+ File.realpath(path)
181
+ rescue SystemCallError
182
+ File.expand_path(path)
183
+ end
184
+ end
185
+ private_constant :Origin
186
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Re-declares one statement of a concern's `included` block on the classes
6
+ # that already include the concern.
7
+ #
8
+ # A mutation inside `included do ... scope :published, -> { } ... end` has two
9
+ # audiences. Classes that include the concern later run the whole block, so
10
+ # registering the mutated block is enough for them. Classes that already
11
+ # include it ran the original, and running the whole block on them again
12
+ # would add its validations and callbacks a second time.
13
+ #
14
+ # So the source evilution evaluates guards every other call of the block with
15
+ # `skipping?` and ends the block with `call(self)`: the block is run once more
16
+ # on each existing includer with the guard up, and only the mutated
17
+ # declaration takes effect there. See BodyCallNeutralizer.
18
+ module Evilution::Integration::Loading::ConcernRedeclaration
19
+ KEY = :evilution_concern_redeclaration
20
+ INCLUDED_BLOCK = :@_included_block
21
+
22
+ # Called through UnboundMethods: ObjectSpace hands over every class in the
23
+ # VM, and one that defines `include?` or `superclass` of its own for its
24
+ # own purposes must not be asked that question.
25
+ INCLUDES = Module.instance_method(:include?)
26
+ SUPERCLASS = Class.instance_method(:superclass)
27
+ SINGLETON = Module.instance_method(:singleton_class?)
28
+ private_constant :KEY, :INCLUDED_BLOCK, :INCLUDES, :SUPERCLASS, :SINGLETON
29
+
30
+ def self.skipping?
31
+ Thread.current[KEY] == true
32
+ end
33
+
34
+ def self.call(concern)
35
+ return unless concern.instance_variable_defined?(INCLUDED_BLOCK)
36
+
37
+ block = concern.instance_variable_get(INCLUDED_BLOCK)
38
+ includers(concern).each { |klass| redeclare(klass, block) }
39
+ end
40
+
41
+ # The classes that include the concern themselves; a subclass inherits what
42
+ # its parent declares.
43
+ def self.includers(concern)
44
+ ObjectSpace.each_object(Class).select do |klass|
45
+ next false if SINGLETON.bind_call(klass) || !INCLUDES.bind_call(klass, concern)
46
+
47
+ parent = SUPERCLASS.bind_call(klass)
48
+ parent.nil? || !INCLUDES.bind_call(parent, concern)
49
+ end
50
+ end
51
+
52
+ def self.redeclare(klass, block)
53
+ previous = Thread.current[KEY]
54
+ Thread.current[KEY] = true
55
+ klass.class_eval(&block)
56
+ ensure
57
+ Thread.current[KEY] = previous
58
+ end
59
+
60
+ private_class_method :includers, :redeclare
61
+ end
@@ -13,19 +13,23 @@ class Evilution::Integration::Loading::ConcernStateCleaner
13
13
 
14
14
  # ObjectSpace hands us every Module in the VM, including ones that answer no
15
15
  # message honestly. ActiveSupport::Deprecation::DeprecatedConstantProxy
16
- # subclasses Module and undefines all of its instance methods, so a plain
17
- # `mod.singleton_class` lands in its method_missing and emits the
18
- # deprecation; where the deprecator's behaviour is :raise (grape's
19
- # spec_helper sets that), the sweep blows up on a module it merely walked
20
- # past. Calling through UnboundMethods bypasses the object's own method
21
- # table, so a proxy is never given the chance to intercept -- and this holds
22
- # for any method_missing-based wrapper, not just ActiveSupport's.
16
+ # subclasses Module and undefines all of its instance methods, so asking it
17
+ # anything lands in its method_missing and emits the deprecation; where the
18
+ # deprecator's behaviour is :raise (grape's spec_helper sets that), the
19
+ # sweep blows up on a module it merely walked past. So the module is never
20
+ # asked: `Concern === mod` is answered by Concern, and the ivars are reached
21
+ # through UnboundMethods, which bypass the object's own method table -- this
22
+ # holds for any method_missing-based wrapper, not just ActiveSupport's.
23
23
  # EV-v2rc / GH #1581.
24
- SINGLETON_CLASS = Object.instance_method(:singleton_class)
24
+ #
25
+ # `===` also leaves the module as it found it. Asking for its
26
+ # singleton_class instead would create one for every module in the VM, each
27
+ # of them a module the next sweep has to visit and give a singleton class
28
+ # of its own; a long in-process run doubles its module count per mutation.
25
29
  IVAR_DEFINED = Object.instance_method(:instance_variable_defined?)
26
30
  IVAR_GET = Object.instance_method(:instance_variable_get)
27
31
  REMOVE_IVAR = Object.instance_method(:remove_instance_variable)
28
- private_constant :SINGLETON_CLASS, :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
32
+ private_constant :IVAR_DEFINED, :IVAR_GET, :REMOVE_IVAR
29
33
 
30
34
  def initialize(subpath_resolver: Evilution::LoadPath::SubpathResolver.new)
31
35
  @subpath_resolver = subpath_resolver
@@ -34,11 +38,14 @@ class Evilution::Integration::Loading::ConcernStateCleaner
34
38
  def call(file_path)
35
39
  return unless defined?(ActiveSupport::Concern)
36
40
 
37
- absolute = File.expand_path(file_path)
38
- subpath = @subpath_resolver.call(file_path)
41
+ # Anchored on the project, not Dir.pwd: an isolated worker has chdir'd
42
+ # into a per-mutation sandbox, where a relative path names nothing and no
43
+ # concern would ever match.
44
+ absolute = File.expand_path(file_path, Evilution.project_base_dir)
45
+ subpath = @subpath_resolver.call(absolute)
39
46
 
40
47
  ObjectSpace.each_object(Module) do |mod|
41
- next unless SINGLETON_CLASS.bind_call(mod).ancestors.include?(ActiveSupport::Concern)
48
+ next unless ActiveSupport::Concern === mod # rubocop:disable Style/CaseEquality
42
49
 
43
50
  clear_concern_ivars(mod, absolute, subpath)
44
51
  end
@@ -6,6 +6,7 @@ require_relative "constant_pinner"
6
6
  require_relative "concern_state_cleaner"
7
7
  require_relative "source_evaluator"
8
8
  require_relative "redefinition_recovery"
9
+ require_relative "../../diagnostic"
9
10
 
10
11
  # Composes the load-time pipeline that applies a mutation's new source to the
11
12
  # running VM: syntax-validate -> pin top-level constants (beats Zeitwerk) ->
@@ -45,6 +46,21 @@ class Evilution::Integration::Loading::MutationApplier
45
46
  failure_result(e, "#{e.class}: #{e.message}")
46
47
  end
47
48
 
49
+ # Evaluates the mutation's restore_source, if it has one. Runs after the
50
+ # tests: a failure here must not change the mutation's result, so it is
51
+ # reported and swallowed. Concern state is cleared first, as it is before
52
+ # applying: a concern ignores an `included` block handed to it twice from
53
+ # the same place, and would keep the mutated one.
54
+ def restore(mutation)
55
+ source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
56
+ return unless source
57
+
58
+ @concern_state_cleaner.call(mutation.file_path)
59
+ @source_evaluator.call(source, mutation.file_path)
60
+ rescue ScriptError, StandardError => e
61
+ Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
62
+ end
63
+
48
64
  private
49
65
 
50
66
  def resolve_eval_target(mutation)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Remembers which test classes each test file registered when it was loaded,
6
+ # so a process that runs many mutations loads a test file once.
7
+ #
8
+ # Minitest and Test::Unit learn of a test class when it is first defined.
9
+ # Loading its file again reopens the class and registers nothing, so the
10
+ # second mutation run in a process would find no tests. The classes of the
11
+ # first load are handed back instead.
12
+ #
13
+ # The block given to .new returns the classes registered so far; what a load
14
+ # adds to them is taken to be that file's.
15
+ class Evilution::Integration::Loading::TestClassCache
16
+ def initialize(&registered)
17
+ @registered = registered
18
+ @classes = {}
19
+ @partial = {}
20
+ end
21
+
22
+ # The classes the given files registered. Yields each file that has not
23
+ # been loaded yet; the block loads it.
24
+ def fetch(files, &)
25
+ files.flat_map { |file| @classes[file] ||= load_once(file, &) }.uniq
26
+ end
27
+
28
+ def clear
29
+ @classes.clear
30
+ @partial.clear
31
+ end
32
+
33
+ private
34
+
35
+ # A load that raises is tried again next time. Classes it defined before
36
+ # raising are already registered and will not register again, so they are
37
+ # kept for that next attempt.
38
+ def load_once(file)
39
+ before = @registered.call
40
+ yield file
41
+ @partial.fetch(file, []) | (@registered.call - before)
42
+ rescue ScriptError, StandardError
43
+ @partial[file] = @partial.fetch(file, []) | (@registered.call - before)
44
+ raise
45
+ end
46
+ end
@@ -22,29 +22,37 @@ module Evilution::Integration::Loading::TestLoadPath
22
22
  # Prepend every relevant test directory to $LOAD_PATH (idempotently).
23
23
  # Iterate in reverse so the first entry from #dirs_for ends up frontmost,
24
24
  # preserving its order (mirrors how `ruby -Ia -Ib` lands a before b).
25
- def add!(files, base: Evilution.project_base_dir)
26
- dirs_for(files, base).reverse_each do |dir|
25
+ def add!(files, base: Evilution.project_base_dir, outside_root_dirs: true)
26
+ dirs_for(files, base, outside_root_dirs: outside_root_dirs).reverse_each do |dir|
27
27
  $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
28
28
  end
29
29
  end
30
30
 
31
- # The directories to put on $LOAD_PATH for the given resolved test files:
32
- # the conventional test/ and spec/ roots under base, each file's own
33
- # directory, and the topmost test/spec ancestor of each file (covers nested
34
- # layouts like test/unit, spec/lib, spec/unit). Existing directories only,
35
- # and only those inside the project base -- never a broad outside-project dir
36
- # (e.g. a /tmp test file), which would over-widen $LOAD_PATH for the whole
37
- # process (the baseline runs in the long-lived parent).
38
- def dirs_for(files, base)
31
+ # The directories to put on $LOAD_PATH for the given files: the
32
+ # conventional test/ and spec/ roots under base, each file's own directory,
33
+ # and the topmost test/spec ancestor of each file (covers nested layouts
34
+ # like test/unit, spec/lib, spec/unit). Existing directories only, and only
35
+ # those inside the project base -- never a broad outside-project dir (e.g. a
36
+ # /tmp test file), which would over-widen $LOAD_PATH for the whole process
37
+ # (the baseline runs in the long-lived parent).
38
+ #
39
+ # A resolved test file outside every test root (a tests/ layout, a test
40
+ # reached through --spec or spec_mappings) still contributes its own
41
+ # directory, mirroring `ruby -Itests`. A preload does not: with
42
+ # `outside_root_dirs: false`, a `--preload config/evilution_preload.rb`
43
+ # would otherwise put config/ ahead of the gems, and `require "puma"` during
44
+ # Bundler.require would load the app's config/puma.rb (GH #1597).
45
+ def dirs_for(files, base, outside_root_dirs: true)
39
46
  base = File.expand_path(base)
40
47
  dirs = conventional_roots(base)
41
48
  Array(files).each do |file|
42
49
  file_dir = File.dirname(File.expand_path(file, base))
43
- dirs << file_dir
44
50
  root = root_ancestor(file_dir, base)
45
- dirs << root if root
51
+ next unless root || outside_root_dirs
52
+
53
+ dirs << file_dir << root
46
54
  end
47
- dirs.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
55
+ dirs.compact.uniq.select { |dir| File.directory?(dir) && within?(dir, base) }
48
56
  end
49
57
 
50
58
  def within?(dir, base)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../minitest"
4
+
5
+ # Names a Minitest test the same way in the baseline and in a mutation run:
6
+ # its class and its method, `CalcTest#test_adds`.
7
+ #
8
+ # A test class without a name has only its method to go by, so two such
9
+ # classes with a method of the same name are told apart by neither run.
10
+ module Evilution::Integration::Minitest::TestIds
11
+ def self.of(result)
12
+ class_name = result.respond_to?(:class_name) ? result.class_name : result.class.name
13
+ "#{class_name}##{result.name}"
14
+ end
15
+
16
+ # The ids of the tests that failed, from the summary reporter of the run.
17
+ # It keeps every result that did not pass, skips included.
18
+ def self.failed(summary)
19
+ summary.results.reject(&:skipped?).map { |result| of(result) }
20
+ end
21
+ end