evilution 1.4.0 → 1.5.1

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +71 -0
  3. data/CHANGELOG.md +60 -0
  4. data/README.md +64 -22
  5. data/docs/architecture.md +56 -12
  6. data/docs/isolation.md +82 -0
  7. data/lib/evilution/ast/class_body.rb +101 -0
  8. data/lib/evilution/ast/included_block.rb +20 -6
  9. data/lib/evilution/ast/never_nil.rb +36 -0
  10. data/lib/evilution/ast/parser.rb +94 -31
  11. data/lib/evilution/ast/round_half_mode.rb +38 -0
  12. data/lib/evilution/ast/source_surgeon.rb +2 -3
  13. data/lib/evilution/ast/syntax_check.rb +56 -0
  14. data/lib/evilution/ast/uncovered_code.rb +7 -7
  15. data/lib/evilution/cache.rb +13 -3
  16. data/lib/evilution/cli/exit_guard.rb +26 -4
  17. data/lib/evilution/integration/base.rb +10 -2
  18. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  19. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  20. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  21. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  22. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  23. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  24. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  25. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  26. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  27. data/lib/evilution/isolation/fork.rb +82 -10
  28. data/lib/evilution/isolation/in_process.rb +79 -4
  29. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  30. data/lib/evilution/mcp/info_tool.rb +3 -2
  31. data/lib/evilution/mutation.rb +18 -11
  32. data/lib/evilution/mutator/base.rb +27 -7
  33. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  34. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  35. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  36. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  37. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  38. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  39. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  40. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  41. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  42. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  43. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  44. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  45. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  46. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  47. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  48. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  49. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  50. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  51. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  52. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  53. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  54. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  55. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  56. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  57. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  58. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  59. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  60. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  61. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  62. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  63. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  64. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  66. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  67. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  68. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  69. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  70. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  71. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  72. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  73. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  74. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  75. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  76. data/lib/evilution/mutator/primitives.rb +24 -2
  77. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  78. data/lib/evilution/mutator/registry.rb +29 -2
  79. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  80. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  81. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  82. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  83. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  84. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  85. data/lib/evilution/parallel/work_queue.rb +15 -9
  86. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  87. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  88. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  89. data/lib/evilution/runner.rb +1 -1
  90. data/lib/evilution/subject.rb +18 -1
  91. data/lib/evilution/version.rb +1 -1
  92. data/lib/evilution.rb +62 -0
  93. 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 -- `scope` lambdas, callback macros, constant lists -- or in a
10
- # concern's `included` block, which is a class body written elsewhere. Mutations are
11
- # generated per subject, so a run aimed at these lines generates none of its
12
- # own, and saying so is the only way a reader can tell "nothing to mutate"
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
- included = Evilution::AST::IncludedBlock.of(node)
59
- return statements(included.body) if included
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]
@@ -12,7 +12,7 @@ class Evilution::Cache
12
12
  end
13
13
 
14
14
  def fetch(mutation)
15
- return nil if mutation.original_source.nil?
15
+ return nil if stripped?(mutation)
16
16
 
17
17
  file_key = file_key(mutation)
18
18
  entry_key = entry_key(mutation)
@@ -27,7 +27,7 @@ class Evilution::Cache
27
27
  end
28
28
 
29
29
  def store(mutation, result_data)
30
- return if mutation.original_source.nil?
30
+ return if stripped?(mutation)
31
31
 
32
32
  file_key = file_key(mutation)
33
33
  entry_key = entry_key(mutation)
@@ -49,13 +49,23 @@ class Evilution::Cache
49
49
 
50
50
  private
51
51
 
52
+ # Both keys digest a source, so a mutation missing either one cannot be keyed.
53
+ def stripped?(mutation)
54
+ mutation.original_source.nil? || mutation.mutated_source.nil?
55
+ end
56
+
52
57
  def file_key(mutation)
53
58
  content_hash = Digest::SHA256.hexdigest(mutation.original_source)
54
59
  "#{safe_filename(mutation.file_path)}_#{content_hash[0, 16]}"
55
60
  end
56
61
 
62
+ # Several operators emit more than one mutation at the same location (e.g.
63
+ # nil_replacement -> true / false / 0), so the location alone would let a
64
+ # killed sibling answer for a survivor. The original source is fixed per
65
+ # cache file, so a digest of the mutated source identifies the mutation.
57
66
  def entry_key(mutation)
58
- "#{mutation.operator_name}:#{mutation.line}:#{mutation.column}"
67
+ mutated_digest = Digest::SHA256.hexdigest(mutation.mutated_source)
68
+ "#{mutation.operator_name}:#{mutation.line}:#{mutation.column}:#{mutated_digest[0, 16]}"
59
69
  end
60
70
 
61
71
  def safe_filename(path)
@@ -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(@status)
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
- # 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
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
- else
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
- block = Evilution::AST::IncludedBlock.of(stmt)
219
+ redeclare_hook(stmt, Evilution::AST::IncludedBlock.of(stmt), REDECLARE)
220
+ redeclare_hook(stmt, Evilution::AST::IncludedBlock.prepended(stmt), REDECLARE_PREPENDED)
221
+ end
222
+
223
+ def redeclare_hook(stmt, block, redeclare)
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, REDECLARE]
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.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]
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. See BodyCallNeutralizer.
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
- INCLUDED_BLOCK = :@_included_block
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
- private_constant :KEY, :INCLUDED_BLOCK, :INCLUDES, :SUPERCLASS, :SINGLETON
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
- def self.call(concern)
35
- return unless concern.instance_variable_defined?(INCLUDED_BLOCK)
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(INCLUDED_BLOCK)
38
- includers(concern).each { |klass| redeclare(klass, block) }
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 include the concern themselves; a subclass inherits what
42
- # its parent declares.
43
- def self.includers(concern)
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? || !INCLUDES.bind_call(parent, concern)
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
- klass.class_eval(&block)
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 :includers, :redeclare
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
- block_file = block.source_location&.first
62
- next unless block_file
61
+ location = block.source_location
62
+ next if location.nil?
63
63
 
64
- expanded = File.expand_path(block_file)
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
- failure_result(e, "#{e.class}: #{e.message}")
54
+ load_failure_result(mutation, e)
47
55
  end
48
56
 
49
- # Evaluates the mutation's restore_source, if it has one. Runs after the
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
- rescue ScriptError, StandardError => e
61
- Evilution::Diagnostic.warn("[evilution] could not restore #{mutation.file_path}: #{e.class}: #{e.message}")
77
+ true
62
78
  end
63
79
 
64
- private
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 if @warned_messages&.include?(error.message)
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
- Evilution::Integration::Loading::ReevalWarningFilter.suppress do
22
- eval(source, TOPLEVEL_BINDING, absolute, 1)
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