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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.beads/interactions.jsonl +70 -0
  3. data/CHANGELOG.md +50 -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/cli/exit_guard.rb +26 -4
  16. data/lib/evilution/integration/base.rb +10 -2
  17. data/lib/evilution/integration/loading/body_call_neutralizer.rb +59 -14
  18. data/lib/evilution/integration/loading/concern_redeclaration.rb +33 -14
  19. data/lib/evilution/integration/loading/concern_state_cleaner.rb +3 -3
  20. data/lib/evilution/integration/loading/mutation_applier.rb +33 -6
  21. data/lib/evilution/integration/loading/own_methods.rb +87 -0
  22. data/lib/evilution/integration/loading/redefinition_recovery.rb +2 -3
  23. data/lib/evilution/integration/loading/source_evaluator.rb +9 -2
  24. data/lib/evilution/integration/loading/superseded_modules.rb +103 -0
  25. data/lib/evilution/integration/loading/syntax_validator.rb +2 -2
  26. data/lib/evilution/isolation/fork.rb +82 -10
  27. data/lib/evilution/isolation/in_process.rb +79 -4
  28. data/lib/evilution/mcp/info_tool/status_glossary.rb +13 -2
  29. data/lib/evilution/mcp/info_tool.rb +3 -2
  30. data/lib/evilution/mutation.rb +18 -11
  31. data/lib/evilution/mutator/base.rb +27 -7
  32. data/lib/evilution/mutator/constant_value_subjects.rb +13 -0
  33. data/lib/evilution/mutator/operator/alias_removal.rb +11 -81
  34. data/lib/evilution/mutator/operator/argument_nil_substitution.rb +2 -2
  35. data/lib/evilution/mutator/operator/argument_removal.rb +2 -2
  36. data/lib/evilution/mutator/operator/array_literal.rb +29 -0
  37. data/lib/evilution/mutator/operator/attr_accessor_swap.rb +68 -0
  38. data/lib/evilution/mutator/operator/boolean_literal_replacement.rb +2 -0
  39. data/lib/evilution/mutator/operator/call_to_nil.rb +12 -3
  40. data/lib/evilution/mutator/operator/complex_literal.rb +39 -0
  41. data/lib/evilution/mutator/operator/compound_assignment.rb +47 -0
  42. data/lib/evilution/mutator/operator/constant_namespace_strip.rb +57 -0
  43. data/lib/evilution/mutator/operator/constant_read_to_nil.rb +58 -0
  44. data/lib/evilution/mutator/operator/constant_write_to_nil.rb +32 -0
  45. data/lib/evilution/mutator/operator/data_struct_member.rb +1 -1
  46. data/lib/evilution/mutator/operator/explicit_super_mutation.rb +2 -2
  47. data/lib/evilution/mutator/operator/float_literal.rb +54 -19
  48. data/lib/evilution/mutator/operator/freeze_removal.rb +86 -0
  49. data/lib/evilution/mutator/operator/hash_literal.rb +51 -0
  50. data/lib/evilution/mutator/operator/index_range_to_drop.rb +67 -0
  51. data/lib/evilution/mutator/operator/index_receiver_to_self.rb +20 -0
  52. data/lib/evilution/mutator/operator/index_to_key_predicate.rb +58 -0
  53. data/lib/evilution/mutator/operator/index_write_to_value.rb +37 -0
  54. data/lib/evilution/mutator/operator/integer_literal.rb +39 -9
  55. data/lib/evilution/mutator/operator/memoization_break.rb +18 -0
  56. data/lib/evilution/mutator/operator/mixin_removal.rb +18 -55
  57. data/lib/evilution/mutator/operator/nil_replacement.rb +2 -0
  58. data/lib/evilution/mutator/operator/operator_write_receiver_promotion.rb +38 -0
  59. data/lib/evilution/mutator/operator/range_replacement.rb +2 -0
  60. data/lib/evilution/mutator/operator/rational_literal.rb +40 -0
  61. data/lib/evilution/mutator/operator/reduce_to_sum.rb +1 -1
  62. data/lib/evilution/mutator/operator/rescue_class_widening.rb +54 -0
  63. data/lib/evilution/mutator/operator/round_half_mode_swap.rb +36 -0
  64. data/lib/evilution/mutator/operator/safe_navigation_insertion.rb +57 -0
  65. data/lib/evilution/mutator/operator/safe_navigation_removal.rb +2 -24
  66. data/lib/evilution/mutator/operator/short_circuit_to_eager.rb +34 -0
  67. data/lib/evilution/mutator/operator/sleep_to_zero.rb +43 -0
  68. data/lib/evilution/mutator/operator/splat_operator.rb +16 -24
  69. data/lib/evilution/mutator/operator/string_literal.rb +29 -0
  70. data/lib/evilution/mutator/operator/superclass_removal.rb +14 -40
  71. data/lib/evilution/mutator/operator/symbol_literal.rb +47 -2
  72. data/lib/evilution/mutator/operator/to_i_to_integer.rb +1 -1
  73. data/lib/evilution/mutator/operator/variable_read_to_nil.rb +113 -0
  74. data/lib/evilution/mutator/operator/visibility_removal.rb +74 -0
  75. data/lib/evilution/mutator/primitives.rb +24 -2
  76. data/lib/evilution/mutator/receiver_positions.rb +79 -0
  77. data/lib/evilution/mutator/registry.rb +29 -2
  78. data/lib/evilution/parallel/work_queue/channel/frame.rb +6 -0
  79. data/lib/evilution/parallel/work_queue/channel.rb +58 -5
  80. data/lib/evilution/parallel/work_queue/dispatcher/reply.rb +41 -0
  81. data/lib/evilution/parallel/work_queue/dispatcher.rb +14 -1
  82. data/lib/evilution/parallel/work_queue/worker/loop.rb +5 -0
  83. data/lib/evilution/parallel/work_queue/worker.rb +13 -4
  84. data/lib/evilution/parallel/work_queue.rb +15 -9
  85. data/lib/evilution/runner/mutation_executor/strategy/parallel.rb +10 -0
  86. data/lib/evilution/runner/subject_pipeline/target.rb +86 -0
  87. data/lib/evilution/runner/subject_pipeline.rb +20 -51
  88. data/lib/evilution/runner.rb +1 -1
  89. data/lib/evilution/subject.rb +18 -1
  90. data/lib/evilution/version.rb +1 -1
  91. data/lib/evilution.rb +62 -0
  92. metadata +32 -2
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../loading"
4
+
5
+ # Empties the anonymous modules an earlier evaluation of a file left behind.
6
+ #
7
+ # A file can install a module it never names:
8
+ #
9
+ # handler = Module.new { def const_missing(name) = ... }
10
+ # Object.extend(handler)
11
+ #
12
+ # Evaluating the file again builds a new module and extends with it again, and
13
+ # Ruby has no way to take the old one off. Under in-process isolation the file
14
+ # is evaluated for every mutation and every restore, so the modules pile up on
15
+ # their host, and a `super` in the mutation under test runs the code of every
16
+ # mutation before it. Its tests then fail on a predecessor's bug, and the
17
+ # mutation is reported killed. Fork isolation never sees this: a child holds
18
+ # one mutation.
19
+ #
20
+ # A module cannot be taken off, but its methods can be removed, and method
21
+ # lookup passes through a module that has none. So the modules created while a
22
+ # file is evaluated are remembered, and emptied when the same file is evaluated
23
+ # next.
24
+ #
25
+ # Two limits keep this to what the new evaluation really replaces. Only
26
+ # modules are emptied, not anonymous classes, whose instances may be held
27
+ # elsewhere. And only methods written in the file itself are removed: a module
28
+ # some library built on the file's behalf holds methods written in the
29
+ # library, which evaluating the file again does not define anew.
30
+ class Evilution::Integration::Loading::SupersededModules
31
+ @registry = {}
32
+
33
+ class << self
34
+ # Created modules by file, for the process: the evaluations of one file
35
+ # are not all made through the same instance.
36
+ attr_reader :registry
37
+ end
38
+
39
+ MODULE_NAME = Module.instance_method(:name)
40
+ INSTANCE_OF = Kernel.instance_method(:instance_of?)
41
+ private_constant :MODULE_NAME, :INSTANCE_OF
42
+
43
+ def initialize(registry: self.class.registry)
44
+ @registry = registry
45
+ end
46
+
47
+ # Evaluates the block as an evaluation of `file_path`. The modules of the
48
+ # evaluation before are emptied first, so that the new one starts from what
49
+ # a fresh process would hold; the modules this one creates are remembered
50
+ # whether or not it completes.
51
+ #
52
+ # A fork child holds one mutation and ends with it, so there is nothing for
53
+ # it to empty later, and walking the heap of a process just forked is dear:
54
+ # every page touched has to be mapped first. It evaluates and no more.
55
+ def track(file_path)
56
+ return yield if Evilution.single_mutation_process?
57
+
58
+ retire(file_path)
59
+ known = anonymous_modules
60
+ yield
61
+ ensure
62
+ remember(file_path, known) if known
63
+ end
64
+
65
+ private
66
+
67
+ # Nothing is kept for a file whose evaluation created none, which is most
68
+ # files: an entry is only worth carrying when there is a module to empty.
69
+ def remember(file_path, known)
70
+ created = anonymous_modules.reject { |mod| known.key?(mod) }.keys
71
+ @registry[file_path] = created unless created.empty?
72
+ end
73
+
74
+ def retire(file_path)
75
+ Array(@registry.delete(file_path)).each { |mod| empty(mod, file_path) }
76
+ end
77
+
78
+ def empty(mod, file_path)
79
+ return if mod.frozen?
80
+
81
+ own = mod.instance_methods(false) + mod.private_instance_methods(false)
82
+ own.each { |name| mod.send(:remove_method, name) if written_in?(mod.instance_method(name), file_path) }
83
+ end
84
+
85
+ def written_in?(method, file_path)
86
+ location = method.source_location
87
+ !location.nil? && location.first == file_path
88
+ end
89
+
90
+ # No message is sent to the modules walked. ObjectSpace yields everything
91
+ # that is a Module, and some of it answers messages in its own way: a class
92
+ # may define `name`, `hash` or `==` for itself, and ActiveSupport's
93
+ # DeprecatedConstantProxy, a Module subclass, turns any message into a
94
+ # deprecation, which a project may have set to raise. Hence the identity
95
+ # keys and the methods bound from Module and Kernel.
96
+ def anonymous_modules
97
+ found = {}.compare_by_identity
98
+ ObjectSpace.each_object(Module) do |mod|
99
+ found[mod] = true if INSTANCE_OF.bind_call(mod, Module) && MODULE_NAME.bind_call(mod).nil?
100
+ end
101
+ found
102
+ end
103
+ end
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "prism"
4
3
  require_relative "../loading"
4
+ require_relative "../../ast/syntax_check"
5
5
 
6
6
  class Evilution::Integration::Loading::SyntaxValidator
7
7
  ERROR_MESSAGE = "mutated source has syntax errors"
8
8
 
9
9
  def call(source)
10
- return nil if Prism.parse(source).success?
10
+ return nil if Evilution::AST::SyntaxCheck.valid?(source)
11
11
 
12
12
  {
13
13
  passed: false,
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "fileutils"
4
5
  require "tmpdir"
5
6
  require_relative "../memory"
@@ -55,8 +56,23 @@ class Evilution::Isolation::Fork
55
56
  # group and the TERM/KILL ladder sweeps the whole subtree on timeout (EV-2sh8
56
57
  # / GH #1330). The block keeps the marshal-pipe protocol: write a
57
58
  # length-prefixed payload, then exit with the pass/fail code.
59
+ #
60
+ # The at_exit hook covers a child that never gets that far. The normal path
61
+ # ends in `exit!`, which skips it, so it runs only when the child is dying.
62
+ #
63
+ # Both reports go through method objects taken before the test command runs.
64
+ # When the mutation under test is one of this file, the child evaluates the
65
+ # mutated source and the methods here are replaced; a method object keeps the
66
+ # definition the child started with, so the result is not written by the
67
+ # mutant it describes.
58
68
  def spawn_child(read_io, write_io, sandbox_dir, mutation, test_command)
59
69
  @supervisor.spawn(sandbox_dir: sandbox_dir) do
70
+ write = method(:write_result)
71
+ report_death = method(:report_death)
72
+ child_pid = Process.pid
73
+ # A process the tests fork inherits this hook and the pipe with it; its
74
+ # death is not the child's to report.
75
+ at_exit { report_death.call(write_io, $ERROR_INFO, write) if Process.pid == child_pid }
60
76
  ENV["TMPDIR"] = sandbox_dir
61
77
  # Path-relativizing mutations (e.g. File.join(dir, name) -> name) would
62
78
  # otherwise write into the parent's CWD (typically the repo root) and
@@ -67,18 +83,54 @@ class Evilution::Isolation::Fork
67
83
  # paths to Evilution::PROJECT_ROOT instead of the sandbox CWD.
68
84
  Dir.chdir(sandbox_dir)
69
85
  Evilution.in_isolated_worker!
86
+ Evilution.single_mutation_process!
70
87
  read_io.close
71
88
  suppress_child_output
72
89
  @hooks.fire(:worker_process_start, mutation:) if @hooks
73
90
  result = execute_in_child(mutation, test_command)
74
- payload = Marshal.dump(result)
75
- write_io.write([payload.bytesize].pack("N"))
76
- write_io.write(payload)
77
- write_io.close
91
+ write.call(write_io, result)
78
92
  exit!(result[:passed] ? 0 : 1)
79
93
  end
80
94
  end
81
95
 
96
+ def write_result(write_io, result)
97
+ payload = Marshal.dump(result)
98
+ write_io.write([payload.bytesize].pack("N"))
99
+ write_io.write(payload)
100
+ write_io.close
101
+ end
102
+
103
+ # Some exceptions reach no `rescue` in the child. `execute_in_child` leaves
104
+ # everything that is not a ScriptError or StandardError alone, and Ruby may
105
+ # run neither `rescue` nor `ensure` when the stack overflows through
106
+ # `const_missing`, which a mutation of a process-wide `const_missing` does.
107
+ # Without a result the parent sees only an exit status.
108
+ #
109
+ # A child that exits on purpose or on a signal keeps its status as the
110
+ # report: the first is its own decision, the second is usually the parent's.
111
+ def report_death(write_io, error, write)
112
+ return if error.nil? || write_io.closed?
113
+ return if error.is_a?(SystemExit) || error.is_a?(SignalException)
114
+
115
+ write.call(write_io, death_result(error))
116
+ end
117
+
118
+ # Dying while the tests run, after the mutated source loaded, is what a test
119
+ # that crashes does, and counts the same. Dying before that says nothing
120
+ # about the tests.
121
+ def death_result(error)
122
+ testing = Evilution.died_running_tests?(error)
123
+ phase = testing ? "while running the tests" : "before the tests ran"
124
+ result = {
125
+ passed: false,
126
+ error: "child died #{phase}: #{error.class}: #{error.message}",
127
+ error_class: error.class.name,
128
+ error_backtrace: Array(error.backtrace).first(5)
129
+ }
130
+ result[:test_crashed] = true if testing
131
+ result
132
+ end
133
+
82
134
  # The parent owns read_io/write_io (write_io is closed right after spawn so
83
135
  # read_io can see EOF), so they are closed here rather than handed to the
84
136
  # supervisor. The supervisor reaps the child and removes the sandbox dir; on
@@ -203,28 +255,48 @@ class Evilution::Isolation::Fork
203
255
  buf
204
256
  end
205
257
 
258
+ # What arrives is not always what the child wrote: every process the child
259
+ # forks inherits the write end of the pipe. A result that does not load, or
260
+ # loads to something other than a result, costs this mutation its verdict
261
+ # and nothing more.
206
262
  def decode_payload(data, handle = nil, terminated: false)
207
263
  return empty_result(handle, terminated: terminated) if data.nil? || data.empty?
208
264
 
209
- { timeout: false }.merge(Marshal.load(data))
265
+ result = Marshal.load(data)
266
+ return unreadable_result(handle, "expected a Hash, got #{result.class}", terminated: terminated) unless result.is_a?(Hash)
267
+
268
+ { timeout: false }.merge(result)
269
+ rescue TypeError, ArgumentError => e
270
+ unreadable_result(handle, "#{e.class}: #{e.message.gsub(/\s+/, " ")}", terminated: terminated)
210
271
  end
211
272
 
212
273
  def empty_result(handle, terminated: false)
213
274
  { timeout: false, passed: false, error: child_death_reason(handle, terminated: terminated) }
214
275
  end
215
276
 
277
+ def unreadable_result(handle, detail, terminated: false)
278
+ ending = child_ending(handle, terminated: terminated)
279
+ subject = ending.nil? ? "child's result" : "#{ending}; its result"
280
+ { timeout: false, passed: false, error: "#{subject} was unreadable (#{detail})" }
281
+ end
282
+
216
283
  # A child that wrote no payload has usually left its stdout/stderr capture
217
284
  # empty too, so its termination status is the only evidence of what happened.
218
285
  # Reporting it separates a segfault from an OOM kill from a clean early exit,
219
286
  # all of which reach this path identically.
220
287
  def child_death_reason(handle, terminated: false)
221
- return "child was terminated after the reap deadline without writing a result" if terminated
288
+ ending = child_ending(handle, terminated: terminated)
289
+ ending.nil? ? "empty result from child" : "#{ending} without writing a result"
290
+ end
291
+
292
+ def child_ending(handle, terminated: false)
293
+ return "child was terminated after the reap deadline" if terminated
222
294
 
223
295
  status = handle.nil? ? nil : handle.status
224
- return "empty result from child" unless status.is_a?(Process::Status)
225
- return "child exited #{status.exitstatus} without writing a result" unless status.signaled?
296
+ return nil unless status.is_a?(Process::Status)
297
+ return "child exited #{status.exitstatus}" unless status.signaled?
226
298
 
227
- "child died on #{signal_name(status.termsig)} without writing a result"
299
+ "child died on #{signal_name(status.termsig)}"
228
300
  end
229
301
 
230
302
  def signal_name(termsig)
@@ -239,7 +311,7 @@ class Evilution::Isolation::Fork
239
311
 
240
312
  def classify_status(result)
241
313
  return :timeout if result[:timeout]
242
- return :killed if result[:test_crashed]
314
+ return :killed if result[:test_crashed] || result[:load_crashed]
243
315
  return :unresolved if result[:unresolved]
244
316
  return :error if result[:error]
245
317
  return :survived if result[:passed]
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "fileutils"
4
5
  require "timeout"
5
6
  require "tmpdir"
@@ -27,12 +28,66 @@ class Evilution::Isolation::InProcess
27
28
 
28
29
  @null_out = File.open(File::NULL, "w")
29
30
  @null_err = File.open(File::NULL, "w")
31
+ @died = false
30
32
 
31
33
  class << self
32
34
  attr_reader :null_out, :null_err
35
+
36
+ # The mutation this process is testing, remembered so that a process dying
37
+ # in the middle of it can say which one it was. Ruby runs no `ensure` on
38
+ # some deaths (a stack overflow through `const_missing`, under the usual
39
+ # stack limit), so the mutation is still on record when the at_exit hook
40
+ # asks. The process is recorded with it: a process the tests fork inherits
41
+ # both the record and the hook, and its death is not this run's.
42
+ #
43
+ # The error stream is recorded as well, as it is before the mutation is
44
+ # tested: `$stderr` is the null stream while it is, and on such a death
45
+ # nothing puts it back.
46
+ def watch(mutation)
47
+ @watched = [Process.pid, mutation, $stderr]
48
+ return if @death_hook_installed
49
+
50
+ @death_hook_installed = true
51
+ at_exit { report_death($ERROR_INFO) }
52
+ end
53
+
54
+ def unwatch
55
+ @watched = nil
56
+ @died = false
57
+ end
58
+
59
+ def watched
60
+ pid, mutation, = @watched
61
+ mutation if pid == Process.pid
62
+ end
63
+
64
+ def report_death(error, stream: nil)
65
+ mutation = watched
66
+ return if mutation.nil? || error.nil?
67
+ return if error.is_a?(SystemExit) || error.is_a?(SignalException)
68
+
69
+ stream ||= @watched.last
70
+ stream.puts(
71
+ "[evilution] the run died while testing a mutation under in-process isolation: " \
72
+ "#{mutation} (#{error.class}: #{error.message}). " \
73
+ "In-process isolation cannot contain a mutation that takes the Ruby process down; " \
74
+ "re-run with --isolation fork."
75
+ )
76
+ stream.flush
77
+ @died = true
78
+ end
79
+
80
+ # Whether this process is ending on a death that was reported. Such a run
81
+ # measured nothing, which whoever decides the process status has to know:
82
+ # the hook that reports the death runs before the project's own, and an
83
+ # exit from there would skip them.
84
+ def died?
85
+ @died
86
+ end
33
87
  end
34
88
 
35
89
  def call(mutation:, test_command:, timeout:)
90
+ self.class.watch(mutation)
36
91
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
92
  rss_before = Evilution::Memory.rss_kb
38
93
  sandbox_dir = Dir.mktmpdir("evilution-run")
@@ -43,6 +98,7 @@ class Evilution::Isolation::InProcess
43
98
 
44
99
  build_mutation_result(mutation, result, duration, rss_before, rss_after, delta)
45
100
  ensure
101
+ self.class.unwatch
46
102
  FileUtils.rm_rf(sandbox_dir) if sandbox_dir
47
103
  end
48
104
 
@@ -68,12 +124,31 @@ class Evilution::Isolation::InProcess
68
124
  rescue Expired
69
125
  { timeout: true }
70
126
  rescue ScriptError, StandardError => e
127
+ failure_result(e, e.message)
128
+ rescue SignalException, SystemExit
129
+ raise
130
+ rescue Exception => e # rubocop:disable Lint/RescueException -- turned into this mutation's result
131
+ crash_result(e)
132
+ end
133
+
134
+ # An exception outside StandardError that no test framework held back: a
135
+ # stack overflow that unwound, most likely. Left alone it would end the run.
136
+ # Leaving the test run that way, after the mutated source loaded, is what a
137
+ # test that crashes does, and counts the same.
138
+ def crash_result(error)
139
+ message = "#{error.class}: #{error.message}"
140
+ return failure_result(error, message) unless Evilution.died_running_tests?(error)
141
+
142
+ failure_result(error, "test run ended by #{message}").merge(test_crashed: true)
143
+ end
144
+
145
+ def failure_result(error, message)
71
146
  {
72
147
  timeout: false,
73
148
  passed: false,
74
- error: e.message,
75
- error_class: e.class.name,
76
- error_backtrace: Array(e.backtrace).first(5)
149
+ error: message,
150
+ error_class: error.class.name,
151
+ error_backtrace: Array(error.backtrace).first(5)
77
152
  }
78
153
  end
79
154
 
@@ -97,7 +172,7 @@ class Evilution::Isolation::InProcess
97
172
 
98
173
  def classify_status(result)
99
174
  return :timeout if result[:timeout]
100
- return :killed if result[:test_crashed]
175
+ return :killed if result[:test_crashed] || result[:load_crashed]
101
176
  return :unresolved if result[:unresolved]
102
177
  return :error if result[:error]
103
178
  return :survived if result[:passed]
@@ -8,7 +8,11 @@ module Evilution::MCP::InfoTool::StatusGlossary
8
8
  {
9
9
  "status" => "killed",
10
10
  "meaning" => "A test failed when the mutation was applied — the test suite caught the mutation. " \
11
- "This is the desired outcome.",
11
+ "This is the desired outcome. Also a mutation that made its own file raise while loading, " \
12
+ "before any test ran, when the original file still loads (error_message starts with " \
13
+ "'load failed:'), and one whose test process died on an exception no handler saw while " \
14
+ "the tests ran, as a crashing test would (error_message starts with 'child died' under " \
15
+ "fork isolation, 'test run ended by' under in-process isolation).",
12
16
  "counted_in_score" => true
13
17
  },
14
18
  {
@@ -27,7 +31,14 @@ module Evilution::MCP::InfoTool::StatusGlossary
27
31
  {
28
32
  "status" => "error",
29
33
  "meaning" => "Mutation execution raised an unexpected error (syntax error at load time, " \
30
- "boot failure, test-infrastructure crash). The mutation could not be evaluated.",
34
+ "boot failure, test-infrastructure crash). The mutation could not be evaluated. " \
35
+ "Includes a test process that died on an unhandled exception before the tests ran " \
36
+ "('child died ... before the tests ran'; error_class is the exception's own class), and " \
37
+ "results that are no verdict on the mutation at all: a fork child's result that " \
38
+ "was unreadable, a worker's result that could not be read (the worker was replaced), " \
39
+ "a child that died on a signal without writing a result (SIGABRT: the Ruby VM aborted; " \
40
+ "SIGSEGV; SIGKILL, e.g. out of memory). Re-run that one mutation; if a VM abort repeats, " \
41
+ "the mutant crashes the interpreter.",
31
42
  "counted_in_score" => false
32
43
  },
33
44
  {
@@ -18,7 +18,8 @@ class Evilution::MCP::InfoTool < MCP::Tool
18
18
  tool_name "evilution-info"
19
19
  description "Discover what evilution sees before running any mutations. " \
20
20
  "One tool, five actions: " \
21
- "'subjects' lists every mutatable method in the target files with its file, line, and mutation count; " \
21
+ "'subjects' lists every mutation subject in the target files (methods, and code outside them: " \
22
+ "constant assignments, scopes, AASM and callback declarations) with its file, line, and mutation count; " \
22
23
  "'tests' resolves which spec/test files cover the given sources (so you pick the right --spec before mutating); " \
23
24
  "'environment' dumps the effective config (version, ruby, config file, timeout, " \
24
25
  "integration, isolation, and every other setting); " \
@@ -36,7 +37,7 @@ class Evilution::MCP::InfoTool < MCP::Tool
36
37
  type: "string",
37
38
  enum: VALID_ACTIONS,
38
39
  description: "Which discovery operation to perform. " \
39
- "'subjects' lists mutatable methods; 'tests' resolves specs for sources; " \
40
+ "'subjects' lists mutation subjects; 'tests' resolves specs for sources; " \
40
41
  "'environment' dumps effective config; 'statuses' returns the result-status glossary; " \
41
42
  "'feedback' returns the discussion URL and consent/privacy guidance."
42
43
  },
@@ -8,10 +8,17 @@ class Evilution::Mutation
8
8
  Slice = Data.define(:original, :mutated)
9
9
  Location = Data.define(:file_path, :line, :column)
10
10
 
11
- # restore_source: the source that puts back what applying this mutation
12
- # changed and re-evaluating another mutation of the file would not -- a
13
- # scope declaration (see Mutator::Base#build_restore_source). nil when
14
- # nothing needs restoring.
11
+ # What a mutation holds once its sources are released (see strip_sources!),
12
+ # and when it was built without a slice. Their fields read as nil, so the
13
+ # readers below need no nil check of their own.
14
+ STRIPPED_SOURCES = Sources.new(original: nil, mutated: nil)
15
+ NO_SLICE = Slice.new(original: nil, mutated: nil)
16
+ private_constant :STRIPPED_SOURCES, :NO_SLICE
17
+
18
+ # restore_source: the original file, evaluated after the mutation's tests
19
+ # to put back what applying it changed (see
20
+ # Mutator::Base#build_restore_source). nil when a mutation was built
21
+ # without one.
15
22
  attr_reader :subject, :operator_name, :parse_status, :location, :restore_source
16
23
 
17
24
  def initialize(subject:, operator_name:, sources:, location:,
@@ -20,7 +27,7 @@ class Evilution::Mutation
20
27
  @operator_name = operator_name
21
28
  @sources = sources
22
29
  @location = location
23
- @slice = slice
30
+ @slice = slice || NO_SLICE
24
31
  @parse_status = parse_status
25
32
  @eval_source = eval_source
26
33
  @restore_source = restore_source
@@ -28,11 +35,11 @@ class Evilution::Mutation
28
35
  end
29
36
 
30
37
  def original_source
31
- @sources&.original
38
+ @sources.original
32
39
  end
33
40
 
34
41
  def mutated_source
35
- @sources&.mutated
42
+ @sources.mutated
36
43
  end
37
44
 
38
45
  # Source to feed to the load-time evaluator. Defaults to mutated_source
@@ -46,11 +53,11 @@ class Evilution::Mutation
46
53
  end
47
54
 
48
55
  def original_slice
49
- @slice&.original
56
+ @slice.original
50
57
  end
51
58
 
52
59
  def mutated_slice
53
- @slice&.mutated
60
+ @slice.mutated
54
61
  end
55
62
 
56
63
  def file_path
@@ -81,7 +88,7 @@ class Evilution::Mutation
81
88
 
82
89
  def strip_sources!
83
90
  diff # ensure diff is cached before clearing sources
84
- @sources = nil
91
+ @sources = STRIPPED_SOURCES
85
92
  end
86
93
 
87
94
  def to_s
@@ -105,7 +112,7 @@ class Evilution::Mutation
105
112
  end
106
113
 
107
114
  def compute_unified_diff
108
- return nil if @slice.nil?
115
+ return nil if @slice == NO_SLICE
109
116
 
110
117
  original_lines = @slice.original.lines
111
118
  mutated_lines = @slice.mutated.lines
@@ -4,6 +4,8 @@ require "prism"
4
4
 
5
5
  require_relative "../mutator"
6
6
  require_relative "primitives"
7
+ require_relative "constant_value_subjects"
8
+ require_relative "receiver_positions"
7
9
  require_relative "../integration/loading/body_call_neutralizer"
8
10
  require_relative "../ast/heredoc_span"
9
11
 
@@ -122,13 +124,14 @@ class Evilution::Mutator::Base < Prism::Visitor
122
124
  @body_call_neutralizer.call(surgery.source, file_path: @subject.file_path, **redeclaration(offset))
123
125
  end
124
126
 
125
- # Re-evaluating any mutation of a file redefines all its methods, which is
126
- # how an in-process run puts a mutated method back. Class-body calls are
127
- # neutralized in that source, so a mutated scope or aasm callable would stay
128
- # installed; the original file with just that declaration kept replaces it
129
- # again.
127
+ # The original file, evaluated after a mutation's tests to put back what
128
+ # the mutation changed. Without it an in-process run would leave the last
129
+ # mutation of a file applied while the files after it are mutated, failing
130
+ # their tests for them. Its class-body calls are neutralized like the eval
131
+ # source's; a scope, aasm or callback subject keeps its own declaration, so
132
+ # the mutated callable is declared over again as written.
130
133
  def build_restore_source(offset)
131
- return nil unless redeclared_subject?
134
+ return self.class.original_restore_source(@subject.file_path, @file_source, @body_call_neutralizer) unless redeclared_subject?
132
135
 
133
136
  @body_call_neutralizer.call(@file_source, file_path: @subject.file_path, **redeclaration(offset))
134
137
  end
@@ -138,8 +141,10 @@ class Evilution::Mutator::Base < Prism::Visitor
138
141
  REDECLARED_KINDS = %i[scope aasm callback].freeze
139
142
  private_constant :REDECLARED_KINDS
140
143
 
144
+ # So is anything written in a concern's hook block, a method included: the
145
+ # block has to run again on the classes that took the concern already.
141
146
  def redeclared_subject?
142
- REDECLARED_KINDS.include?(@subject.kind)
147
+ REDECLARED_KINDS.include?(@subject.kind) || @subject.concern_hook?
143
148
  end
144
149
 
145
150
  # What the neutralizer needs to keep a redeclared subject's declaration:
@@ -213,7 +218,22 @@ class Evilution::Mutator::Base < Prism::Visitor
213
218
  tree
214
219
  end
215
220
 
221
+ @restore_cache = {}
222
+
223
+ # The restore source of a subject with no declaration of its own to keep is
224
+ # the same for every mutation of the file: neutralized once and shared.
225
+ def self.original_restore_source(file_path, file_source, neutralizer)
226
+ cache = Evilution::Mutator::Base.instance_variable_get(:@restore_cache)
227
+ entry = cache[file_path]
228
+ return entry[:source] if entry && entry[:source_hash] == file_source.hash
229
+
230
+ source = neutralizer.call(file_source, file_path: file_path).freeze
231
+ cache[file_path] = { source_hash: file_source.hash, source: source }
232
+ source
233
+ end
234
+
216
235
  def self.clear_parse_cache!
217
236
  Evilution::Mutator::Base.instance_variable_set(:@parse_cache, {})
237
+ Evilution::Mutator::Base.instance_variable_set(:@restore_cache, {})
218
238
  end
219
239
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../mutator"
4
+
5
+ # Extended by the operators that mutate a value wherever it is written, so
6
+ # that they also run on the value of a constant assignment: a literal in
7
+ # `LIMIT = 10` or in `DEFAULTS = { size: 1 }` is as much a literal as one in
8
+ # a method body.
9
+ module Evilution::Mutator::ConstantValueSubjects
10
+ def subject_kinds
11
+ super + %i[constant_write]
12
+ end
13
+ end