henitai 0.2.0 → 0.3.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 +105 -1
- data/README.md +138 -5
- data/assets/schema/henitai.schema.json +41 -0
- data/lib/henitai/available_cpu_count.rb +16 -23
- data/lib/henitai/canonical_report_merger.rb +107 -0
- data/lib/henitai/canonical_report_writer.rb +22 -0
- data/lib/henitai/checkpoint_reporter.rb +79 -0
- data/lib/henitai/cli/clean_command.rb +54 -0
- data/lib/henitai/cli/command_support.rb +52 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +96 -0
- data/lib/henitai/cli/options.rb +84 -0
- data/lib/henitai/cli/run_command.rb +136 -0
- data/lib/henitai/cli/run_options.rb +108 -0
- data/lib/henitai/cli.rb +27 -407
- data/lib/henitai/composite_progress_reporter.rb +42 -0
- data/lib/henitai/configuration.rb +42 -9
- data/lib/henitai/configuration_validator/rules.rb +161 -0
- data/lib/henitai/configuration_validator/scalars.rb +169 -0
- data/lib/henitai/configuration_validator.rb +20 -240
- data/lib/henitai/coverage_bootstrapper.rb +54 -5
- data/lib/henitai/coverage_formatter.rb +5 -1
- data/lib/henitai/coverage_report_reader.rb +28 -5
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine/env_scope.rb +67 -0
- data/lib/henitai/execution_engine.rb +115 -53
- data/lib/henitai/generated_artifacts.rb +58 -0
- data/lib/henitai/incremental_filter.rb +100 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +119 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +52 -0
- data/lib/henitai/integration/minitest.rb +101 -0
- data/lib/henitai/integration/minitest_load_path.rb +14 -0
- data/lib/henitai/integration/minitest_suite_command.rb +17 -0
- data/lib/henitai/integration/minitest_test_runner.rb +39 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_test_selection.rb +138 -0
- data/lib/henitai/integration/scenario_log_support.rb +160 -0
- data/lib/henitai/integration.rb +22 -846
- data/lib/henitai/minitest_coverage_reporter.rb +4 -1
- data/lib/henitai/mutant/activator.rb +15 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -6
- data/lib/henitai/mutant_history_store/sql.rb +90 -0
- data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
- data/lib/henitai/mutant_history_store.rb +67 -81
- data/lib/henitai/mutant_identity.rb +33 -2
- data/lib/henitai/mutation_skip_directives.rb +227 -0
- data/lib/henitai/operator.rb +3 -1
- data/lib/henitai/operators/equality_identity_operator.rb +51 -0
- data/lib/henitai/operators/equality_operator.rb +8 -3
- data/lib/henitai/operators.rb +1 -0
- data/lib/henitai/per_test_coverage.rb +66 -0
- data/lib/henitai/per_test_coverage_collector.rb +23 -6
- data/lib/henitai/per_test_coverage_selector.rb +11 -33
- data/lib/henitai/process_worker_runner.rb +48 -334
- data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
- data/lib/henitai/reporter.rb +258 -125
- data/lib/henitai/reports_directory_lock.rb +76 -0
- data/lib/henitai/result.rb +88 -23
- data/lib/henitai/runner.rb +137 -218
- data/lib/henitai/scenario_execution_result.rb +12 -0
- data/lib/henitai/slot_scheduler/draining.rb +146 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +272 -0
- data/lib/henitai/static_filter.rb +16 -6
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_test_filter.rb +1 -1
- data/lib/henitai/test_prioritizer.rb +28 -2
- data/lib/henitai/timeout_calibrator.rb +44 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/verdict_fingerprint.rb +155 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +16 -1
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +386 -96
- metadata +45 -3
- data/lib/henitai/parallel_execution_runner.rb +0 -153
|
@@ -10,10 +10,21 @@ module Henitai
|
|
|
10
10
|
# session UUID or source coordinates, so it survives ordinary line shifts.
|
|
11
11
|
module MutantIdentity
|
|
12
12
|
def self.stable_id(mutant)
|
|
13
|
-
|
|
13
|
+
digest(identity_components(mutant))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Stable id formula used before site offsets were introduced. Emitted as a
|
|
17
|
+
# temporary report alias so scoped runs can replace pre-migration entries.
|
|
18
|
+
def self.legacy_stable_id(mutant)
|
|
19
|
+
digest(legacy_identity_components(mutant))
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def self.identity_components(mutant)
|
|
23
|
+
legacy_identity_components(mutant) + [site_offset(mutant)]
|
|
24
|
+
end
|
|
25
|
+
private_class_method :identity_components
|
|
26
|
+
|
|
27
|
+
def self.legacy_identity_components(mutant)
|
|
17
28
|
[
|
|
18
29
|
mutant.subject.expression,
|
|
19
30
|
mutant.operator,
|
|
@@ -22,7 +33,27 @@ module Henitai
|
|
|
22
33
|
mutation_signature(mutant)
|
|
23
34
|
]
|
|
24
35
|
end
|
|
25
|
-
private_class_method :
|
|
36
|
+
private_class_method :legacy_identity_components
|
|
37
|
+
|
|
38
|
+
def self.digest(components)
|
|
39
|
+
Digest::SHA256.hexdigest(components.join("\0"))
|
|
40
|
+
end
|
|
41
|
+
private_class_method :digest
|
|
42
|
+
|
|
43
|
+
# Position of the mutation site relative to its subject's own start,
|
|
44
|
+
# not the file's absolute line/column — this still disambiguates two
|
|
45
|
+
# call sites that produce the same operator/description/signature
|
|
46
|
+
# within one subject (e.g. two `MethodExpression — replaced with nil`
|
|
47
|
+
# mutants on different lines of a method), while surviving line drift
|
|
48
|
+
# elsewhere in the file, which would shift absolute coordinates but
|
|
49
|
+
# not this offset.
|
|
50
|
+
def self.site_offset(mutant)
|
|
51
|
+
subject_start = mutant.subject.source_range&.begin
|
|
52
|
+
return mutant.location[:start_col].to_s unless subject_start
|
|
53
|
+
|
|
54
|
+
"#{mutant.location[:start_line] - subject_start}:#{mutant.location[:start_col]}"
|
|
55
|
+
end
|
|
56
|
+
private_class_method :site_offset
|
|
26
57
|
|
|
27
58
|
def self.mutation_signature(mutant)
|
|
28
59
|
Unparser.unparse(mutant.mutated_node)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Reads `# henitai:disable` magic comments from subject source files and
|
|
7
|
+
# decides whether a mutant is excluded from the run.
|
|
8
|
+
#
|
|
9
|
+
# Grammar (backward compatible — a bare directive means "all operators"):
|
|
10
|
+
#
|
|
11
|
+
# # henitai:disable all operators, current scope
|
|
12
|
+
# # henitai:disable -- prose same; `--` introduces prose
|
|
13
|
+
# # henitai:disable OpA, OpB only the named operators
|
|
14
|
+
# # henitai:disable OpA: reason reason lands in the report
|
|
15
|
+
# # henitai:disable: reason all operators, with reason
|
|
16
|
+
# # henitai:disable-start [ops][: reason] region begin
|
|
17
|
+
# # henitai:disable-end region end
|
|
18
|
+
#
|
|
19
|
+
# Scopes: trailing comment (line), standalone comment directly above a
|
|
20
|
+
# `def` (method), and disable-start/disable-end pairs (region, no nesting).
|
|
21
|
+
# Operator names must exactly match the canonical registry names
|
|
22
|
+
# (`henitai operator list`); unknown names, unmatched or nested region
|
|
23
|
+
# directives raise Henitai::ConfigurationError with file:line.
|
|
24
|
+
#
|
|
25
|
+
# Matching mutants are reported as ignored by {StaticFilter}, not dropped.
|
|
26
|
+
class MutationSkipDirectives
|
|
27
|
+
DIRECTIVE = /\A#\s*henitai:disable(?<kind>-start|-end)?(?<rest>[:\s].*)?\z/
|
|
28
|
+
VALID_OPERATOR_NAMES = Operator::FULL_SET
|
|
29
|
+
|
|
30
|
+
# A parsed directive: +operators+ is nil (all) or a Set of canonical
|
|
31
|
+
# operator names; +reason+ is optional free text shown in reports.
|
|
32
|
+
Directive = Data.define(:operators, :reason) do
|
|
33
|
+
def match?(operator)
|
|
34
|
+
operators.nil? || operators.include?(operator.to_s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Per-file directives, classified by scope.
|
|
39
|
+
Index = Data.define(:trailing, :standalone, :regions, :standalone_comment_lines)
|
|
40
|
+
EMPTY_INDEX = Index.new(
|
|
41
|
+
trailing: {}.freeze,
|
|
42
|
+
standalone: {}.freeze,
|
|
43
|
+
regions: [].freeze,
|
|
44
|
+
standalone_comment_lines: Set.new.freeze
|
|
45
|
+
).freeze
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@index_cache = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def skip?(mutant)
|
|
52
|
+
!directive_for(mutant).nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Directive, nil] the directive excluding this mutant, if any.
|
|
56
|
+
def directive_for(mutant)
|
|
57
|
+
line_directive_for(mutant) || region_directive_for(mutant) || method_directive_for(mutant)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def line_directive_for(mutant)
|
|
63
|
+
directive = index_for(mutant.location[:file]).trailing[mutant.location[:start_line]]
|
|
64
|
+
directive&.match?(mutant.operator) ? directive : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def region_directive_for(mutant)
|
|
68
|
+
index = index_for(mutant.location[:file])
|
|
69
|
+
line = mutant.location[:start_line]
|
|
70
|
+
_range, directive = index.regions.find do |range, region_directive|
|
|
71
|
+
range.cover?(line) && region_directive.match?(mutant.operator)
|
|
72
|
+
end
|
|
73
|
+
directive
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def method_directive_for(mutant)
|
|
77
|
+
range = mutant.subject&.source_range
|
|
78
|
+
return nil unless range
|
|
79
|
+
|
|
80
|
+
index = index_for(mutant.subject.source_file)
|
|
81
|
+
line = range.begin - 1
|
|
82
|
+
while index.standalone_comment_lines.include?(line)
|
|
83
|
+
directive = index.standalone[line]
|
|
84
|
+
return directive if directive&.match?(mutant.operator)
|
|
85
|
+
|
|
86
|
+
line -= 1
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def index_for(path)
|
|
92
|
+
mtime = File.mtime(path)
|
|
93
|
+
cached_mtime, cached_index = @index_cache[path]
|
|
94
|
+
return cached_index if cached_mtime == mtime
|
|
95
|
+
|
|
96
|
+
index = build_index(path)
|
|
97
|
+
@index_cache[path] = [mtime, index]
|
|
98
|
+
index
|
|
99
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
100
|
+
EMPTY_INDEX
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_index(path)
|
|
104
|
+
source = File.read(path)
|
|
105
|
+
lines = source.lines
|
|
106
|
+
builder = IndexBuilder.new(path)
|
|
107
|
+
|
|
108
|
+
Prism.parse(source).comments.sort_by { |comment| comment.location.start_line }.each do |comment|
|
|
109
|
+
builder.record(comment, standalone: standalone?(comment, lines))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
builder.index
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def standalone?(comment, lines)
|
|
116
|
+
prefix = lines[comment.location.start_line - 1].to_s[0...comment.location.start_column]
|
|
117
|
+
prefix.strip.empty?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Accumulates classified directives for one file, tracking open
|
|
121
|
+
# disable-start regions and raising on malformed directive usage.
|
|
122
|
+
class IndexBuilder
|
|
123
|
+
def initialize(path)
|
|
124
|
+
@path = path
|
|
125
|
+
@trailing = {}
|
|
126
|
+
@standalone = {}
|
|
127
|
+
@regions = []
|
|
128
|
+
@standalone_comment_lines = Set.new
|
|
129
|
+
@open_region = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record(comment, standalone:)
|
|
133
|
+
line = comment.location.start_line
|
|
134
|
+
@standalone_comment_lines << line if standalone
|
|
135
|
+
match = DIRECTIVE.match(comment.slice)
|
|
136
|
+
return unless match
|
|
137
|
+
|
|
138
|
+
dispatch(match, line, standalone)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def index
|
|
142
|
+
ensure_all_regions_closed!
|
|
143
|
+
|
|
144
|
+
Index.new(
|
|
145
|
+
trailing: @trailing.freeze,
|
|
146
|
+
standalone: @standalone.freeze,
|
|
147
|
+
regions: @regions.freeze,
|
|
148
|
+
standalone_comment_lines: @standalone_comment_lines.freeze
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def ensure_all_regions_closed!
|
|
155
|
+
return unless @open_region
|
|
156
|
+
|
|
157
|
+
error("unclosed `henitai:disable-start` (opened at line #{@open_region.fetch(:line)})")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def dispatch(match, line, standalone)
|
|
161
|
+
case match[:kind]
|
|
162
|
+
when "-start" then open_region(parse_payload(match[:rest], line), line)
|
|
163
|
+
when "-end" then close_region(line)
|
|
164
|
+
else
|
|
165
|
+
bucket = standalone ? @standalone : @trailing
|
|
166
|
+
bucket[line] = parse_payload(match[:rest], line)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def open_region(directive, line)
|
|
171
|
+
error("nested `henitai:disable-start` at line #{line}") if @open_region
|
|
172
|
+
|
|
173
|
+
@open_region = { line:, directive: }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def close_region(line)
|
|
177
|
+
error("`henitai:disable-end` without a matching start at line #{line}") unless @open_region
|
|
178
|
+
|
|
179
|
+
@regions << [(@open_region.fetch(:line)..line), @open_region.fetch(:directive)]
|
|
180
|
+
@open_region = nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def parse_payload(rest, line)
|
|
184
|
+
text = rest.to_s.strip
|
|
185
|
+
return Directive.new(operators: nil, reason: nil) if prose?(text)
|
|
186
|
+
return Directive.new(operators: nil, reason: presence(text[1..])) if text.start_with?(":")
|
|
187
|
+
|
|
188
|
+
operators_part, reason = text.split(":", 2)
|
|
189
|
+
Directive.new(
|
|
190
|
+
operators: parse_operator_names(operators_part, line),
|
|
191
|
+
reason: presence(reason)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Free-form rationale after a bare directive stays valid (pre-grammar
|
|
196
|
+
# behavior): empty, `-- prose`, or anything whose first token cannot be
|
|
197
|
+
# an operator name (operators are CamelCase). Only CamelCase-looking
|
|
198
|
+
# tokens are validated against the registry and can raise.
|
|
199
|
+
def prose?(text)
|
|
200
|
+
text.empty? || text.start_with?("--") ||
|
|
201
|
+
(!text.start_with?(":") && !text.match?(/\A[A-Z]/))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def parse_operator_names(operators_part, line)
|
|
205
|
+
names = operators_part.split(",", -1).map(&:strip)
|
|
206
|
+
names.each do |name|
|
|
207
|
+
next if VALID_OPERATOR_NAMES.include?(name)
|
|
208
|
+
|
|
209
|
+
error(
|
|
210
|
+
"unknown operator #{name.inspect} in `henitai:disable` directive at line #{line} " \
|
|
211
|
+
"(valid names: `henitai operator list`)"
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
names.to_set
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def presence(text)
|
|
218
|
+
stripped = text.to_s.strip
|
|
219
|
+
stripped.empty? ? nil : stripped
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def error(message)
|
|
223
|
+
raise Henitai::ConfigurationError, "#{@path}: #{message}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/henitai/operator.rb
CHANGED
|
@@ -14,7 +14,8 @@ module Henitai
|
|
|
14
14
|
# Additional operators (full set):
|
|
15
15
|
# ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
|
|
16
16
|
# PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
|
|
17
|
-
# UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
|
|
17
|
+
# UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap,
|
|
18
|
+
# EqualityIdentityOperator
|
|
18
19
|
#
|
|
19
20
|
# Each operator subclass must implement:
|
|
20
21
|
# - .node_types → Array<Symbol> AST node types this operator handles
|
|
@@ -43,6 +44,7 @@ module Henitai
|
|
|
43
44
|
AssignmentExpression
|
|
44
45
|
UnaryOperator
|
|
45
46
|
UpdateOperator
|
|
47
|
+
EqualityIdentityOperator
|
|
46
48
|
]).freeze
|
|
47
49
|
|
|
48
50
|
# @param set [Symbol] :light or :full
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
module Operators
|
|
7
|
+
# Replaces relational operators with identity methods (`eql?`, `equal?`)
|
|
8
|
+
# and vice versa.
|
|
9
|
+
#
|
|
10
|
+
# This is the noisy half of the equality/identity pairing split out of
|
|
11
|
+
# EqualityOperator: most Ruby objects don't observably distinguish `==`
|
|
12
|
+
# from `eql?`/`equal?`, so these mutations are frequently unkillable by
|
|
13
|
+
# ordinary tests. They stay available in the full operator set rather
|
|
14
|
+
# than the default light set.
|
|
15
|
+
class EqualityIdentityOperator < Henitai::Operator
|
|
16
|
+
NODE_TYPES = [:send].freeze
|
|
17
|
+
RELATIONAL = %i[== != < > <= >= <=>].freeze
|
|
18
|
+
IDENTITY = %i[eql? equal?].freeze
|
|
19
|
+
OPERATORS = (RELATIONAL + IDENTITY).freeze
|
|
20
|
+
|
|
21
|
+
def self.node_types
|
|
22
|
+
NODE_TYPES
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def mutate(node, subject:)
|
|
26
|
+
method_name = node.children[1]
|
|
27
|
+
return [] unless OPERATORS.include?(method_name)
|
|
28
|
+
|
|
29
|
+
OPERATORS.each_with_object([]) do |replacement, mutants|
|
|
30
|
+
next if replacement == method_name
|
|
31
|
+
next if RELATIONAL.include?(method_name) && RELATIONAL.include?(replacement)
|
|
32
|
+
|
|
33
|
+
mutants << build_mutant(
|
|
34
|
+
subject:,
|
|
35
|
+
original_node: node,
|
|
36
|
+
mutated_node: mutated_node(node, replacement),
|
|
37
|
+
description: "replaced #{method_name} with #{replacement}"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def mutated_node(node, replacement)
|
|
45
|
+
receiver = node.children[0]
|
|
46
|
+
arguments = node.children[2..]
|
|
47
|
+
Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -4,10 +4,15 @@ require_relative "../parser_current"
|
|
|
4
4
|
|
|
5
5
|
module Henitai
|
|
6
6
|
module Operators
|
|
7
|
-
# Replaces
|
|
7
|
+
# Replaces relational/equality operators with the other relational operators.
|
|
8
|
+
#
|
|
9
|
+
# Identity-method comparisons (`eql?`, `equal?`) are handled separately by
|
|
10
|
+
# EqualityIdentityOperator: that pairing is the hardest to kill in practice
|
|
11
|
+
# (most objects don't observably distinguish `==` from `eql?`/`equal?`), so
|
|
12
|
+
# it is kept out of the default light set.
|
|
8
13
|
class EqualityOperator < Henitai::Operator
|
|
9
14
|
NODE_TYPES = [:send].freeze
|
|
10
|
-
OPERATORS = %i[== != < > <= >= <=>
|
|
15
|
+
OPERATORS = %i[== != < > <= >= <=>].freeze
|
|
11
16
|
|
|
12
17
|
def self.node_types
|
|
13
18
|
NODE_TYPES
|
|
@@ -33,7 +38,7 @@ module Henitai
|
|
|
33
38
|
|
|
34
39
|
def mutated_node(node, replacement)
|
|
35
40
|
receiver = node.children[0]
|
|
36
|
-
arguments = node.children[2..]
|
|
41
|
+
arguments = node.children[2..]
|
|
37
42
|
Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
|
|
38
43
|
end
|
|
39
44
|
end
|
data/lib/henitai/operators.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Henitai
|
|
|
8
8
|
module Operators
|
|
9
9
|
autoload :ArithmeticOperator, "henitai/operators/arithmetic_operator"
|
|
10
10
|
autoload :EqualityOperator, "henitai/operators/equality_operator"
|
|
11
|
+
autoload :EqualityIdentityOperator, "henitai/operators/equality_identity_operator"
|
|
11
12
|
autoload :LogicalOperator, "henitai/operators/logical_operator"
|
|
12
13
|
autoload :BooleanLiteral, "henitai/operators/boolean_literal"
|
|
13
14
|
autoload :ConditionalExpression, "henitai/operators/conditional_expression"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Read-side view of the per-test coverage map (henitai_per_test.json):
|
|
5
|
+
# which test files reach which source lines. The single implementation of
|
|
6
|
+
# the line-intersection check, shared by test selection
|
|
7
|
+
# (PerTestCoverageSelector) and survivor verdict reuse (IncrementalFilter),
|
|
8
|
+
# so both callers always agree on what "covers" means.
|
|
9
|
+
class PerTestCoverage
|
|
10
|
+
def initialize(reports_dir:, coverage_report_reader: CoverageReportReader.new)
|
|
11
|
+
@reports_dir = reports_dir
|
|
12
|
+
@coverage_report_reader = coverage_report_reader
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def available?
|
|
16
|
+
!map.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The full-map intersection set: every test file whose covered lines
|
|
20
|
+
# intersect the mutant's current line range.
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<String>] sorted test file paths; empty when the map or
|
|
23
|
+
# the mutant's location is unavailable — callers must treat that as
|
|
24
|
+
# "unknown", never as "not covered".
|
|
25
|
+
def tests_covering(mutant)
|
|
26
|
+
return [] unless location_available?(mutant)
|
|
27
|
+
|
|
28
|
+
map.keys.select { |test| covers?(test, mutant) }.sort
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when the given test file's recorded coverage intersects the
|
|
32
|
+
# mutant's current line range.
|
|
33
|
+
def covers?(test, mutant)
|
|
34
|
+
return false unless location_available?(mutant)
|
|
35
|
+
|
|
36
|
+
coverage_lines_for(test, mutant).intersect?(mutant_lines(mutant))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :reports_dir, :coverage_report_reader
|
|
42
|
+
|
|
43
|
+
def location_available?(mutant)
|
|
44
|
+
mutant.respond_to?(:location) &&
|
|
45
|
+
mutant.location.is_a?(Hash) &&
|
|
46
|
+
mutant.location[:file] &&
|
|
47
|
+
mutant.location[:start_line] &&
|
|
48
|
+
mutant.location[:end_line]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def coverage_lines_for(test, mutant)
|
|
52
|
+
source_map = map[test.to_s] || {}
|
|
53
|
+
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def mutant_lines(mutant)
|
|
57
|
+
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def map
|
|
61
|
+
@map ||= coverage_report_reader.test_lines_by_file(
|
|
62
|
+
File.join(reports_dir, PerTestCoverageCollector::REPORT_FILE_NAME)
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -18,11 +18,12 @@ module Henitai
|
|
|
18
18
|
@coverage_by_test = Hash.new do |hash, test_file|
|
|
19
19
|
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
20
20
|
end
|
|
21
|
+
@duration_by_test = Hash.new(0.0)
|
|
21
22
|
@previous_snapshot = {}
|
|
22
23
|
@warned_missing_coverage = false
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def record_test(test_file)
|
|
26
|
+
def record_test(test_file, duration: nil)
|
|
26
27
|
snapshot = current_snapshot
|
|
27
28
|
return warn_missing_coverage unless snapshot
|
|
28
29
|
|
|
@@ -31,6 +32,7 @@ module Henitai
|
|
|
31
32
|
@coverage_by_test[test_file][source_file].uniq!
|
|
32
33
|
@coverage_by_test[test_file][source_file].sort!
|
|
33
34
|
end
|
|
35
|
+
@duration_by_test[test_file] += duration.to_f
|
|
34
36
|
@previous_snapshot = snapshot
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -53,17 +55,28 @@ module Henitai
|
|
|
53
55
|
|
|
54
56
|
def current_snapshot
|
|
55
57
|
Coverage.peek_result
|
|
56
|
-
rescue
|
|
58
|
+
rescue RuntimeError
|
|
59
|
+
# Raised when coverage measurement is not running; caller warns and skips.
|
|
60
|
+
# Other errors are genuine bugs and must surface.
|
|
57
61
|
nil
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
def warn_missing_coverage
|
|
61
65
|
return if @warned_missing_coverage
|
|
66
|
+
return if coverage_suppressed?
|
|
62
67
|
|
|
63
68
|
warn "Per-test coverage unavailable; skipping coverage formatter output"
|
|
64
69
|
@warned_missing_coverage = true
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
# Mutant children deliberately suppress coverage startup
|
|
73
|
+
# (CoverageRuntimeSuppressors), so missing coverage is expected there and
|
|
74
|
+
# warning on every child run is pure noise.
|
|
75
|
+
def coverage_suppressed?
|
|
76
|
+
defined?(Henitai::Integration::CoverageRuntimeSuppressors) &&
|
|
77
|
+
Henitai::Integration::CoverageRuntimeSuppressors.active?
|
|
78
|
+
end
|
|
79
|
+
|
|
67
80
|
def new_lines(snapshot)
|
|
68
81
|
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
69
82
|
next unless source_file?(source_file)
|
|
@@ -76,10 +89,13 @@ module Henitai
|
|
|
76
89
|
end
|
|
77
90
|
end
|
|
78
91
|
|
|
92
|
+
# Count-delta attribution: a line belongs to every test whose run
|
|
93
|
+
# incremented its cumulative count, not just the first toucher. This keeps
|
|
94
|
+
# the per-test map a complete over-approximation of reachability — the
|
|
95
|
+
# soundness precondition for survivor verdict reuse (ADR-11).
|
|
79
96
|
def new_line_numbers(file_coverage, previous_counts)
|
|
80
97
|
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
81
|
-
next unless count.to_i.
|
|
82
|
-
next if previous_counts.fetch(index, 0).to_i.positive?
|
|
98
|
+
next unless count.to_i > previous_counts.fetch(index, 0).to_i
|
|
83
99
|
|
|
84
100
|
index + 1
|
|
85
101
|
end
|
|
@@ -109,10 +125,11 @@ module Henitai
|
|
|
109
125
|
end
|
|
110
126
|
|
|
111
127
|
def serializable_report
|
|
112
|
-
@coverage_by_test.
|
|
113
|
-
source_map.to_h do |source_file, lines|
|
|
128
|
+
@coverage_by_test.to_h do |test_file, source_map|
|
|
129
|
+
coverage = source_map.to_h do |source_file, lines|
|
|
114
130
|
[File.expand_path(source_file), lines.uniq.sort]
|
|
115
131
|
end
|
|
132
|
+
[test_file, { "coverage" => coverage, "duration" => @duration_by_test[test_file] }]
|
|
116
133
|
end
|
|
117
134
|
end
|
|
118
135
|
end
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Henitai
|
|
4
4
|
# Narrows candidate test files using the per-test coverage report.
|
|
5
|
+
#
|
|
6
|
+
# The intersection check itself lives in {PerTestCoverage}; this class only
|
|
7
|
+
# adds the selection policy: when no candidate provably covers the mutant
|
|
8
|
+
# (or the map / location is unavailable), it falls back to all candidates —
|
|
9
|
+
# over-selection costs time, never correctness.
|
|
5
10
|
class PerTestCoverageSelector
|
|
6
11
|
def initialize(coverage_report_reader: CoverageReportReader.new)
|
|
7
12
|
@coverage_report_reader = coverage_report_reader
|
|
@@ -10,49 +15,22 @@ module Henitai
|
|
|
10
15
|
def filter(tests, mutant, reports_dir:)
|
|
11
16
|
candidates = Array(tests)
|
|
12
17
|
return candidates if candidates.empty?
|
|
13
|
-
return candidates unless location_available?(mutant)
|
|
14
|
-
return candidates unless per_test_coverage_available?(reports_dir)
|
|
15
18
|
|
|
19
|
+
coverage = per_test_coverage(reports_dir)
|
|
16
20
|
covered_tests = candidates.select do |test|
|
|
17
|
-
|
|
21
|
+
coverage.covers?(test, mutant)
|
|
18
22
|
end
|
|
19
23
|
covered_tests.empty? ? candidates : covered_tests
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
private
|
|
23
27
|
|
|
24
|
-
def location_available?(mutant)
|
|
25
|
-
mutant.respond_to?(:location) &&
|
|
26
|
-
mutant.location.is_a?(Hash) &&
|
|
27
|
-
mutant.location[:file] &&
|
|
28
|
-
mutant.location[:start_line] &&
|
|
29
|
-
mutant.location[:end_line]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def covers_mutant?(test, mutant, reports_dir)
|
|
33
|
-
covered_lines = coverage_lines_for(test, mutant, reports_dir)
|
|
34
|
-
mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def coverage_lines_for(test, mutant, reports_dir)
|
|
38
|
-
source_map = per_test_coverage(reports_dir)[test.to_s] || {}
|
|
39
|
-
Array(source_map[File.expand_path(mutant.location[:file])]).uniq
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def mutant_lines(mutant)
|
|
43
|
-
(mutant.location[:start_line]..mutant.location[:end_line]).to_a
|
|
44
|
-
end
|
|
45
|
-
|
|
46
28
|
def per_test_coverage(reports_dir)
|
|
47
29
|
@per_test_coverage ||= {}
|
|
48
|
-
@per_test_coverage[reports_dir] ||=
|
|
49
|
-
|
|
50
|
-
coverage_report_reader
|
|
51
|
-
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def per_test_coverage_available?(reports_dir)
|
|
55
|
-
!per_test_coverage(reports_dir).empty?
|
|
30
|
+
@per_test_coverage[reports_dir] ||= PerTestCoverage.new(
|
|
31
|
+
reports_dir:,
|
|
32
|
+
coverage_report_reader:
|
|
33
|
+
)
|
|
56
34
|
end
|
|
57
35
|
|
|
58
36
|
attr_reader :coverage_report_reader
|