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.
- checksums.yaml +4 -4
- data/.beads/interactions.jsonl +71 -0
- data/CHANGELOG.md +60 -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/cache.rb +13 -3
- 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,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
return "child exited #{status.exitstatus}
|
|
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)}
|
|
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:
|
|
75
|
-
error_class:
|
|
76
|
-
error_backtrace: Array(
|
|
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
|
|
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
|
|
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
|
},
|
data/lib/evilution/mutation.rb
CHANGED
|
@@ -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
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
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
|
|
38
|
+
@sources.original
|
|
32
39
|
end
|
|
33
40
|
|
|
34
41
|
def mutated_source
|
|
35
|
-
@sources
|
|
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
|
|
56
|
+
@slice.original
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
def mutated_slice
|
|
53
|
-
@slice
|
|
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 =
|
|
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
|
|
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
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
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
|
|
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
|