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
|
@@ -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
|
|
@@ -5,8 +5,11 @@ require_relative "../parser_current"
|
|
|
5
5
|
module Henitai
|
|
6
6
|
module Operators
|
|
7
7
|
# Replaces generic method call results with nil.
|
|
8
|
+
#
|
|
9
|
+
# A call with a block is replaced together with its block: replacing only
|
|
10
|
+
# the call would leave +nil { ... }+ behind, which does not parse.
|
|
8
11
|
class MethodExpression < Henitai::Operator
|
|
9
|
-
NODE_TYPES = [
|
|
12
|
+
NODE_TYPES = %i[send block numblock].freeze
|
|
10
13
|
EXCLUDED_METHODS = %i[
|
|
11
14
|
+
|
|
12
15
|
-
|
|
@@ -31,9 +34,10 @@ module Henitai
|
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
def mutate(node, subject:)
|
|
34
|
-
|
|
37
|
+
call = called_method(node)
|
|
38
|
+
return [] unless call&.type == :send
|
|
35
39
|
|
|
36
|
-
_receiver, method_name, *_arguments =
|
|
40
|
+
_receiver, method_name, *_arguments = call.children
|
|
37
41
|
return [] if excluded_method?(method_name)
|
|
38
42
|
|
|
39
43
|
[
|
|
@@ -48,6 +52,13 @@ module Henitai
|
|
|
48
52
|
|
|
49
53
|
private
|
|
50
54
|
|
|
55
|
+
def called_method(node)
|
|
56
|
+
case node.type
|
|
57
|
+
when :send then node
|
|
58
|
+
when :block, :numblock then node.children.first
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def excluded_method?(method_name)
|
|
52
63
|
method_name.to_s.end_with?("=") || EXCLUDED_METHODS.include?(method_name)
|
|
53
64
|
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module Operators
|
|
5
|
+
# Locates the regexp metacharacters that RegexMutator edits.
|
|
6
|
+
#
|
|
7
|
+
# An escaped character and the contents of a character class are literal
|
|
8
|
+
# text: the +^+ in +[^0-9]+ negates the class and is no anchor, and the
|
|
9
|
+
# +++ in +[+-]+ or +\++ is no quantifier. The scan therefore skips
|
|
10
|
+
# escapes and tracks character-class nesting, including POSIX brackets
|
|
11
|
+
# such as +[[:alpha:]]+.
|
|
12
|
+
class RegexMetacharacters
|
|
13
|
+
ANCHORS = %w[^ $].freeze
|
|
14
|
+
QUANTIFIERS = %w[+ * ?].freeze
|
|
15
|
+
GROUPING = %w[( |].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(source)
|
|
18
|
+
@source = source
|
|
19
|
+
@found = Hash.new { |hash, key| hash[key] = [] }
|
|
20
|
+
scan
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Indices of the +char+ quantifier ("+" or "*") applied to an atom.
|
|
24
|
+
def quantifiers(char)
|
|
25
|
+
@found[[:quantifier, char]].dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Indices of the +char+ anchor ("^" or "$").
|
|
29
|
+
def anchors(char)
|
|
30
|
+
@found[[:anchor, char]].dup
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Indices of the opening brackets of top-level, non-negated classes.
|
|
34
|
+
def class_openers
|
|
35
|
+
@found[:class_opener].dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def scan
|
|
41
|
+
@index = 0
|
|
42
|
+
@depth = 0
|
|
43
|
+
@previous = nil
|
|
44
|
+
step while @index < @source.length
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def step
|
|
48
|
+
char = @source[@index]
|
|
49
|
+
if char == "\\"
|
|
50
|
+
@previous = :atom
|
|
51
|
+
@index += 2
|
|
52
|
+
else
|
|
53
|
+
@depth.positive? ? within_class(char) : top_level(char)
|
|
54
|
+
@index += 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def within_class(char)
|
|
59
|
+
@depth += 1 if char == "["
|
|
60
|
+
@depth -= 1 if char == "]"
|
|
61
|
+
@previous = :atom if @depth.zero?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def top_level(char)
|
|
65
|
+
record(char)
|
|
66
|
+
@previous = kind_of_token(char)
|
|
67
|
+
@depth = 1 if char == "["
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def record(char)
|
|
71
|
+
if ANCHORS.include?(char)
|
|
72
|
+
@found[[:anchor, char]] << @index
|
|
73
|
+
elsif char == "["
|
|
74
|
+
@found[:class_opener] << @index unless @source[@index + 1] == "^"
|
|
75
|
+
elsif QUANTIFIERS.include?(char) && @previous == :atom
|
|
76
|
+
@found[[:quantifier, char]] << @index
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def kind_of_token(char)
|
|
81
|
+
return :anchor if ANCHORS.include?(char)
|
|
82
|
+
return :quantifier if QUANTIFIERS.include?(char) || char == "}"
|
|
83
|
+
return :group if GROUPING.include?(char)
|
|
84
|
+
|
|
85
|
+
:atom
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../parser_current"
|
|
4
|
+
require_relative "regex_metacharacters"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
module Operators
|
|
@@ -8,7 +9,10 @@ module Henitai
|
|
|
8
9
|
# and character-class negation.
|
|
9
10
|
#
|
|
10
11
|
# Each applicable transformation yields a separate mutant. Invalid
|
|
11
|
-
# results (unparseable regex) are discarded before emission.
|
|
12
|
+
# results (unparseable regex) are discarded before emission. Only static,
|
|
13
|
+
# single-segment literals are mutated: an interpolated or multi-line
|
|
14
|
+
# regexp is split into several parts, and rewriting one part alone would
|
|
15
|
+
# drop or misread the others.
|
|
12
16
|
# Anchor removal and character-class negation are intentionally one-way:
|
|
13
17
|
# they reduce noisy patterns rather than mirroring a full edit matrix.
|
|
14
18
|
class RegexMutator < Henitai::Operator
|
|
@@ -30,11 +34,11 @@ module Henitai
|
|
|
30
34
|
private
|
|
31
35
|
|
|
32
36
|
def extract_parts(node)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
37
|
+
*parts, opts_node = node.children
|
|
38
|
+
return unless opts_node.is_a?(Parser::AST::Node) && opts_node.type == :regopt
|
|
39
|
+
return unless parts.size == 1 && parts.first.is_a?(Parser::AST::Node) && parts.first.type == :str
|
|
36
40
|
|
|
37
|
-
[
|
|
41
|
+
[parts.first.children[0], opts_node]
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def build_regex_mutant(node, opts_node, new_source, original, description, subject:) # rubocop:disable Metrics/ParameterLists
|
|
@@ -47,35 +51,26 @@ module Henitai
|
|
|
47
51
|
end
|
|
48
52
|
|
|
49
53
|
def transformations(source)
|
|
54
|
+
found = RegexMetacharacters.new(source)
|
|
50
55
|
[
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
[edit(source, found.quantifiers("+"), "*"), "replaced + quantifier with *"],
|
|
57
|
+
[edit(source, found.quantifiers("*"), "+"), "replaced * quantifier with +"],
|
|
58
|
+
# Anchors are removed, never added, and character classes are only
|
|
59
|
+
# negated: the reverse edits would just recreate an input.
|
|
60
|
+
[edit(source, found.anchors("^").first(1), ""), "removed ^ anchor"],
|
|
61
|
+
[edit(source, found.anchors("$").last(1), ""), "removed $ anchor"],
|
|
62
|
+
[insert_after(source, found.class_openers, "^"), "negated character class"]
|
|
54
63
|
]
|
|
55
64
|
end
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
[
|
|
61
|
-
[source.gsub(/(?<=[^*+?\\])\+/, "*"), "replaced + quantifier with *"],
|
|
62
|
-
[source.gsub(/(?<=[^*+?\\])\*/, "+"), "replaced * quantifier with +"]
|
|
63
|
-
]
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def anchor_removals(source)
|
|
67
|
-
# Anchors are removed, not added back. The mutation is deliberately
|
|
68
|
-
# asymmetric because "restoring" anchors would just recreate the input.
|
|
69
|
-
[
|
|
70
|
-
[source.sub("^", ""), "removed ^ anchor"],
|
|
71
|
-
[source.sub(/\$$/, ""), "removed $ anchor"]
|
|
72
|
-
]
|
|
66
|
+
# Replaces the single characters at +indices+, from the last to the
|
|
67
|
+
# first so earlier indices stay valid.
|
|
68
|
+
def edit(source, indices, text)
|
|
69
|
+
indices.sort.reverse.each_with_object(source.dup) { |index, result| result[index] = text }
|
|
73
70
|
end
|
|
74
71
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
# emit the reverse mutation because that would mirror the same edit.
|
|
78
|
-
[[source.gsub(/\[(?!\^)/, "[^"), "negated character class"]]
|
|
72
|
+
def insert_after(source, indices, text)
|
|
73
|
+
indices.sort.reverse.each_with_object(source.dup) { |index, result| result.insert(index + 1, text) }
|
|
79
74
|
end
|
|
80
75
|
|
|
81
76
|
def valid_regex?(source)
|
|
@@ -47,7 +47,7 @@ module Henitai
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def mutate_implicit_return(node, subject:)
|
|
50
|
-
return [] unless node
|
|
50
|
+
return [] unless node.equal?(final_expression_node(subject&.ast_node))
|
|
51
51
|
|
|
52
52
|
replacement_nodes(node).map do |replacement|
|
|
53
53
|
build_mutant(
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Henitai
|
|
|
24
24
|
autoload :MethodExpression, "henitai/operators/method_expression"
|
|
25
25
|
autoload :AssignmentExpression, "henitai/operators/assignment_expression"
|
|
26
26
|
autoload :MethodChainUnwrap, "henitai/operators/method_chain_unwrap"
|
|
27
|
+
autoload :RegexMetacharacters, "henitai/operators/regex_metacharacters"
|
|
27
28
|
autoload :RegexMutator, "henitai/operators/regex_mutator"
|
|
28
29
|
autoload :UnaryOperator, "henitai/operators/unary_operator"
|
|
29
30
|
autoload :UpdateOperator, "henitai/operators/update_operator"
|