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
@@ -24,11 +24,21 @@ class Evilution::Integration::Base
24
24
  return load_error if load_error
25
25
 
26
26
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
27
- run_tests(mutation)
27
+ Evilution.while_running_tests { run_tests(mutation) }
28
+ ensure
29
+ @mutation_applier.restore(mutation) unless restored_by_applier?(load_error)
28
30
  end
29
31
 
30
32
  private
31
33
 
34
+ # The applier evaluates the original to tell that a mutant broke the load of
35
+ # its file; after that the file is as it was, and need not be restored again.
36
+ def restored_by_applier?(load_error)
37
+ return false if load_error.nil?
38
+
39
+ load_error[:load_crashed] == true
40
+ end
41
+
32
42
  def ensure_framework_loaded
33
43
  raise NotImplementedError, "#{self.class}#ensure_framework_loaded must be implemented"
34
44
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../integration"
4
+
5
+ # The tests the baseline saw failing before any mutation ran, by the ids the
6
+ # integration gives them.
7
+ #
8
+ # Such a test fails whatever the mutation is, so a mutation run in which
9
+ # nothing else failed has no verdict. The question is asked in the process
10
+ # that ran the tests: the names of everything that failed need not travel
11
+ # back, only the answer.
12
+ class Evilution::Integration::KnownFailures
13
+ def initialize(ids)
14
+ @ids = ids.to_set
15
+ end
16
+
17
+ def empty?
18
+ @ids.empty?
19
+ end
20
+
21
+ # Whether failed_ids names at least one test, and none the baseline did not
22
+ # already see failing.
23
+ def only?(failed_ids)
24
+ !failed_ids.empty? && failed_ids.all? { |id| @ids.include?(id) }
25
+ end
26
+ end
@@ -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
@@ -14,8 +19,9 @@ require_relative "../loading"
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
21
  # are preserved; everything else has its source range replaced with `()`.
17
- # The replacement is shorter than the call it replaces, so edits are applied
18
- # from the end of the source backwards to keep earlier offsets valid.
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.
19
25
  class Evilution::Integration::Loading::BodyCallNeutralizer
20
26
  IDEMPOTENT_CALLS = %i[
21
27
  include extend prepend using
@@ -33,6 +39,12 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
33
39
  # to nil without the warning on every supported Ruby (`(nil)` still warns on 3.3).
34
40
  REPLACEMENT = "()"
35
41
 
42
+ REDECLARATION = "::Evilution::Integration::Loading::ConcernRedeclaration"
43
+ GUARD = "#{REDECLARATION}.skipping? or ".freeze
44
+ REDECLARE = "; #{REDECLARATION}.call(self)".freeze
45
+ REDECLARE_PREPENDED = "; #{REDECLARATION}.call(self, :prepended)".freeze
46
+ CALLBACKS = "::Evilution::Integration::Loading::CallbackRedeclaration"
47
+
36
48
  class << self
37
49
  attr_writer :preloaded_features
38
50
 
@@ -60,13 +72,35 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
60
72
  # strips method definitions that subsequent sibling statements (alias, etc.)
61
73
  # depend on, producing cascading NameError. Callers that don't pass a path
62
74
  # get the legacy always-neutralize behavior.
63
- def call(source, file_path: nil)
75
+ #
76
+ # `keep_offset` (optional) spares the one class-body call whose source
77
+ # contains that byte offset: a mutation inside a scope body only takes
78
+ # effect if the mutated file re-runs that `scope` call to replace it.
79
+ # When that call is an `aasm` machine, only the `event` or `state` holding
80
+ # the offset is spared inside its block: re-running one declaration replaces
81
+ # it, while re-running the whole machine would register its
82
+ # `after_all_transitions`-style callbacks a second time.
83
+ # A concern's `included` block is never blanked, whether or not it holds
84
+ # the offset: classes that include the concern later run all of it. Neither
85
+ # is its `prepended` block, for the classes that prepend it later.
86
+ # When it does hold the offset, every other call in
87
+ # it is guarded and the block is followed by a ConcernRedeclaration call,
88
+ # which re-declares the spared statement alone on the classes that include
89
+ # the concern already. Both are added on existing lines, so no line moves.
90
+ # An `aasm` machine inside such a block is treated the same way one level
91
+ # down: its other declarations are guarded, not blanked.
92
+ # When it is a callback declaration (`validate ... if: -> { }`), it is
93
+ # wrapped in a CallbackRedeclaration call, which puts what the re-run
94
+ # registers in the place of what the declaration registered before.
95
+ # `keep_lines` is where that was: the declaration's lines in the file as it
96
+ # was loaded.
97
+ def call(source, file_path: nil, keep_offset: nil, keep_lines: nil)
64
98
  return source if file_path && !preloaded?(file_path)
65
99
 
66
100
  result = Prism.parse(source)
67
101
  return source if result.failure?
68
102
 
69
- edits = collect_edits(result.value)
103
+ edits = collect_edits(source, result.value, keep_offset, keep_lines)
70
104
  return source if edits.empty?
71
105
 
72
106
  apply_edits(source, edits)
@@ -78,26 +112,36 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
78
112
  self.class.preloaded_features.include?(File.expand_path(file_path))
79
113
  end
80
114
 
81
- def collect_edits(tree)
115
+ def collect_edits(source, tree, keep_offset, keep_lines)
82
116
  edits = []
83
- walker = Walker.new(IDEMPOTENT_CALLS, edits)
117
+ walker = Walker.new(source, IDEMPOTENT_CALLS, edits, keep_offset, keep_lines)
84
118
  walker.visit(tree)
85
119
  edits
86
120
  end
87
121
 
88
122
  def apply_edits(source, edits)
89
123
  bytes = source.b
90
- edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
91
- bytes[start_offset, end_offset - start_offset] = REPLACEMENT
124
+ edits.sort_by!(&:first).reverse_each do |start_offset, end_offset, replacement|
125
+ bytes[start_offset, end_offset - start_offset] = replacement.b
92
126
  end
93
127
  bytes.force_encoding(source.encoding)
94
128
  end
95
129
 
96
130
  class Walker < Prism::Visitor
97
- def initialize(allowlist, edits)
131
+ CONSTANT_WRITES = [
132
+ Prism::ConstantWriteNode, Prism::ConstantPathWriteNode,
133
+ Prism::ConstantOperatorWriteNode, Prism::ConstantPathOperatorWriteNode,
134
+ Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode,
135
+ Prism::ConstantAndWriteNode, Prism::ConstantPathAndWriteNode
136
+ ].freeze
137
+
138
+ def initialize(source, allowlist, edits, keep_offset, keep_lines)
98
139
  super()
140
+ @source = source
99
141
  @allowlist = allowlist
100
142
  @edits = edits
143
+ @keep_offset = keep_offset
144
+ @keep_lines = keep_lines
101
145
  end
102
146
 
103
147
  def visit_class_node(node)
@@ -126,14 +170,134 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
126
170
  return unless body_node.is_a?(Prism::StatementsNode)
127
171
 
128
172
  body_node.body.each do |stmt|
129
- next unless stmt.is_a?(Prism::CallNode)
130
- next if @allowlist.include?(stmt.name)
131
- next if stmt.receiver && !stmt.receiver.is_a?(Prism::SelfNode)
173
+ next unless neutralizable?(stmt)
174
+
175
+ edit = edit_for(stmt)
176
+ if kept?(edit)
177
+ scan_kept(stmt)
178
+ elsif !registration_only?(stmt)
179
+ @edits << edit
180
+ end
181
+ end
182
+ end
183
+
184
+ # A concern's `included do ... end` runs nothing where it is written: it
185
+ # hands the block over for the classes that include the concern from then
186
+ # on. Blanked, it would leave the concern without one -- ConcernStateCleaner
187
+ # has just dropped the block it had -- and a class including the concern
188
+ # during the run would get none of its declarations. Left as written, it
189
+ # registers the same block again. `prepended do ... end` is the same hook
190
+ # for the classes that prepend the concern.
191
+ def registration_only?(stmt)
192
+ block = Evilution::AST::IncludedBlock.of(stmt) || Evilution::AST::IncludedBlock.prepended(stmt)
193
+ !block.nil?
194
+ end
132
195
 
133
- @edits << [stmt.location.start_offset, replacement_end_offset(stmt)]
196
+ def scan_kept(stmt)
197
+ scan_machine(stmt)
198
+ scan_included(stmt)
199
+ wrap_callbacks(stmt)
200
+ end
201
+
202
+ # A kept callback declaration runs inside a block handed to
203
+ # CallbackRedeclaration, opened and closed on the declaration's own lines.
204
+ def wrap_callbacks(stmt)
205
+ return unless @keep_lines && Evilution::AST::CallbackDeclaration.match?(stmt)
206
+
207
+ start_offset = stmt.location.start_offset
208
+ end_offset = stmt.location.end_offset
209
+ lines = "#{@keep_lines.first}..#{@keep_lines.last}"
210
+ @edits << [start_offset, start_offset, "#{CALLBACKS}.call(self, __FILE__, #{lines}) { "]
211
+ @edits << [end_offset, end_offset, " }"]
212
+ end
213
+
214
+ # A kept `included` block stays whole. Its other calls are guarded rather
215
+ # than blanked, and what follows it re-declares the spared one on the
216
+ # classes that include the concern already. A kept `prepended` block is
217
+ # treated the same, for the classes that prepend it.
218
+ def scan_included(stmt)
219
+ redeclare_hook(stmt, Evilution::AST::IncludedBlock.of(stmt), REDECLARE)
220
+ redeclare_hook(stmt, Evilution::AST::IncludedBlock.prepended(stmt), REDECLARE_PREPENDED)
221
+ end
222
+
223
+ def redeclare_hook(stmt, block, redeclare)
224
+ return unless block && block.body.is_a?(Prism::StatementsNode)
225
+
226
+ guard_others(block)
227
+ end_offset = stmt.location.end_offset
228
+ @edits << [end_offset, end_offset, redeclare]
229
+ end
230
+
231
+ # Guards every call of a block but the one holding the offset. When that
232
+ # one is an `aasm` machine, its own declarations are guarded the same way
233
+ # rather than blanked: a class that includes the concern later needs the
234
+ # whole machine, and one that includes it already only the mutated event.
235
+ # When it is a callback declaration, it is wrapped as it would be in a
236
+ # class body: the block runs as the including class, so that is whose
237
+ # chains CallbackRedeclaration rebuilds.
238
+ # A method defined in the block is guarded like a call, unless it holds
239
+ # the offset: defined again on a class that includes the concern already,
240
+ # it would replace what that class has since defined under the same name.
241
+ # So is a constant assigned there, which would lose what it has gathered
242
+ # since. A local variable is assigned again: the rest of the block reads it.
243
+ def guard_others(block)
244
+ block.body.body.each do |statement|
245
+ case statement
246
+ when Prism::CallNode then guard_call(statement)
247
+ when Prism::DefNode, *CONSTANT_WRITES then guard(statement) unless holds_offset?(statement)
248
+ end
134
249
  end
135
250
  end
136
251
 
252
+ def guard_call(call)
253
+ return guard(call) unless kept?(edit_for(call))
254
+
255
+ machine = Evilution::AST::AasmDeclaration.machine_block(call)
256
+ guard_others(machine) if machine && machine.body.is_a?(Prism::StatementsNode)
257
+ wrap_callbacks(call)
258
+ end
259
+
260
+ def guard(statement)
261
+ start_offset = statement.location.start_offset
262
+ @edits << [start_offset, start_offset, GUARD]
263
+ end
264
+
265
+ def holds_offset?(node)
266
+ kept?([node.location.start_offset, node.location.end_offset])
267
+ end
268
+
269
+ def neutralizable?(stmt)
270
+ return false unless stmt.is_a?(Prism::CallNode)
271
+ return false if @allowlist.include?(stmt.name)
272
+
273
+ stmt.receiver.nil? || stmt.receiver.is_a?(Prism::SelfNode)
274
+ end
275
+
276
+ # Inside a kept `aasm` block every call is a declaration of the machine,
277
+ # so all of them but the one holding the offset go.
278
+ def scan_machine(stmt)
279
+ block = Evilution::AST::AasmDeclaration.machine_block(stmt)
280
+ return unless block && block.body.is_a?(Prism::StatementsNode)
281
+
282
+ block.body.body.grep(Prism::CallNode).each do |declaration|
283
+ edit = edit_for(declaration)
284
+ @edits << edit unless kept?(edit)
285
+ end
286
+ end
287
+
288
+ # The lines a blanked call took up are kept as empty lines: everything
289
+ # after it then sits on the line it has in the file, which is where a proc
290
+ # created by this source says it was written.
291
+ def edit_for(call)
292
+ start_offset = call.location.start_offset
293
+ end_offset = replacement_end_offset(call)
294
+ [start_offset, end_offset, REPLACEMENT + ("\n" * @source.byteslice(start_offset...end_offset).count("\n"))]
295
+ end
296
+
297
+ def kept?((start_offset, end_offset, _replacement))
298
+ @keep_offset && @keep_offset >= start_offset && @keep_offset < end_offset
299
+ end
300
+
137
301
  # Prism CallNode location ends at the close of the call syntax (e.g. the
138
302
  # closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
139
303
  # 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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+ require_relative "own_methods"
5
+
6
+ # Re-declares one statement of a concern's `included` block on the classes
7
+ # that already include the concern, or of its `prepended` block on those that
8
+ # prepend it.
9
+ #
10
+ # A mutation inside `included do ... scope :published, -> { } ... end` has two
11
+ # audiences. Classes that include the concern later run the whole block, so
12
+ # registering the mutated block is enough for them. Classes that already
13
+ # include it ran the original, and running the whole block on them again
14
+ # would add its validations and callbacks a second time.
15
+ #
16
+ # So the source evilution evaluates guards every other call of the block with
17
+ # `skipping?` and ends the block with `call(self)`: the block is run once more
18
+ # on each existing includer with the guard up, and only the mutated
19
+ # declaration takes effect there. What the block holds besides calls -- a
20
+ # method definition, an assignment -- runs again as well, which is how a
21
+ # mutated method of the block reaches those classes. A method the class has
22
+ # defined itself over one of the block's is kept (OwnMethods). See
23
+ # BodyCallNeutralizer.
24
+ module Evilution::Integration::Loading::ConcernRedeclaration
25
+ KEY = :evilution_concern_redeclaration
26
+ BLOCKS = { included: :@_included_block, prepended: :@_prepended_block }.freeze
27
+
28
+ # Called through UnboundMethods: ObjectSpace hands over every class in the
29
+ # VM, and one that defines `include?` or `superclass` of its own for its
30
+ # own purposes must not be asked that question.
31
+ INCLUDES = Module.instance_method(:include?)
32
+ SUPERCLASS = Class.instance_method(:superclass)
33
+ SINGLETON = Module.instance_method(:singleton_class?)
34
+ ANCESTORS = Module.instance_method(:ancestors)
35
+ private_constant :KEY, :BLOCKS, :INCLUDES, :SUPERCLASS, :SINGLETON, :ANCESTORS
36
+
37
+ def self.skipping?
38
+ Thread.current[KEY] == true
39
+ end
40
+
41
+ # `hook` names the block: `:included` or `:prepended`.
42
+ def self.call(concern, hook = :included)
43
+ ivar = BLOCKS.fetch(hook)
44
+ return unless concern.instance_variable_defined?(ivar)
45
+
46
+ block = concern.instance_variable_get(ivar)
47
+ takers(concern, prepended: hook == :prepended).each { |klass| redeclare(klass, block) }
48
+ end
49
+
50
+ # The classes that took the concern themselves, by including it or by
51
+ # prepending it; a subclass inherits what its parent declares.
52
+ def self.takers(concern, prepended:)
53
+ ObjectSpace.each_object(Class).select do |klass|
54
+ next false if SINGLETON.bind_call(klass) || !INCLUDES.bind_call(klass, concern)
55
+
56
+ parent = SUPERCLASS.bind_call(klass)
57
+ inherited = !parent.nil? && INCLUDES.bind_call(parent, concern)
58
+ !inherited && prepended?(klass, concern) == prepended
59
+ end
60
+ end
61
+
62
+ # A prepended module comes before the class in its ancestors, an included
63
+ # one after it.
64
+ def self.prepended?(klass, concern)
65
+ ancestors = ANCESTORS.bind_call(klass)
66
+ ancestors.index(concern) < ancestors.index(klass)
67
+ end
68
+
69
+ def self.redeclare(klass, block)
70
+ previous = Thread.current[KEY]
71
+ Thread.current[KEY] = true
72
+ Evilution::Integration::Loading::OwnMethods.keeping(klass, block.source_location.first) do
73
+ klass.class_eval(&block)
74
+ end
75
+ ensure
76
+ Thread.current[KEY] = previous
77
+ end
78
+
79
+ private_class_method :takers, :prepended?, :redeclare
80
+ 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
@@ -51,10 +58,10 @@ class Evilution::Integration::Loading::ConcernStateCleaner
51
58
  next unless IVAR_DEFINED.bind_call(mod, ivar)
52
59
 
53
60
  block = IVAR_GET.bind_call(mod, ivar)
54
- block_file = block.source_location&.first
55
- next unless block_file
61
+ location = block.source_location
62
+ next if location.nil?
56
63
 
57
- expanded = File.expand_path(block_file)
64
+ expanded = File.expand_path(location.first)
58
65
  REMOVE_IVAR.bind_call(mod, ivar) if source_matches?(expanded, absolute, subpath)
59
66
  end
60
67
  end