henitai 0.3.1 → 0.5.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -1
  3. data/README.md +11 -1
  4. data/assets/schema/henitai.schema.json +1 -1
  5. data/lib/henitai/cli/operator_command.rb +2 -1
  6. data/lib/henitai/cli/run_options.rb +1 -1
  7. data/lib/henitai/cli.rb +1 -1
  8. data/lib/henitai/configuration.rb +9 -2
  9. data/lib/henitai/configuration_validator.rb +1 -1
  10. data/lib/henitai/dirty_source_detector.rb +53 -0
  11. data/lib/henitai/equivalence_detector/operand_predicates.rb +49 -0
  12. data/lib/henitai/equivalence_detector.rb +6 -23
  13. data/lib/henitai/excluded_test_filter.rb +47 -0
  14. data/lib/henitai/execution_engine.rb +5 -11
  15. data/lib/henitai/inherited_fd_registry.rb +66 -0
  16. data/lib/henitai/integration/base.rb +7 -2
  17. data/lib/henitai/integration/child_bootstrap.rb +27 -0
  18. data/lib/henitai/integration/child_debug_log.rb +135 -0
  19. data/lib/henitai/integration/child_runtime_control.rb +6 -18
  20. data/lib/henitai/integration/loaded_features.rb +38 -0
  21. data/lib/henitai/integration/mutant_run_support.rb +5 -5
  22. data/lib/henitai/integration/rspec_child_runner.rb +16 -15
  23. data/lib/henitai/integration/rspec_process_runner.rb +7 -2
  24. data/lib/henitai/integration.rb +10 -7
  25. data/lib/henitai/mutation_skip_directives.rb +7 -1
  26. data/lib/henitai/operator.rb +12 -2
  27. data/lib/henitai/operators/hash_key_type.rb +50 -0
  28. data/lib/henitai/operators/hash_literal.rb +19 -20
  29. data/lib/henitai/operators/return_value.rb +1 -1
  30. data/lib/henitai/operators.rb +1 -0
  31. data/lib/henitai/orphan_watchdog.rb +93 -0
  32. data/lib/henitai/process_liveness.rb +41 -0
  33. data/lib/henitai/reports_directory_lock.rb +12 -11
  34. data/lib/henitai/result.rb +30 -3
  35. data/lib/henitai/runner.rb +41 -123
  36. data/lib/henitai/runner_dependencies.rb +75 -0
  37. data/lib/henitai/slot_scheduler/drain_verdict.rb +29 -0
  38. data/lib/henitai/slot_scheduler/draining.rb +7 -17
  39. data/lib/henitai/slot_scheduler/retry_policy.rb +21 -0
  40. data/lib/henitai/slot_scheduler/slot_deadline.rb +37 -0
  41. data/lib/henitai/slot_scheduler/slot_table.rb +75 -0
  42. data/lib/henitai/slot_scheduler/test_file_selection.rb +40 -0
  43. data/lib/henitai/slot_scheduler.rb +68 -80
  44. data/lib/henitai/source_file_selection.rb +76 -0
  45. data/lib/henitai/subject_selection.rb +33 -0
  46. data/lib/henitai/survivor_rerun_strategy.rb +7 -19
  47. data/lib/henitai/version.rb +1 -1
  48. data/lib/henitai.rb +8 -0
  49. data/sig/henitai.rbs +94 -38
  50. metadata +32 -9
  51. data/lib/henitai/integration/child_debug_support.rb +0 -119
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Resolves the test files a mutant should be run against.
6
+ #
7
+ # Three sources, in precedence order: a caller-supplied resolver lambda, a
8
+ # fixed list, or the integration's own per-subject selection. The first two
9
+ # exist so a survivor rerun or a per-test-coverage run can narrow the
10
+ # selection without the integration knowing about it.
11
+ #
12
+ # Precedence is by key *presence*, not truthiness: an explicit empty list
13
+ # means "no tests cover this", which the scheduler turns into
14
+ # `:no_coverage`. Falling through to the integration there would silently
15
+ # widen the selection back out.
16
+ class TestFileSelection
17
+ def initialize(options:, integration:)
18
+ @options = options
19
+ @integration = integration
20
+ end
21
+
22
+ def for(mutant)
23
+ if @options.key?(:test_file_resolver)
24
+ @options[:test_file_resolver].call(mutant)
25
+ elsif @options.key?(:test_files)
26
+ @options[:test_files]
27
+ else
28
+ @integration.select_tests(mutant.subject)
29
+ end
30
+ end
31
+
32
+ # True when the resolver was asked and came back empty. Only meaningful
33
+ # for a resolver-driven run: a fixed empty `test_files` list is a
34
+ # deliberate "run nothing", not an absence of coverage.
35
+ def resolved_empty?(test_files)
36
+ @options.key?(:test_file_resolver) && test_files.empty?
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,11 @@
2
2
 
3
3
  require_relative "slot_scheduler/process_control"
4
4
  require_relative "slot_scheduler/draining"
5
+ require_relative "slot_scheduler/drain_verdict"
6
+ require_relative "slot_scheduler/retry_policy"
7
+ require_relative "slot_scheduler/slot_deadline"
8
+ require_relative "slot_scheduler/slot_table"
9
+ require_relative "slot_scheduler/test_file_selection"
5
10
 
6
11
  module Henitai
7
12
  # Owns the process-slot table for a single parallel mutation run.
@@ -40,18 +45,23 @@ module Henitai
40
45
  # @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
41
46
  attr_reader :flaky_retry_count, :results
42
47
 
43
- def initialize(integration:, config:, progress_reporter:, options:, host:)
48
+ # +slot_table+ is injectable so a spec can seed mid-run state, or assert
49
+ # against the table through its own public interface, without reaching a
50
+ # private reader here.
51
+ # rubocop:disable Metrics/ParameterLists -- every one of these is a distinct
52
+ # collaborator supplied by ProcessWorkerRunner; bundling them into a context
53
+ # object would only move the list.
54
+ def initialize(integration:, config:, progress_reporter:, options:, host:, slot_table: SlotTable.new)
55
+ # rubocop:enable Metrics/ParameterLists
44
56
  @integration = integration
45
57
  @config = config
46
58
  @progress_reporter = progress_reporter
47
59
  @options = options
48
60
  @host = host
61
+ @slot_table = slot_table
49
62
  @pending = []
50
- @slots = {}
51
- @pid_to_slot = {}
52
63
  @results = []
53
64
  @flaky_retry_count = 0
54
- @next_slot_id = 0
55
65
  end
56
66
 
57
67
  # Queues the mutants to be scheduled into worker slots.
@@ -60,11 +70,11 @@ module Henitai
60
70
  end
61
71
 
62
72
  def done?
63
- pending.empty? && slots.empty?
73
+ pending.empty? && slot_table.empty?
64
74
  end
65
75
 
66
76
  def fill_idle_slots
67
- while slots.size < worker_count && !pending.empty?
77
+ while slot_table.size < worker_count && !pending.empty?
68
78
  mutant = pending.shift
69
79
  spawn_into_slot(mutant)
70
80
  end
@@ -83,8 +93,8 @@ module Henitai
83
93
 
84
94
  def next_event_timeout
85
95
  now = monotonic_time
86
- slot_timeouts = slots.each_value.filter_map do |slot|
87
- remaining_slot_timeout(slot, now)
96
+ slot_timeouts = slot_table.each_value.filter_map do |slot|
97
+ slot_deadline.remaining(slot, now)
88
98
  end
89
99
 
90
100
  slot_timeouts.min
@@ -92,7 +102,7 @@ module Henitai
92
102
 
93
103
  private
94
104
 
95
- attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
105
+ attr_reader :pending, :slot_table, :integration, :config,
96
106
  :progress_reporter, :options, :host
97
107
 
98
108
  def worker_count = host.worker_count
@@ -101,12 +111,12 @@ module Henitai
101
111
  def shutdown? = host.shutdown_requested?
102
112
 
103
113
  def spawn_into_slot(mutant)
104
- test_files = resolve_test_files(mutant)
105
- mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
106
- mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
107
- return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
114
+ selection = test_file_selection
115
+ test_files = selection.for(mutant)
116
+ annotate_selection(mutant, test_files)
117
+ return record_no_coverage(mutant) if selection.resolved_empty?(test_files)
108
118
 
109
- worker_index = next_free_worker_index
119
+ worker_index = slot_table.next_free_worker_index(worker_count)
110
120
  with_worker_slot(worker_index) do
111
121
  handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
112
122
  register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
@@ -115,11 +125,18 @@ module Henitai
115
125
  record_spawn_failure(mutant, e)
116
126
  end
117
127
 
128
+ # Reported back onto the mutant so the report can show which tests were
129
+ # considered, whether or not any of them ran. Guarded by respond_to? because
130
+ # specs pass Struct stand-ins without these members.
131
+ def annotate_selection(mutant, test_files)
132
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
133
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
134
+ end
135
+
118
136
  def register_slot(handle, mutant, worker_index, timeout)
119
- slot_id = next_slot_id!
120
- slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
121
- slots[slot_id] = slot
122
- pid_to_slot[handle.pid] = slot_id
137
+ slot = build_slot(slot_table.next_slot_id!, mutant, handle, worker_index, timeout)
138
+ slot_table.add(slot)
139
+ slot_table.register_pid(handle.pid, slot.slot_id)
123
140
  Integration::SchedulerDiagnostics.child_started(handle.pid)
124
141
  end
125
142
 
@@ -137,20 +154,11 @@ module Henitai
137
154
  resolver ? resolver.call(mutant, test_files) : config.timeout
138
155
  end
139
156
 
140
- # Smallest index in 0...worker_count not held by a live slot, so
141
- # concurrently-running children always see distinct values and freed
142
- # indices are reused. Slot ids themselves grow monotonically and are
143
- # unsuitable as a resource token.
144
- def next_free_worker_index
145
- used = slots.each_value.map(&:worker_index)
146
- (0...worker_count).find { |index| !used.include?(index) } || used.size
147
- end
148
-
149
157
  def complete_slot(pid, wait_result)
150
- slot_id = pid_to_slot.delete(pid)
158
+ slot_id = slot_table.release_pid(pid)
151
159
  return unless slot_id
152
160
 
153
- slot = slots[slot_id]
161
+ slot = slot_table.fetch(slot_id)
154
162
  return unless slot
155
163
 
156
164
  Integration::SchedulerDiagnostics.child_ended(pid)
@@ -159,29 +167,46 @@ module Henitai
159
167
  end
160
168
 
161
169
  def dispatch_slot_result(slot, result)
162
- if should_retry?(slot, result)
163
- retry_slot(slot)
164
- else
165
- slots.delete(slot.slot_id)
166
- slot.mutant.status = result.status
167
- results << result
168
- progress_reporter&.progress(slot.mutant, scenario_result: result)
169
- result.release_output! if result.respond_to?(:release_output!)
170
- end
170
+ return retry_slot(slot) if retry_policy.retry?(slot: slot, result: result, shutdown: shutdown?)
171
+
172
+ finalize_slot(slot, result)
173
+ end
174
+
175
+ def finalize_slot(slot, result)
176
+ slot_table.delete(slot.slot_id)
177
+ slot.mutant.status = result.status
178
+ results << result
179
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
180
+ result.release_output! if result.respond_to?(:release_output!)
181
+ end
182
+
183
+ # Memoized lazily, not built in #initialize: specs (and the no-op scheduler
184
+ # the runner builds when nothing is pending) pass `config: nil`, and this
185
+ # is only reached once a slot has actually finished.
186
+ def retry_policy
187
+ @retry_policy ||= RetryPolicy.new(max_retries: config.max_flaky_retries)
188
+ end
189
+
190
+ def drain_verdict
191
+ @drain_verdict ||= DrainVerdict.new(integration: integration)
192
+ end
193
+
194
+ def slot_deadline
195
+ @slot_deadline ||= SlotDeadline.new(drain_window: PROCESS_DRAIN_WINDOW)
171
196
  end
172
197
 
173
- def should_retry?(slot, result)
174
- !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
198
+ def test_file_selection
199
+ @test_file_selection ||= TestFileSelection.new(options: options, integration: integration)
175
200
  end
176
201
 
177
202
  def retry_slot(slot)
178
- test_files = resolve_test_files(slot.mutant)
203
+ test_files = test_file_selection.for(slot.mutant)
179
204
  with_worker_slot(slot.worker_index) do
180
205
  handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
181
206
  finish_retry(slot, handle)
182
207
  end
183
208
  rescue StandardError => e
184
- slots.delete(slot.slot_id)
209
+ slot_table.delete(slot.slot_id)
185
210
  record_spawn_failure(slot.mutant, e)
186
211
  end
187
212
 
@@ -189,7 +214,7 @@ module Henitai
189
214
  @flaky_retry_count += 1 if slot.retry_count.zero?
190
215
  slot.retry_count += 1
191
216
  reset_slot_for_retry(slot, handle)
192
- pid_to_slot[handle.pid] = slot.slot_id
217
+ slot_table.register_pid(handle.pid, slot.slot_id)
193
218
  Integration::SchedulerDiagnostics.child_started(handle.pid)
194
219
  end
195
220
 
@@ -220,43 +245,6 @@ module Henitai
220
245
  progress_reporter&.progress(mutant, scenario_result: nil)
221
246
  end
222
247
 
223
- def resolved_selection_empty?(test_files)
224
- options.key?(:test_file_resolver) && test_files.empty?
225
- end
226
-
227
- def remaining_slot_timeout(slot, now)
228
- # Invariant: drain_draining_slots runs (and removes draining slots) before
229
- # the event wait, so next_event_timeout never observes a draining slot
230
- # whose SIGTERM has not been sent. Guard term_sent_at_monotonic defensively
231
- # against a future ordering change: an unsignalled draining slot is due now.
232
- return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
233
-
234
- deadline =
235
- if slot.draining
236
- slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
237
- else
238
- slot.started_at_monotonic + slot.timeout
239
- end
240
- remaining = deadline - now
241
- remaining.positive? ? remaining : 0.0
242
- end
243
-
244
- def resolve_test_files(mutant)
245
- if @options.key?(:test_file_resolver)
246
- @options[:test_file_resolver].call(mutant)
247
- elsif @options.key?(:test_files)
248
- @options[:test_files]
249
- else
250
- integration.select_tests(mutant.subject)
251
- end
252
- end
253
-
254
- def next_slot_id!
255
- id = @next_slot_id
256
- @next_slot_id += 1
257
- id
258
- end
259
-
260
248
  def with_worker_slot(worker_index)
261
249
  previous = ENV.fetch(WORKER_SLOT_ENV, nil)
262
250
  ENV[WORKER_SLOT_ENV] = worker_index.to_s
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Resolves which source files a run mutates: everything under `includes:`,
5
+ # minus anything matched by `excludes:`, optionally narrowed to what changed
6
+ # since a git ref.
7
+ #
8
+ # Excludes are absolute, not a tiebreak: an excluded file (a standalone entry
9
+ # point that cannot be mutation-tested in-process, say) stays excluded even
10
+ # when it is the only thing that changed. Both stages are filters over the
11
+ # same list, so they commute — excludes run first only to keep the
12
+ # per-test-coverage lookups in `filter_changed` off files that are already
13
+ # out of scope.
14
+ class SourceFileSelection
15
+ def initialize(config:, since:, git_diff_analyzer:, per_test_coverage:)
16
+ @config = config
17
+ @since = since
18
+ @git_diff_analyzer = git_diff_analyzer
19
+ @per_test_coverage = per_test_coverage
20
+ end
21
+
22
+ def call
23
+ filter_changed(reject_excluded(included_source_files))
24
+ end
25
+
26
+ def included_source_files
27
+ Array(@config.includes).flat_map do |include_path|
28
+ Dir.glob(File.join(include_path, "**", "*.rb"))
29
+ end.uniq
30
+ end
31
+
32
+ # Drops files matched by any `excludes:` glob. Compared as expanded paths so
33
+ # a relative glob and an absolute candidate still match.
34
+ def reject_excluded(files)
35
+ excluded = excluded_source_files
36
+ return files if excluded.empty?
37
+
38
+ files.reject { |path| excluded.include?(normalize_path(path)) }
39
+ end
40
+
41
+ def filter_changed(files)
42
+ return files unless @since
43
+
44
+ changed = changed_paths_since.map { |path| normalize_path(path) }
45
+ changed += covered_sources_for_changed_tests(changed)
46
+ files.select { |path| changed.include?(normalize_path(path)) }
47
+ end
48
+
49
+ private
50
+
51
+ def excluded_source_files
52
+ Array(@config.excludes)
53
+ .flat_map { |pattern| Dir.glob(pattern) }
54
+ .map { |path| normalize_path(path) }
55
+ end
56
+
57
+ # Committed changes since the ref plus the current working tree (tracked
58
+ # dirty and untracked files): the working tree is what gets tested, so it is
59
+ # always part of "changed since REF".
60
+ def changed_paths_since
61
+ @git_diff_analyzer.changed_files(from: @since, to: "HEAD") +
62
+ @git_diff_analyzer.working_tree_changed_files
63
+ end
64
+
65
+ # Changed test files select the source files they cover, so an edited test
66
+ # re-tests the subjects it can kill. Uses the per-test map from the previous
67
+ # run — Gate 1 runs before this run's bootstrap finishes.
68
+ def covered_sources_for_changed_tests(changed_paths)
69
+ changed_paths
70
+ .flat_map { |path| @per_test_coverage.source_files_covered_by(path) }
71
+ .map { |path| normalize_path(path) }
72
+ end
73
+
74
+ def normalize_path(path) = File.expand_path(path)
75
+ end
76
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Turns a list of source files into the subjects a run mutates, applying any
5
+ # CLI subject patterns.
6
+ #
7
+ # With no patterns every resolved subject is kept. With patterns, each is
8
+ # applied independently and the results concatenated, so overlapping patterns
9
+ # can name the same subject twice — hence the de-duplication on
10
+ # `[expression, source_file]` rather than on object identity. Expression alone
11
+ # is not enough: the same expression can legitimately appear in two files.
12
+ class SubjectSelection
13
+ def initialize(subject_resolver:, patterns:)
14
+ @subject_resolver = subject_resolver
15
+ @patterns = patterns
16
+ end
17
+
18
+ def resolve(source_files)
19
+ subjects = @subject_resolver.resolve_from_files(source_files)
20
+ return subjects if pattern_expressions.empty?
21
+
22
+ unique(pattern_expressions.flat_map { |expression| @subject_resolver.apply_pattern(subjects, expression) })
23
+ end
24
+
25
+ def unique(subjects)
26
+ subjects.uniq { |subject| [subject.expression, subject.source_file] }
27
+ end
28
+
29
+ private
30
+
31
+ def pattern_expressions = Array(@patterns).map(&:expression)
32
+ end
33
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "dirty_source_detector"
4
+
3
5
  module Henitai
4
6
  # Survivor-rerun fast path for {Runner}.
5
7
  #
@@ -154,27 +156,13 @@ module Henitai
154
156
  end
155
157
 
156
158
  def dirty_source_files?(dirty_worktree_files, git_sha: nil)
157
- return true if dirty_worktree_files.nil?
158
-
159
- all_changed = dirty_worktree_files + committed_changed_files(git_sha)
160
- include_roots = Array(@config.includes).map { |path| normalize_path(path) }
161
- all_changed.any? { |path| in_include_root?(normalize_path(path), include_roots) }
162
- rescue StandardError
163
- true
159
+ dirty_source_detector.dirty?(dirty_worktree_files, git_sha: git_sha)
164
160
  end
165
161
 
166
- def committed_changed_files(git_sha)
167
- return [] unless git_sha
168
-
169
- @git_diff_analyzer.changed_files(from: git_sha, to: "HEAD")
170
- end
171
-
172
- def in_include_root?(path, include_roots)
173
- include_roots.any? { |root| path == root || path.start_with?("#{root}/") }
174
- end
175
-
176
- def normalize_path(path)
177
- File.expand_path(path)
162
+ def dirty_source_detector
163
+ @dirty_source_detector ||= DirtySourceDetector.new(
164
+ includes: @config.includes, git_diff_analyzer: @git_diff_analyzer
165
+ )
178
166
  end
179
167
 
180
168
  def warn_survivor_drift(selector)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.3.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/henitai.rb CHANGED
@@ -58,6 +58,9 @@ module Henitai
58
58
  autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
59
59
  autoload :SurvivorActivationCache, "henitai/survivor_activation_cache"
60
60
  autoload :SurvivorRerunStrategy, "henitai/survivor_rerun_strategy"
61
+ autoload :DirtySourceDetector, "henitai/dirty_source_detector"
62
+ autoload :SourceFileSelection, "henitai/source_file_selection"
63
+ autoload :SubjectSelection, "henitai/subject_selection"
61
64
  autoload :ScenarioExecutionResult, "henitai/scenario_execution_result"
62
65
  autoload :CoverageFormatter, "henitai/coverage_formatter"
63
66
  autoload :MinitestCoverageReporter, "henitai/minitest_coverage_reporter"
@@ -65,12 +68,17 @@ module Henitai
65
68
  autoload :SyntaxValidator, "henitai/syntax_validator"
66
69
  autoload :SamplingStrategy, "henitai/sampling_strategy"
67
70
  autoload :TestPrioritizer, "henitai/test_prioritizer"
71
+ autoload :ExcludedTestFilter, "henitai/excluded_test_filter"
68
72
  autoload :TimeoutCalibrator, "henitai/timeout_calibrator"
69
73
  autoload :ExecutionEngine, "henitai/execution_engine"
70
74
  autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
71
75
  autoload :SlotScheduler, "henitai/slot_scheduler"
72
76
  autoload :ProcessWakeup, "henitai/process_wakeup"
77
+ autoload :ProcessLiveness, "henitai/process_liveness"
78
+ autoload :InheritedFdRegistry, "henitai/inherited_fd_registry"
79
+ autoload :OrphanWatchdog, "henitai/orphan_watchdog"
73
80
  autoload :Runner, "henitai/runner"
81
+ autoload :RunnerDependencies, "henitai/runner_dependencies"
74
82
  autoload :Reporter, "henitai/reporter"
75
83
  autoload :Integration, "henitai/integration"
76
84
  autoload :Result, "henitai/result"