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
|
@@ -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
|
|
@@ -6,7 +6,7 @@ require_relative "source_parser"
|
|
|
6
6
|
module Henitai
|
|
7
7
|
# Traverses a subject's AST and asks operators to build mutants.
|
|
8
8
|
class MutantGenerator
|
|
9
|
-
def generate(subjects, operators, config: nil)
|
|
9
|
+
def generate(subjects, operators, config: nil, sample: true)
|
|
10
10
|
normalized_operators = normalize_operators(operators)
|
|
11
11
|
arid_node_filter = AridNodeFilter.new
|
|
12
12
|
syntax_validator = SyntaxValidator.new
|
|
@@ -22,7 +22,12 @@ module Henitai
|
|
|
22
22
|
)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# Deduplicate programs before sampling: unsampled generation (survivor reruns) must keep the same
|
|
26
|
+
# representative of a duplicate program as a sampled run, or a reported survivor's stable id would
|
|
27
|
+
# not be found again. Sampling is stratified by operator within each subject, using a seeded
|
|
28
|
+
# per-mutant order, and returns kept mutants in input order.
|
|
29
|
+
unique = distinct_programs(distinct_identities(mutants))
|
|
30
|
+
sample ? sample_mutants(unique, config:, sampling_strategy:) : unique
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
private
|
|
@@ -71,14 +76,12 @@ module Henitai
|
|
|
71
76
|
def walk(node, parent: nil)
|
|
72
77
|
return unless node.is_a?(Parser::AST::Node)
|
|
73
78
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# a
|
|
79
|
-
|
|
80
|
-
# inserting "" there stays valid Ruby.
|
|
81
|
-
return if embedded_non_heredoc_dstr_str?(node, parent)
|
|
79
|
+
# A str node inside an interpolated string, heredoc, symbol, command,
|
|
80
|
+
# regexp or word array is raw text without quotes of its own.
|
|
81
|
+
# Replacing it would insert a quoted literal into the text -- a
|
|
82
|
+
# different mutation than reported, or a SyntaxError -- so it is not
|
|
83
|
+
# a StringLiteral site; its container is mutated instead.
|
|
84
|
+
return if raw_text_segment?(node, parent) || shorthand_value?(node, parent)
|
|
82
85
|
|
|
83
86
|
apply_operators(node) if node_within_subject_range?(node)
|
|
84
87
|
node.children.each do |child|
|
|
@@ -86,18 +89,29 @@ module Henitai
|
|
|
86
89
|
end
|
|
87
90
|
end
|
|
88
91
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
RAW_TEXT_CONTAINERS = %i[dstr dsym xstr regexp].freeze
|
|
93
|
+
private_constant :RAW_TEXT_CONTAINERS
|
|
94
|
+
|
|
95
|
+
def raw_text_segment?(node, parent)
|
|
96
|
+
return false unless node.type == :str && parent
|
|
97
|
+
|
|
98
|
+
RAW_TEXT_CONTAINERS.include?(parent.type) || word_array?(parent)
|
|
93
99
|
end
|
|
94
100
|
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
# The value of +build(config:)+ is an implicit call that shares its
|
|
102
|
+
# source range with the key; replacing it would rewrite the key.
|
|
103
|
+
def shorthand_value?(node, parent)
|
|
104
|
+
return false unless parent&.type == :pair && node.equal?(parent.children[1])
|
|
105
|
+
|
|
106
|
+
node.location.expression == parent.children[0].location.expression
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def word_array?(node)
|
|
110
|
+
node.type == :array && node.location.begin&.source.to_s.start_with?("%")
|
|
97
111
|
end
|
|
98
112
|
|
|
99
113
|
def apply_operators(node)
|
|
100
|
-
return if @arid_node_filter.suppressed?(node
|
|
114
|
+
return if @arid_node_filter.suppressed?(node)
|
|
101
115
|
|
|
102
116
|
@operators_by_node_type[node.type].each do |operator|
|
|
103
117
|
operator.mutate(node, subject: @subject).each do |mutant|
|
|
@@ -132,6 +146,35 @@ module Henitai
|
|
|
132
146
|
end
|
|
133
147
|
end
|
|
134
148
|
|
|
149
|
+
# Mutants sharing an identity are told apart by where their original node
|
|
150
|
+
# ends; those that also end at the same place are the same mutation
|
|
151
|
+
# twice, and only the first is kept.
|
|
152
|
+
def distinct_identities(mutants)
|
|
153
|
+
mutants.group_by { |mutant| MutantIdentity.stable_id(mutant) }.each_value do |group|
|
|
154
|
+
group.each { |mutant| mutant.identity_discriminator = node_end(mutant) } if group.size > 1
|
|
155
|
+
end
|
|
156
|
+
mutants.uniq { |mutant| MutantIdentity.stable_id(mutant) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Different operators can produce the same program -- BooleanLiteral's
|
|
160
|
+
# `return false` for `return true if x` is also ReturnValue's. Running it
|
|
161
|
+
# twice would count one result twice; the first mutant is kept. Mutants
|
|
162
|
+
# without a computable activation are left to the later checks.
|
|
163
|
+
def distinct_programs(mutants)
|
|
164
|
+
seen = {}
|
|
165
|
+
mutants.select do |mutant|
|
|
166
|
+
program = Mutant::Activator.activation_source_for(mutant)
|
|
167
|
+
key = [mutant.subject, program]
|
|
168
|
+
program.nil? || (!seen.key?(key) && (seen[key] = true))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def node_end(mutant)
|
|
173
|
+
range = mutant.original_node&.location&.expression
|
|
174
|
+
start = mutant.subject.source_range&.begin
|
|
175
|
+
range && start ? "#{range.last_line - start}:#{range.last_column}" : range&.end_pos.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
135
178
|
def sample_mutants(mutants, config:, sampling_strategy:)
|
|
136
179
|
sampling = config&.sampling
|
|
137
180
|
return mutants unless sampling
|
|
@@ -140,7 +183,8 @@ module Henitai
|
|
|
140
183
|
sampling_strategy.sample(
|
|
141
184
|
mutants,
|
|
142
185
|
ratio: sampling[:ratio],
|
|
143
|
-
strategy: sampling[:strategy] || :stratified
|
|
186
|
+
strategy: sampling[:strategy] || :stratified,
|
|
187
|
+
seed: sampling[:seed] || SamplingStrategy::DEFAULT_SEED
|
|
144
188
|
)
|
|
145
189
|
end
|
|
146
190
|
|
|
@@ -29,7 +29,7 @@ module Henitai
|
|
|
29
29
|
def killed_cache_bindings(mutant)
|
|
30
30
|
[
|
|
31
31
|
VerdictFingerprint.subject_source_hash(mutant),
|
|
32
|
-
VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant))
|
|
32
|
+
VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant), context_sha: reuse_context&.fingerprint)
|
|
33
33
|
]
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -38,34 +38,35 @@ module Henitai
|
|
|
38
38
|
# which reflects selector fallback and test_excludes and would
|
|
39
39
|
# permanently mismatch the live set. When the fingerprint cannot be
|
|
40
40
|
# recomputed (no per-test map, recipe stubs without a source range),
|
|
41
|
-
# the previously stored bindings are kept
|
|
42
|
-
#
|
|
43
|
-
#
|
|
41
|
+
# the previously stored bindings are kept, but only from a survived
|
|
42
|
+
# row of the same subject source: a killed row's or an edited method's
|
|
43
|
+
# fingerprints would vouch for a state this survivor never ran in.
|
|
44
44
|
def survived_cache_bindings(mutant, existing_row)
|
|
45
45
|
bindings = [
|
|
46
46
|
VerdictFingerprint.subject_source_hash(mutant),
|
|
47
47
|
survived_tests_fingerprint(mutant)
|
|
48
48
|
]
|
|
49
49
|
return bindings unless bindings.any?(&:nil?)
|
|
50
|
+
return [nil, nil] unless carry_survived_bindings?(bindings.first, existing_row)
|
|
50
51
|
|
|
51
52
|
existing_cache_bindings(existing_row)
|
|
52
53
|
end
|
|
53
54
|
|
|
55
|
+
def carry_survived_bindings?(source_hash, existing_row)
|
|
56
|
+
return false unless existing_row && existing_row["current_status"] == "survived"
|
|
57
|
+
|
|
58
|
+
source_hash.nil? || source_hash == existing_row["subject_source_hash"]
|
|
59
|
+
end
|
|
60
|
+
|
|
54
61
|
def survived_tests_fingerprint(mutant)
|
|
55
62
|
return nil unless per_test_coverage
|
|
56
63
|
|
|
57
64
|
VerdictFingerprint.survivor_tests_fingerprint(
|
|
58
65
|
per_test_coverage.tests_covering(mutant),
|
|
59
|
-
|
|
66
|
+
context_sha: reuse_context&.fingerprint
|
|
60
67
|
)
|
|
61
68
|
end
|
|
62
69
|
|
|
63
|
-
# One dependency sha per record call set, computed lazily so runs
|
|
64
|
-
# without survivors never touch the dependency files.
|
|
65
|
-
def recording_dependency_sha
|
|
66
|
-
@recording_dependency_sha ||= VerdictFingerprint.dependency_fingerprint
|
|
67
|
-
end
|
|
68
|
-
|
|
69
70
|
def existing_cache_bindings(existing_row)
|
|
70
71
|
return [nil, nil] unless existing_row
|
|
71
72
|
|
|
@@ -17,9 +17,12 @@ module Henitai
|
|
|
17
17
|
# @param per_test_coverage [PerTestCoverage, nil] live per-test coverage
|
|
18
18
|
# view used to record the full-map intersection set for survived
|
|
19
19
|
# verdicts; without it survived rows stay NULL (never reusable).
|
|
20
|
-
|
|
20
|
+
# @param reuse_context [ReuseContext, nil] fingerprint recorded with every
|
|
21
|
+
# cached verdict; without it no verdict is cached (never reusable).
|
|
22
|
+
def initialize(path:, per_test_coverage: nil, reuse_context: nil)
|
|
21
23
|
@path = path
|
|
22
24
|
@per_test_coverage = per_test_coverage
|
|
25
|
+
@reuse_context = reuse_context
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
attr_reader :path
|
|
@@ -30,7 +33,7 @@ module Henitai
|
|
|
30
33
|
with_database do |db|
|
|
31
34
|
ensure_schema(db)
|
|
32
35
|
db.transaction do
|
|
33
|
-
insert_run(db, result, version, recorded_at)
|
|
36
|
+
insert_run(db, result, version, recorded_at) if full_run?(result)
|
|
34
37
|
Array(result.mutants).each do |mutant|
|
|
35
38
|
upsert_mutant(db, mutant, version, recorded_at)
|
|
36
39
|
end
|
|
@@ -72,7 +75,7 @@ module Henitai
|
|
|
72
75
|
|
|
73
76
|
private
|
|
74
77
|
|
|
75
|
-
attr_reader :per_test_coverage
|
|
78
|
+
attr_reader :per_test_coverage, :reuse_context
|
|
76
79
|
|
|
77
80
|
def verdict_from_row(row)
|
|
78
81
|
return nil unless row && VerdictCache::CACHEABLE_STATUSES.include?(row["current_status"])
|
|
@@ -85,6 +88,11 @@ module Henitai
|
|
|
85
88
|
}
|
|
86
89
|
end
|
|
87
90
|
|
|
91
|
+
def full_run?(result)
|
|
92
|
+
authoritative = !result.respond_to?(:authoritative?) || result.authoritative?
|
|
93
|
+
authoritative && !partial_rerun?(result)
|
|
94
|
+
end
|
|
95
|
+
|
|
88
96
|
def partial_rerun?(result)
|
|
89
97
|
result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
90
98
|
end
|
|
@@ -133,8 +141,10 @@ module Henitai
|
|
|
133
141
|
end
|
|
134
142
|
end
|
|
135
143
|
|
|
144
|
+
# Recipe stubs carry a precomputed id that cannot be recomputed from
|
|
145
|
+
# their (absent) AST nodes.
|
|
136
146
|
def stable_mutant_id(mutant)
|
|
137
|
-
MutantIdentity.stable_id(mutant)
|
|
147
|
+
mutant.respond_to?(:stable_id) ? mutant.stable_id : MutantIdentity.stable_id(mutant)
|
|
138
148
|
end
|
|
139
149
|
|
|
140
150
|
def mutation_history_entry(mutant, version, recorded_at)
|
|
@@ -19,8 +19,12 @@ module Henitai
|
|
|
19
19
|
digest(legacy_identity_components(mutant))
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# The discriminator is present only for mutants that would otherwise
|
|
23
|
+
# share an identity -- nested nodes starting at the same position, such
|
|
24
|
+
# as the calls of `x.a.b.c` -- so every other identity stays unchanged.
|
|
22
25
|
def self.identity_components(mutant)
|
|
23
|
-
|
|
26
|
+
discriminator = mutant.respond_to?(:identity_discriminator) && mutant.identity_discriminator
|
|
27
|
+
legacy_identity_components(mutant) + [site_offset(mutant)] + [discriminator].compact
|
|
24
28
|
end
|
|
25
29
|
private_class_method :identity_components
|
|
26
30
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Classifies one mutant run from the child's own report and the way the
|
|
5
|
+
# child process ended.
|
|
6
|
+
#
|
|
7
|
+
# The child reports what it got to: a failed activation, a finished test run
|
|
8
|
+
# with the exit code the tests returned, or a SystemExit raised while the
|
|
9
|
+
# tests ran. Only a finished test run yields a test verdict (Killed or
|
|
10
|
+
# Survived); everything else is a harness or runtime status that stays out of
|
|
11
|
+
# the detected count. In particular a child that died without a report, or
|
|
12
|
+
# was killed by a signal, is a RuntimeError, never a kill.
|
|
13
|
+
class MutantVerdict
|
|
14
|
+
def initialize(wait_result:, report:, stdout:, stderr:)
|
|
15
|
+
@wait_result = wait_result
|
|
16
|
+
@report = report
|
|
17
|
+
@output = "#{stdout}\n#{stderr}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Array(Symbol, String|nil)] status and reason
|
|
21
|
+
def to_a
|
|
22
|
+
return [:timeout, nil] if @wait_result == :timeout
|
|
23
|
+
return [:runtime_error, "child terminated by signal #{signal_name}"] if signaled?
|
|
24
|
+
return [:runtime_error, "child exited with status #{exit_status} without reporting a result"] unless @report
|
|
25
|
+
|
|
26
|
+
reported_verdict
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] expanded paths of the test files that failed
|
|
30
|
+
# in the child, or an empty list when the child did not report them or
|
|
31
|
+
# also saw a failure it could not attribute to a file (which makes the
|
|
32
|
+
# kill genuine, never one a flaky file alone may have caused)
|
|
33
|
+
def killing_tests
|
|
34
|
+
return [] if @report.nil? || @report["unattributed_failures"]
|
|
35
|
+
|
|
36
|
+
Array(@report["failed_tests"]).map { |path| File.expand_path(path) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def reported_verdict
|
|
42
|
+
case @report["outcome"]
|
|
43
|
+
when "activation_failed" then [:compile_error, "activation failed: #{@report['reason']}"]
|
|
44
|
+
when "environment_failed"
|
|
45
|
+
[:compile_error, "the test files failed to load, before the mutant was activated"]
|
|
46
|
+
when "system_exit"
|
|
47
|
+
[:runtime_error, "the test run ended early with SystemExit (status #{@report['status']})"]
|
|
48
|
+
when "tests_finished" then test_verdict
|
|
49
|
+
else [:runtime_error, "child reported an unknown outcome: #{@report['outcome']}"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_verdict
|
|
54
|
+
return [:compile_error, "no examples ran"] if ExampleSummary.none_ran?(@output)
|
|
55
|
+
|
|
56
|
+
@report["exit_code"].to_i.zero? ? [:survived, nil] : [:killed, nil]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def signaled?
|
|
60
|
+
@wait_result.respond_to?(:signaled?) && @wait_result.signaled?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def signal_name
|
|
64
|
+
"SIG#{Signal.signame(@wait_result.termsig)}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def exit_status
|
|
68
|
+
@wait_result.respond_to?(:exitstatus) ? @wait_result.exitstatus : "unknown"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -12,7 +12,11 @@ module Henitai
|
|
|
12
12
|
NODE_TYPES
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# A list without brackets (rescue classes, multiple return values,
|
|
16
|
+
# multiple assignment) is not an array literal and has no empty form.
|
|
15
17
|
def mutate(node, subject:)
|
|
18
|
+
return [] unless node.location.begin
|
|
19
|
+
|
|
16
20
|
if node.children.empty?
|
|
17
21
|
[empty_array_mutant(subject:, node:)]
|
|
18
22
|
else
|
|
@@ -51,6 +51,8 @@ module Henitai
|
|
|
51
51
|
when :casgn
|
|
52
52
|
namespace, name = left.children
|
|
53
53
|
Parser::AST::Node.new(:casgn, [namespace, name, right])
|
|
54
|
+
when :indexasgn
|
|
55
|
+
left.updated(nil, [*left.children, right])
|
|
54
56
|
when :send
|
|
55
57
|
assignment_name = left.children[1] == :[] ? :[]= : :"#{left.children[1]}="
|
|
56
58
|
receiver, _method_name, *arguments = left.children
|
|
@@ -28,6 +28,8 @@ module Henitai
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
30
|
def mutate_if(node, subject:)
|
|
31
|
+
return elsif_condition_mutants(node, subject:) if elsif?(node)
|
|
32
|
+
|
|
31
33
|
condition, then_branch, else_branch = node.children
|
|
32
34
|
mutations = condition_variants(node, subject:, condition:)
|
|
33
35
|
|
|
@@ -38,6 +40,22 @@ module Henitai
|
|
|
38
40
|
mutations
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# An elsif's source range starts at `elsif` and has no `end`, so a
|
|
44
|
+
# re-rendered `if ... end` cannot take its place. Only its condition is
|
|
45
|
+
# mutated, in place; removing a branch is what "replaced condition with
|
|
46
|
+
# false" already does.
|
|
47
|
+
def elsif_condition_mutants(node, subject:)
|
|
48
|
+
condition = node.children.first
|
|
49
|
+
[[true_node, "replaced condition with true"], [false_node, "replaced condition with false"],
|
|
50
|
+
[negate(condition), "negated condition"]].map do |replacement, description|
|
|
51
|
+
build_mutant(subject:, original_node: condition, mutated_node: replacement, description:)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def elsif?(node)
|
|
56
|
+
node.location.respond_to?(:keyword) && node.location.keyword&.source == "elsif"
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
def mutate_case(node, subject:)
|
|
42
60
|
condition = node.children.first
|
|
43
61
|
when_nodes, else_branch = case_children(node.children.drop(1))
|
|
@@ -154,8 +172,10 @@ module Henitai
|
|
|
154
172
|
Parser::AST::Node.new(node.type, children)
|
|
155
173
|
end
|
|
156
174
|
|
|
175
|
+
# The parentheses are part of the mutation: `!a > b` would negate only
|
|
176
|
+
# `a` and compare the boolean, which is not the reported mutant.
|
|
157
177
|
def negate(node)
|
|
158
|
-
Parser::AST::Node.new(:send, [node, :!])
|
|
178
|
+
Parser::AST::Node.new(:send, [Parser::AST::Node.new(:begin, [node]), :!])
|
|
159
179
|
end
|
|
160
180
|
|
|
161
181
|
# rubocop:disable Lint/BooleanSymbol
|
|
@@ -9,7 +9,10 @@ module Henitai
|
|
|
9
9
|
# set) because framework key normalization makes it frequently unkillable
|
|
10
10
|
# (ADR-12).
|
|
11
11
|
class HashLiteral < Henitai::Operator
|
|
12
|
-
|
|
12
|
+
# Keyword arguments (kwargs) are mutated like hash literals; the mutant
|
|
13
|
+
# keeps the node type, so a removed keyword never turns the remaining
|
|
14
|
+
# ones into a positional hash.
|
|
15
|
+
NODE_TYPES = %i[hash kwargs].freeze
|
|
13
16
|
|
|
14
17
|
def self.node_types
|
|
15
18
|
NODE_TYPES
|
|
@@ -29,8 +32,8 @@ module Henitai
|
|
|
29
32
|
build_mutant(
|
|
30
33
|
subject:,
|
|
31
34
|
original_node: node,
|
|
32
|
-
mutated_node:
|
|
33
|
-
description: "replaced hash with empty hash"
|
|
35
|
+
mutated_node: node.updated(nil, []),
|
|
36
|
+
description: node.type == :kwargs ? "removed keyword arguments" : "replaced hash with empty hash"
|
|
34
37
|
)
|
|
35
38
|
end
|
|
36
39
|
|
|
@@ -52,7 +55,7 @@ module Henitai
|
|
|
52
55
|
|
|
53
56
|
def hash_without_pair(node, pair_index)
|
|
54
57
|
remaining = node.children.reject.with_index { |_pair, index| index == pair_index }
|
|
55
|
-
|
|
58
|
+
node.updated(nil, remaining)
|
|
56
59
|
end
|
|
57
60
|
|
|
58
61
|
# Pair keys are always AST nodes (sym/str/…); their first child is the
|