henitai 0.5.3 → 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 +222 -1
- data/README.md +100 -13
- 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 +36 -10
- 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 +7 -5
- 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 +225 -45
- metadata +46 -3
- data/lib/henitai/survivor_test_filter.rb +0 -72
data/lib/henitai/integration.rb
CHANGED
|
@@ -6,12 +6,15 @@ require_relative "process_wakeup"
|
|
|
6
6
|
require_relative "integration/child_bootstrap"
|
|
7
7
|
require_relative "integration/rspec_process_runner"
|
|
8
8
|
require_relative "integration/scenario_log_support"
|
|
9
|
+
require_relative "integration/child_report_store"
|
|
9
10
|
require_relative "integration/coverage_suppression"
|
|
10
11
|
require_relative "integration/child_debug_log"
|
|
11
12
|
require_relative "integration/loaded_features"
|
|
12
13
|
require_relative "integration/base"
|
|
13
14
|
require_relative "integration/mutant_run_support"
|
|
14
15
|
require_relative "integration/rspec_child_runner"
|
|
16
|
+
require_relative "rspec_outcomes"
|
|
17
|
+
require_relative "integration/rspec_preload"
|
|
15
18
|
require_relative "integration/rspec_test_selection"
|
|
16
19
|
|
|
17
20
|
module Henitai
|
|
@@ -50,6 +53,7 @@ module Henitai
|
|
|
50
53
|
class Rspec < Base
|
|
51
54
|
include MutantRunSupport
|
|
52
55
|
include RspecChildRunner
|
|
56
|
+
include RspecPreload
|
|
53
57
|
include RspecTestSelection
|
|
54
58
|
|
|
55
59
|
DEFAULT_SUITE_TIMEOUT = 300.0
|
|
@@ -98,7 +102,7 @@ module Henitai
|
|
|
98
102
|
File.open(log_paths[:stdout_path], "w") do |stdout_file|
|
|
99
103
|
File.open(log_paths[:stderr_path], "w") do |stderr_file|
|
|
100
104
|
Process.spawn(
|
|
101
|
-
|
|
105
|
+
suite_process_env,
|
|
102
106
|
*suite_command(test_files),
|
|
103
107
|
out: stdout_file,
|
|
104
108
|
err: stderr_file,
|
|
@@ -110,14 +114,23 @@ module Henitai
|
|
|
110
114
|
|
|
111
115
|
private
|
|
112
116
|
|
|
113
|
-
def
|
|
117
|
+
def outcome_recording_hook(outcomes, after_load)
|
|
118
|
+
lambda do
|
|
119
|
+
RspecOutcomes.register(outcomes, ::RSpec.__send__(:configuration).reporter)
|
|
120
|
+
after_load.nil? || after_load.call
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Yields once the spec files are loaded and before they run; a falsy
|
|
125
|
+
# block result skips the run (see MutantRunSupport#run_tests_in_child).
|
|
126
|
+
def run_tests(test_files, outcomes, &after_load)
|
|
114
127
|
require "rspec/core"
|
|
115
128
|
::RSpec.__send__(:configuration).fail_if_no_examples = true
|
|
116
129
|
log = child_debug_log
|
|
117
130
|
log.rspec_trace(test_files:, rspec_options: [], rspec_argv: test_files)
|
|
118
131
|
log.example_count("before_run")
|
|
119
132
|
log.write("[henitai-debug-child] runner_run_start")
|
|
120
|
-
status = run_rspec_runner(test_files)
|
|
133
|
+
status = run_rspec_runner(test_files, &outcome_recording_hook(outcomes, after_load))
|
|
121
134
|
log.write("[henitai-debug-child] runner_run_return status=#{status.inspect}")
|
|
122
135
|
log.example_count("after_run")
|
|
123
136
|
log.rspec_exit(status)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "minitest"
|
|
4
4
|
require "henitai/per_test_coverage_collector"
|
|
5
|
+
require "henitai/test_file_outcomes"
|
|
6
|
+
require "henitai/minitest_result_file"
|
|
5
7
|
|
|
6
8
|
module Henitai
|
|
7
9
|
# Minitest reporter that collects per-test line coverage deltas.
|
|
@@ -11,26 +13,40 @@ module Henitai
|
|
|
11
13
|
# to PerTestCoverageCollector so the JSON output format is identical to the
|
|
12
14
|
# RSpec integration.
|
|
13
15
|
class MinitestCoverageReporter < Minitest::Reporter
|
|
14
|
-
def initialize(io = $stdout, options = {})
|
|
16
|
+
def initialize(io = $stdout, options = {}, boot_clock: SuiteBootClock.from_env(ENV))
|
|
17
|
+
super(io, options)
|
|
18
|
+
@collector = PerTestCoverageCollector.new(boot_clock:)
|
|
19
|
+
@outcomes = TestFileOutcomes.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def prerecord(klass, name)
|
|
15
23
|
super
|
|
16
|
-
@collector
|
|
24
|
+
@collector.test_started
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
def record(result)
|
|
20
28
|
super
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
duration: result.time
|
|
24
|
-
)
|
|
29
|
+
record_coverage(result)
|
|
30
|
+
MinitestResultFile.record(@outcomes, result)
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def report
|
|
28
34
|
super
|
|
29
35
|
@collector.write_report
|
|
36
|
+
@outcomes.write(TestFileOutcomes.default_report_path)
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
private
|
|
33
40
|
|
|
41
|
+
# A result without a resolvable file is skipped, so its coverage delta is
|
|
42
|
+
# not attributed to the next recorded test.
|
|
43
|
+
def record_coverage(result)
|
|
44
|
+
file = MinitestResultFile.for(result)
|
|
45
|
+
return @collector.skip_test unless file
|
|
46
|
+
|
|
47
|
+
@collector.record_test(relative_to_pwd(file), duration: result.time)
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def relative_to_pwd(path)
|
|
35
51
|
prefix = "#{Dir.pwd}#{File::SEPARATOR}"
|
|
36
52
|
path.start_with?(prefix) ? path.sub(prefix, "") : path
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Finds the test file a Minitest result belongs to.
|
|
5
|
+
#
|
|
6
|
+
# `Result#source_location` is where the test method is defined. For tests
|
|
7
|
+
# inherited from a base class or an included module that is a shared file,
|
|
8
|
+
# not the file of the test class that ran, so the file that defines the
|
|
9
|
+
# result's class is used for those. A method defined directly on the class
|
|
10
|
+
# keeps its own file: the class may have been reopened in another test file.
|
|
11
|
+
# The method location is also the fallback for classes that cannot be
|
|
12
|
+
# resolved (anonymous or removed).
|
|
13
|
+
module MinitestResultFile
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Records the result's outcome; a failure with no usable file is
|
|
17
|
+
# recorded as unattributed rather than dropped.
|
|
18
|
+
def record(outcomes, result)
|
|
19
|
+
failed = !result.passed? && !result.skipped?
|
|
20
|
+
file = self.for(result)
|
|
21
|
+
if file
|
|
22
|
+
outcomes.record(file, failed:)
|
|
23
|
+
elsif failed
|
|
24
|
+
outcomes.record_unattributed_failure
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] the test file, or nil when none can be found
|
|
29
|
+
def for(result)
|
|
30
|
+
return usable(method_file(result)) || usable(class_file(result)) if defined_on_class?(result)
|
|
31
|
+
|
|
32
|
+
usable(class_file(result)) || usable(method_file(result))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def defined_on_class?(result)
|
|
36
|
+
klass = Object.const_get(result.klass.to_s)
|
|
37
|
+
klass.instance_method(result.name).owner == klass
|
|
38
|
+
rescue NameError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def class_file(result)
|
|
43
|
+
class_file_of(result.klass.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The file that defines the named class; nil for anonymous or unknown names.
|
|
47
|
+
def class_file_of(name)
|
|
48
|
+
usable(Object.const_source_location(name)&.first)
|
|
49
|
+
rescue NameError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def method_file(result)
|
|
54
|
+
result.source_location&.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Minitest reports no location for some results and "unknown" for others.
|
|
58
|
+
def usable(file)
|
|
59
|
+
file if file.is_a?(String) && !file.empty? && file != "unknown" && file != "(eval)"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
class Mutant
|
|
5
|
+
# The Ruby source that puts a mutant in place, and how to evaluate it.
|
|
6
|
+
#
|
|
7
|
+
# +mode+ is :toplevel for a real +def+ re-evaluated inside the original
|
|
8
|
+
# module and class openers (ADR-13), evaluated at the top level with
|
|
9
|
+
# +file+ and +line+ so +__FILE__+, +__LINE__+ and backtraces stay those of
|
|
10
|
+
# the original source; +def_offset+ is the index of the +def+ line within
|
|
11
|
+
# +source+. +mode+ is :class_eval for the +define_method+ fallback,
|
|
12
|
+
# evaluated inside the target module.
|
|
13
|
+
#
|
|
14
|
+
# For :toplevel, +lexical_scope+ holds the opener lines (e.g. "module A",
|
|
15
|
+
# "class B"), +owner+ the expression the +def+ is defined on ("self" or
|
|
16
|
+
# the receiver of +def Other.name+), and +singleton+ whether it is a
|
|
17
|
+
# +defs+. They let a replayed recipe reopen the scope without the AST.
|
|
18
|
+
Activation = Struct.new(:source, :mode, :file, :line, :def_offset, :lexical_scope, :owner, :singleton,
|
|
19
|
+
keyword_init: true) do
|
|
20
|
+
def self.from_h(hash)
|
|
21
|
+
new(source: hash.fetch("source"), mode: hash.fetch("mode").to_sym, file: hash["file"],
|
|
22
|
+
line: hash["line"], def_offset: hash["defOffset"], lexical_scope: hash["lexicalScope"],
|
|
23
|
+
owner: hash["owner"], singleton: hash["singleton"])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ "source" => source, "mode" => mode.to_s, "file" => file, "line" => line, "defOffset" => def_offset,
|
|
28
|
+
"lexicalScope" => lexical_scope, "owner" => owner, "singleton" => singleton }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -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
|