henitai 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -23,11 +23,17 @@ module Henitai
23
23
 
24
24
  PROCESS_DRAIN_WINDOW = 0.2
25
25
 
26
+ # Environment variable exposing a stable worker-slot index (0..jobs-1) to
27
+ # each forked child so test suites can isolate shared resources per slot
28
+ # (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
29
+ # keeps the original attempt's value.
30
+ WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
31
+
26
32
  # Tracks one in-flight mutant child process.
27
33
  Slot = Struct.new(
28
34
  :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
29
35
  :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
30
- :forced_outcome
36
+ :forced_outcome, :worker_index
31
37
  )
32
38
 
33
39
  # @return [Integer] mutants that required at least one retry during the run.
@@ -98,28 +104,48 @@ module Henitai
98
104
  test_files = resolve_test_files(mutant)
99
105
  mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
100
106
  mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
101
- handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
102
- register_slot(handle, mutant)
107
+ return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
108
+
109
+ worker_index = next_free_worker_index
110
+ with_worker_slot(worker_index) do
111
+ handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
112
+ register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
113
+ end
103
114
  rescue StandardError => e
104
115
  record_spawn_failure(mutant, e)
105
116
  end
106
117
 
107
- def register_slot(handle, mutant)
118
+ def register_slot(handle, mutant, worker_index, timeout)
108
119
  slot_id = next_slot_id!
109
- slot = build_slot(slot_id, mutant, handle)
120
+ slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
110
121
  slots[slot_id] = slot
111
122
  pid_to_slot[handle.pid] = slot_id
112
123
  Integration::SchedulerDiagnostics.child_started(handle.pid)
113
124
  end
114
125
 
115
- def build_slot(slot_id, mutant, handle)
126
+ def build_slot(slot_id, mutant, handle, worker_index, timeout)
116
127
  Slot.new(
117
128
  slot_id, mutant, handle.pid,
118
129
  monotonic_time,
119
- config.timeout, handle.log_paths, 0, false, nil, nil
130
+ timeout, handle.log_paths, 0, false, nil, nil,
131
+ worker_index
120
132
  )
121
133
  end
122
134
 
135
+ def slot_timeout(mutant, test_files)
136
+ resolver = options[:timeout_resolver]
137
+ resolver ? resolver.call(mutant, test_files) : config.timeout
138
+ end
139
+
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
+
123
149
  def complete_slot(pid, wait_result)
124
150
  slot_id = pid_to_slot.delete(pid)
125
151
  return unless slot_id
@@ -140,6 +166,7 @@ module Henitai
140
166
  slot.mutant.status = result.status
141
167
  results << result
142
168
  progress_reporter&.progress(slot.mutant, scenario_result: result)
169
+ result.release_output! if result.respond_to?(:release_output!)
143
170
  end
144
171
  end
145
172
 
@@ -147,22 +174,32 @@ module Henitai
147
174
  !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
148
175
  end
149
176
 
150
- def retry_slot(slot) # rubocop:disable Metrics/AbcSize
177
+ def retry_slot(slot)
178
+ test_files = resolve_test_files(slot.mutant)
179
+ with_worker_slot(slot.worker_index) do
180
+ handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
181
+ finish_retry(slot, handle)
182
+ end
183
+ rescue StandardError => e
184
+ slots.delete(slot.slot_id)
185
+ record_spawn_failure(slot.mutant, e)
186
+ end
187
+
188
+ def finish_retry(slot, handle)
151
189
  @flaky_retry_count += 1 if slot.retry_count.zero?
152
190
  slot.retry_count += 1
153
- test_files = resolve_test_files(slot.mutant)
154
- handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
191
+ reset_slot_for_retry(slot, handle)
192
+ pid_to_slot[handle.pid] = slot.slot_id
193
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
194
+ end
195
+
196
+ def reset_slot_for_retry(slot, handle)
155
197
  slot.pid = handle.pid
156
198
  slot.log_paths = handle.log_paths
157
199
  slot.started_at_monotonic = monotonic_time
158
200
  slot.draining = false
159
201
  slot.term_sent_at_monotonic = nil
160
202
  slot.forced_outcome = nil
161
- pid_to_slot[handle.pid] = slot.slot_id
162
- Integration::SchedulerDiagnostics.child_started(handle.pid)
163
- rescue StandardError => e
164
- slots.delete(slot.slot_id)
165
- record_spawn_failure(slot.mutant, e)
166
203
  end
167
204
 
168
205
  def record_spawn_failure(mutant, error)
@@ -178,6 +215,15 @@ module Henitai
178
215
  progress_reporter&.progress(mutant, scenario_result: result)
179
216
  end
180
217
 
218
+ def record_no_coverage(mutant)
219
+ mutant.status = :no_coverage
220
+ progress_reporter&.progress(mutant, scenario_result: nil)
221
+ end
222
+
223
+ def resolved_selection_empty?(test_files)
224
+ options.key?(:test_file_resolver) && test_files.empty?
225
+ end
226
+
181
227
  def remaining_slot_timeout(slot, now)
182
228
  # Invariant: drain_draining_slots runs (and removes draining slots) before
183
229
  # the event wait, so next_event_timeout never observes a draining slot
@@ -210,5 +256,17 @@ module Henitai
210
256
  @next_slot_id += 1
211
257
  id
212
258
  end
259
+
260
+ def with_worker_slot(worker_index)
261
+ previous = ENV.fetch(WORKER_SLOT_ENV, nil)
262
+ ENV[WORKER_SLOT_ENV] = worker_index.to_s
263
+ yield
264
+ ensure
265
+ if previous.nil?
266
+ ENV.delete(WORKER_SLOT_ENV)
267
+ else
268
+ ENV[WORKER_SLOT_ENV] = previous
269
+ end
270
+ end
213
271
  end
214
272
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "coverage_report_reader"
4
+ require_relative "mutation_skip_directives"
4
5
 
5
6
  module Henitai
6
7
  # Applies static, pre-execution filtering to generated mutants.
@@ -8,8 +9,10 @@ module Henitai
8
9
  DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
9
10
  DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
10
11
 
11
- def initialize(coverage_report_reader: CoverageReportReader.new)
12
+ def initialize(coverage_report_reader: CoverageReportReader.new,
13
+ skip_directives: MutationSkipDirectives.new)
12
14
  @coverage_report_reader = coverage_report_reader
15
+ @skip_directives = skip_directives
13
16
  end
14
17
 
15
18
  # This method is the gate-level filter orchestrator.
@@ -18,7 +21,7 @@ module Henitai
18
21
  coverage_report_present = coverage_report_present?(config)
19
22
 
20
23
  Array(mutants).each do |mutant|
21
- next if ignored_mutant?(mutant, config)
24
+ next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
22
25
 
23
26
  mark_equivalent_mutant(mutant)
24
27
  mark_no_coverage_mutant(
@@ -61,7 +64,7 @@ module Henitai
61
64
 
62
65
  private
63
66
 
64
- attr_reader :coverage_report_reader
67
+ attr_reader :coverage_report_reader, :skip_directives
65
68
 
66
69
  def ignored?(mutant, config)
67
70
  source = source_for(mutant)
@@ -79,6 +82,15 @@ module Henitai
79
82
  true
80
83
  end
81
84
 
85
+ def skip_directive_mutant?(mutant)
86
+ directive = skip_directives.directive_for(mutant)
87
+ return false unless directive
88
+
89
+ mutant.status = :ignored
90
+ mutant.ignore_reason = directive.reason if mutant.respond_to?(:ignore_reason=)
91
+ true
92
+ end
93
+
82
94
  def mark_equivalent_mutant(mutant)
83
95
  return unless mutant.pending?
84
96
 
@@ -96,9 +108,7 @@ module Henitai
96
108
  def covered?(mutant, coverage_lines)
97
109
  file = normalize_path(mutant.location[:file])
98
110
  covered = Array(coverage_lines[file])
99
- (mutant.location[:start_line]..mutant.location[:end_line]).any? do |line|
100
- covered.include?(line)
101
- end
111
+ covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
102
112
  end
103
113
 
104
114
  def source_for(mutant)
@@ -137,7 +137,7 @@ module Henitai
137
137
  split[:stable] + split[:pending]
138
138
  end
139
139
 
140
- def test_filter(loaded, dirty_source_files: false)
140
+ def test_filter(loaded, dirty_source_files:)
141
141
  SurvivorTestFilter.new(
142
142
  coverage_map: loaded.coverage_map,
143
143
  git_sha: loaded.git_sha,
@@ -57,7 +57,7 @@ module Henitai
57
57
  covering = @coverage_map[mutant.stable_id]
58
58
  return false if covering.nil? || covering.empty?
59
59
 
60
- covering.none? { |test_file| changed.include?(test_file) }
60
+ !changed.intersect?(covering)
61
61
  end
62
62
 
63
63
  # Returns a Set of changed test file paths, or nil on any git error
@@ -2,15 +2,41 @@
2
2
 
3
3
  module Henitai
4
4
  # Orders test files so previously effective tests run first.
5
+ #
6
+ # Primary key: kill-history count (a test that killed neighbouring mutants
7
+ # probably kills this one). Tiebreaker: measured per-test-file runtime,
8
+ # ascending, so the first kill costs as little wall-clock as possible.
9
+ # Untimed tests sort after timed ones by original inventory order; with no
10
+ # timing source at all the ordering is exactly the historical behavior.
5
11
  class TestPrioritizer
12
+ # @param timing_source [#call, nil] returns a Hash of test file path to
13
+ # wall-clock seconds; resolved lazily on first sort.
14
+ def initialize(timing_source: nil)
15
+ @timing_source = timing_source
16
+ end
17
+
6
18
  def sort(tests, _mutant, history)
7
19
  Array(tests).each_with_index.sort_by do |test, index|
8
- [-history_count(history, test), index]
20
+ [-history_count(history, test), runtime(test), index]
9
21
  end.map(&:first)
10
22
  end
11
23
 
12
24
  private
13
25
 
26
+ def runtime(test)
27
+ durations = timing_durations
28
+ history_key_candidates(test).each do |key|
29
+ value = durations[key]
30
+ return value.to_f unless value.nil?
31
+ end
32
+
33
+ Float::INFINITY
34
+ end
35
+
36
+ def timing_durations
37
+ @timing_durations ||= @timing_source&.call || {}
38
+ end
39
+
14
40
  def history_count(history, test)
15
41
  return 0 unless history.respond_to?(:fetch)
16
42
 
@@ -45,7 +71,7 @@ module Henitai
45
71
 
46
72
  def relative_history_key(path)
47
73
  pathname = Pathname.new(path)
48
- return unless pathname.absolute?
74
+ return path unless pathname.absolute?
49
75
 
50
76
  pathname.relative_path_from(Pathname.pwd).to_s
51
77
  rescue StandardError
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Derives a per-mutant timeout from measured per-test-file durations.
5
+ #
6
+ # The calibrated value is `multiplier × sum(durations of the selected test
7
+ # files)`, clamped to a floor so a near-instant baseline doesn't flag normal
8
+ # jitter as a timeout. Returns nil — caller falls back to the static
9
+ # default — when any selected test lacks timing data, so a partially
10
+ # measured run is treated as uncalibratable rather than under-estimated.
11
+ class TimeoutCalibrator
12
+ FLOOR_SECONDS = 2.0
13
+
14
+ # @param timing_source [#call] returns a Hash of test file path to
15
+ # wall-clock seconds; resolved lazily on first use.
16
+ # @param multiplier [Numeric]
17
+ def initialize(timing_source:, multiplier:)
18
+ @timing_source = timing_source
19
+ @multiplier = multiplier
20
+ end
21
+
22
+ # @param test_files [Array<String>]
23
+ # @return [Float, nil]
24
+ def timeout_for(test_files)
25
+ files = Array(test_files)
26
+ return nil if files.empty? || durations.empty?
27
+
28
+ baselines = files.map { |file| durations[normalize(file)] }
29
+ return nil if baselines.any?(&:nil?)
30
+
31
+ [@multiplier * baselines.sum, FLOOR_SECONDS].max
32
+ end
33
+
34
+ private
35
+
36
+ def durations
37
+ @durations ||= (@timing_source.call || {}).transform_keys { |key| normalize(key) }
38
+ end
39
+
40
+ def normalize(path)
41
+ File.expand_path(path.to_s)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "find"
5
+ require "json"
6
+
7
+ require_relative "generated_artifacts"
8
+
9
+ module Henitai
10
+ # Content fingerprints that decide whether a stored verdict is still
11
+ # trustworthy: the subject's source (method body) and the covering test
12
+ # files must both be byte-identical to what was recorded. Survived verdicts
13
+ # additionally record the full-map covering-set membership plus a run-level
14
+ # dependency fingerprint (ADR-11). Any read failure yields nil / a mismatch
15
+ # — conservative, never reuse on doubt.
16
+ module VerdictFingerprint
17
+ # Files that influence test behavior but never appear in any covering
18
+ # set: helpers, support code, fixtures/factories, lockfile and tool
19
+ # config. One combined hash over all of them gates survivor reuse.
20
+ DEPENDENCY_GLOBS = %w[
21
+ spec/spec_helper.rb
22
+ spec/rails_helper.rb
23
+ spec/support/**/*
24
+ spec/fixtures/**/*
25
+ spec/factories/**/*
26
+ test/test_helper.rb
27
+ test/support/**/*
28
+ test/fixtures/**/*
29
+ test/factories/**/*
30
+ Gemfile.lock
31
+ .henitai.yml
32
+ .rspec
33
+ ].freeze
34
+
35
+ module_function
36
+
37
+ # SHA256 of the subject's source lines. nil when the subject has no
38
+ # source range or the file cannot be read.
39
+ def subject_source_hash(mutant)
40
+ subject = mutant.subject
41
+ return nil unless subject.respond_to?(:source_file) && subject.respond_to?(:source_range)
42
+
43
+ range = subject.source_range
44
+ return nil unless range
45
+
46
+ lines = File.readlines(subject.source_file)
47
+ Digest::SHA256.hexdigest(lines[(range.begin - 1)..(range.end - 1)].to_a.join)
48
+ rescue SystemCallError, IOError
49
+ nil
50
+ end
51
+
52
+ # JSON fingerprint of the covering test files: their sorted paths plus a
53
+ # combined content hash. nil when no tests are known or any is unreadable.
54
+ def tests_fingerprint(test_files)
55
+ paths = Array(test_files).map(&:to_s).sort
56
+ return nil if paths.empty?
57
+
58
+ sha = combined_content_sha(paths)
59
+ return nil unless sha
60
+
61
+ JSON.generate("paths" => paths, "sha" => sha)
62
+ end
63
+
64
+ # True when every recorded test file still exists with identical content.
65
+ def tests_fingerprint_current?(fingerprint_json)
66
+ return false if fingerprint_json.nil?
67
+
68
+ fingerprint = JSON.parse(fingerprint_json)
69
+ combined_content_sha(fingerprint.fetch("paths")) == fingerprint.fetch("sha")
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ # Existing dependency files resolved against +root+, sorted. Shared by
75
+ # the fingerprint below and by the coverage freshness watch — a stale
76
+ # per-test map after a dependency edit would let the test selector omit
77
+ # a newly covering test. Uses Find with pruning instead of Dir.glob so
78
+ # generated subtrees (thousands of mutation-log files under fixture
79
+ # projects) are never walked at all.
80
+ def dependency_files(root = Dir.pwd)
81
+ files = DEPENDENCY_GLOBS.flat_map do |pattern|
82
+ base = File.join(root, pattern)
83
+ pattern.include?("*") ? pruned_tree_files(File.dirname(base.sub("/**/*", "/x"))) : [base]
84
+ end
85
+ files.select { |path| File.file?(path) }.uniq.sort
86
+ end
87
+
88
+ def pruned_tree_files(dir)
89
+ return [] unless File.directory?(dir)
90
+
91
+ collected = [] # : Array[String]
92
+ Find.find(dir) do |path|
93
+ Find.prune if File.directory?(path) && GeneratedArtifacts.generated_dir?(path)
94
+ collected << path if File.file?(path)
95
+ end
96
+ collected
97
+ end
98
+
99
+ # Run-level combined SHA over the dependency file set. Missing files are
100
+ # simply excluded (not an error); a read failure yields nil — no reuse.
101
+ def dependency_fingerprint(root = Dir.pwd) = combined_content_sha(dependency_files(root))
102
+
103
+ # Fingerprint recorded for a Survived verdict: the sorted full-map
104
+ # intersection set (paths + combined content sha) plus the run-level
105
+ # dependency sha. nil when any input is unavailable — the verdict is
106
+ # then never reusable.
107
+ def survivor_tests_fingerprint(test_files, dependency_sha:)
108
+ return nil if dependency_sha.nil?
109
+
110
+ paths = Array(test_files).map(&:to_s).sort
111
+ return nil if paths.empty?
112
+
113
+ sha = combined_content_sha(paths)
114
+ return nil unless sha
115
+
116
+ JSON.generate("paths" => paths, "sha" => sha, "dependencies" => dependency_sha)
117
+ end
118
+
119
+ # True when the recorded covering set still equals the live one in
120
+ # membership AND content, and the dependency fingerprint is unchanged.
121
+ # Any parse/read failure or missing field resolves to stale.
122
+ def survivor_fingerprint_current?(fingerprint_json, live_paths:, dependency_sha:)
123
+ return false if fingerprint_json.nil? || dependency_sha.nil?
124
+
125
+ survivor_fingerprint_matches?(JSON.parse(fingerprint_json), live_paths, dependency_sha)
126
+ rescue StandardError
127
+ false
128
+ end
129
+
130
+ def survivor_fingerprint_matches?(fingerprint, live_paths, dependency_sha)
131
+ recorded_paths = fingerprint.fetch("paths")
132
+ return false if recorded_paths.empty?
133
+
134
+ recorded_paths == Array(live_paths).map(&:to_s).sort &&
135
+ fingerprint.fetch("dependencies") == dependency_sha &&
136
+ combined_content_sha(recorded_paths) == fingerprint.fetch("sha")
137
+ end
138
+
139
+ def combined_content_sha(paths)
140
+ digest = Digest::SHA256.new
141
+ paths.each do |path|
142
+ content = File.read(path)
143
+ digest.update(path.bytesize.to_s)
144
+ digest.update(":")
145
+ digest.update(path)
146
+ digest.update(content.bytesize.to_s)
147
+ digest.update(":")
148
+ digest.update(content)
149
+ end
150
+ digest.hexdigest
151
+ rescue StandardError
152
+ nil
153
+ end
154
+ end
155
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/henitai.rb CHANGED
@@ -20,9 +20,13 @@ module Henitai
20
20
  # Raised when coverage data cannot be bootstrapped or validated.
21
21
  class CoverageError < StandardError; end
22
22
 
23
+ # Raised when another process is using the configured reports directory.
24
+ class ConcurrentRunError < StandardError; end
25
+
23
26
  autoload :Configuration, "henitai/configuration"
24
27
  autoload :CoverageBootstrapper, "henitai/coverage_bootstrapper"
25
28
  autoload :CoverageReportReader, "henitai/coverage_report_reader"
29
+ autoload :PerTestCoverage, "henitai/per_test_coverage"
26
30
  autoload :PerTestCoverageSelector, "henitai/per_test_coverage_selector"
27
31
  autoload :Subject, "henitai/subject"
28
32
  autoload :Mutant, "henitai/mutant"
@@ -31,15 +35,24 @@ module Henitai
31
35
  autoload :Operators, "henitai/operators"
32
36
  autoload :SourceParser, "henitai/source_parser"
33
37
  autoload :SubjectResolver, "henitai/subject_resolver"
38
+ autoload :GeneratedArtifacts, "henitai/generated_artifacts"
34
39
  autoload :GitDiffAnalyzer, "henitai/git_diff_analyzer"
35
40
  autoload :GitDiffError, "henitai/git_diff_analyzer"
36
41
  autoload :MutantGenerator, "henitai/mutant_generator"
37
42
  autoload :MutantHistoryStore, "henitai/mutant_history_store"
43
+ autoload :MutationSkipDirectives, "henitai/mutation_skip_directives"
38
44
  autoload :AridNodeFilter, "henitai/arid_node_filter"
39
45
  autoload :AvailableCpuCount, "henitai/available_cpu_count"
40
46
  autoload :EquivalenceDetector, "henitai/equivalence_detector"
41
47
  autoload :StaticFilter, "henitai/static_filter"
48
+ autoload :IncrementalFilter, "henitai/incremental_filter"
49
+ autoload :VerdictFingerprint, "henitai/verdict_fingerprint"
42
50
  autoload :StillbornFilter, "henitai/stillborn_filter"
51
+ autoload :CanonicalReportMerger, "henitai/canonical_report_merger"
52
+ autoload :CanonicalReportWriter, "henitai/canonical_report_writer"
53
+ autoload :CheckpointReporter, "henitai/checkpoint_reporter"
54
+ autoload :CompositeProgressReporter, "henitai/composite_progress_reporter"
55
+ autoload :ReportsDirectoryLock, "henitai/reports_directory_lock"
43
56
  autoload :SurvivorLoader, "henitai/survivor_loader"
44
57
  autoload :SurvivorSelector, "henitai/survivor_selector"
45
58
  autoload :SurvivorTestFilter, "henitai/survivor_test_filter"
@@ -52,8 +65,8 @@ module Henitai
52
65
  autoload :SyntaxValidator, "henitai/syntax_validator"
53
66
  autoload :SamplingStrategy, "henitai/sampling_strategy"
54
67
  autoload :TestPrioritizer, "henitai/test_prioritizer"
68
+ autoload :TimeoutCalibrator, "henitai/timeout_calibrator"
55
69
  autoload :ExecutionEngine, "henitai/execution_engine"
56
- autoload :ParallelExecutionRunner, "henitai/parallel_execution_runner"
57
70
  autoload :ProcessWorkerRunner, "henitai/process_worker_runner"
58
71
  autoload :SlotScheduler, "henitai/slot_scheduler"
59
72
  autoload :ProcessWakeup, "henitai/process_wakeup"