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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +62 -0
- data/.rubocop_todo.yml +5 -0
- data/CHANGELOG.md +77 -0
- data/README.md +80 -24
- data/docs/architecture.md +89 -9
- data/docs/ast_pattern_syntax.md +50 -5
- data/docs/isolation.md +39 -2
- data/docs/migration-from-mutant.md +1 -1
- data/lib/evilution/ast/aasm_declaration.rb +133 -0
- data/lib/evilution/ast/callback_declaration.rb +91 -0
- data/lib/evilution/ast/included_block.rb +27 -0
- data/lib/evilution/ast/literal_callable.rb +21 -0
- data/lib/evilution/ast/parser.rb +119 -16
- data/lib/evilution/ast/pattern/method_name.rb +63 -0
- data/lib/evilution/ast/pattern/parser.rb +14 -15
- data/lib/evilution/ast/regexp_pattern.rb +104 -0
- data/lib/evilution/ast/scope_declaration.rb +45 -0
- data/lib/evilution/ast/uncovered_code.rb +88 -0
- data/lib/evilution/ast/value_object_definition.rb +25 -0
- data/lib/evilution/baseline/failure_formatter.rb +27 -0
- data/lib/evilution/baseline/report.rb +66 -0
- data/lib/evilution/baseline/spec_failure.rb +38 -0
- data/lib/evilution/baseline.rb +67 -30
- data/lib/evilution/cli/parser/file_args.rb +2 -1
- data/lib/evilution/cli/parser/options_builder.rb +1 -1
- data/lib/evilution/cli.rb +3 -2
- data/lib/evilution/config/validators/spec_mappings.rb +2 -1
- data/lib/evilution/config.rb +3 -2
- data/lib/evilution/equivalent/detector.rb +3 -1
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/condition.rb +66 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/disturbance.rb +54 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/early_exit.rb +73 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/guard.rb +36 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/index_read.rb +82 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch/node_path.rb +43 -0
- data/lib/evilution/equivalent/heuristic/guarded_index_fetch.rb +65 -0
- data/lib/evilution/example_filter.rb +45 -0
- data/lib/evilution/hooks/registry.rb +2 -1
- data/lib/evilution/integration/base.rb +2 -0
- data/lib/evilution/integration/known_failures.rb +26 -0
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +132 -13
- data/lib/evilution/integration/loading/callback_redeclaration.rb +186 -0
- data/lib/evilution/integration/loading/concern_redeclaration.rb +61 -0
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +19 -12
- data/lib/evilution/integration/loading/mutation_applier.rb +16 -0
- data/lib/evilution/integration/loading/test_class_cache.rb +46 -0
- data/lib/evilution/integration/minitest/test_ids.rb +21 -0
- data/lib/evilution/integration/minitest.rb +45 -6
- data/lib/evilution/integration/rspec/baseline_runner.rb +47 -3
- data/lib/evilution/integration/rspec/example_ids.rb +35 -0
- data/lib/evilution/integration/rspec/result_builder.rb +6 -1
- data/lib/evilution/integration/rspec/state_guard/anonymous_example_group_examples.rb +36 -0
- data/lib/evilution/integration/rspec/state_guard.rb +4 -1
- data/lib/evilution/integration/rspec.rb +22 -4
- data/lib/evilution/integration/test_unit/result_builder.rb +6 -0
- data/lib/evilution/integration/test_unit/test_ids.rb +18 -0
- data/lib/evilution/integration/test_unit.rb +37 -9
- data/lib/evilution/isolation/fork.rb +2 -1
- data/lib/evilution/isolation/in_process.rb +21 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +2 -2
- data/lib/evilution/mcp/mutate_tool/progress_streamer.rb +2 -1
- data/lib/evilution/memory/leak_check.rb +27 -3
- data/lib/evilution/mutation.rb +7 -2
- data/lib/evilution/mutator/base.rb +49 -5
- data/lib/evilution/mutator/operator/alias_removal.rb +126 -0
- data/lib/evilution/mutator/operator/argument_order_permutation.rb +101 -0
- data/lib/evilution/mutator/operator/argument_removal.rb +9 -1
- data/lib/evilution/mutator/operator/comparison_operand_swap.rb +54 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +82 -0
- data/lib/evilution/mutator/operator/exception_swallow.rb +84 -0
- data/lib/evilution/mutator/operator/format_specifier_swap.rb +100 -0
- data/lib/evilution/mutator/operator/forwarded_argument_drop.rb +142 -0
- data/lib/evilution/mutator/operator/integer_division_to_fdiv.rb +62 -0
- data/lib/evilution/mutator/operator/keyword_argument.rb +25 -2
- data/lib/evilution/mutator/operator/keyword_value_swap.rb +75 -0
- data/lib/evilution/mutator/operator/no_matching_pattern_else.rb +47 -0
- data/lib/evilution/mutator/operator/numbered_parameter_swap.rb +78 -0
- data/lib/evilution/mutator/operator/off_by_one_boundary.rb +68 -0
- data/lib/evilution/mutator/operator/optional_parameter_to_required.rb +14 -3
- data/lib/evilution/mutator/operator/pattern_matching_array.rb +12 -1
- data/lib/evilution/mutator/operator/pattern_wildcard_widening.rb +117 -0
- data/lib/evilution/mutator/operator/pin_operator_removal.rb +48 -0
- data/lib/evilution/mutator/operator/regex_simplification.rb +53 -132
- data/lib/evilution/mutator/operator/regexp_alternation_branch_deletion.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_anchor_promotion.rb +43 -0
- data/lib/evilution/mutator/operator/regexp_capture_to_passive.rb +61 -0
- data/lib/evilution/mutator/operator/regexp_character_type_complement.rb +54 -0
- data/lib/evilution/mutator/operator/regexp_named_group_rename.rb +106 -0
- data/lib/evilution/mutator/operator/regexp_option_removal.rb +51 -0
- data/lib/evilution/mutator/operator/regexp_quantifier_minimum_swap.rb +45 -0
- data/lib/evilution/mutator/operator/rescue_else_concatenation.rb +62 -0
- data/lib/evilution/mutator/operator/rescue_handler_concatenation.rb +69 -0
- data/lib/evilution/mutator/operator/rescue_handler_promotion.rb +65 -0
- data/lib/evilution/mutator/operator/return_keyword_removal.rb +79 -0
- data/lib/evilution/mutator/operator/rightward_assignment.rb +46 -0
- data/lib/evilution/mutator/operator/send_mutation.rb +2 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +38 -13
- data/lib/evilution/mutator/operator/statement_reorder.rb +153 -0
- data/lib/evilution/mutator/registry.rb +31 -2
- data/lib/evilution/mutator/rescue_handlers.rb +81 -0
- data/lib/evilution/process_supervisor.rb +3 -2
- data/lib/evilution/reporter/cli/line_formatters/baseline_neutralized_notice.rb +55 -0
- data/lib/evilution/reporter/cli/metrics_block.rb +2 -0
- data/lib/evilution/reporter/json/baseline.rb +15 -0
- data/lib/evilution/reporter/json.rb +7 -2
- data/lib/evilution/result/baseline_neutralization.rb +10 -0
- data/lib/evilution/result/mutation_result.rb +9 -1
- data/lib/evilution/result/summary.rb +42 -2
- data/lib/evilution/runner/baseline_runner.rb +9 -4
- data/lib/evilution/runner/canary.rb +2 -57
- data/lib/evilution/runner/canary_failure_message.rb +101 -0
- data/lib/evilution/runner/mutation_executor/neutralizer/baseline_failed.rb +22 -8
- data/lib/evilution/runner/mutation_executor/result_cache.rb +3 -0
- data/lib/evilution/runner/mutation_executor/result_packer.rb +4 -2
- data/lib/evilution/runner/mutation_executor.rb +1 -1
- data/lib/evilution/runner/mutation_planner.rb +11 -2
- data/lib/evilution/runner/report_publisher.rb +3 -2
- data/lib/evilution/runner/subject_pipeline.rb +46 -1
- data/lib/evilution/runner.rb +4 -1
- data/lib/evilution/subject.rb +8 -2
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +28 -0
- data/script/memory_check +62 -16
- 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
|
|
58
|
+
Evilution::Diagnostic.warn("[evilution] hook error in #{event}: #{error.message}")
|
|
58
59
|
end
|
|
59
60
|
end
|
|
60
61
|
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
|
-
#
|
|
18
|
-
# from the end of the source backwards
|
|
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
|
-
|
|
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] =
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|