henitai 0.1.10 → 0.2.1
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 +94 -1
- data/README.md +33 -7
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +17 -327
- data/lib/henitai/configuration.rb +26 -12
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +6 -11
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +43 -519
- data/lib/henitai/mutant/activator.rb +13 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +12 -91
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +148 -0
- data/lib/henitai/reporter.rb +96 -11
- data/lib/henitai/result.rb +49 -16
- data/lib/henitai/runner.rb +96 -30
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +10 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +329 -53
- metadata +46 -2
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
module ConfigurationValidator
|
|
5
|
+
# Leaf validators for individual configuration values.
|
|
6
|
+
#
|
|
7
|
+
# Each method returns silently for an acceptable value or raises
|
|
8
|
+
# +Henitai::ConfigurationError+ via {Rules.configuration_error}.
|
|
9
|
+
module Scalars
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate_operator(value)
|
|
13
|
+
return if value.nil?
|
|
14
|
+
|
|
15
|
+
operator = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
16
|
+
return if VALID_OPERATORS.include?(operator)
|
|
17
|
+
|
|
18
|
+
Rules.configuration_error(
|
|
19
|
+
"Invalid configuration value for mutation.operators: expected one of " \
|
|
20
|
+
"#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate_timeout(value)
|
|
25
|
+
return if value.nil?
|
|
26
|
+
return if value.is_a?(Numeric)
|
|
27
|
+
|
|
28
|
+
Rules.configuration_error(
|
|
29
|
+
"Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}"
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_threshold(value, path)
|
|
34
|
+
return if value.is_a?(Integer) && value.between?(0, 100)
|
|
35
|
+
|
|
36
|
+
Rules.configuration_error(
|
|
37
|
+
"Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
|
|
38
|
+
"got #{value.inspect}"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate_boolean(value, path)
|
|
43
|
+
return if [true, false].include?(value)
|
|
44
|
+
|
|
45
|
+
Rules.configuration_error(
|
|
46
|
+
"Invalid configuration value for #{path}: expected true or false, got #{value.inspect}"
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def validate_optional_string(value, path)
|
|
51
|
+
return if value.nil?
|
|
52
|
+
return if value.is_a?(String)
|
|
53
|
+
|
|
54
|
+
Rules.configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_string_array(value, path)
|
|
58
|
+
return if value.nil?
|
|
59
|
+
return if value.is_a?(Array) && value.all?(String)
|
|
60
|
+
|
|
61
|
+
Rules.configuration_error(
|
|
62
|
+
"Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_ignore_patterns(value)
|
|
67
|
+
Array(value).each do |pattern|
|
|
68
|
+
Regexp.new(pattern)
|
|
69
|
+
rescue RegexpError => e
|
|
70
|
+
Rules.configuration_error(
|
|
71
|
+
"Invalid configuration value for mutation.ignore_patterns: " \
|
|
72
|
+
"invalid regular expression #{pattern.inspect}: #{e.message}"
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_max_flaky_retries(value)
|
|
78
|
+
return if value.nil?
|
|
79
|
+
return if value.is_a?(Integer) && value >= 0
|
|
80
|
+
|
|
81
|
+
Rules.configuration_error(
|
|
82
|
+
"Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_sampling_ratio(value)
|
|
87
|
+
return if value.nil?
|
|
88
|
+
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
89
|
+
|
|
90
|
+
Rules.configuration_error(
|
|
91
|
+
"Invalid configuration value for mutation.sampling.ratio: " \
|
|
92
|
+
"expected Numeric between 0 and 1, got #{value.inspect}"
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_sampling_strategy(value)
|
|
97
|
+
return if value.nil?
|
|
98
|
+
|
|
99
|
+
strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
100
|
+
return if strategy == :stratified
|
|
101
|
+
|
|
102
|
+
Rules.configuration_error(
|
|
103
|
+
"Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_sampling_completeness(value)
|
|
108
|
+
return if value.key?(:ratio) && value.key?(:strategy)
|
|
109
|
+
|
|
110
|
+
Rules.configuration_error(
|
|
111
|
+
"Invalid configuration value for mutation.sampling: expected both ratio and strategy"
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def describe_array_type(value)
|
|
116
|
+
return value.class.name unless value.is_a?(Array)
|
|
117
|
+
|
|
118
|
+
element_types = value.map { |item| item.class.name }.uniq.join(", ")
|
|
119
|
+
"Array<#{element_types}>"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "configuration_validator/rules"
|
|
4
|
+
|
|
3
5
|
module Henitai
|
|
4
|
-
# rubocop:disable Metrics/ModuleLength
|
|
5
6
|
# Internal validator for configuration data loaded from YAML and CLI overrides.
|
|
7
|
+
#
|
|
8
|
+
# The public entry point is {.validate!}; the individual rules live in
|
|
9
|
+
# {Rules}. Unknown-key warnings are emitted via {.warn} so that callers (and
|
|
10
|
+
# specs) can observe them on this module.
|
|
6
11
|
module ConfigurationValidator
|
|
7
12
|
VALID_TOP_LEVEL_KEYS = %i[
|
|
8
13
|
integration
|
|
9
14
|
includes
|
|
15
|
+
excludes
|
|
10
16
|
mutation
|
|
11
17
|
coverage_criteria
|
|
12
18
|
thresholds
|
|
@@ -27,6 +33,7 @@ module Henitai
|
|
|
27
33
|
validate_top_level_keys
|
|
28
34
|
validate_integration
|
|
29
35
|
validate_includes
|
|
36
|
+
validate_excludes
|
|
30
37
|
validate_jobs
|
|
31
38
|
validate_reporters
|
|
32
39
|
validate_reports_dir
|
|
@@ -38,246 +45,12 @@ module Henitai
|
|
|
38
45
|
].freeze
|
|
39
46
|
|
|
40
47
|
def self.validate!(raw)
|
|
41
|
-
ensure_hash!(raw, "configuration")
|
|
42
|
-
VALIDATION_STEPS.each { |step|
|
|
48
|
+
Rules.ensure_hash!(raw, "configuration")
|
|
49
|
+
VALIDATION_STEPS.each { |step| Rules.public_send(step, raw) }
|
|
43
50
|
end
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
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_flaky_retries(value[:max_flaky_retries])
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def validate_mutation_filters(value)
|
|
117
|
-
validate_string_array(value[:ignore_patterns], "mutation.ignore_patterns")
|
|
118
|
-
validate_ignore_patterns(value[:ignore_patterns])
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def validate_coverage_criteria(raw)
|
|
122
|
-
value = raw[:coverage_criteria]
|
|
123
|
-
return if value.nil?
|
|
124
|
-
|
|
125
|
-
ensure_hash!(value, "coverage_criteria")
|
|
126
|
-
warn_unknown_keys(value, VALID_COVERAGE_CRITERIA_KEYS, "coverage_criteria")
|
|
127
|
-
value.each do |key, flag|
|
|
128
|
-
validate_boolean(flag, "coverage_criteria.#{key}")
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def validate_thresholds(raw)
|
|
133
|
-
value = raw[:thresholds]
|
|
134
|
-
return if value.nil?
|
|
135
|
-
|
|
136
|
-
ensure_hash!(value, "thresholds")
|
|
137
|
-
warn_unknown_keys(value, VALID_THRESHOLDS_KEYS, "thresholds")
|
|
138
|
-
value.each do |key, threshold|
|
|
139
|
-
validate_threshold(threshold, "thresholds.#{key}")
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def validate_operator(value)
|
|
144
|
-
return if value.nil?
|
|
145
|
-
|
|
146
|
-
operator = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
147
|
-
return if VALID_OPERATORS.include?(operator)
|
|
148
|
-
|
|
149
|
-
configuration_error(
|
|
150
|
-
"Invalid configuration value for mutation.operators: expected one of " \
|
|
151
|
-
"#{VALID_OPERATORS.join(', ')}, got #{value.inspect}"
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def validate_timeout(value)
|
|
156
|
-
return if value.nil?
|
|
157
|
-
return if value.is_a?(Numeric)
|
|
158
|
-
|
|
159
|
-
configuration_error("Invalid configuration value for mutation.timeout: expected Numeric, got #{value.class}")
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def validate_threshold(value, path)
|
|
163
|
-
return if value.is_a?(Integer) && value.between?(0, 100)
|
|
164
|
-
|
|
165
|
-
configuration_error(
|
|
166
|
-
"Invalid configuration value for #{path}: expected Integer between 0 and 100, " \
|
|
167
|
-
"got #{value.inspect}"
|
|
168
|
-
)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def validate_boolean(value, path)
|
|
172
|
-
return if [true, false].include?(value)
|
|
173
|
-
|
|
174
|
-
configuration_error("Invalid configuration value for #{path}: expected true or false, got #{value.inspect}")
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def validate_optional_string(value, path)
|
|
178
|
-
return if value.nil?
|
|
179
|
-
return if value.is_a?(String)
|
|
180
|
-
|
|
181
|
-
configuration_error("Invalid configuration value for #{path}: expected String, got #{value.class}")
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def validate_string_array(value, path)
|
|
185
|
-
return if value.nil?
|
|
186
|
-
return if value.is_a?(Array) && value.all?(String)
|
|
187
|
-
|
|
188
|
-
configuration_error(
|
|
189
|
-
"Invalid configuration value for #{path}: expected Array<String>, got #{describe_array_type(value)}"
|
|
190
|
-
)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def validate_ignore_patterns(value)
|
|
194
|
-
Array(value).each do |pattern|
|
|
195
|
-
Regexp.new(pattern)
|
|
196
|
-
rescue RegexpError => e
|
|
197
|
-
configuration_error(
|
|
198
|
-
"Invalid configuration value for mutation.ignore_patterns: " \
|
|
199
|
-
"invalid regular expression #{pattern.inspect}: #{e.message}"
|
|
200
|
-
)
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def validate_max_flaky_retries(value)
|
|
205
|
-
return if value.nil?
|
|
206
|
-
return if value.is_a?(Integer) && value >= 0
|
|
207
|
-
|
|
208
|
-
configuration_error(
|
|
209
|
-
"Invalid configuration value for mutation.max_flaky_retries: expected Integer >= 0, got #{value.inspect}"
|
|
210
|
-
)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def validate_sampling(value)
|
|
214
|
-
return if value.nil?
|
|
215
|
-
|
|
216
|
-
ensure_hash!(value, "mutation.sampling")
|
|
217
|
-
warn_unknown_keys(value, VALID_SAMPLING_KEYS, "mutation.sampling")
|
|
218
|
-
validate_sampling_completeness(value)
|
|
219
|
-
validate_sampling_ratio(value[:ratio])
|
|
220
|
-
validate_sampling_strategy(value[:strategy])
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def validate_sampling_ratio(value)
|
|
224
|
-
return if value.nil?
|
|
225
|
-
return if value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
226
|
-
|
|
227
|
-
configuration_error(
|
|
228
|
-
"Invalid configuration value for mutation.sampling.ratio: " \
|
|
229
|
-
"expected Numeric between 0 and 1, got #{value.inspect}"
|
|
230
|
-
)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def validate_sampling_strategy(value)
|
|
234
|
-
return if value.nil?
|
|
235
|
-
|
|
236
|
-
strategy = value.respond_to?(:to_sym) ? value.to_sym : nil
|
|
237
|
-
return if strategy == :stratified
|
|
238
|
-
|
|
239
|
-
configuration_error(
|
|
240
|
-
"Invalid configuration value for mutation.sampling.strategy: expected stratified, got #{value.inspect}"
|
|
241
|
-
)
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def validate_sampling_completeness(value)
|
|
245
|
-
return if value.key?(:ratio) && value.key?(:strategy)
|
|
246
|
-
|
|
247
|
-
configuration_error(
|
|
248
|
-
"Invalid configuration value for mutation.sampling: expected both ratio and strategy"
|
|
249
|
-
)
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def warn_unknown_keys(raw, allowed_keys, path = nil)
|
|
253
|
-
raw.each_key do |key|
|
|
254
|
-
next if allowed_keys.include?(key)
|
|
255
|
-
|
|
256
|
-
warn "Unknown configuration key: #{key_path(path, key)}"
|
|
257
|
-
end
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
def key_path(path, key)
|
|
261
|
-
path ? "#{path}.#{key}" : key.to_s
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def ensure_hash!(value, path)
|
|
265
|
-
return if value.is_a?(Hash)
|
|
266
|
-
|
|
267
|
-
configuration_error("Invalid configuration value for #{path}: expected Hash, got #{value.class}")
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
def describe_array_type(value)
|
|
271
|
-
return value.class.name unless value.is_a?(Array)
|
|
272
|
-
|
|
273
|
-
element_types = value.map { |item| item.class.name }.uniq.join(", ")
|
|
274
|
-
"Array<#{element_types}>"
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def configuration_error(message)
|
|
278
|
-
raise Henitai::ConfigurationError, message
|
|
279
|
-
end
|
|
52
|
+
def self.warn(message)
|
|
53
|
+
Kernel.warn(message)
|
|
280
54
|
end
|
|
281
55
|
end
|
|
282
|
-
# rubocop:enable Metrics/ModuleLength
|
|
283
56
|
end
|
|
@@ -18,16 +18,18 @@ module Henitai
|
|
|
18
18
|
def ensure!(source_files:, config:, integration:, test_files: nil)
|
|
19
19
|
return if source_files.empty?
|
|
20
20
|
|
|
21
|
+
resolved_test_files = resolve_test_files(integration, test_files)
|
|
22
|
+
|
|
21
23
|
# Skip the bootstrap only when the coverage artifacts are both newer than
|
|
22
24
|
# all watched files and actually cover the configured sources. A fresh
|
|
23
25
|
# but irrelevant report (e.g. from a different working directory) must
|
|
24
26
|
# still trigger a re-bootstrap rather than silently proceeding with no
|
|
25
27
|
# usable coverage.
|
|
26
|
-
unless coverage_ready?(source_files, config, integration,
|
|
27
|
-
bootstrap_coverage(integration, config,
|
|
28
|
+
unless coverage_ready?(source_files, config, integration, resolved_test_files)
|
|
29
|
+
bootstrap_coverage(integration, config, resolved_test_files)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
return if coverage_available?(source_files, config
|
|
32
|
+
return if coverage_available?(source_files, config)
|
|
31
33
|
|
|
32
34
|
raise CoverageError,
|
|
33
35
|
"Coverage data is unavailable for the configured source files"
|
|
@@ -37,20 +39,16 @@ module Henitai
|
|
|
37
39
|
|
|
38
40
|
attr_reader :static_filter
|
|
39
41
|
|
|
40
|
-
def coverage_available?(source_files, config
|
|
42
|
+
def coverage_available?(source_files, config)
|
|
41
43
|
coverage_lines = static_filter.coverage_lines_for(config)
|
|
42
44
|
covered_sources = covered_source_files(source_files, coverage_lines)
|
|
43
|
-
existing_sources = existing_source_file_paths(source_files)
|
|
44
|
-
|
|
45
|
-
return covered_sources.any? if existing_sources.empty?
|
|
46
|
-
return covered_sources.any? if test_files
|
|
47
45
|
|
|
48
46
|
covered_sources.any?
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
def coverage_ready?(source_files, config, integration, test_files)
|
|
52
|
-
coverage_fresh?(source_files, config,
|
|
53
|
-
coverage_available?(source_files, config
|
|
50
|
+
coverage_fresh?(source_files, config, test_files) &&
|
|
51
|
+
coverage_available?(source_files, config) &&
|
|
54
52
|
per_test_coverage_ready?(source_files, config, integration, test_files)
|
|
55
53
|
end
|
|
56
54
|
|
|
@@ -64,17 +62,12 @@ module Henitai
|
|
|
64
62
|
Array(source_files).map { |path| File.expand_path(path) }
|
|
65
63
|
end
|
|
66
64
|
|
|
67
|
-
def existing_source_file_paths(source_files)
|
|
68
|
-
source_file_paths(source_files).select { |path| File.exist?(path) }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
65
|
# Returns true when a coverage report already exists and is newer than
|
|
72
66
|
# every watched source and test file. Stale or absent reports return false.
|
|
73
|
-
def coverage_fresh?(source_files, config,
|
|
67
|
+
def coverage_fresh?(source_files, config, test_files)
|
|
74
68
|
watched_files_fresh?(
|
|
75
69
|
coverage_report_path(config),
|
|
76
70
|
source_files,
|
|
77
|
-
integration,
|
|
78
71
|
test_files
|
|
79
72
|
)
|
|
80
73
|
end
|
|
@@ -88,9 +81,11 @@ module Henitai
|
|
|
88
81
|
end
|
|
89
82
|
|
|
90
83
|
def bootstrap_coverage(integration, config, test_files = nil)
|
|
84
|
+
test_files ||= integration.test_files
|
|
85
|
+
|
|
91
86
|
with_reports_dir(config) do
|
|
92
87
|
with_coverage_dir(config) do
|
|
93
|
-
result = integration.run_suite(test_files
|
|
88
|
+
result = integration.run_suite(test_files)
|
|
94
89
|
return if result == :survived
|
|
95
90
|
|
|
96
91
|
raise CoverageError, build_bootstrap_error(result)
|
|
@@ -139,11 +134,10 @@ module Henitai
|
|
|
139
134
|
File.join(reports_dir, "coverage")
|
|
140
135
|
end
|
|
141
136
|
|
|
142
|
-
def per_test_coverage_fresh?(source_files, config,
|
|
137
|
+
def per_test_coverage_fresh?(source_files, config, test_files)
|
|
143
138
|
watched_files_fresh?(
|
|
144
139
|
per_test_coverage_report_path(config),
|
|
145
140
|
source_files,
|
|
146
|
-
integration,
|
|
147
141
|
test_files
|
|
148
142
|
)
|
|
149
143
|
end
|
|
@@ -155,7 +149,7 @@ module Henitai
|
|
|
155
149
|
def per_test_coverage_ready?(source_files, config, integration, test_files)
|
|
156
150
|
return true unless per_test_coverage_supported?(integration)
|
|
157
151
|
|
|
158
|
-
per_test_coverage_fresh?(source_files, config,
|
|
152
|
+
per_test_coverage_fresh?(source_files, config, test_files) &&
|
|
159
153
|
per_test_coverage_available?(config)
|
|
160
154
|
end
|
|
161
155
|
|
|
@@ -165,21 +159,27 @@ module Henitai
|
|
|
165
159
|
integration.per_test_coverage_supported?
|
|
166
160
|
end
|
|
167
161
|
|
|
168
|
-
def watched_files_fresh?(report_path, source_files,
|
|
162
|
+
def watched_files_fresh?(report_path, source_files, test_files)
|
|
169
163
|
# This check assumes a single writer owns the coverage artifacts for the
|
|
170
164
|
# workspace. It is intentionally not an atomic snapshot-and-validate step.
|
|
171
165
|
return false unless File.exist?(report_path)
|
|
172
166
|
|
|
173
167
|
report_mtime = File.mtime(report_path)
|
|
174
|
-
watched_files(source_files,
|
|
168
|
+
watched_files(source_files, test_files).all? do |path|
|
|
175
169
|
File.mtime(path) <= report_mtime
|
|
176
170
|
rescue Errno::ENOENT
|
|
177
171
|
false
|
|
178
172
|
end
|
|
179
173
|
end
|
|
180
174
|
|
|
181
|
-
def watched_files(source_files,
|
|
182
|
-
Array(source_files) + Array(test_files
|
|
175
|
+
def watched_files(source_files, test_files)
|
|
176
|
+
Array(source_files) + Array(test_files)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def resolve_test_files(integration, test_files)
|
|
180
|
+
return test_files unless test_files.nil?
|
|
181
|
+
|
|
182
|
+
integration.test_files
|
|
183
183
|
end
|
|
184
184
|
|
|
185
185
|
def reports_dir(config)
|
data/lib/henitai/eager_load.rb
CHANGED
|
@@ -2,10 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
require "henitai"
|
|
4
4
|
|
|
5
|
-
#
|
|
6
|
-
# (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
-
|
|
5
|
+
# Standalone entry point that forces every Henitai constant to load so external
|
|
6
|
+
# mutation tooling (e.g. mutant) can discover subjects via ObjectSpace.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ruby -r henitai/eager_load -e ""
|
|
10
|
+
#
|
|
11
|
+
# A bare `Dir[].each { require }` glob loads files in filesystem order, which
|
|
12
|
+
# does not respect dependency order: a child file that reopens an autoloaded
|
|
13
|
+
# constant (e.g. `class CLI` or `class Rspec < Base`) triggers the parent
|
|
14
|
+
# autoload before the child's own constants exist, raising NameError. To avoid
|
|
15
|
+
# that, first touch every constant in the autoload table so each entry-point
|
|
16
|
+
# file loads with its `require_relative` children in the correct order; only
|
|
17
|
+
# then glob the remaining files, by which point every base class and namespace
|
|
18
|
+
# is already defined and load order no longer matters.
|
|
8
19
|
|
|
9
|
-
|
|
10
|
-
|
|
20
|
+
# Files that run as side effects on load (test hooks and coverage formatters)
|
|
21
|
+
# and must therefore be skipped by the eager loader.
|
|
22
|
+
SIDE_EFFECT_FILES = %w[
|
|
23
|
+
minitest_simplecov.rb
|
|
24
|
+
minitest_coverage_hook.rb
|
|
25
|
+
rspec_coverage_formatter.rb
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Recursively force every autoloaded constant under a module to load, so nested
|
|
29
|
+
# namespaces (Operators::*, Mutant::*, CLI::*, Integration::*) load their bases
|
|
30
|
+
# before the glob below touches their files.
|
|
31
|
+
force_autoloads = lambda do |mod|
|
|
32
|
+
mod.constants(false).each do |constant|
|
|
33
|
+
value = mod.const_get(constant)
|
|
34
|
+
force_autoloads.call(value) if value.is_a?(Module) && value.name&.start_with?("Henitai")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
force_autoloads.call(Henitai)
|
|
39
|
+
|
|
40
|
+
Dir[File.join(__dir__, "**/*.rb")].each do |file|
|
|
41
|
+
require file unless SIDE_EFFECT_FILES.any? { |name| file.end_with?(name) }
|
|
11
42
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "parallel_execution_runner"
|
|
4
|
+
require_relative "process_worker_runner"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
6
7
|
# Runs pending mutants through the selected integration.
|
|
@@ -46,22 +47,16 @@ module Henitai
|
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
def run_parallel(mutants, integration, config, progress_reporter)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
).run(
|
|
50
|
+
runner = ProcessWorkerRunner.new(worker_count: worker_count(config))
|
|
51
|
+
results = runner.run(
|
|
52
52
|
mutants,
|
|
53
53
|
integration,
|
|
54
54
|
config,
|
|
55
55
|
progress_reporter,
|
|
56
|
-
|
|
57
|
-
process_mutant: method(:process_mutant)
|
|
56
|
+
test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
|
|
58
57
|
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def pipe_stdin?
|
|
62
|
-
$stdin.stat.pipe?
|
|
63
|
-
rescue Errno::EBADF
|
|
64
|
-
false
|
|
58
|
+
@flaky_retry_count = runner.flaky_retry_count
|
|
59
|
+
results
|
|
65
60
|
end
|
|
66
61
|
|
|
67
62
|
def process_mutant(mutant, integration, config, progress_reporter, mutex)
|
|
@@ -19,6 +19,21 @@ module Henitai
|
|
|
19
19
|
stdout.split("\n").reject(&:empty?)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def working_tree_changed_files(dir: Dir.pwd)
|
|
23
|
+
tracked = working_tree_tracked_files(dir)
|
|
24
|
+
untracked = untracked_files(dir)
|
|
25
|
+
|
|
26
|
+
(tracked + untracked).uniq
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def head_sha(dir: Dir.pwd)
|
|
30
|
+
command = ["git", "-C", dir, "rev-parse", "HEAD"]
|
|
31
|
+
stdout, _, status = Open3.capture3(*command)
|
|
32
|
+
stdout.strip if status.success? && !stdout.strip.empty?
|
|
33
|
+
rescue Errno::ENOENT
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
def changed_methods(from:, to:, dir: Dir.pwd)
|
|
23
38
|
changed_files(from:, to:, dir:).flat_map do |path|
|
|
24
39
|
changed_methods_in_file(path, from:, to:, dir:)
|
|
@@ -78,5 +93,24 @@ module Henitai
|
|
|
78
93
|
|
|
79
94
|
Open3.capture3(*command)
|
|
80
95
|
end
|
|
96
|
+
|
|
97
|
+
def working_tree_tracked_files(dir)
|
|
98
|
+
stdout, stderr, status = git_diff(dir, "--name-only", "HEAD")
|
|
99
|
+
|
|
100
|
+
raise GitDiffError, stderr.strip unless status.success?
|
|
101
|
+
|
|
102
|
+
stdout.split("\n").reject(&:empty?)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def untracked_files(dir)
|
|
106
|
+
command = ["git"]
|
|
107
|
+
command += ["-C", dir] if dir
|
|
108
|
+
command += ["ls-files", "--others", "--exclude-standard"]
|
|
109
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
110
|
+
|
|
111
|
+
raise GitDiffError, stderr.strip unless status.success?
|
|
112
|
+
|
|
113
|
+
stdout.split("\n").reject(&:empty?)
|
|
114
|
+
end
|
|
81
115
|
end
|
|
82
116
|
end
|