evilution 1.4.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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +70 -0
- data/CHANGELOG.md +50 -0
- data/README.md +64 -22
- data/docs/architecture.md +56 -12
- data/docs/isolation.md +82 -0
- data/lib/evilution/ast/class_body.rb +101 -0
- data/lib/evilution/ast/included_block.rb +20 -6
- data/lib/evilution/ast/never_nil.rb +36 -0
- data/lib/evilution/ast/parser.rb +94 -31
- data/lib/evilution/ast/round_half_mode.rb +38 -0
- data/lib/evilution/ast/source_surgeon.rb +2 -3
- data/lib/evilution/ast/syntax_check.rb +56 -0
- data/lib/evilution/ast/uncovered_code.rb +7 -7
- data/lib/evilution/cli/exit_guard.rb +26 -4
- data/lib/evilution/integration/base.rb +10 -2
- data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
- data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
- data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
- data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
- data/lib/evilution/integration/loading/own_methods.rb +87 -0
- data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
- data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
- data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
- data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
- data/lib/evilution/isolation/fork.rb +82 -10
- data/lib/evilution/isolation/in_process.rb +79 -4
- data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
- data/lib/evilution/mcp/info_tool.rb +3 -2
- data/lib/evilution/mutation.rb +18 -11
- data/lib/evilution/mutator/base.rb +27 -7
- data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
- data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
- data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
- data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
- data/lib/evilution/mutator/operator/array_literal.rb +29 -0
- data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
- data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
- data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
- data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
- data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
- data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
- data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
- data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
- data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
- data/lib/evilution/mutator/operator/float_literal.rb +54 -19
- data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
- data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
- data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
- data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
- data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
- data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
- data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
- data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
- data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
- data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
- data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
- data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
- data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
- data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
- data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
- data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
- data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
- data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
- data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
- data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
- data/lib/evilution/mutator/operator/string_literal.rb +29 -0
- data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
- data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
- data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
- data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
- data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
- data/lib/evilution/mutator/primitives.rb +24 -2
- data/lib/evilution/mutator/receiver_positions.rb +79 -0
- data/lib/evilution/mutator/registry.rb +29 -2
- data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
- data/lib/evilution/parallel/work_queue/channel.rb +58 -5
- data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
- data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
- data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
- data/lib/evilution/parallel/work_queue/worker.rb +13 -4
- data/lib/evilution/parallel/work_queue.rb +15 -9
- data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
- data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
- data/lib/evilution/runner/subject_pipeline.rb +20 -51
- data/lib/evilution/runner.rb +1 -1
- data/lib/evilution/subject.rb +18 -1
- data/lib/evilution/version.rb +1 -1
- data/lib/evilution.rb +62 -0
- metadata +32 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../ast"
|
|
6
|
+
|
|
7
|
+
# Answers whether mutated source is Ruby the running interpreter will accept.
|
|
8
|
+
#
|
|
9
|
+
# Mutations are built and screened with Prism, but Prism and the older
|
|
10
|
+
# parse.y parser do not agree on everything: Prism takes a bare constant
|
|
11
|
+
# operator write inside a method (`def f; LIMIT ||= 1; end`) and a `nil[...]`
|
|
12
|
+
# pattern, both of which parse.y rejects. An interpreter that compiles with
|
|
13
|
+
# parse.y -- Ruby 3.3 by default, any later one started with
|
|
14
|
+
# `--parser=parse.y` -- would be handed a mutant marked parseable and raise
|
|
15
|
+
# SyntaxError evaluating it.
|
|
16
|
+
#
|
|
17
|
+
# So Prism decides first, and where the interpreter does not compile with
|
|
18
|
+
# Prism, parse.y is asked as well. That second parse costs about what the
|
|
19
|
+
# first does and is only paid on those interpreters.
|
|
20
|
+
module Evilution::AST::SyntaxCheck
|
|
21
|
+
# How the interpreter says it compiles: `ruby 3.4.1 (...) +PRISM [...]`.
|
|
22
|
+
PRISM_INTERPRETER = RUBY_DESCRIPTION.include?("+PRISM")
|
|
23
|
+
|
|
24
|
+
def self.valid?(source, prism_interpreter: PRISM_INTERPRETER)
|
|
25
|
+
return false unless Prism.parse(source).success?
|
|
26
|
+
|
|
27
|
+
prism_interpreter || parse_y_accepts?(source)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# RubyVM::AbstractSyntaxTree is built on parse.y whatever the interpreter
|
|
31
|
+
# compiles with. It is CRuby's own: another implementation has no parse.y
|
|
32
|
+
# to ask, and there Prism's answer stands, as it does after a failure that
|
|
33
|
+
# is not about syntax.
|
|
34
|
+
def self.parse_y_accepts?(source)
|
|
35
|
+
return true unless defined?(RubyVM::AbstractSyntaxTree)
|
|
36
|
+
|
|
37
|
+
without_warnings { RubyVM::AbstractSyntaxTree.parse(source) }
|
|
38
|
+
true
|
|
39
|
+
rescue SyntaxError
|
|
40
|
+
false
|
|
41
|
+
rescue StandardError
|
|
42
|
+
true
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# parse.y warns as it parses ("possibly useless use of == in void
|
|
46
|
+
# context"), which a mutant earns far more often than written code does.
|
|
47
|
+
def self.without_warnings
|
|
48
|
+
verbose = $VERBOSE
|
|
49
|
+
$VERBOSE = nil
|
|
50
|
+
yield
|
|
51
|
+
ensure
|
|
52
|
+
$VERBOSE = verbose
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private_class_method :parse_y_accepts?, :without_warnings
|
|
56
|
+
end
|
|
@@ -6,11 +6,11 @@ require_relative "included_block"
|
|
|
6
6
|
|
|
7
7
|
# The lines of a file that hold code no subject covers: statements written in
|
|
8
8
|
# a class, module or `class << self` body, or at the top level, outside every
|
|
9
|
-
# method --
|
|
10
|
-
# concern's `included` block, which is a class body written
|
|
11
|
-
# generated per subject, so a run aimed at these
|
|
12
|
-
# own, and saying so is the only way a reader can
|
|
13
|
-
# from "nothing reached".
|
|
9
|
+
# method -- callback macros, DSL calls, logical writes on a constant -- or in a
|
|
10
|
+
# concern's `included` or `prepended` block, which is a class body written
|
|
11
|
+
# elsewhere. Mutations are generated per subject, so a run aimed at these
|
|
12
|
+
# lines generates none of its own, and saying so is the only way a reader can
|
|
13
|
+
# tell "nothing to mutate" from "nothing reached".
|
|
14
14
|
#
|
|
15
15
|
# Visibility keywords without arguments (`private`) carry no behaviour of
|
|
16
16
|
# their own and are left out, as are `require` and `require_relative`: loading
|
|
@@ -55,8 +55,8 @@ module Evilution::AST::UncoveredCode
|
|
|
55
55
|
def self.expand(node)
|
|
56
56
|
return statements(node.body) if SCOPE_NODES.any? { |type| node.is_a?(type) }
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
return statements(
|
|
58
|
+
hook = Evilution::AST::IncludedBlock.of(node) || Evilution::AST::IncludedBlock.prepended(node)
|
|
59
|
+
return statements(hook.body) if hook
|
|
60
60
|
return [] if bare_visibility?(node) || load_call?(node)
|
|
61
61
|
|
|
62
62
|
[node]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../cli"
|
|
4
|
+
require_relative "../isolation/in_process"
|
|
4
5
|
|
|
5
6
|
# Gives evilution the final word on the process exit status.
|
|
6
7
|
#
|
|
@@ -18,18 +19,28 @@ require_relative "../cli"
|
|
|
18
19
|
# With no status recorded, evilution did not finish normally: an exception is on
|
|
19
20
|
# its way out, and Ruby's own handling decides the status instead.
|
|
20
21
|
#
|
|
22
|
+
# One such ending has a status of its own. A mutation that takes the process
|
|
23
|
+
# down under in-process isolation leaves a run that measured nothing, and Ruby
|
|
24
|
+
# would end it with 1: the status of a score below the minimum. It ends with
|
|
25
|
+
# the status of a tool error instead. The hook that reports the death is the
|
|
26
|
+
# first to run, so the status is left to this one, after the project's hooks
|
|
27
|
+
# and evilution's own cleanup.
|
|
28
|
+
#
|
|
21
29
|
# The hook belongs to the process that installed it. Evilution forks workers,
|
|
22
30
|
# and a fork inherits its parent's at_exit hooks; a worker running this one
|
|
23
31
|
# would exit with the parent's status and skip its own ending. The guard
|
|
24
32
|
# therefore remembers its process and stands down anywhere else.
|
|
25
33
|
class Evilution::CLI::ExitGuard
|
|
34
|
+
DIED = 2
|
|
35
|
+
|
|
26
36
|
attr_accessor :status
|
|
27
37
|
|
|
28
|
-
def initialize(register: nil, exiter: nil, pid_source: nil, streams: nil)
|
|
38
|
+
def initialize(register: nil, exiter: nil, pid_source: nil, streams: nil, died: nil)
|
|
29
39
|
@register = register || ->(&hook) { at_exit(&hook) }
|
|
30
40
|
@exiter = exiter || ->(code) { exit!(code) }
|
|
31
|
-
@streams = streams || -> { [$stdout, $stderr] }
|
|
41
|
+
@streams = streams || -> { [$stdout, $stderr, STDOUT, STDERR] } # rubocop:disable Style/GlobalStdStream -- the process's own, whatever the globals point at
|
|
32
42
|
@pid_source = pid_source || -> { Process.pid }
|
|
43
|
+
@died = died || -> { Evilution::Isolation::InProcess.died? }
|
|
33
44
|
@status = nil
|
|
34
45
|
end
|
|
35
46
|
|
|
@@ -42,15 +53,26 @@ class Evilution::CLI::ExitGuard
|
|
|
42
53
|
private
|
|
43
54
|
|
|
44
55
|
def fire
|
|
45
|
-
return if @status.nil?
|
|
46
56
|
return unless @pid_source.call == @pid
|
|
47
57
|
|
|
58
|
+
status = final_status
|
|
59
|
+
return if status.nil?
|
|
60
|
+
|
|
48
61
|
flush_streams
|
|
49
|
-
@exiter.call(
|
|
62
|
+
@exiter.call(status)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def final_status
|
|
66
|
+
return @status unless @status.nil?
|
|
67
|
+
|
|
68
|
+
DIED if @died.call
|
|
50
69
|
end
|
|
51
70
|
|
|
52
71
|
# `exit!` leaves buffered output where it stands, so anything written by the
|
|
53
72
|
# report, or by a hook that ran before this one, has to be pushed out first.
|
|
73
|
+
# The process's own streams are flushed as well as the current ones: a run
|
|
74
|
+
# that died under in-process isolation is left with the null streams of the
|
|
75
|
+
# mutation it was testing.
|
|
54
76
|
def flush_streams
|
|
55
77
|
@streams.call.each do |stream|
|
|
56
78
|
stream.flush
|
|
@@ -24,13 +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
28
|
ensure
|
|
29
|
-
@mutation_applier.restore(mutation)
|
|
29
|
+
@mutation_applier.restore(mutation) unless restored_by_applier?(load_error)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
private
|
|
33
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
|
+
|
|
34
42
|
def ensure_framework_loaded
|
|
35
43
|
raise NotImplementedError, "#{self.class}#ensure_framework_loaded must be implemented"
|
|
36
44
|
end
|
|
@@ -42,6 +42,7 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
42
42
|
REDECLARATION = "::Evilution::Integration::Loading::ConcernRedeclaration"
|
|
43
43
|
GUARD = "#{REDECLARATION}.skipping? or ".freeze
|
|
44
44
|
REDECLARE = "; #{REDECLARATION}.call(self)".freeze
|
|
45
|
+
REDECLARE_PREPENDED = "; #{REDECLARATION}.call(self, :prepended)".freeze
|
|
45
46
|
CALLBACKS = "::Evilution::Integration::Loading::CallbackRedeclaration"
|
|
46
47
|
|
|
47
48
|
class << self
|
|
@@ -79,8 +80,10 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
79
80
|
# the offset is spared inside its block: re-running one declaration replaces
|
|
80
81
|
# it, while re-running the whole machine would register its
|
|
81
82
|
# `after_all_transitions`-style callbacks a second time.
|
|
82
|
-
#
|
|
83
|
-
# classes that include the concern later run all of it
|
|
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
|
|
84
87
|
# it is guarded and the block is followed by a ConcernRedeclaration call,
|
|
85
88
|
# which re-declares the spared statement alone on the classes that include
|
|
86
89
|
# the concern already. Both are added on existing lines, so no line moves.
|
|
@@ -125,6 +128,13 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
class Walker < Prism::Visitor
|
|
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
|
+
|
|
128
138
|
def initialize(source, allowlist, edits, keep_offset, keep_lines)
|
|
129
139
|
super()
|
|
130
140
|
@source = source
|
|
@@ -165,12 +175,24 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
165
175
|
edit = edit_for(stmt)
|
|
166
176
|
if kept?(edit)
|
|
167
177
|
scan_kept(stmt)
|
|
168
|
-
|
|
178
|
+
elsif !registration_only?(stmt)
|
|
169
179
|
@edits << edit
|
|
170
180
|
end
|
|
171
181
|
end
|
|
172
182
|
end
|
|
173
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
|
|
195
|
+
|
|
174
196
|
def scan_kept(stmt)
|
|
175
197
|
scan_machine(stmt)
|
|
176
198
|
scan_included(stmt)
|
|
@@ -191,14 +213,19 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
191
213
|
|
|
192
214
|
# A kept `included` block stays whole. Its other calls are guarded rather
|
|
193
215
|
# than blanked, and what follows it re-declares the spared one on the
|
|
194
|
-
# classes that include the concern already.
|
|
216
|
+
# classes that include the concern already. A kept `prepended` block is
|
|
217
|
+
# treated the same, for the classes that prepend it.
|
|
195
218
|
def scan_included(stmt)
|
|
196
|
-
|
|
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)
|
|
197
224
|
return unless block && block.body.is_a?(Prism::StatementsNode)
|
|
198
225
|
|
|
199
226
|
guard_others(block)
|
|
200
227
|
end_offset = stmt.location.end_offset
|
|
201
|
-
@edits << [end_offset, end_offset,
|
|
228
|
+
@edits << [end_offset, end_offset, redeclare]
|
|
202
229
|
end
|
|
203
230
|
|
|
204
231
|
# Guards every call of a block but the one holding the offset. When that
|
|
@@ -208,19 +235,37 @@ class Evilution::Integration::Loading::BodyCallNeutralizer
|
|
|
208
235
|
# When it is a callback declaration, it is wrapped as it would be in a
|
|
209
236
|
# class body: the block runs as the including class, so that is whose
|
|
210
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.
|
|
211
243
|
def guard_others(block)
|
|
212
|
-
block.body.body.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
wrap_callbacks(call)
|
|
217
|
-
else
|
|
218
|
-
start_offset = call.location.start_offset
|
|
219
|
-
@edits << [start_offset, start_offset, GUARD]
|
|
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)
|
|
220
248
|
end
|
|
221
249
|
end
|
|
222
250
|
end
|
|
223
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
|
+
|
|
224
269
|
def neutralizable?(stmt)
|
|
225
270
|
return false unless stmt.is_a?(Prism::CallNode)
|
|
226
271
|
return false if @allowlist.include?(stmt.name)
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../loading"
|
|
4
|
+
require_relative "own_methods"
|
|
4
5
|
|
|
5
6
|
# Re-declares one statement of a concern's `included` block on the classes
|
|
6
|
-
# that already include the concern
|
|
7
|
+
# that already include the concern, or of its `prepended` block on those that
|
|
8
|
+
# prepend it.
|
|
7
9
|
#
|
|
8
10
|
# A mutation inside `included do ... scope :published, -> { } ... end` has two
|
|
9
11
|
# audiences. Classes that include the concern later run the whole block, so
|
|
@@ -14,10 +16,14 @@ require_relative "../loading"
|
|
|
14
16
|
# So the source evilution evaluates guards every other call of the block with
|
|
15
17
|
# `skipping?` and ends the block with `call(self)`: the block is run once more
|
|
16
18
|
# on each existing includer with the guard up, and only the mutated
|
|
17
|
-
# declaration takes effect there.
|
|
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.
|
|
18
24
|
module Evilution::Integration::Loading::ConcernRedeclaration
|
|
19
25
|
KEY = :evilution_concern_redeclaration
|
|
20
|
-
|
|
26
|
+
BLOCKS = { included: :@_included_block, prepended: :@_prepended_block }.freeze
|
|
21
27
|
|
|
22
28
|
# Called through UnboundMethods: ObjectSpace hands over every class in the
|
|
23
29
|
# VM, and one that defines `include?` or `superclass` of its own for its
|
|
@@ -25,37 +31,50 @@ module Evilution::Integration::Loading::ConcernRedeclaration
|
|
|
25
31
|
INCLUDES = Module.instance_method(:include?)
|
|
26
32
|
SUPERCLASS = Class.instance_method(:superclass)
|
|
27
33
|
SINGLETON = Module.instance_method(:singleton_class?)
|
|
28
|
-
|
|
34
|
+
ANCESTORS = Module.instance_method(:ancestors)
|
|
35
|
+
private_constant :KEY, :BLOCKS, :INCLUDES, :SUPERCLASS, :SINGLETON, :ANCESTORS
|
|
29
36
|
|
|
30
37
|
def self.skipping?
|
|
31
38
|
Thread.current[KEY] == true
|
|
32
39
|
end
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
36
45
|
|
|
37
|
-
block = concern.instance_variable_get(
|
|
38
|
-
|
|
46
|
+
block = concern.instance_variable_get(ivar)
|
|
47
|
+
takers(concern, prepended: hook == :prepended).each { |klass| redeclare(klass, block) }
|
|
39
48
|
end
|
|
40
49
|
|
|
41
|
-
# The classes that
|
|
42
|
-
# its parent declares.
|
|
43
|
-
def self.
|
|
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:)
|
|
44
53
|
ObjectSpace.each_object(Class).select do |klass|
|
|
45
54
|
next false if SINGLETON.bind_call(klass) || !INCLUDES.bind_call(klass, concern)
|
|
46
55
|
|
|
47
56
|
parent = SUPERCLASS.bind_call(klass)
|
|
48
|
-
parent.nil?
|
|
57
|
+
inherited = !parent.nil? && INCLUDES.bind_call(parent, concern)
|
|
58
|
+
!inherited && prepended?(klass, concern) == prepended
|
|
49
59
|
end
|
|
50
60
|
end
|
|
51
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
|
+
|
|
52
69
|
def self.redeclare(klass, block)
|
|
53
70
|
previous = Thread.current[KEY]
|
|
54
71
|
Thread.current[KEY] = true
|
|
55
|
-
|
|
72
|
+
Evilution::Integration::Loading::OwnMethods.keeping(klass, block.source_location.first) do
|
|
73
|
+
klass.class_eval(&block)
|
|
74
|
+
end
|
|
56
75
|
ensure
|
|
57
76
|
Thread.current[KEY] = previous
|
|
58
77
|
end
|
|
59
78
|
|
|
60
|
-
private_class_method :
|
|
79
|
+
private_class_method :takers, :prepended?, :redeclare
|
|
61
80
|
end
|
|
@@ -58,10 +58,10 @@ class Evilution::Integration::Loading::ConcernStateCleaner
|
|
|
58
58
|
next unless IVAR_DEFINED.bind_call(mod, ivar)
|
|
59
59
|
|
|
60
60
|
block = IVAR_GET.bind_call(mod, ivar)
|
|
61
|
-
|
|
62
|
-
next
|
|
61
|
+
location = block.source_location
|
|
62
|
+
next if location.nil?
|
|
63
63
|
|
|
64
|
-
expanded = File.expand_path(
|
|
64
|
+
expanded = File.expand_path(location.first)
|
|
65
65
|
REMOVE_IVAR.bind_call(mod, ivar) if source_matches?(expanded, absolute, subpath)
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -20,6 +20,14 @@ require_relative "../../diagnostic"
|
|
|
20
20
|
# RedefinitionRecovery stays as a safety net for cases the neutralizer's
|
|
21
21
|
# allowlist heuristic misses. Returns nil on success or a failure-shaped
|
|
22
22
|
# hash on any error.
|
|
23
|
+
#
|
|
24
|
+
# A mutated source that raises while it is evaluated never reaches the tests.
|
|
25
|
+
# When the original still evaluates right after it, the mutation is what broke
|
|
26
|
+
# the load, and the failure is marked `load_crashed`: the mutant is detected,
|
|
27
|
+
# as one that crashes a test is. When the original fails too, or there is none
|
|
28
|
+
# to compare with, the failure says nothing about the mutant and stays an
|
|
29
|
+
# error. A syntax error is never the mutant's doing in that sense: it means
|
|
30
|
+
# the mutation was not valid Ruby to begin with.
|
|
23
31
|
class Evilution::Integration::Loading::MutationApplier
|
|
24
32
|
def initialize(syntax_validator: Evilution::Integration::Loading::SyntaxValidator.new,
|
|
25
33
|
constant_pinner: Evilution::Integration::Loading::ConstantPinner.new,
|
|
@@ -43,25 +51,44 @@ class Evilution::Integration::Loading::MutationApplier
|
|
|
43
51
|
rescue SyntaxError => e
|
|
44
52
|
failure_result(e, "syntax error in mutated source: #{e.message}")
|
|
45
53
|
rescue ScriptError, StandardError => e
|
|
46
|
-
|
|
54
|
+
load_failure_result(mutation, e)
|
|
47
55
|
end
|
|
48
56
|
|
|
49
|
-
# Evaluates the mutation's restore_source
|
|
57
|
+
# Evaluates the mutation's restore_source: the original file. Runs after the
|
|
50
58
|
# tests: a failure here must not change the mutation's result, so it is
|
|
51
59
|
# reported and swallowed. Concern state is cleared first, as it is before
|
|
52
60
|
# applying: a concern ignores an `included` block handed to it twice from
|
|
53
61
|
# the same place, and would keep the mutated one.
|
|
54
62
|
def restore(mutation)
|
|
63
|
+
evaluate_original(mutation)
|
|
64
|
+
rescue ScriptError, StandardError => e
|
|
65
|
+
Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Whether there was an original to evaluate.
|
|
71
|
+
def evaluate_original(mutation)
|
|
55
72
|
source = mutation.respond_to?(:restore_source) ? mutation.restore_source : nil
|
|
56
|
-
return unless source
|
|
73
|
+
return false unless source
|
|
57
74
|
|
|
58
75
|
@concern_state_cleaner.call(mutation.file_path)
|
|
59
76
|
@source_evaluator.call(source, mutation.file_path)
|
|
60
|
-
|
|
61
|
-
Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
|
|
77
|
+
true
|
|
62
78
|
end
|
|
63
79
|
|
|
64
|
-
|
|
80
|
+
def original_loads?(mutation)
|
|
81
|
+
evaluate_original(mutation)
|
|
82
|
+
rescue ScriptError, StandardError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_failure_result(mutation, error)
|
|
87
|
+
message = "#{error.class}: #{error.message}"
|
|
88
|
+
return failure_result(error, message) unless original_loads?(mutation)
|
|
89
|
+
|
|
90
|
+
failure_result(error, "load failed: #{message}").merge(load_crashed: true)
|
|
91
|
+
end
|
|
65
92
|
|
|
66
93
|
def resolve_eval_target(mutation)
|
|
67
94
|
return mutation.eval_source if mutation.respond_to?(:eval_source)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../loading"
|
|
4
|
+
|
|
5
|
+
# The methods a class defined itself, held while a concern's hook block is run
|
|
6
|
+
# on it a second time.
|
|
7
|
+
#
|
|
8
|
+
# The first time, the block ran when the class took the concern, and whatever
|
|
9
|
+
# the class went on to define under the same name replaced the block's method.
|
|
10
|
+
# Run again, the block would put its own back in that place: code that is dead
|
|
11
|
+
# for the class would come alive, and its tests would fail whatever the mutant.
|
|
12
|
+
#
|
|
13
|
+
# A method counts as the class's own when it was not written in the block's
|
|
14
|
+
# file. One that is found written there afterwards was replaced by the block,
|
|
15
|
+
# and is put back. A class that defines such a method in the concern's own file
|
|
16
|
+
# cannot be told apart and is not protected.
|
|
17
|
+
class Evilution::Integration::Loading::OwnMethods
|
|
18
|
+
# Called through UnboundMethods: a class may define any of these for its
|
|
19
|
+
# own purposes.
|
|
20
|
+
PUBLIC = Module.instance_method(:public_instance_methods)
|
|
21
|
+
PROTECTED = Module.instance_method(:protected_instance_methods)
|
|
22
|
+
PRIVATE = Module.instance_method(:private_instance_methods)
|
|
23
|
+
LOOKUP = Module.instance_method(:instance_method)
|
|
24
|
+
DEFINED = Module.instance_method(:method_defined?)
|
|
25
|
+
PRIVATELY_DEFINED = Module.instance_method(:private_method_defined?)
|
|
26
|
+
DEFINE = Module.instance_method(:define_method)
|
|
27
|
+
SINGLETON_CLASS = Kernel.instance_method(:singleton_class)
|
|
28
|
+
VISIBILITIES = { public: PUBLIC, protected: PROTECTED, private: PRIVATE }.freeze
|
|
29
|
+
SETTERS = VISIBILITIES.keys.to_h { |visibility| [visibility, Module.instance_method(visibility)] }.freeze
|
|
30
|
+
private_constant :PUBLIC, :PROTECTED, :PRIVATE, :LOOKUP, :DEFINED, :PRIVATELY_DEFINED, :DEFINE, :SINGLETON_CLASS,
|
|
31
|
+
:VISIBILITIES, :SETTERS
|
|
32
|
+
|
|
33
|
+
Kept = Struct.new(:owner, :name, :definition, :visibility)
|
|
34
|
+
private_constant :Kept
|
|
35
|
+
|
|
36
|
+
# Runs the block, then puts back the methods of `klass` that the code of
|
|
37
|
+
# `file` replaced meanwhile -- also when the block raises.
|
|
38
|
+
def self.keeping(klass, file)
|
|
39
|
+
own = new(klass, file)
|
|
40
|
+
begin
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
own.put_back
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(klass, file)
|
|
48
|
+
@file = file
|
|
49
|
+
@kept = [klass, SINGLETON_CLASS.bind_call(klass)].flat_map { |owner| own_methods_of(owner) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def put_back
|
|
53
|
+
@kept.each { |kept| restore(kept) if replaced?(kept) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def own_methods_of(owner)
|
|
59
|
+
VISIBILITIES.flat_map do |visibility, list|
|
|
60
|
+
list.bind_call(owner, false).filter_map do |name|
|
|
61
|
+
definition = LOOKUP.bind_call(owner, name)
|
|
62
|
+
Kept.new(owner, name, definition, visibility) unless written_in_file?(definition)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def written_in_file?(definition)
|
|
68
|
+
location = definition.source_location
|
|
69
|
+
!location.nil? && location.first == @file
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A method the block removed stays removed.
|
|
73
|
+
def replaced?(kept)
|
|
74
|
+
return false unless defined_on?(kept.owner, kept.name)
|
|
75
|
+
|
|
76
|
+
written_in_file?(LOOKUP.bind_call(kept.owner, kept.name))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def defined_on?(owner, name)
|
|
80
|
+
DEFINED.bind_call(owner, name) || PRIVATELY_DEFINED.bind_call(owner, name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def restore(kept)
|
|
84
|
+
DEFINE.bind_call(kept.owner, kept.name, kept.definition)
|
|
85
|
+
SETTERS.fetch(kept.visibility).bind_call(kept.owner, kept.name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -24,6 +24,7 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
24
24
|
|
|
25
25
|
def initialize(constant_names: Evilution::AST::ConstantNames.new)
|
|
26
26
|
@constant_names = constant_names
|
|
27
|
+
@warned_messages = Set.new
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def call(source, &block)
|
|
@@ -73,10 +74,8 @@ class Evilution::Integration::Loading::RedefinitionRecovery
|
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
def warn_once_for(error)
|
|
76
|
-
return
|
|
77
|
+
return unless @warned_messages.add?(error.message)
|
|
77
78
|
|
|
78
|
-
@warned_messages ||= []
|
|
79
|
-
@warned_messages << error.message
|
|
80
79
|
$stderr.write(
|
|
81
80
|
"[evilution] swallowed idempotency violation on re-eval: " \
|
|
82
81
|
"#{error.class}: #{error.message}. " \
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../loading"
|
|
4
4
|
require_relative "reeval_warning_filter"
|
|
5
|
+
require_relative "superseded_modules"
|
|
5
6
|
|
|
6
7
|
# Evaluate source with __FILE__ set to the absolute original path so that
|
|
7
8
|
# `require_relative` and `__dir__` resolve against the real source tree, where
|
|
@@ -13,13 +14,19 @@ require_relative "reeval_warning_filter"
|
|
|
13
14
|
# between this eval path and a plain `require` of the same file is that we
|
|
14
15
|
# substitute the mutated bytes — the privilege level is identical.
|
|
15
16
|
class Evilution::Integration::Loading::SourceEvaluator
|
|
17
|
+
def initialize(superseded_modules: Evilution::Integration::Loading::SupersededModules.new)
|
|
18
|
+
@superseded_modules = superseded_modules
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
def call(source, file_path)
|
|
17
22
|
# When the isolator has chdir'd into a per-mutation sandbox (EV-wqxu /
|
|
18
23
|
# GH #1278), anchor the eval __FILE__ against PROJECT_ROOT so siblings
|
|
19
24
|
# `require_relative` can find each other from the real source tree.
|
|
20
25
|
absolute = File.expand_path(file_path, Evilution.project_base_dir)
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
@superseded_modules.track(absolute) do
|
|
27
|
+
Evilution::Integration::Loading::ReevalWarningFilter.suppress do
|
|
28
|
+
eval(source, TOPLEVEL_BINDING, absolute, 1)
|
|
29
|
+
end
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
end
|