henitai 0.1.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 +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +182 -0
- data/assets/schema/henitai.schema.json +123 -0
- data/exe/henitai +6 -0
- data/lib/henitai/arid_node_filter.rb +97 -0
- data/lib/henitai/cli.rb +341 -0
- data/lib/henitai/configuration.rb +132 -0
- data/lib/henitai/configuration_validator.rb +293 -0
- data/lib/henitai/coverage_bootstrapper.rb +75 -0
- data/lib/henitai/coverage_formatter.rb +112 -0
- data/lib/henitai/equivalence_detector.rb +85 -0
- data/lib/henitai/execution_engine.rb +174 -0
- data/lib/henitai/git_diff_analyzer.rb +82 -0
- data/lib/henitai/integration.rb +417 -0
- data/lib/henitai/mutant/activator.rb +234 -0
- data/lib/henitai/mutant.rb +68 -0
- data/lib/henitai/mutant_generator.rb +158 -0
- data/lib/henitai/mutant_history_store.rb +279 -0
- data/lib/henitai/operator.rb +96 -0
- data/lib/henitai/operators/arithmetic_operator.rb +46 -0
- data/lib/henitai/operators/array_declaration.rb +52 -0
- data/lib/henitai/operators/assignment_expression.rb +78 -0
- data/lib/henitai/operators/block_statement.rb +31 -0
- data/lib/henitai/operators/boolean_literal.rb +70 -0
- data/lib/henitai/operators/conditional_expression.rb +184 -0
- data/lib/henitai/operators/equality_operator.rb +41 -0
- data/lib/henitai/operators/hash_literal.rb +66 -0
- data/lib/henitai/operators/logical_operator.rb +84 -0
- data/lib/henitai/operators/method_expression.rb +56 -0
- data/lib/henitai/operators/pattern_match.rb +66 -0
- data/lib/henitai/operators/range_literal.rb +40 -0
- data/lib/henitai/operators/return_value.rb +105 -0
- data/lib/henitai/operators/safe_navigation.rb +34 -0
- data/lib/henitai/operators/string_literal.rb +64 -0
- data/lib/henitai/operators.rb +25 -0
- data/lib/henitai/parser_current.rb +7 -0
- data/lib/henitai/reporter.rb +432 -0
- data/lib/henitai/result.rb +170 -0
- data/lib/henitai/runner.rb +183 -0
- data/lib/henitai/sampling_strategy.rb +33 -0
- data/lib/henitai/scenario_execution_result.rb +71 -0
- data/lib/henitai/source_parser.rb +41 -0
- data/lib/henitai/static_filter.rb +186 -0
- data/lib/henitai/stillborn_filter.rb +34 -0
- data/lib/henitai/subject.rb +71 -0
- data/lib/henitai/subject_resolver.rb +232 -0
- data/lib/henitai/syntax_validator.rb +16 -0
- data/lib/henitai/test_prioritizer.rb +55 -0
- data/lib/henitai/unparse_helper.rb +24 -0
- data/lib/henitai/version.rb +5 -0
- data/lib/henitai/warning_silencer.rb +16 -0
- data/lib/henitai.rb +51 -0
- data/sig/configuration_validator.rbs +29 -0
- data/sig/henitai.rbs +594 -0
- data/sig/unparser.rbs +3 -0
- metadata +153 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# rubocop:disable Metrics/ModuleLength
|
|
5
|
+
# Internal validator for configuration data loaded from YAML and CLI overrides.
|
|
6
|
+
module ConfigurationValidator
|
|
7
|
+
VALID_TOP_LEVEL_KEYS = %i[
|
|
8
|
+
integration
|
|
9
|
+
includes
|
|
10
|
+
mutation
|
|
11
|
+
coverage_criteria
|
|
12
|
+
thresholds
|
|
13
|
+
reporters
|
|
14
|
+
reports_dir
|
|
15
|
+
all_logs
|
|
16
|
+
dashboard
|
|
17
|
+
jobs
|
|
18
|
+
].freeze
|
|
19
|
+
VALID_MUTATION_KEYS = %i[operators timeout ignore_patterns max_mutants_per_line max_flaky_retries sampling].freeze
|
|
20
|
+
VALID_SAMPLING_KEYS = %i[ratio strategy].freeze
|
|
21
|
+
VALID_COVERAGE_CRITERIA_KEYS = %i[test_result timeout process_abort].freeze
|
|
22
|
+
VALID_THRESHOLDS_KEYS = %i[high low].freeze
|
|
23
|
+
VALID_DASHBOARD_KEYS = %i[project base_url].freeze
|
|
24
|
+
VALID_INTEGRATION_KEYS = %i[name].freeze
|
|
25
|
+
VALID_OPERATORS = %i[light full].freeze
|
|
26
|
+
VALIDATION_STEPS = %i[
|
|
27
|
+
validate_top_level_keys
|
|
28
|
+
validate_integration
|
|
29
|
+
validate_includes
|
|
30
|
+
validate_jobs
|
|
31
|
+
validate_reporters
|
|
32
|
+
validate_reports_dir
|
|
33
|
+
validate_all_logs
|
|
34
|
+
validate_dashboard
|
|
35
|
+
validate_mutation
|
|
36
|
+
validate_coverage_criteria
|
|
37
|
+
validate_thresholds
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
def self.validate!(raw)
|
|
41
|
+
ensure_hash!(raw, "configuration")
|
|
42
|
+
VALIDATION_STEPS.each { |step| send(step, raw) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class << self
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_top_level_keys(raw)
|
|
49
|
+
warn_unknown_keys(raw, VALID_TOP_LEVEL_KEYS)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def validate_integration(raw)
|
|
53
|
+
value = raw[:integration]
|
|
54
|
+
return if value.nil?
|
|
55
|
+
return if value.is_a?(String)
|
|
56
|
+
|
|
57
|
+
ensure_hash!(value, "integration")
|
|
58
|
+
warn_unknown_keys(value, VALID_INTEGRATION_KEYS, "integration")
|
|
59
|
+
validate_optional_string(value[:name], "integration.name")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_includes(raw)
|
|
63
|
+
validate_string_array(raw[:includes], "includes")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_jobs(raw)
|
|
67
|
+
value = raw[:jobs]
|
|
68
|
+
return if value.nil?
|
|
69
|
+
return if value.is_a?(Integer)
|
|
70
|
+
|
|
71
|
+
configuration_error("Invalid configuration value for jobs: expected Integer, got #{value.class}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_reporters(raw)
|
|
75
|
+
validate_string_array(raw[:reporters], "reporters")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_reports_dir(raw)
|
|
79
|
+
validate_optional_string(raw[:reports_dir], "reports_dir")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_all_logs(raw)
|
|
83
|
+
value = raw[:all_logs]
|
|
84
|
+
return if value.nil?
|
|
85
|
+
|
|
86
|
+
validate_boolean(value, "all_logs")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_dashboard(raw)
|
|
90
|
+
value = raw[:dashboard]
|
|
91
|
+
return if value.nil?
|
|
92
|
+
|
|
93
|
+
ensure_hash!(value, "dashboard")
|
|
94
|
+
warn_unknown_keys(value, VALID_DASHBOARD_KEYS, "dashboard")
|
|
95
|
+
validate_optional_string(value[:project], "dashboard.project")
|
|
96
|
+
validate_optional_string(value[:base_url], "dashboard.base_url")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_mutation(raw)
|
|
100
|
+
value = raw[:mutation]
|
|
101
|
+
return if value.nil?
|
|
102
|
+
|
|
103
|
+
ensure_hash!(value, "mutation")
|
|
104
|
+
warn_unknown_keys(value, VALID_MUTATION_KEYS, "mutation")
|
|
105
|
+
validate_operator(value[:operators])
|
|
106
|
+
validate_mutation_limits(value)
|
|
107
|
+
validate_mutation_filters(value)
|
|
108
|
+
validate_sampling(value[:sampling])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_mutation_limits(value)
|
|
112
|
+
validate_timeout(value[:timeout])
|
|
113
|
+
validate_max_mutants_per_line(value[:max_mutants_per_line])
|
|
114
|
+
validate_max_flaky_retries(value[:max_flaky_retries])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_mutation_filters(value)
|
|
118
|
+
validate_string_array(value[:ignore_patterns], "mutation.ignore_patterns")
|
|
119
|
+
validate_ignore_patterns(value[:ignore_patterns])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_coverage_criteria(raw)
|
|
123
|
+
value = raw[:coverage_criteria]
|
|
124
|
+
return if value.nil?
|
|
125
|
+
|
|
126
|
+
ensure_hash!(value, "coverage_criteria")
|
|
127
|
+
warn_unknown_keys(value, VALID_COVERAGE_CRITERIA_KEYS, "coverage_criteria")
|
|
128
|
+
value.each do |key, flag|
|
|
129
|
+
validate_boolean(flag, "coverage_criteria.#{key}")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def validate_thresholds(raw)
|
|
134
|
+
value = raw[:thresholds]
|
|
135
|
+
return if value.nil?
|
|
136
|
+
|
|
137
|
+
ensure_hash!(value, "thresholds")
|
|
138
|
+
warn_unknown_keys(value, VALID_THRESHOLDS_KEYS, "thresholds")
|
|
139
|
+
value.each do |key, threshold|
|
|
140
|
+
validate_threshold(threshold, "thresholds.#{key}")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_operator(value)
|
|
145
|
+
return if value.nil?
|
|
146
|
+
|
|
147
|
+
operator = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
148
|
+
return if VALID_OPERATORS.include?(operator)
|
|
149
|
+
|
|
150
|
+
configuration_error(
|
|
151
|
+
"Invalid configuration value for mutation.operators: expected one of " \
|
|
152
|
+
"#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_timeout(value)
|
|
157
|
+
return if value.nil?
|
|
158
|
+
return if value.is_a?(Numeric)
|
|
159
|
+
|
|
160
|
+
configuration_error("Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def validate_threshold(value, path)
|
|
164
|
+
return if value.is_a?(Integer) && value.between?(0, 100)
|
|
165
|
+
|
|
166
|
+
configuration_error(
|
|
167
|
+
"Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
|
|
168
|
+
"got #{value.inspect}"
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def validate_boolean(value, path)
|
|
173
|
+
return if [true, false].include?(value)
|
|
174
|
+
|
|
175
|
+
configuration_error("Invalid configuration value for #{path}: expected true or false, got #{value.inspect}")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_optional_string(value, path)
|
|
179
|
+
return if value.nil?
|
|
180
|
+
return if value.is_a?(String)
|
|
181
|
+
|
|
182
|
+
configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_string_array(value, path)
|
|
186
|
+
return if value.nil?
|
|
187
|
+
return if value.is_a?(Array) && value.all?(String)
|
|
188
|
+
|
|
189
|
+
configuration_error(
|
|
190
|
+
"Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
|
|
191
|
+
)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_ignore_patterns(value)
|
|
195
|
+
Array(value).each do |pattern|
|
|
196
|
+
Regexp.new(pattern)
|
|
197
|
+
rescue RegexpError => e
|
|
198
|
+
configuration_error(
|
|
199
|
+
"Invalid configuration value for mutation.ignore_patterns: " \
|
|
200
|
+
"invalid regular expression #{pattern.inspect}: #{e.message}"
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def validate_max_mutants_per_line(value)
|
|
206
|
+
return if value.nil?
|
|
207
|
+
return if value.is_a?(Integer) && value >= 1
|
|
208
|
+
|
|
209
|
+
configuration_error(
|
|
210
|
+
"Invalid configuration value for mutation.max_mutants_per_line: expected Integer >= 1, got #{value.inspect}"
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_max_flaky_retries(value)
|
|
215
|
+
return if value.nil?
|
|
216
|
+
return if value.is_a?(Integer) && value >= 0
|
|
217
|
+
|
|
218
|
+
configuration_error(
|
|
219
|
+
"Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def validate_sampling(value)
|
|
224
|
+
return if value.nil?
|
|
225
|
+
|
|
226
|
+
ensure_hash!(value, "mutation.sampling")
|
|
227
|
+
warn_unknown_keys(value, VALID_SAMPLING_KEYS, "mutation.sampling")
|
|
228
|
+
validate_sampling_completeness(value)
|
|
229
|
+
validate_sampling_ratio(value[:ratio])
|
|
230
|
+
validate_sampling_strategy(value[:strategy])
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def validate_sampling_ratio(value)
|
|
234
|
+
return if value.nil?
|
|
235
|
+
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
236
|
+
|
|
237
|
+
configuration_error(
|
|
238
|
+
"Invalid configuration value for mutation.sampling.ratio: " \
|
|
239
|
+
"expected Numeric between 0 and 1, got #{value.inspect}"
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def validate_sampling_strategy(value)
|
|
244
|
+
return if value.nil?
|
|
245
|
+
|
|
246
|
+
strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
247
|
+
return if strategy == :stratified
|
|
248
|
+
|
|
249
|
+
configuration_error(
|
|
250
|
+
"Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def validate_sampling_completeness(value)
|
|
255
|
+
return if value.key?(:ratio) && value.key?(:strategy)
|
|
256
|
+
|
|
257
|
+
configuration_error(
|
|
258
|
+
"Invalid configuration value for mutation.sampling: expected both ratio and strategy"
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def warn_unknown_keys(raw, allowed_keys, path = nil)
|
|
263
|
+
raw.each_key do |key|
|
|
264
|
+
next if allowed_keys.include?(key)
|
|
265
|
+
|
|
266
|
+
warn "Unknown configuration key: #{key_path(path, key)}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def key_path(path, key)
|
|
271
|
+
path ? "#{path}.#{key}" : key.to_s
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def ensure_hash!(value, path)
|
|
275
|
+
return if value.is_a?(Hash)
|
|
276
|
+
|
|
277
|
+
configuration_error("Invalid configuration value for #{path}: expected Hash, got #{value.class}")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def describe_array_type(value)
|
|
281
|
+
return value.class.name unless value.is_a?(Array)
|
|
282
|
+
|
|
283
|
+
element_types = value.map { |item| item.class.name }.uniq.join(", ")
|
|
284
|
+
"Array<#{element_types}>"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def configuration_error(message)
|
|
288
|
+
raise Henitai::ConfigurationError, message
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
# rubocop:enable Metrics/ModuleLength
|
|
293
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Ensures coverage data exists before the mutation pipeline starts.
|
|
5
|
+
class CoverageBootstrapper
|
|
6
|
+
def initialize(static_filter: StaticFilter.new)
|
|
7
|
+
@static_filter = static_filter
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def ensure!(source_files:, config:, integration:)
|
|
11
|
+
return if source_files.empty?
|
|
12
|
+
return if coverage_available?(source_files, config)
|
|
13
|
+
|
|
14
|
+
bootstrap_coverage(integration, config)
|
|
15
|
+
return if coverage_available?(source_files, config)
|
|
16
|
+
|
|
17
|
+
raise CoverageError,
|
|
18
|
+
"Coverage data is unavailable for the configured source files"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :static_filter
|
|
24
|
+
|
|
25
|
+
def coverage_available?(source_files, config)
|
|
26
|
+
coverage_lines = static_filter.coverage_lines_for(config)
|
|
27
|
+
|
|
28
|
+
source_file_paths(source_files).any? do |path|
|
|
29
|
+
Array(coverage_lines[path]).any?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def source_file_paths(source_files)
|
|
34
|
+
Array(source_files).map { |path| File.expand_path(path) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def bootstrap_coverage(integration, config)
|
|
38
|
+
with_coverage_dir(config) do
|
|
39
|
+
result = integration.run_suite(integration.test_files)
|
|
40
|
+
return if result == :survived
|
|
41
|
+
|
|
42
|
+
raise CoverageError, build_bootstrap_error(result)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_bootstrap_error(result)
|
|
47
|
+
return "Configured test suite failed while generating coverage" unless result.respond_to?(:log_path)
|
|
48
|
+
|
|
49
|
+
tail = result.tail(12).strip
|
|
50
|
+
message = +"Configured test suite failed while generating coverage"
|
|
51
|
+
message << " (see #{result.log_path})"
|
|
52
|
+
message << "\n#{tail}" unless tail.empty?
|
|
53
|
+
message
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_coverage_dir(config)
|
|
57
|
+
original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
|
|
58
|
+
ENV["HENITAI_COVERAGE_DIR"] = coverage_dir(config)
|
|
59
|
+
yield
|
|
60
|
+
ensure
|
|
61
|
+
if original_coverage_dir.nil?
|
|
62
|
+
ENV.delete("HENITAI_COVERAGE_DIR")
|
|
63
|
+
else
|
|
64
|
+
ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def coverage_dir(config)
|
|
69
|
+
reports_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
|
|
70
|
+
return "coverage" if reports_dir.nil? || reports_dir.empty?
|
|
71
|
+
|
|
72
|
+
File.join(reports_dir, "coverage")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "coverage"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Henitai
|
|
8
|
+
# Collects per-test coverage data for static filtering heuristics.
|
|
9
|
+
class CoverageFormatter
|
|
10
|
+
REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
|
|
11
|
+
REPORT_FILE_NAME = "henitai_per_test.json"
|
|
12
|
+
|
|
13
|
+
RSpec::Core::Formatters.register self, :example_finished, :dump_summary
|
|
14
|
+
|
|
15
|
+
def initialize(_output)
|
|
16
|
+
@coverage_by_test = Hash.new do |hash, test_file|
|
|
17
|
+
hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
|
|
18
|
+
end
|
|
19
|
+
@previous_snapshot = {}
|
|
20
|
+
@warned_missing_coverage = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def example_finished(notification)
|
|
24
|
+
snapshot = current_snapshot
|
|
25
|
+
return warn_missing_coverage unless snapshot
|
|
26
|
+
|
|
27
|
+
test_file = notification.example.metadata[:file_path]
|
|
28
|
+
new_lines(snapshot).each do |source_file, lines|
|
|
29
|
+
@coverage_by_test[test_file][source_file].concat(lines)
|
|
30
|
+
@coverage_by_test[test_file][source_file].uniq!
|
|
31
|
+
@coverage_by_test[test_file][source_file].sort!
|
|
32
|
+
end
|
|
33
|
+
@previous_snapshot = snapshot
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dump_summary(_summary)
|
|
37
|
+
return if @coverage_by_test.empty?
|
|
38
|
+
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(report_path))
|
|
40
|
+
File.write(report_path, JSON.pretty_generate(serializable_report))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def report_path
|
|
46
|
+
File.join(reports_dir, REPORT_FILE_NAME)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reports_dir
|
|
50
|
+
ENV.fetch(REPORT_DIR_ENV, "coverage")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def current_snapshot
|
|
54
|
+
Coverage.peek_result
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warn_missing_coverage
|
|
60
|
+
return if @warned_missing_coverage
|
|
61
|
+
|
|
62
|
+
warn "Per-test coverage unavailable; skipping coverage formatter output"
|
|
63
|
+
@warned_missing_coverage = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def new_lines(snapshot)
|
|
67
|
+
snapshot.each_with_object({}) do |(source_file, file_coverage), result|
|
|
68
|
+
next unless source_file?(source_file)
|
|
69
|
+
|
|
70
|
+
lines = new_line_numbers(
|
|
71
|
+
file_coverage,
|
|
72
|
+
previous_line_counts(source_file)
|
|
73
|
+
)
|
|
74
|
+
result[source_file] = lines unless lines.empty?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def new_line_numbers(file_coverage, previous_counts)
|
|
79
|
+
line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
|
|
80
|
+
next unless count.to_i.positive?
|
|
81
|
+
next if previous_counts.fetch(index, 0).to_i.positive?
|
|
82
|
+
|
|
83
|
+
index + 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def previous_line_counts(source_file)
|
|
88
|
+
line_counts_for(@previous_snapshot.fetch(source_file, []))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def line_counts_for(file_coverage)
|
|
92
|
+
case file_coverage
|
|
93
|
+
when Hash
|
|
94
|
+
Array(file_coverage["lines"])
|
|
95
|
+
else
|
|
96
|
+
Array(file_coverage)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def source_file?(path)
|
|
101
|
+
expanded = File.expand_path(path)
|
|
102
|
+
expanded.start_with?(Dir.pwd) &&
|
|
103
|
+
!expanded.include?("#{File::SEPARATOR}spec#{File::SEPARATOR}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def serializable_report
|
|
107
|
+
@coverage_by_test.transform_values do |source_map|
|
|
108
|
+
source_map.transform_values { |lines| lines.uniq.sort }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "parser_current"
|
|
4
|
+
|
|
5
|
+
module Henitai
|
|
6
|
+
# Detects obvious equivalent mutants before execution.
|
|
7
|
+
#
|
|
8
|
+
# The detector is intentionally conservative: it only marks mutations as
|
|
9
|
+
# equivalent when the AST shape and the operand literals make the equivalence
|
|
10
|
+
# obvious enough to be useful.
|
|
11
|
+
class EquivalenceDetector
|
|
12
|
+
def analyze(mutant)
|
|
13
|
+
return mutant unless equivalent_arithmetic_mutation?(mutant)
|
|
14
|
+
|
|
15
|
+
mutant.status = :equivalent
|
|
16
|
+
mutant
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def equivalent_arithmetic_mutation?(mutant)
|
|
22
|
+
original = mutant.original_node
|
|
23
|
+
mutated = mutant.mutated_node
|
|
24
|
+
return false unless binary_send?(original) && binary_send?(mutated)
|
|
25
|
+
return false unless same_receiver?(original, mutated)
|
|
26
|
+
|
|
27
|
+
additive_equivalent?(original, mutated) ||
|
|
28
|
+
multiplicative_equivalent?(original, mutated)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def binary_send?(node)
|
|
32
|
+
node.is_a?(Parser::AST::Node) && node.type == :send && node.children.size >= 3
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def same_receiver?(original, mutated)
|
|
36
|
+
same_node?(original.children[0], mutated.children[0])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def additive_equivalent?(original, mutated)
|
|
40
|
+
additive_operator?(original.children[1]) &&
|
|
41
|
+
additive_operator?(mutated.children[1]) &&
|
|
42
|
+
zero_operand?(original) &&
|
|
43
|
+
zero_operand?(mutated)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def multiplicative_equivalent?(original, mutated)
|
|
47
|
+
multiplicative_operator?(original.children[1]) &&
|
|
48
|
+
multiplicative_operator?(mutated.children[1]) &&
|
|
49
|
+
one_operand?(original) &&
|
|
50
|
+
one_operand?(mutated)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def additive_operator?(operator)
|
|
54
|
+
%i[+ -].include?(operator)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def multiplicative_operator?(operator)
|
|
58
|
+
%i[* / **].include?(operator)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def zero_operand?(node)
|
|
62
|
+
numeric_operand?(node, 0)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def one_operand?(node)
|
|
66
|
+
numeric_operand?(node, 1)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def numeric_operand?(node, value)
|
|
70
|
+
operand = node.children[2]
|
|
71
|
+
return false unless operand.is_a?(Parser::AST::Node)
|
|
72
|
+
|
|
73
|
+
case operand.type
|
|
74
|
+
when :int, :float
|
|
75
|
+
operand.children.first == value || operand.children.first == value.to_i
|
|
76
|
+
else
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def same_node?(left, right)
|
|
82
|
+
left == right
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|