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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Drain/timeout state machine for in-flight slots.
6
+ #
7
+ # A slot enters the draining state either when it exceeds its timeout
8
+ # ({#check_timeouts}) or when a shutdown is requested
9
+ # ({#interrupt_active_slots}). {#drain_draining_slots} then performs the
10
+ # two-phase SIGTERM/SIGKILL broadcast and the final blocking reap.
11
+ #
12
+ # Mixed into {SlotScheduler}; relies on its slot table, +integration+,
13
+ # +progress_reporter+, +wakeup+ and the {ProcessControl} primitives.
14
+ module Draining
15
+ # Per-slot timeout check. Must be called after reap_all_completed_children
16
+ # so that naturally-exited processes are already removed from slots.
17
+ def check_timeouts
18
+ now = monotonic_time
19
+ slots.each_value do |slot|
20
+ next if slot.draining
21
+ next unless now >= slot.started_at_monotonic + slot.timeout
22
+
23
+ # Final targeted reap: if the child already exited, classify it normally.
24
+ pid, status = wnohang_reap(slot.pid)
25
+ if pid
26
+ complete_slot(pid, status)
27
+ else
28
+ slot.forced_outcome = :timeout
29
+ slot.draining = true
30
+ end
31
+ end
32
+ end
33
+
34
+ def draining_slots?
35
+ slots.any? { |_, slot| slot.draining }
36
+ end
37
+
38
+ # Two-phase broadcast cleanup for all slots that are in draining state.
39
+ #
40
+ # Precision rule: before signalling, do one final WNOHANG pass to catch
41
+ # processes that exited naturally in the window between check_timeouts and
42
+ # now. If SIGTERM gets ESRCH, the process is already gone — we must not
43
+ # force-label those as :timeout.
44
+ def drain_draining_slots
45
+ draining = draining_slots
46
+ return if draining.empty?
47
+
48
+ prune_raced_draining_slots(draining)
49
+
50
+ return if draining.empty?
51
+
52
+ broadcast_term(draining)
53
+ wait_for_drain_window
54
+ signal_draining_slots(draining)
55
+ reap_and_remove_draining(draining)
56
+ end
57
+
58
+ def interrupt_active_slots
59
+ slots.each_value do |slot|
60
+ next if slot.draining
61
+
62
+ slot.forced_outcome = :interrupted
63
+ slot.draining = true
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def draining_slots
70
+ slots.select { |_, slot| slot.draining }
71
+ end
72
+
73
+ def prune_raced_draining_slots(draining)
74
+ draining.reject! do |_, slot|
75
+ pid, status = wnohang_reap(slot.pid)
76
+ next false unless pid
77
+
78
+ complete_slot(pid, status)
79
+ true
80
+ end
81
+ end
82
+
83
+ def wait_for_drain_window
84
+ wakeup&.wait(PROCESS_DRAIN_WINDOW)
85
+ wakeup&.drain
86
+ end
87
+
88
+ def signal_draining_slots(draining)
89
+ draining.each_value { |slot| signal_process_group(slot.pid, :SIGKILL) }
90
+ end
91
+
92
+ def broadcast_term(draining)
93
+ now = monotonic_time
94
+ draining.each_value do |slot|
95
+ slot.term_sent_at_monotonic = now
96
+ signal_process_group(slot.pid, :SIGTERM)
97
+ end
98
+ end
99
+
100
+ # After SIGKILL window: blocking reap each slot, then build its result.
101
+ #
102
+ # Interrupted slots are cleaned up but produce no result — the scheduler
103
+ # is shutting down and does not emit verdicts for in-flight mutants.
104
+ #
105
+ # For timeout slots: a real exit status only wins if observed before any
106
+ # parent signal was sent. Once SIGTERM has been dispatched, the forced
107
+ # outcome is authoritative — a child handling SIGTERM and exiting 0 must
108
+ # not be misclassified as :survived.
109
+ def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
110
+ draining.each_value do |slot|
111
+ # One last WNOHANG before blocking: catches processes that exited
112
+ # between SIGKILL and here.
113
+ _, final_status = wnohang_reap(slot.pid)
114
+ reap_pid(slot.pid) unless final_status
115
+
116
+ pid_to_slot.delete(slot.pid)
117
+ slots.delete(slot.slot_id)
118
+ Integration::SchedulerDiagnostics.child_ended(slot.pid)
119
+
120
+ next if slot.forced_outcome == :interrupted
121
+
122
+ result = build_drain_result(slot, final_status)
123
+ slot.mutant.status = result.status
124
+ results << result
125
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
126
+ end
127
+ end
128
+
129
+ # Choose result: use real exit status only if observed before any parent
130
+ # signal was sent. After SIGTERM, the forced outcome is authoritative.
131
+ def build_drain_result(slot, final_status)
132
+ if final_status&.exited? && slot.term_sent_at_monotonic.nil?
133
+ integration.build_result(final_status, slot.log_paths)
134
+ else
135
+ integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class SlotScheduler
5
+ # Low-level bridge to the OS process and signal primitives.
6
+ #
7
+ # Every Process.wait*/kill call routes through +runtime+ so that the
8
+ # scheduler remains the single caller of the process table. Mixed into
9
+ # {SlotScheduler}; relies on its +runtime+ reader.
10
+ module ProcessControl
11
+ private
12
+
13
+ def monotonic_time
14
+ runtime.clock_gettime(Process::CLOCK_MONOTONIC)
15
+ end
16
+
17
+ def wnohang_reap(pid)
18
+ runtime.wait2(pid, Process::WNOHANG)
19
+ rescue Errno::ECHILD, Errno::ESRCH
20
+ nil
21
+ end
22
+
23
+ def signal_process_group(pid, signal)
24
+ runtime.kill(signal, -pid)
25
+ rescue Errno::ESRCH
26
+ nil
27
+ rescue Errno::EPERM
28
+ # Process group not yet established; fall back to signalling the pid.
29
+ begin
30
+ runtime.kill(signal, pid)
31
+ rescue Errno::ESRCH
32
+ nil
33
+ end
34
+ end
35
+
36
+ def reap_pid(pid)
37
+ runtime.wait(pid)
38
+ rescue Errno::ECHILD, Errno::ESRCH
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot_scheduler/process_control"
4
+ require_relative "slot_scheduler/draining"
5
+
6
+ module Henitai
7
+ # Owns the process-slot table for a single parallel mutation run.
8
+ #
9
+ # {ProcessWorkerRunner} drives the event loop and OS signal handling and
10
+ # delegates every slot operation here: filling idle slots, reaping completed
11
+ # children, retrying flaky survivors, detecting timeouts and running the
12
+ # drain/broadcast state machine. Keeping the table behind one collaborator
13
+ # means Process.wait* has a single caller, so there are no races between
14
+ # threads reaping the same child.
15
+ #
16
+ # The drain/timeout state machine lives in {Draining}; the low-level process
17
+ # and signal primitives live in {ProcessControl}. +host+ is the owning
18
+ # {ProcessWorkerRunner}, which supplies +runtime+, +wakeup+, +worker_count+
19
+ # and the shutdown flag.
20
+ class SlotScheduler
21
+ include ProcessControl
22
+ include Draining
23
+
24
+ PROCESS_DRAIN_WINDOW = 0.2
25
+
26
+ # Tracks one in-flight mutant child process.
27
+ Slot = Struct.new(
28
+ :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
29
+ :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
30
+ :forced_outcome
31
+ )
32
+
33
+ # @return [Integer] mutants that required at least one retry during the run.
34
+ # @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
35
+ attr_reader :flaky_retry_count, :results
36
+
37
+ def initialize(integration:, config:, progress_reporter:, options:, host:)
38
+ @integration = integration
39
+ @config = config
40
+ @progress_reporter = progress_reporter
41
+ @options = options
42
+ @host = host
43
+ @pending = []
44
+ @slots = {}
45
+ @pid_to_slot = {}
46
+ @results = []
47
+ @flaky_retry_count = 0
48
+ @next_slot_id = 0
49
+ end
50
+
51
+ # Queues the mutants to be scheduled into worker slots.
52
+ def enqueue(mutants)
53
+ @pending = mutants.dup
54
+ end
55
+
56
+ def done?
57
+ pending.empty? && slots.empty?
58
+ end
59
+
60
+ def fill_idle_slots
61
+ while slots.size < worker_count && !pending.empty?
62
+ mutant = pending.shift
63
+ spawn_into_slot(mutant)
64
+ end
65
+ end
66
+
67
+ def reap_all_completed_children
68
+ loop do
69
+ pid, status = runtime.wait2(-1, Process::WNOHANG)
70
+ break unless pid
71
+
72
+ complete_slot(pid, status)
73
+ end
74
+ rescue Errno::ECHILD
75
+ nil
76
+ end
77
+
78
+ def next_event_timeout
79
+ now = monotonic_time
80
+ slot_timeouts = slots.each_value.filter_map do |slot|
81
+ remaining_slot_timeout(slot, now)
82
+ end
83
+
84
+ slot_timeouts.min
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
90
+ :progress_reporter, :options, :host
91
+
92
+ def worker_count = host.worker_count
93
+ def runtime = host.runtime
94
+ def wakeup = host.wakeup
95
+ def shutdown? = host.shutdown_requested?
96
+
97
+ def spawn_into_slot(mutant)
98
+ test_files = resolve_test_files(mutant)
99
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
100
+ 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)
103
+ rescue StandardError => e
104
+ record_spawn_failure(mutant, e)
105
+ end
106
+
107
+ def register_slot(handle, mutant)
108
+ slot_id = next_slot_id!
109
+ slot = build_slot(slot_id, mutant, handle)
110
+ slots[slot_id] = slot
111
+ pid_to_slot[handle.pid] = slot_id
112
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
113
+ end
114
+
115
+ def build_slot(slot_id, mutant, handle)
116
+ Slot.new(
117
+ slot_id, mutant, handle.pid,
118
+ monotonic_time,
119
+ config.timeout, handle.log_paths, 0, false, nil, nil
120
+ )
121
+ end
122
+
123
+ def complete_slot(pid, wait_result)
124
+ slot_id = pid_to_slot.delete(pid)
125
+ return unless slot_id
126
+
127
+ slot = slots[slot_id]
128
+ return unless slot
129
+
130
+ Integration::SchedulerDiagnostics.child_ended(pid)
131
+ result = integration.build_result(wait_result, slot.log_paths)
132
+ dispatch_slot_result(slot, result)
133
+ end
134
+
135
+ def dispatch_slot_result(slot, result)
136
+ if should_retry?(slot, result)
137
+ retry_slot(slot)
138
+ else
139
+ slots.delete(slot.slot_id)
140
+ slot.mutant.status = result.status
141
+ results << result
142
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
143
+ end
144
+ end
145
+
146
+ def should_retry?(slot, result)
147
+ !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
148
+ end
149
+
150
+ def retry_slot(slot) # rubocop:disable Metrics/AbcSize
151
+ @flaky_retry_count += 1 if slot.retry_count.zero?
152
+ slot.retry_count += 1
153
+ test_files = resolve_test_files(slot.mutant)
154
+ handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
155
+ slot.pid = handle.pid
156
+ slot.log_paths = handle.log_paths
157
+ slot.started_at_monotonic = monotonic_time
158
+ slot.draining = false
159
+ slot.term_sent_at_monotonic = nil
160
+ 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
+ end
167
+
168
+ def record_spawn_failure(mutant, error)
169
+ result = ScenarioExecutionResult.new(
170
+ status: :compile_error,
171
+ stdout: "",
172
+ stderr: "spawn failed: #{error.message}",
173
+ log_path: "/dev/null",
174
+ exit_status: nil
175
+ )
176
+ mutant.status = result.status
177
+ results << result
178
+ progress_reporter&.progress(mutant, scenario_result: result)
179
+ end
180
+
181
+ def remaining_slot_timeout(slot, now)
182
+ # Invariant: drain_draining_slots runs (and removes draining slots) before
183
+ # the event wait, so next_event_timeout never observes a draining slot
184
+ # whose SIGTERM has not been sent. Guard term_sent_at_monotonic defensively
185
+ # against a future ordering change: an unsignalled draining slot is due now.
186
+ return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
187
+
188
+ deadline =
189
+ if slot.draining
190
+ slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
191
+ else
192
+ slot.started_at_monotonic + slot.timeout
193
+ end
194
+ remaining = deadline - now
195
+ remaining.positive? ? remaining : 0.0
196
+ end
197
+
198
+ def resolve_test_files(mutant)
199
+ if @options.key?(:test_file_resolver)
200
+ @options[:test_file_resolver].call(mutant)
201
+ elsif @options.key?(:test_files)
202
+ @options[:test_files]
203
+ else
204
+ integration.select_tests(mutant.subject)
205
+ end
206
+ end
207
+
208
+ def next_slot_id!
209
+ id = @next_slot_id
210
+ @next_slot_id += 1
211
+ id
212
+ end
213
+ end
214
+ end
@@ -37,11 +37,18 @@ module Henitai
37
37
 
38
38
  coverage_lines = coverage_lines_by_file(coverage_report_path)
39
39
  coverage_lines = merge_method_coverage(coverage_lines, coverage_report_path)
40
- return coverage_lines unless coverage_lines.empty?
41
-
42
- coverage_lines_from_test_lines(
40
+ per_test_lines = coverage_lines_from_test_lines(
43
41
  test_lines_by_file(per_test_coverage_report_path)
44
42
  )
43
+
44
+ return per_test_lines if coverage_lines.empty?
45
+
46
+ # Merge per-test coverage into the standard coverage map.
47
+ # Standard coverage may be incomplete when child processes fork before
48
+ # all files are loaded; per-test coverage widens the result set.
49
+ per_test_lines.each_with_object(coverage_lines) do |(file, lines), merged|
50
+ merged[file] = ((merged[file] || []) + lines).uniq.sort
51
+ end
45
52
  end
46
53
 
47
54
  def coverage_lines_by_file(path = DEFAULT_COVERAGE_REPORT_PATH)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Henitai
7
+ # Stores and retrieves pre-computed +define_method+ activation sources for
8
+ # survived mutants, enabling survivor reruns to skip the full mutant-generation
9
+ # pipeline.
10
+ #
11
+ # The cache artifact (+activation-recipes.json+) is written alongside the
12
+ # session snapshot in +reports/sessions/<session_id>/+. When a survivor rerun
13
+ # finds this file next to the report it was given, it can build stub Mutant
14
+ # objects directly and execute them without re-parsing source files.
15
+ #
16
+ # A recipe entry encodes everything needed to activate and re-report a mutant:
17
+ # the +define_method+ source, subject coordinates, operator, description,
18
+ # location, and the coveredBy test list.
19
+ class SurvivorActivationCache
20
+ FILENAME = "activation-recipes.json"
21
+
22
+ # Build a recipe hash for each survived mutant that has a computable
23
+ # activation source.
24
+ #
25
+ # @param survived_mutants [Array<Mutant>]
26
+ # @return [Hash<String, Hash>] stableId → recipe
27
+ def self.compute(survived_mutants)
28
+ survived_mutants.each_with_object({}) do |mutant, cache|
29
+ source = Mutant::Activator.activation_source_for(mutant)
30
+ next unless source
31
+
32
+ cache[mutant.stable_id] = build_recipe(mutant, source)
33
+ end
34
+ end
35
+
36
+ # @param path [String] path to +activation-recipes.json+
37
+ # @return [Hash, nil] nil when the file is absent or unparseable
38
+ def self.load(path)
39
+ return nil unless File.exist?(path)
40
+
41
+ JSON.parse(File.read(path))
42
+ rescue JSON::ParserError
43
+ nil
44
+ end
45
+
46
+ # @param path [String]
47
+ # @param recipes [Hash<String, Hash>]
48
+ def self.write(path, recipes)
49
+ FileUtils.mkdir_p(File.dirname(path))
50
+ File.write(path, JSON.pretty_generate(recipes))
51
+ end
52
+
53
+ class << self
54
+ private
55
+
56
+ def build_recipe(mutant, activation_source)
57
+ {
58
+ "activationSource" => activation_source,
59
+ "namespace" => mutant.subject.namespace,
60
+ "methodName" => mutant.subject.method_name,
61
+ "methodType" => mutant.subject.method_type.to_s,
62
+ "sourceFile" => mutant.subject.source_file,
63
+ "operator" => mutant.operator,
64
+ "description" => mutant.description,
65
+ "location" => serialize_location(mutant.location),
66
+ "coveredBy" => Array(mutant.covered_by).compact
67
+ }
68
+ end
69
+
70
+ def serialize_location(location)
71
+ {
72
+ "file" => location[:file],
73
+ "startLine" => location[:start_line],
74
+ "endLine" => location[:end_line],
75
+ "startCol" => location[:start_col],
76
+ "endCol" => location[:end_col]
77
+ }.compact
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Henitai
6
+ # Reads a Stryker-compatible mutation report and extracts survivor data.
7
+ #
8
+ # Returns a +Report+ value object carrying:
9
+ # - +survivor_ids+ — stable IDs of survived mutants
10
+ # - +coverage_map+ — stableId → [test_files] from prior coveredBy data
11
+ # - +git_sha+ — git HEAD at the time the report was written (may be nil)
12
+ #
13
+ # Scope validation is intentionally shallow: checks schemaVersion presence
14
+ # and at least one file path overlap with config.includes.
15
+ class SurvivorLoader
16
+ # Value object returned by #load.
17
+ Report = Struct.new(:survivor_ids, :coverage_map, :git_sha)
18
+
19
+ class FileNotFoundError < StandardError; end
20
+ class InvalidReportError < StandardError; end
21
+ class ScopeMismatchError < StandardError; end
22
+
23
+ # @param path [String] path to a Stryker-compatible JSON report
24
+ # @param include_paths [Array<String>] from config.includes; used for scope validation
25
+ def initialize(path, include_paths: [])
26
+ @path = path
27
+ @include_paths = include_paths
28
+ end
29
+
30
+ # @return [Report]
31
+ def load
32
+ raw = read_file
33
+ report = parse_json(raw)
34
+ validate_scope(report)
35
+ build_report(report)
36
+ end
37
+
38
+ private
39
+
40
+ def build_report(report)
41
+ entries = known_entries(report)
42
+ Report.new(
43
+ survivor_ids: extract_survivor_ids(entries),
44
+ coverage_map: extract_coverage_map(entries),
45
+ git_sha: report["gitSha"]
46
+ )
47
+ end
48
+
49
+ # Returns mutant entries that have a stableId, warning about those that don't.
50
+ def known_entries(report)
51
+ all_mutants(report).select do |entry|
52
+ if entry["stableId"]
53
+ true
54
+ else
55
+ warn "henitai: survivor report entry missing stableId — skipping"
56
+ false
57
+ end
58
+ end
59
+ end
60
+
61
+ def extract_survivor_ids(entries)
62
+ entries.filter_map { |e| e["stableId"] if e["status"] == "Survived" }
63
+ end
64
+
65
+ def extract_coverage_map(entries)
66
+ entries.each_with_object({}) do |entry, map|
67
+ covered = Array(entry["coveredBy"]).compact
68
+ map[entry["stableId"]] = covered unless covered.empty?
69
+ end
70
+ end
71
+
72
+ def read_file
73
+ File.read(@path)
74
+ rescue Errno::ENOENT
75
+ raise FileNotFoundError, "Survivor report not found: #{@path}"
76
+ end
77
+
78
+ def parse_json(raw)
79
+ JSON.parse(raw)
80
+ rescue JSON::ParserError => e
81
+ raise InvalidReportError, "Invalid JSON in survivor report #{@path}: #{e.message}"
82
+ end
83
+
84
+ def validate_scope(report)
85
+ validate_schema_version!(report)
86
+ return if @include_paths.empty?
87
+
88
+ report_files = normalized_report_files(report)
89
+ include_dirs_raw = normalized_include_dirs_raw
90
+ include_dirs_abs = normalized_include_dirs_abs(include_dirs_raw)
91
+
92
+ return if any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
93
+
94
+ raise ScopeMismatchError,
95
+ "Survivor report #{@path} has no file overlap with configured includes — " \
96
+ "did you pass a report from a different project?"
97
+ end
98
+
99
+ def validate_schema_version!(report)
100
+ return if report.key?("schemaVersion")
101
+
102
+ raise InvalidReportError,
103
+ "Survivor report #{@path} is missing schemaVersion — is this a Henitai report?"
104
+ end
105
+
106
+ def normalized_report_files(report)
107
+ (report.fetch("files", {}) || {}).keys.map { |p| strip_trailing_slash(p.to_s) }
108
+ end
109
+
110
+ def normalized_include_dirs_raw
111
+ @include_paths.map { |p| strip_trailing_slash(p.to_s) }.uniq
112
+ end
113
+
114
+ def normalized_include_dirs_abs(dirs_raw)
115
+ dirs_raw.map { |p| File.expand_path(p) }.uniq
116
+ end
117
+
118
+ def strip_trailing_slash(path)
119
+ path.sub(%r{/\z}, "")
120
+ end
121
+
122
+ def any_report_file_overlaps?(report_files, include_dirs_raw, include_dirs_abs)
123
+ report_files.any? do |file|
124
+ include_dirs_raw.any? { |inc| path_prefix_match?(file, inc) } ||
125
+ include_dirs_abs.any? { |inc_abs| path_prefix_match?(File.expand_path(file), inc_abs) }
126
+ end
127
+ end
128
+
129
+ def path_prefix_match?(path, dir)
130
+ return false if path.empty? || dir.empty?
131
+
132
+ path == dir || path.start_with?(dir + File::SEPARATOR)
133
+ end
134
+
135
+ def all_mutants(report)
136
+ files = report.fetch("files", {}) || {}
137
+ files.values.compact.flat_map { |file_data| file_data.fetch("mutants", []) }
138
+ end
139
+ end
140
+ end