henitai 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +296 -1
- data/README.md +109 -16
- data/assets/schema/henitai.schema.json +20 -0
- data/lib/henitai/arid_node_filter.rb +4 -19
- data/lib/henitai/bootstrap_error_message.rb +20 -0
- data/lib/henitai/canonical_report_merger.rb +23 -2
- data/lib/henitai/cli/run_command.rb +25 -7
- data/lib/henitai/cli/run_options.rb +1 -1
- data/lib/henitai/cli.rb +4 -1
- data/lib/henitai/configuration.rb +4 -1
- data/lib/henitai/configuration_validator/rules.rb +8 -0
- data/lib/henitai/configuration_validator/scalars.rb +9 -0
- data/lib/henitai/configuration_validator.rb +2 -1
- data/lib/henitai/constant_path.rb +48 -0
- data/lib/henitai/control_run.rb +73 -0
- data/lib/henitai/coverage_bootstrapper.rb +57 -15
- data/lib/henitai/coverage_formatter.rb +26 -3
- data/lib/henitai/coverage_report_reader.rb +21 -0
- data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
- data/lib/henitai/equivalence_detector.rb +46 -38
- data/lib/henitai/example_summary.rb +24 -0
- data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
- data/lib/henitai/execution_engine.rb +75 -60
- data/lib/henitai/fidelity_check.rb +173 -0
- data/lib/henitai/flaky_detection_baseline.rb +61 -0
- data/lib/henitai/flaky_mutant_retrier.rb +43 -0
- data/lib/henitai/flaky_retry_decision.rb +23 -0
- data/lib/henitai/flaky_test_registry.rb +63 -0
- data/lib/henitai/fork_server/client.rb +231 -0
- data/lib/henitai/fork_server/group_killer.rb +48 -0
- data/lib/henitai/fork_server/group_terminator.rb +47 -0
- data/lib/henitai/fork_server/launcher.rb +38 -0
- data/lib/henitai/fork_server/mutant_runner.rb +141 -0
- data/lib/henitai/fork_server/protocol.rb +73 -0
- data/lib/henitai/fork_server/reaper.rb +24 -0
- data/lib/henitai/fork_server/runtime.rb +109 -0
- data/lib/henitai/fork_server/server.rb +108 -0
- data/lib/henitai/fork_server/server_process.rb +73 -0
- data/lib/henitai/fork_server/status.rb +52 -0
- data/lib/henitai/fork_server.rb +43 -0
- data/lib/henitai/generated_artifacts.rb +3 -0
- data/lib/henitai/git_diff_analyzer.rb +28 -11
- data/lib/henitai/incremental_filter.rb +20 -10
- data/lib/henitai/integration/base.rb +72 -5
- data/lib/henitai/integration/capped_log_sink.rb +34 -0
- data/lib/henitai/integration/child_bootstrap.rb +7 -0
- data/lib/henitai/integration/child_report_store.rb +36 -0
- data/lib/henitai/integration/minitest.rb +17 -7
- data/lib/henitai/integration/minitest_autorun.rb +20 -0
- data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
- data/lib/henitai/integration/minitest_preload.rb +30 -0
- data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
- data/lib/henitai/integration/minitest_test_runner.rb +32 -15
- data/lib/henitai/integration/mutant_run_support.rb +73 -13
- data/lib/henitai/integration/rspec_child_runner.rb +44 -9
- data/lib/henitai/integration/rspec_preload.rb +35 -0
- data/lib/henitai/integration/rspec_process_runner.rb +4 -1
- data/lib/henitai/integration/scenario_log_support.rb +31 -18
- data/lib/henitai/integration.rb +16 -3
- data/lib/henitai/minitest_coverage_reporter.rb +22 -6
- data/lib/henitai/minitest_result_file.rb +62 -0
- data/lib/henitai/mutant/activation.rb +32 -0
- data/lib/henitai/mutant/activator.rb +75 -55
- data/lib/henitai/mutant/def_injection.rb +136 -0
- data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
- data/lib/henitai/mutant/lexical_target.rb +105 -0
- data/lib/henitai/mutant/replacement.rb +52 -0
- data/lib/henitai/mutant.rb +27 -8
- data/lib/henitai/mutant_generator.rb +62 -18
- data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
- data/lib/henitai/mutant_history_store.rb +14 -4
- data/lib/henitai/mutant_identity.rb +5 -1
- data/lib/henitai/mutant_verdict.rb +71 -0
- data/lib/henitai/operators/array_declaration.rb +4 -0
- data/lib/henitai/operators/assignment_expression.rb +2 -0
- data/lib/henitai/operators/conditional_expression.rb +21 -1
- data/lib/henitai/operators/hash_literal.rb +7 -4
- data/lib/henitai/operators/method_expression.rb +14 -3
- data/lib/henitai/operators/regex_metacharacters.rb +89 -0
- data/lib/henitai/operators/regex_mutator.rb +23 -28
- data/lib/henitai/operators/return_value.rb +1 -1
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +11 -7
- data/lib/henitai/per_test_coverage_collector.rb +30 -31
- data/lib/henitai/per_test_coverage_delta.rb +60 -0
- data/lib/henitai/per_test_coverage_selector.rb +26 -13
- data/lib/henitai/process_wakeup.rb +8 -3
- data/lib/henitai/process_worker_runner.rb +19 -5
- data/lib/henitai/reporter.rb +15 -8
- data/lib/henitai/result.rb +32 -16
- data/lib/henitai/reuse_context.rb +50 -0
- data/lib/henitai/rspec_coverage_formatter.rb +4 -0
- data/lib/henitai/rspec_outcomes.rb +72 -0
- data/lib/henitai/runner.rb +58 -19
- data/lib/henitai/runner_dependencies.rb +22 -1
- data/lib/henitai/sampling_strategy.rb +36 -6
- data/lib/henitai/scenario_execution_result.rb +28 -5
- data/lib/henitai/signature_line.rb +21 -0
- data/lib/henitai/slot_scheduler/draining.rb +12 -2
- data/lib/henitai/slot_scheduler/process_control.rb +33 -0
- data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
- data/lib/henitai/slot_scheduler.rb +47 -8
- data/lib/henitai/source_file_selection.rb +1 -1
- data/lib/henitai/source_parser.rb +29 -9
- data/lib/henitai/static_filter.rb +45 -14
- data/lib/henitai/stillborn_filter.rb +70 -2
- data/lib/henitai/subject.rb +8 -3
- data/lib/henitai/subject_resolver.rb +65 -31
- data/lib/henitai/suite_boot_clock.rb +48 -0
- data/lib/henitai/survivor_activation_cache.rb +18 -11
- data/lib/henitai/survivor_rerun_strategy.rb +47 -43
- data/lib/henitai/test_file_outcomes.rb +79 -0
- data/lib/henitai/timeout_calibrator.rb +31 -9
- data/lib/henitai/timeout_ceiling.rb +37 -0
- data/lib/henitai/verdict_fingerprint.rb +29 -30
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +17 -1
- data/sig/configuration_validator.rbs +1 -0
- data/sig/henitai.rbs +227 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
|
@@ -7,10 +7,16 @@ module Henitai
|
|
|
7
7
|
class Mutant
|
|
8
8
|
# Activates a mutant inside the forked child process.
|
|
9
9
|
class Activator
|
|
10
|
+
SubjectNotLoadedError = LexicalTarget::SubjectNotLoadedError
|
|
11
|
+
|
|
12
|
+
# File name the activation source is evaluated under. It decides what
|
|
13
|
+
# +__FILE__+ and +__LINE__+ mean inside an activated mutant.
|
|
14
|
+
EVAL_FILE = __FILE__
|
|
15
|
+
|
|
10
16
|
# Filters "already initialized constant" C-level warnings that fire when
|
|
11
17
|
# a source file is loaded into a process that already has the constant
|
|
12
18
|
# defined via require. Uses a thread-local flag so the filter is active
|
|
13
|
-
# only during load_source_file, leaving all other warnings untouched.
|
|
19
|
+
# only during LexicalTarget#load_source_file, leaving all other warnings untouched.
|
|
14
20
|
module ConstantRedefinitionFilter
|
|
15
21
|
PATTERN = /already initialized constant|previous definition of/
|
|
16
22
|
private_constant :PATTERN
|
|
@@ -27,23 +33,34 @@ module Henitai
|
|
|
27
33
|
new.activate!(mutant)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
# Returns the
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
# Returns the Activation for +mutant+ without evaluating it: a real
|
|
37
|
+
# +def+ in the original lexical scope when the subject can be reopened
|
|
38
|
+
# (ADR-13), otherwise the +define_method+ fallback. Used by the fidelity
|
|
39
|
+
# check and to pre-compute activation recipes. Returns nil if the source
|
|
40
|
+
# cannot be computed (e.g. unsupported AST node).
|
|
41
|
+
def self.activation_for(mutant)
|
|
42
|
+
new.activation_for(mutant)
|
|
35
43
|
rescue StandardError
|
|
36
44
|
nil
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
def self.activation_source_for(mutant)
|
|
48
|
+
activation_for(mutant)&.source
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def activation_for(mutant)
|
|
52
|
+
DefInjection.build(mutant, def_replacement(mutant)) ||
|
|
53
|
+
Activation.new(source: method_source(mutant), mode: :class_eval, file: EVAL_FILE)
|
|
54
|
+
end
|
|
55
|
+
|
|
39
56
|
def activate!(mutant)
|
|
40
57
|
subject = mutant.subject
|
|
41
58
|
raise ArgumentError, "Cannot activate wildcard subjects" if subject.method_name.nil?
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
target = target_for(subject)
|
|
60
|
+
activation = mutant.precomputed_activation || activation_for(mutant)
|
|
61
|
+
target = activation.mode == :toplevel ? LexicalTarget.resolve(subject, activation) : target_for(subject)
|
|
45
62
|
Henitai::WarningSilencer.silence do
|
|
46
|
-
|
|
63
|
+
evaluate(activation, target, subject)
|
|
47
64
|
nil
|
|
48
65
|
end
|
|
49
66
|
rescue Unparser::UnsupportedNodeError, SyntaxError
|
|
@@ -52,8 +69,44 @@ module Henitai
|
|
|
52
69
|
|
|
53
70
|
private
|
|
54
71
|
|
|
72
|
+
def evaluate(activation, target, subject)
|
|
73
|
+
return inject_def(activation, target, subject) if activation.mode == :toplevel
|
|
74
|
+
|
|
75
|
+
target.class_eval(activation.source, EVAL_FILE, __LINE__)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A re-evaluated +def+ is public, so the method's visibility -- or its
|
|
79
|
+
# +module_function+ copy -- is read before and restored after. +target+
|
|
80
|
+
# is already the singleton class for a class method.
|
|
81
|
+
def inject_def(activation, target, subject)
|
|
82
|
+
name = subject.method_name.to_sym
|
|
83
|
+
visibility = visibility_of(target, name)
|
|
84
|
+
copy = module_function_copy?(target, name, subject)
|
|
85
|
+
TOPLEVEL_BINDING.eval(activation.source, activation.file, activation.line)
|
|
86
|
+
return target.send(:module_function, name) if copy
|
|
87
|
+
|
|
88
|
+
target.send(visibility, name) if visibility
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def visibility_of(owner, name)
|
|
92
|
+
return :private if owner.private_method_defined?(name)
|
|
93
|
+
|
|
94
|
+
:protected if owner.protected_method_defined?(name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def module_function_copy?(target, name, subject)
|
|
98
|
+
subject.method_type == :instance && !target.is_a?(Class) &&
|
|
99
|
+
target.private_method_defined?(name) && target.singleton_class.method_defined?(name) &&
|
|
100
|
+
target.instance_method(name).source_location == target.method(name).source_location
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def def_replacement(mutant)
|
|
104
|
+
original_range = mutant.original_node&.location&.expression
|
|
105
|
+
replacement_source(mutant, original_range) if original_range
|
|
106
|
+
end
|
|
107
|
+
|
|
55
108
|
def target_for(subject)
|
|
56
|
-
target = load_target(subject)
|
|
109
|
+
target = LexicalTarget.load_target(subject)
|
|
57
110
|
subject.method_type == :class ? target.singleton_class : target
|
|
58
111
|
end
|
|
59
112
|
|
|
@@ -73,7 +126,8 @@ module Henitai
|
|
|
73
126
|
<<~RUBY
|
|
74
127
|
__henitai_module_function__ = !is_a?(Class) &&
|
|
75
128
|
private_method_defined?(:#{method_name}) &&
|
|
76
|
-
singleton_class.method_defined?(:#{method_name})
|
|
129
|
+
singleton_class.method_defined?(:#{method_name}) &&
|
|
130
|
+
instance_method(:#{method_name}).source_location == method(:#{method_name}).source_location
|
|
77
131
|
define_method(:#{method_name}) do |#{parameters}|
|
|
78
132
|
#{replacement}
|
|
79
133
|
end
|
|
@@ -96,8 +150,13 @@ module Henitai
|
|
|
96
150
|
location = body.location
|
|
97
151
|
return source_body(location, body) unless original_range && location
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
153
|
+
body_source_for_location(location, original_range, replacement_source(mutant, original_range), body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# An unmutated control keeps the original text: re-rendering it would
|
|
157
|
+
# test the unparser, not the activation path.
|
|
158
|
+
def replacement_source(mutant, original_range)
|
|
159
|
+
Replacement.render(mutant, original_range)
|
|
101
160
|
end
|
|
102
161
|
|
|
103
162
|
def body_source_for_location(location, original_range, replacement, body)
|
|
@@ -157,55 +216,16 @@ module Henitai
|
|
|
157
216
|
end
|
|
158
217
|
|
|
159
218
|
def expression_source(location, original_range, replacement)
|
|
160
|
-
|
|
161
|
-
return unless source_range
|
|
162
|
-
|
|
163
|
-
replace_source_fragment(source_range, original_range, replacement)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def load_target(subject)
|
|
167
|
-
Object.const_get(subject.namespace.delete_prefix("::"))
|
|
168
|
-
rescue NameError
|
|
169
|
-
load_source_file(subject)
|
|
170
|
-
Object.const_get(subject.namespace.delete_prefix("::"))
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def load_source_file(subject)
|
|
174
|
-
source_file = subject.source_file || source_file_from_ast(subject)
|
|
175
|
-
return unless source_file && File.file?(source_file)
|
|
176
|
-
|
|
177
|
-
Thread.current[:henitai_filter_const_warnings] = true
|
|
178
|
-
load(source_file)
|
|
179
|
-
loaded_feature = File.expand_path(source_file)
|
|
180
|
-
$LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
|
|
181
|
-
ensure
|
|
182
|
-
Thread.current[:henitai_filter_const_warnings] = false
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def source_file_from_ast(subject)
|
|
186
|
-
ast_node = subject.ast_node
|
|
187
|
-
return unless ast_node
|
|
188
|
-
|
|
189
|
-
location = ast_node.location
|
|
190
|
-
return unless location
|
|
191
|
-
|
|
192
|
-
expression = location.expression
|
|
193
|
-
return unless expression
|
|
194
|
-
|
|
195
|
-
expression.source_buffer.name
|
|
219
|
+
replace_source_fragment(location.expression, original_range, replacement)
|
|
196
220
|
end
|
|
197
221
|
|
|
198
222
|
def replace_source_fragment(source_range, original_range, replacement)
|
|
199
223
|
source = source_range.source
|
|
200
224
|
start = original_range.begin_pos - source_range.begin_pos
|
|
201
225
|
stop = original_range.end_pos - source_range.begin_pos
|
|
202
|
-
return unless start >= 0 && stop <= source.
|
|
203
|
-
|
|
204
|
-
prefix = source.byteslice(0, start)
|
|
205
|
-
suffix = source.byteslice(stop, source.bytesize - stop)
|
|
206
|
-
return unless prefix && suffix
|
|
226
|
+
return unless start >= 0 && stop <= source.length && start <= stop
|
|
207
227
|
|
|
208
|
-
|
|
228
|
+
source[0, start] + replacement + source[stop..]
|
|
209
229
|
end
|
|
210
230
|
|
|
211
231
|
def compile_safe_unparse(node)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class Mutant
|
|
5
|
+
# Builds the :toplevel activation for a mutant (ADR-13): the subject's
|
|
6
|
+
# own +def+, with the mutation spliced in, wrapped in the module and class
|
|
7
|
+
# openers that surround it in the source.
|
|
8
|
+
#
|
|
9
|
+
# Re-evaluating a real +def+ in its lexical scope keeps everything a
|
|
10
|
+
# +define_method+ block loses: constant lookup through the enclosing
|
|
11
|
+
# modules, implicit +super+, +yield+ and +block_given?+, the exact
|
|
12
|
+
# parameter list, +__FILE__+ and +__LINE__+, and the file's
|
|
13
|
+
# +frozen_string_literal+ comment. Visibility is restored by the Activator.
|
|
14
|
+
#
|
|
15
|
+
# Returns nil when the subject cannot be reopened this way (a
|
|
16
|
+
# +define_method+ subject, a method in +class << obj+, a subject without
|
|
17
|
+
# recorded scope); the Activator then falls back to +define_method+.
|
|
18
|
+
class DefInjection
|
|
19
|
+
# Matches +# key: value+ and the emacs form +# -*- key: value -*-+ only,
|
|
20
|
+
# so prose that merely mentions a magic comment does not match.
|
|
21
|
+
MAGIC_COMMENT = /
|
|
22
|
+
\A\#\s*(?:-\*-(?:[^;:]*:[^;]*;)*\s*)?
|
|
23
|
+
(?:frozen_string_literal|shareable_constant_value|warn_indent)\s*:
|
|
24
|
+
/ix
|
|
25
|
+
|
|
26
|
+
def self.build(mutant, replacement)
|
|
27
|
+
new.build(mutant, replacement)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(mutant, replacement)
|
|
31
|
+
subject = mutant.subject
|
|
32
|
+
node = subject.ast_node
|
|
33
|
+
scope = subject.lexical_scope
|
|
34
|
+
return unless injectable?(node, scope)
|
|
35
|
+
|
|
36
|
+
def_source = spliced_def(node.location.expression, mutant.original_node, replacement)
|
|
37
|
+
return unless def_source
|
|
38
|
+
|
|
39
|
+
assemble(node, scope, def_source)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# A top-level +def Other.name+ has no openers; an instance method needs
|
|
45
|
+
# at least one, or it would land on Object.
|
|
46
|
+
def injectable?(node, scope)
|
|
47
|
+
return false unless node && scope
|
|
48
|
+
|
|
49
|
+
node.type == :defs || (node.type == :def && !scope.empty?)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Splices by character offsets, as parser ranges count characters. A
|
|
53
|
+
# heredoc's expression range covers only its opener, so the bodies of
|
|
54
|
+
# heredocs inside the replaced node are removed as well; otherwise they
|
|
55
|
+
# would stay behind as code.
|
|
56
|
+
def spliced_def(def_range, original_node, replacement)
|
|
57
|
+
original_range = original_node&.location&.expression
|
|
58
|
+
return unless original_range && within?(original_range, def_range)
|
|
59
|
+
# An unmutated control splices the original text back. Its heredoc
|
|
60
|
+
# bodies must stay, so the def is used verbatim.
|
|
61
|
+
return def_range.source if replacement == original_range.source
|
|
62
|
+
|
|
63
|
+
edits = heredoc_bodies(original_node).map { |range| [range, ""] } << [original_range, replacement]
|
|
64
|
+
apply(def_range, edits)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Applies non-overlapping edits from the last to the first, so earlier
|
|
68
|
+
# offsets stay valid.
|
|
69
|
+
def apply(def_range, edits)
|
|
70
|
+
edits.sort_by { |range, _| -range.begin_pos }.each_with_object(def_range.source.dup) do |(range, insert), text|
|
|
71
|
+
start = range.begin_pos - def_range.begin_pos
|
|
72
|
+
text[start...(range.end_pos - def_range.begin_pos)] = insert
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def heredoc_bodies(node)
|
|
77
|
+
return [] unless node.is_a?(Parser::AST::Node)
|
|
78
|
+
|
|
79
|
+
own = node.location.respond_to?(:heredoc_body) && node.location.heredoc_body
|
|
80
|
+
bodies = own ? [own.join(node.location.heredoc_end)] : []
|
|
81
|
+
bodies + node.children.flat_map { |child| heredoc_bodies(child) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def within?(inner, outer)
|
|
85
|
+
inner.begin_pos.between?(outer.begin_pos, inner.end_pos) && inner.end_pos <= outer.end_pos
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def assemble(node, scope, def_source)
|
|
89
|
+
def_range = node.location.expression
|
|
90
|
+
prefix = prefix_lines(def_range)
|
|
91
|
+
Activation.new(source: source_lines(prefix, scope, def_source), mode: :toplevel,
|
|
92
|
+
**location_fields(def_range, prefix), **owner_fields(node, scope))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Only magic-comment header lines precede the def; the openers share
|
|
96
|
+
# its line, so the def keeps its original line number.
|
|
97
|
+
def prefix_lines(def_range)
|
|
98
|
+
magic_comments(def_range.source_buffer.source)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def location_fields(def_range, prefix)
|
|
102
|
+
{ file: File.expand_path(def_range.source_buffer.name),
|
|
103
|
+
line: [def_range.line - prefix.size, 1].max, def_offset: prefix.size }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def owner_fields(node, scope)
|
|
107
|
+
{ lexical_scope: scope, owner: owner_source(node), singleton: node.type == :defs }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The expression the +def+ is defined on, as the openers must evaluate
|
|
111
|
+
# it: +self+, or the receiver of +def Other.name+.
|
|
112
|
+
def owner_source(node)
|
|
113
|
+
node.type == :defs ? node.children.first.location.expression.source : "self"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The expression starts at column 0, so its leading indent is dropped.
|
|
117
|
+
def source_lines(prefix, scope, def_source)
|
|
118
|
+
lines = prefix + [wrap_in_scope(def_source.lstrip, scope)]
|
|
119
|
+
"#{lines.join("\n")}\n"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Openers and closers go on the def's own line; with an empty scope
|
|
123
|
+
# the def stands alone.
|
|
124
|
+
def wrap_in_scope(definition, scope)
|
|
125
|
+
return definition if scope.empty?
|
|
126
|
+
|
|
127
|
+
"#{scope.join('; ')}; #{definition}; #{Array.new(scope.size, 'end').join('; ')}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def magic_comments(file_source)
|
|
131
|
+
header = file_source.each_line.take_while { |line| line.strip.empty? || line.start_with?("#") }
|
|
132
|
+
header.map(&:chomp).grep(MAGIC_COMMENT)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class Mutant
|
|
5
|
+
# Decides whether the scope openers of an activation can be reopened.
|
|
6
|
+
#
|
|
7
|
+
# Evaluating an opener such as "class Inner" creates the class when it is
|
|
8
|
+
# missing, which would inject a mutant into an empty phantom class. This
|
|
9
|
+
# check walks the openers the way Ruby resolves them, but never evaluates
|
|
10
|
+
# them, so it never defines a constant.
|
|
11
|
+
class LexicalScopeCheck
|
|
12
|
+
CLASS_EVAL_SUFFIX = ".class_eval do"
|
|
13
|
+
SINGLETON_PREFIX = "class << "
|
|
14
|
+
|
|
15
|
+
# The lexical nesting (innermost first, ending with Object, mirroring
|
|
16
|
+
# Ruby's cref chain) and the current +self+, which a class_eval block
|
|
17
|
+
# changes without changing the nesting.
|
|
18
|
+
Scope = Data.define(:nesting, :receiver)
|
|
19
|
+
|
|
20
|
+
def self.resolves?(openers)
|
|
21
|
+
new.resolves?(openers)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resolves?(openers)
|
|
25
|
+
scope = Scope.new(nesting: [Object], receiver: Object)
|
|
26
|
+
openers.all? { |opener| scope = next_scope(opener, scope) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def next_scope(opener, scope)
|
|
32
|
+
return singleton_scope(opener, scope) if opener.start_with?(SINGLETON_PREFIX)
|
|
33
|
+
return class_eval_scope(opener, scope) if opener.end_with?(CLASS_EVAL_SUFFIX)
|
|
34
|
+
|
|
35
|
+
target = opener_target(opener, scope)
|
|
36
|
+
target && Scope.new(nesting: [target, *scope.nesting], receiver: target)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A singleton opener cannot create a constant, but its receiver must
|
|
40
|
+
# resolve; its singleton class becomes both the cref and +self+.
|
|
41
|
+
def singleton_scope(opener, scope)
|
|
42
|
+
found, value = resolve_expression(opener.delete_prefix(SINGLETON_PREFIX), scope)
|
|
43
|
+
return unless found
|
|
44
|
+
|
|
45
|
+
singleton = value.singleton_class
|
|
46
|
+
Scope.new(nesting: [singleton, *scope.nesting], receiver: singleton)
|
|
47
|
+
rescue TypeError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A block evaluated with class_eval keeps the enclosing constant lookup
|
|
52
|
+
# and only changes +self+ to its receiver.
|
|
53
|
+
def class_eval_scope(opener, scope)
|
|
54
|
+
found, value = resolve_expression(opener.delete_suffix(CLASS_EVAL_SUFFIX), scope)
|
|
55
|
+
found && value.is_a?(Module) && scope.with(receiver: value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def opener_target(opener, scope)
|
|
59
|
+
kind, path = opener.split(" ", 2)
|
|
60
|
+
segments = path.delete_prefix("::").split("::")
|
|
61
|
+
parent = path.start_with?("::") ? walk(Object, segments[0...-1]) : parent_of(segments[0...-1], scope)
|
|
62
|
+
value = parent.is_a?(Module) && constant_value(parent, segments.last)
|
|
63
|
+
value if matches_kind?(value, kind)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolves a constant expression such as +Point+, +self::Inner+ or
|
|
67
|
+
# +::Outer::Point+. Returns whether it resolved and its value.
|
|
68
|
+
def resolve_expression(path, scope)
|
|
69
|
+
return [true, scope.receiver] if path == "self"
|
|
70
|
+
|
|
71
|
+
value = path.match?(/\A\w+\z/) ? lexical_lookup(path, scope.nesting) : qualified_value(path, scope)
|
|
72
|
+
[!value.nil?, value]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def qualified_value(path, scope)
|
|
76
|
+
segments = path.delete_prefix("::").split("::")
|
|
77
|
+
base = path.start_with?("::") ? Object : parent_of(segments.first(1), scope)
|
|
78
|
+
walk(base, segments.drop(path.start_with?("::") ? 0 : 1))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A leading +self+ (as in +class self::Inner+) is the current receiver.
|
|
82
|
+
def parent_of(segments, scope)
|
|
83
|
+
return scope.nesting.first if segments.empty?
|
|
84
|
+
|
|
85
|
+
first = segments.first == "self" ? scope.receiver : lexical_lookup(segments.first, scope.nesting)
|
|
86
|
+
walk(first, segments.drop(1))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def walk(base, segments)
|
|
90
|
+
segments.reduce(base) { |current, name| current.is_a?(Module) ? own_constant(current, name) : nil }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Ruby's order: the own constants of each lexical scope (innermost
|
|
94
|
+
# first, without Object), then the ancestors of the innermost scope,
|
|
95
|
+
# then the top level.
|
|
96
|
+
def lexical_lookup(name, nesting)
|
|
97
|
+
scope = nesting[0...-1].find { |candidate| candidate.const_defined?(name, false) }
|
|
98
|
+
return scope.const_get(name, false) if scope
|
|
99
|
+
|
|
100
|
+
innermost = nesting.first
|
|
101
|
+
innermost.const_get(name) if innermost.const_defined?(name) || Object.const_defined?(name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def own_constant(base, name)
|
|
105
|
+
base.const_get(name, false) if base.const_defined?(name, false)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def constant_value(parent, name)
|
|
109
|
+
inherit = parent.equal?(Object)
|
|
110
|
+
parent.const_get(name, inherit) if parent.const_defined?(name, inherit)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def matches_kind?(value, kind)
|
|
114
|
+
return false unless value.is_a?(Module)
|
|
115
|
+
|
|
116
|
+
kind == "class" ? value.is_a?(Class) : !value.is_a?(Class)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class Mutant
|
|
5
|
+
# Resolves the object a re-evaluated +def+ must be defined on, and loads
|
|
6
|
+
# the subject's source file when its namespace is still undefined.
|
|
7
|
+
class LexicalTarget
|
|
8
|
+
# Raised when a subject's namespace is still undefined after its source
|
|
9
|
+
# file was loaded, so there is no class to reopen.
|
|
10
|
+
class SubjectNotLoadedError < StandardError; end
|
|
11
|
+
|
|
12
|
+
def self.resolve(subject, activation)
|
|
13
|
+
new.resolve(subject, activation)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.load_target(subject)
|
|
17
|
+
new.load_target(subject)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The object the re-evaluated +def+ defines its method on, as Ruby
|
|
21
|
+
# resolves it: the openers are evaluated with +self+ -- or the receiver
|
|
22
|
+
# of +def Other.name+ -- as their body. The subject's namespace is only
|
|
23
|
+
# the resolver's static reading of the source, which cannot tell
|
|
24
|
+
# whether +Other+ inside +module Outer+ is +Outer::Other+ or +::Other+.
|
|
25
|
+
# The scope, owner and singleton flag come from the activation, so a
|
|
26
|
+
# replayed recipe does not need the AST. The subject's file is loaded
|
|
27
|
+
# first when needed (see +ensure_loaded+), so that evaluating the openers
|
|
28
|
+
# reopens the loaded class instead of creating an empty one.
|
|
29
|
+
def resolve(subject, activation)
|
|
30
|
+
scope = activation.lexical_scope
|
|
31
|
+
ensure_loaded(subject, scope)
|
|
32
|
+
source = "#{scope.join('; ')}\n#{activation.owner}\n#{Array.new(scope.size, 'end').join('; ')}"
|
|
33
|
+
owner = TOPLEVEL_BINDING.eval(source, activation.file, activation.line)
|
|
34
|
+
activation.singleton ? owner.singleton_class : owner
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def load_target(subject)
|
|
38
|
+
Object.const_get(subject.namespace)
|
|
39
|
+
rescue NameError
|
|
40
|
+
load_source_file(subject)
|
|
41
|
+
Object.const_get(subject.namespace)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Evaluating the openers must reopen existing constants, never create
|
|
47
|
+
# them: a missing class would become an empty phantom that swallows the
|
|
48
|
+
# mutant. The scope is therefore checked without evaluation first. If it
|
|
49
|
+
# does not resolve, the subject's file is loaded and checked again. If it
|
|
50
|
+
# still does not resolve, activation fails instead of creating anything.
|
|
51
|
+
def ensure_loaded(subject, scope)
|
|
52
|
+
return load_toplevel_source(subject) if scope.empty?
|
|
53
|
+
return if LexicalScopeCheck.resolves?(scope)
|
|
54
|
+
|
|
55
|
+
load_subject_source(subject)
|
|
56
|
+
return if LexicalScopeCheck.resolves?(scope)
|
|
57
|
+
|
|
58
|
+
raise SubjectNotLoadedError,
|
|
59
|
+
"cannot activate #{subject.expression}: its source did not define #{subject.namespace}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Without openers no phantom class can be created; the file is loaded
|
|
63
|
+
# only when the subject's namespace constant is still missing. A file
|
|
64
|
+
# that fails to load fails the activation instead of crashing the child.
|
|
65
|
+
def load_toplevel_source(subject)
|
|
66
|
+
load_target(subject)
|
|
67
|
+
rescue NameError
|
|
68
|
+
nil
|
|
69
|
+
rescue StandardError, ScriptError => e
|
|
70
|
+
raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_subject_source(subject)
|
|
74
|
+
load_source_file(subject)
|
|
75
|
+
rescue StandardError, ScriptError => e
|
|
76
|
+
raise SubjectNotLoadedError, "cannot activate #{subject.namespace}: its source failed to load (#{e.message})"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def load_source_file(subject)
|
|
80
|
+
source_file = subject.source_file || source_file_from_ast(subject)
|
|
81
|
+
return unless source_file && File.file?(source_file)
|
|
82
|
+
|
|
83
|
+
Thread.current[:henitai_filter_const_warnings] = true
|
|
84
|
+
load(source_file)
|
|
85
|
+
loaded_feature = File.expand_path(source_file)
|
|
86
|
+
$LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
|
|
87
|
+
ensure
|
|
88
|
+
Thread.current[:henitai_filter_const_warnings] = false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def source_file_from_ast(subject)
|
|
92
|
+
ast_node = subject.ast_node
|
|
93
|
+
return unless ast_node
|
|
94
|
+
|
|
95
|
+
location = ast_node.location
|
|
96
|
+
return unless location
|
|
97
|
+
|
|
98
|
+
expression = location.expression
|
|
99
|
+
return unless expression
|
|
100
|
+
|
|
101
|
+
expression.source_buffer.name
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "unparser"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
class Mutant
|
|
7
|
+
# Renders the source text spliced in place of a mutant's original node.
|
|
8
|
+
#
|
|
9
|
+
# The text replaces only the original node's range, so it must parse as
|
|
10
|
+
# one expression wherever it lands. An operator expression such as
|
|
11
|
+
# `a || b` does not: spliced into `a && b && c` it reads as
|
|
12
|
+
# `a || (b && c)`. Operator expressions are therefore parenthesised;
|
|
13
|
+
# parentheses never change an expression's meaning, missing ones can. An
|
|
14
|
+
# unmutated control keeps the original text.
|
|
15
|
+
class Replacement
|
|
16
|
+
OPERATOR_METHODS = %i[
|
|
17
|
+
+ - * / % ** == != < > <= >= <=> === =~ !~ << >> & | ^ ! ~ -@ +@
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def self.render(mutant, original_range)
|
|
21
|
+
new(mutant, original_range).render
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(mutant, original_range)
|
|
25
|
+
@mutant = mutant
|
|
26
|
+
@original_range = original_range
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render
|
|
30
|
+
return @original_range.source if @mutant.mutated_node.equal?(@mutant.original_node)
|
|
31
|
+
|
|
32
|
+
text = unparse(@mutant.mutated_node)
|
|
33
|
+
operator_expression?(@mutant.mutated_node) ? "(#{text})" : text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def unparse(node)
|
|
39
|
+
Unparser.unparse(node)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
raise Unparser::UnsupportedNodeError, e.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def operator_expression?(node)
|
|
45
|
+
return false unless node.is_a?(Parser::AST::Node)
|
|
46
|
+
return true if %i[and or].include?(node.type)
|
|
47
|
+
|
|
48
|
+
%i[send csend].include?(node.type) && OPERATOR_METHODS.include?(node.children[1])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/henitai/mutant.rb
CHANGED
|
@@ -21,6 +21,11 @@ module Henitai
|
|
|
21
21
|
|
|
22
22
|
autoload :Activator, "henitai/mutant/activator"
|
|
23
23
|
autoload :ParameterSource, "henitai/mutant/parameter_source"
|
|
24
|
+
autoload :Activation, "henitai/mutant/activation"
|
|
25
|
+
autoload :LexicalScopeCheck, "henitai/mutant/lexical_scope_check"
|
|
26
|
+
autoload :LexicalTarget, "henitai/mutant/lexical_target"
|
|
27
|
+
autoload :DefInjection, "henitai/mutant/def_injection"
|
|
28
|
+
autoload :Replacement, "henitai/mutant/replacement"
|
|
24
29
|
|
|
25
30
|
# Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
|
|
26
31
|
# :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
|
|
@@ -40,9 +45,12 @@ module Henitai
|
|
|
40
45
|
|
|
41
46
|
attr_reader :id, :subject, :operator, :original_node, :mutated_node,
|
|
42
47
|
:mutation_type, :description, :location,
|
|
43
|
-
:precomputed_stable_id, :
|
|
48
|
+
:precomputed_stable_id, :precomputed_activation, :precomputed_replacement
|
|
44
49
|
attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed,
|
|
45
|
-
:ignore_reason, :from_cache
|
|
50
|
+
:ignore_reason, :status_reason, :from_cache
|
|
51
|
+
# Set by MutantGenerator only for mutants whose identity would otherwise
|
|
52
|
+
# collide with another one (see MutantIdentity).
|
|
53
|
+
attr_accessor :identity_discriminator
|
|
46
54
|
|
|
47
55
|
# @param subject [Subject] the subject being mutated
|
|
48
56
|
# @param operator [Symbol] operator name, e.g. :ArithmeticOperator
|
|
@@ -51,7 +59,8 @@ module Henitai
|
|
|
51
59
|
# @param location [Hash] { file:, start_line:, end_line:, start_col:, end_col: }
|
|
52
60
|
# rubocop:disable Metrics/ParameterLists
|
|
53
61
|
def initialize(subject:, operator:, nodes:, description:, location:,
|
|
54
|
-
precomputed_stable_id: nil,
|
|
62
|
+
precomputed_stable_id: nil, precomputed_activation: nil,
|
|
63
|
+
precomputed_replacement: nil)
|
|
55
64
|
@id = SecureRandom.uuid
|
|
56
65
|
@subject = subject
|
|
57
66
|
@operator = operator
|
|
@@ -60,10 +69,9 @@ module Henitai
|
|
|
60
69
|
@description = description
|
|
61
70
|
@location = location
|
|
62
71
|
@precomputed_stable_id = precomputed_stable_id
|
|
63
|
-
@
|
|
64
|
-
@
|
|
65
|
-
|
|
66
|
-
@from_cache = false
|
|
72
|
+
@precomputed_activation = precomputed_activation
|
|
73
|
+
@precomputed_replacement = precomputed_replacement
|
|
74
|
+
reset_outcome
|
|
67
75
|
end
|
|
68
76
|
# rubocop:enable Metrics/ParameterLists
|
|
69
77
|
|
|
@@ -73,7 +81,9 @@ module Henitai
|
|
|
73
81
|
|
|
74
82
|
# Unparsing is expensive and the value is needed by both the JSON and
|
|
75
83
|
# the terminal reporter — compute once per mutant.
|
|
76
|
-
def mutated_source
|
|
84
|
+
def mutated_source
|
|
85
|
+
@mutated_source ||= @precomputed_replacement || safe_unparse(mutated_node)
|
|
86
|
+
end
|
|
77
87
|
|
|
78
88
|
def killed? = @status == :killed
|
|
79
89
|
def from_cache? = @from_cache == true
|
|
@@ -85,5 +95,14 @@ module Henitai
|
|
|
85
95
|
def to_s
|
|
86
96
|
"#{operator}@#{location[:file]}:#{location[:start_line]} — #{description}"
|
|
87
97
|
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def reset_outcome
|
|
102
|
+
@status = :pending
|
|
103
|
+
@killing_test = @duration = @covered_by = @tests_completed = nil
|
|
104
|
+
@ignore_reason = @status_reason = nil
|
|
105
|
+
@from_cache = false
|
|
106
|
+
end
|
|
88
107
|
end
|
|
89
108
|
end
|