evilution 1.3.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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +62 -0
  3. data/.rubocop_todo.yml +5 -0
  4. data/CHANGELOG.md +77 -0
  5. data/README.md +80 -24
  6. data/docs/architecture.md +89 -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 +119 -16
  15. data/lib/evilution/ast/pattern/method_name.rb +63 -0
  16. data/lib/evilution/ast/pattern/parser.rb +14 -15
  17. data/lib/evilution/ast/regexp_pattern.rb +104 -0
  18. data/lib/evilution/ast/scope_declaration.rb +45 -0
  19. data/lib/evilution/ast/uncovered_code.rb +88 -0
  20. data/lib/evilution/ast/value_object_definition.rb +25 -0
  21. data/lib/evilution/baseline/failure_formatter.rb +27 -0
  22. data/lib/evilution/baseline/report.rb +66 -0
  23. data/lib/evilution/baseline/spec_failure.rb +38 -0
  24. data/lib/evilution/baseline.rb +67 -30
  25. data/lib/evilution/cli/parser/file_args.rb +2 -1
  26. data/lib/evilution/cli/parser/options_builder.rb +1 -1
  27. data/lib/evilution/cli.rb +3 -2
  28. data/lib/evilution/config/validators/spec_mappings.rb +2 -1
  29. data/lib/evilution/config.rb +3 -2
  30. data/lib/evilution/equivalent/detector.rb +3 -1
  31. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
  32. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
  33. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
  34. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
  35. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
  36. data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
  37. data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
  38. data/lib/evilution/example_filter.rb +45 -0
  39. data/lib/evilution/hooks/registry.rb +2 -1
  40. data/lib/evilution/integration/base.rb +2 -0
  41. data/lib/evilution/integration/known_failures.rb +26 -0
  42. data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
  43. data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
  44. data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
  45. data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
  46. data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
  47. data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
  48. data/lib/evilution/integration/minitest/test_ids.rb +21 -0
  49. data/lib/evilution/integration/minitest.rb +45 -6
  50. data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
  51. data/lib/evilution/integration/rspec/example_ids.rb +35 -0
  52. data/lib/evilution/integration/rspec/result_builder.rb +6 -1
  53. data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
  54. data/lib/evilution/integration/rspec/state_guard.rb +4 -1
  55. data/lib/evilution/integration/rspec.rb +22 -4
  56. data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
  57. data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
  58. data/lib/evilution/integration/test_unit.rb +37 -9
  59. data/lib/evilution/isolation/fork.rb +2 -1
  60. data/lib/evilution/isolation/in_process.rb +21 -4
  61. data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
  62. data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
  63. data/lib/evilution/memory/leak_check.rb +27 -3
  64. data/lib/evilution/mutation.rb +7 -2
  65. data/lib/evilution/mutator/base.rb +49 -5
  66. data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
  67. data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
  68. data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
  69. data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
  70. data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
  71. data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
  72. data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
  73. data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
  74. data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
  75. data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
  76. data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
  77. data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
  78. data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
  79. data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
  80. data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
  81. data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
  82. data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
  83. data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
  84. data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
  85. data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
  86. data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
  87. data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
  88. data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
  89. data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
  90. data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
  91. data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
  92. data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
  93. data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
  94. data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
  95. data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
  96. data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
  97. data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
  98. data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
  99. data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
  100. data/lib/evilution/mutator/registry.rb +31 -2
  101. data/lib/evilution/mutator/rescue_handlers.rb +81 -0
  102. data/lib/evilution/process_supervisor.rb +3 -2
  103. data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
  104. data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
  105. data/lib/evilution/reporter/json/baseline.rb +15 -0
  106. data/lib/evilution/reporter/json.rb +7 -2
  107. data/lib/evilution/result/baseline_neutralization.rb +10 -0
  108. data/lib/evilution/result/mutation_result.rb +9 -1
  109. data/lib/evilution/result/summary.rb +42 -2
  110. data/lib/evilution/runner/baseline_runner.rb +9 -4
  111. data/lib/evilution/runner/canary.rb +2 -57
  112. data/lib/evilution/runner/canary_failure_message.rb +101 -0
  113. data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
  114. data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
  115. data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
  116. data/lib/evilution/runner/mutation_executor.rb +1 -1
  117. data/lib/evilution/runner/mutation_planner.rb +11 -2
  118. data/lib/evilution/runner/report_publisher.rb +3 -2
  119. data/lib/evilution/runner/subject_pipeline.rb +46 -1
  120. data/lib/evilution/runner.rb +4 -1
  121. data/lib/evilution/subject.rb +8 -2
  122. data/lib/evilution/version.rb +1 -1
  123. data/lib/evilution.rb +28 -0
  124. data/script/memory_check +62 -16
  125. metadata +83 -4
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require_relative "../heuristic"
5
+
6
+ # `index_to_fetch` turns `recv[key]` into `recv.fetch(key)`, which differs only
7
+ # when the key is absent. Under a guard that is truthy only when the key is
8
+ # there -- `if config[:k]`, `config.key?(:k) && ...` -- it cannot be absent, so
9
+ # the mutant behaves like the original and no test can kill it.
10
+ #
11
+ # The read in the guard itself is a different matter: `if config.fetch(:k)`
12
+ # raises where `if config[:k]` took the other branch. Only reads inside the
13
+ # guarded part match -- the branch a guard opens, or the statements after a
14
+ # guard that leaves (`return unless config[:k]`).
15
+ #
16
+ # A heuristic, not a proof: a hash with a default value passes the guard with
17
+ # the key absent, and a method-call receiver may answer with another object
18
+ # the second time.
19
+ class Evilution::Equivalent::Heuristic::GuardedIndexFetch
20
+ OPERATOR = "index_to_fetch"
21
+
22
+ def match?(mutation)
23
+ return false unless mutation.operator_name == OPERATOR
24
+
25
+ path = NodePath.new.call(mutation)
26
+ return false unless path
27
+
28
+ read = IndexRead.from(path.last)
29
+ return false unless read
30
+
31
+ guarded?(path, read)
32
+ end
33
+
34
+ private
35
+
36
+ # Walks outwards from the read. Each step asks whether the node just left
37
+ # was the guarded part of its parent, or a statement that follows an early
38
+ # exit in its parent's list; a scope the guard does not reach into ends the
39
+ # walk.
40
+ def guarded?(path, read)
41
+ path.each_cons(2).reverse_each do |parent, child|
42
+ return false if scope_boundary?(parent, read)
43
+ return true if Guard.new(parent, read).protects?(child) || EarlyExit.new(parent, read).protects?(child)
44
+ end
45
+ false
46
+ end
47
+
48
+ # A nested method or lambda runs later, when the guard no longer speaks for
49
+ # the receiver. A block runs in place, unless it declares the receiver's
50
+ # name as its own variable.
51
+ def scope_boundary?(node, read)
52
+ case node
53
+ when Prism::DefNode, Prism::LambdaNode then true
54
+ when Prism::BlockNode then read.local_root? && node.locals.include?(read.root_name)
55
+ else false
56
+ end
57
+ end
58
+ end
59
+
60
+ require_relative "guarded_index_fetch/node_path"
61
+ require_relative "guarded_index_fetch/index_read"
62
+ require_relative "guarded_index_fetch/condition"
63
+ require_relative "guarded_index_fetch/disturbance"
64
+ require_relative "guarded_index_fetch/guard"
65
+ require_relative "guarded_index_fetch/early_exit"
@@ -3,6 +3,9 @@
3
3
  require "prism"
4
4
  require_relative "version"
5
5
  require_relative "spec_ast_cache"
6
+ require_relative "ast/value_object_definition"
7
+ require_relative "ast/scope_declaration"
8
+ require_relative "ast/aasm_declaration"
6
9
 
7
10
  class Evilution::ExampleFilter
8
11
  VALID_FALLBACKS = %i[full_file unresolved].freeze
@@ -119,8 +122,50 @@ class Evilution::ExampleFilter
119
122
  @class_stack.pop
120
123
  end
121
124
 
125
+ # A value-object definition assigned to a constant names the type its
126
+ # specs build (`Point = Data.define(:x, :y)` -> `Point`), like a class.
127
+ def visit_constant_write_node(node)
128
+ within_value_object(node, node.name.to_s) { super }
129
+ end
130
+
131
+ def visit_constant_path_write_node(node)
132
+ within_value_object(node, node.target.name.to_s) { super }
133
+ end
134
+
135
+ # A scope declaration names the class method its specs call
136
+ # (`scope :for_owner, -> { }` -> `for_owner`), like a def; nothing inside
137
+ # its body names anything closer.
138
+ #
139
+ # An aasm event or state names the methods its specs call (`event :ship`
140
+ # -> `ship!`, `state :paid` -> `paid?`), which share its name as a word.
141
+ def visit_call_node(node)
142
+ return super unless target_within?(node)
143
+
144
+ token = declaration_token(node)
145
+ return super unless token
146
+
147
+ @token = token
148
+ end
149
+
122
150
  private
123
151
 
152
+ def declaration_token(node)
153
+ if Evilution::AST::ScopeDeclaration.body_of(node)
154
+ Evilution::AST::ScopeDeclaration.scope_name(node)
155
+ else
156
+ Evilution::AST::AasmDeclaration.token_at(node, @target_line)
157
+ end
158
+ end
159
+
160
+ def within_value_object(node, name)
161
+ return yield unless Evilution::AST::ValueObjectDefinition.match?(node.value)
162
+
163
+ @class_stack.push(name)
164
+ capture_if_match(node)
165
+ yield
166
+ @class_stack.pop
167
+ end
168
+
124
169
  def capture_if_match(node)
125
170
  return if @found
126
171
  return unless target_within?(node)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../hooks"
4
+ require_relative "../diagnostic"
4
5
 
5
6
  class Evilution::Hooks::Registry
6
7
  def initialize(on_error: nil)
@@ -54,7 +55,7 @@ class Evilution::Hooks::Registry
54
55
  if @on_error
55
56
  @on_error.call(event, error)
56
57
  else
57
- warn "[evilution] hook error in #{event}: #{error.message}"
58
+ Evilution::Diagnostic.warn("[evilution] hook error in #{event}: #{error.message}")
58
59
  end
59
60
  end
60
61
  end
@@ -25,6 +25,8 @@ class Evilution::Integration::Base
25
25
 
26
26
  fire_hook(:mutation_insert_post, mutation: mutation, file_path: mutation.file_path)
27
27
  run_tests(mutation)
28
+ ensure
29
+ @mutation_applier.restore(mutation)
28
30
  end
29
31
 
30
32
  private
@@ -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,11 @@ 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
+ CALLBACKS = "::Evilution::Integration::Loading::CallbackRedeclaration"
46
+
36
47
  class << self
37
48
  attr_writer :preloaded_features
38
49
 
@@ -60,13 +71,33 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
60
71
  # strips method definitions that subsequent sibling statements (alias, etc.)
61
72
  # depend on, producing cascading NameError. Callers that don't pass a path
62
73
  # get the legacy always-neutralize behavior.
63
- 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)
64
95
  return source if file_path && !preloaded?(file_path)
65
96
 
66
97
  result = Prism.parse(source)
67
98
  return source if result.failure?
68
99
 
69
- edits = collect_edits(result.value)
100
+ edits = collect_edits(source, result.value, keep_offset, keep_lines)
70
101
  return source if edits.empty?
71
102
 
72
103
  apply_edits(source, edits)
@@ -78,26 +109,29 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
78
109
  self.class.preloaded_features.include?(File.expand_path(file_path))
79
110
  end
80
111
 
81
- def collect_edits(tree)
112
+ def collect_edits(source, tree, keep_offset, keep_lines)
82
113
  edits = []
83
- walker = Walker.new(IDEMPOTENT_CALLS, edits)
114
+ walker = Walker.new(source, IDEMPOTENT_CALLS, edits, keep_offset, keep_lines)
84
115
  walker.visit(tree)
85
116
  edits
86
117
  end
87
118
 
88
119
  def apply_edits(source, edits)
89
120
  bytes = source.b
90
- edits.sort_by!(&:first).reverse_each do |start_offset, end_offset|
91
- bytes[start_offset, end_offset - start_offset] = REPLACEMENT
121
+ edits.sort_by!(&:first).reverse_each do |start_offset, end_offset, replacement|
122
+ bytes[start_offset, end_offset - start_offset] = replacement.b
92
123
  end
93
124
  bytes.force_encoding(source.encoding)
94
125
  end
95
126
 
96
127
  class Walker < Prism::Visitor
97
- def initialize(allowlist, edits)
128
+ def initialize(source, allowlist, edits, keep_offset, keep_lines)
98
129
  super()
130
+ @source = source
99
131
  @allowlist = allowlist
100
132
  @edits = edits
133
+ @keep_offset = keep_offset
134
+ @keep_lines = keep_lines
101
135
  end
102
136
 
103
137
  def visit_class_node(node)
@@ -126,14 +160,99 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
126
160
  return unless body_node.is_a?(Prism::StatementsNode)
127
161
 
128
162
  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)
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
132
203
 
133
- @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
134
221
  end
135
222
  end
136
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
+
137
256
  # Prism CallNode location ends at the close of the call syntax (e.g. the
138
257
  # closing `)` or the `<<~MARKER` opener for a heredoc argument). It does
139
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