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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/README.md +33 -7
  4. data/assets/schema/henitai.schema.json +6 -0
  5. data/lib/henitai/cli/clean_command.rb +48 -0
  6. data/lib/henitai/cli/command_support.rb +51 -0
  7. data/lib/henitai/cli/init_command.rb +64 -0
  8. data/lib/henitai/cli/operator_command.rb +95 -0
  9. data/lib/henitai/cli/options.rb +120 -0
  10. data/lib/henitai/cli/run_command.rb +103 -0
  11. data/lib/henitai/cli.rb +17 -327
  12. data/lib/henitai/configuration.rb +26 -12
  13. data/lib/henitai/configuration_validator/rules.rb +143 -0
  14. data/lib/henitai/configuration_validator/scalars.rb +123 -0
  15. data/lib/henitai/configuration_validator.rb +12 -239
  16. data/lib/henitai/coverage_bootstrapper.rb +24 -24
  17. data/lib/henitai/eager_load.rb +36 -5
  18. data/lib/henitai/execution_engine.rb +6 -11
  19. data/lib/henitai/git_diff_analyzer.rb +34 -0
  20. data/lib/henitai/integration/base.rb +171 -0
  21. data/lib/henitai/integration/child_debug_support.rb +115 -0
  22. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  23. data/lib/henitai/integration/coverage_suppression.rb +43 -0
  24. data/lib/henitai/integration/minitest.rb +133 -0
  25. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  26. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  27. data/lib/henitai/integration/rspec_process_runner.rb +66 -13
  28. data/lib/henitai/integration/rspec_test_selection.rb +135 -0
  29. data/lib/henitai/integration/scenario_log_support.rb +116 -0
  30. data/lib/henitai/integration.rb +43 -519
  31. data/lib/henitai/mutant/activator.rb +13 -79
  32. data/lib/henitai/mutant/parameter_source.rb +98 -0
  33. data/lib/henitai/mutant.rb +14 -2
  34. data/lib/henitai/mutant_generator.rb +21 -2
  35. data/lib/henitai/mutant_history_store/sql.rb +72 -0
  36. data/lib/henitai/mutant_history_store.rb +12 -91
  37. data/lib/henitai/mutant_identity.rb +34 -0
  38. data/lib/henitai/parallel_execution_runner.rb +29 -11
  39. data/lib/henitai/per_test_coverage_collector.rb +3 -1
  40. data/lib/henitai/process_wakeup.rb +49 -0
  41. data/lib/henitai/process_worker_runner.rb +148 -0
  42. data/lib/henitai/reporter.rb +96 -11
  43. data/lib/henitai/result.rb +49 -16
  44. data/lib/henitai/runner.rb +96 -30
  45. data/lib/henitai/scenario_execution_result.rb +16 -3
  46. data/lib/henitai/slot_scheduler/draining.rb +140 -0
  47. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  48. data/lib/henitai/slot_scheduler.rb +214 -0
  49. data/lib/henitai/static_filter.rb +10 -3
  50. data/lib/henitai/survivor_activation_cache.rb +81 -0
  51. data/lib/henitai/survivor_loader.rb +140 -0
  52. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  53. data/lib/henitai/survivor_selector.rb +36 -0
  54. data/lib/henitai/survivor_test_filter.rb +72 -0
  55. data/lib/henitai/unparse_helper.rb +5 -2
  56. data/lib/henitai/version.rb +1 -1
  57. data/lib/henitai.rb +10 -0
  58. data/sig/configuration_validator.rbs +46 -22
  59. data/sig/henitai.rbs +329 -53
  60. 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| send(step, raw) }
48
+ Rules.ensure_hash!(raw, "configuration")
49
+ VALIDATION_STEPS.each { |step| Rules.public_send(step, raw) }
43
50
  end
44
51
 
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_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, test_files)
27
- bootstrap_coverage(integration, config, test_files)
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, test_files)
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, test_files)
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, integration, test_files) &&
53
- coverage_available?(source_files, config, test_files) &&
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, integration, test_files)
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 || integration.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, integration, test_files)
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, integration, test_files) &&
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, integration, test_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, integration, test_files).all? do |path|
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, integration, test_files)
182
- Array(source_files) + Array(test_files || integration.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)
@@ -2,10 +2,41 @@
2
2
 
3
3
  require "henitai"
4
4
 
5
- # Force all autoloaded constants to load so mutation testing tools
6
- # (e.g. mutant) can discover subjects via ObjectSpace.
7
- SIDE_EFFECT_FILES = %w[minitest_simplecov.rb minitest_coverage_hook.rb rspec_coverage_formatter.rb].freeze
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
- Dir[File.join(__dir__, "**/*.rb")].each do |f|
10
- require f unless SIDE_EFFECT_FILES.any? { |name| f.end_with?(name) }
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
- ParallelExecutionRunner.new(
50
- worker_count: worker_count(config)
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
- stdin_pipe: pipe_stdin?,
57
- process_mutant: method(:process_mutant)
56
+ test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
58
57
  )
59
- end
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