henitai 0.1.10 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -1
- data/README.md +33 -7
- data/assets/schema/henitai.schema.json +6 -0
- data/lib/henitai/cli/clean_command.rb +48 -0
- data/lib/henitai/cli/command_support.rb +51 -0
- data/lib/henitai/cli/init_command.rb +64 -0
- data/lib/henitai/cli/operator_command.rb +95 -0
- data/lib/henitai/cli/options.rb +120 -0
- data/lib/henitai/cli/run_command.rb +103 -0
- data/lib/henitai/cli.rb +17 -327
- data/lib/henitai/configuration.rb +26 -12
- data/lib/henitai/configuration_validator/rules.rb +143 -0
- data/lib/henitai/configuration_validator/scalars.rb +123 -0
- data/lib/henitai/configuration_validator.rb +12 -239
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/eager_load.rb +36 -5
- data/lib/henitai/execution_engine.rb +6 -11
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/base.rb +171 -0
- data/lib/henitai/integration/child_debug_support.rb +115 -0
- data/lib/henitai/integration/child_runtime_control.rb +50 -0
- data/lib/henitai/integration/coverage_suppression.rb +43 -0
- data/lib/henitai/integration/minitest.rb +133 -0
- data/lib/henitai/integration/mutant_run_support.rb +77 -0
- data/lib/henitai/integration/rspec_child_runner.rb +61 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration/rspec_test_selection.rb +135 -0
- data/lib/henitai/integration/scenario_log_support.rb +116 -0
- data/lib/henitai/integration.rb +43 -519
- data/lib/henitai/mutant/activator.rb +13 -79
- data/lib/henitai/mutant/parameter_source.rb +98 -0
- data/lib/henitai/mutant.rb +14 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store/sql.rb +72 -0
- data/lib/henitai/mutant_history_store.rb +12 -91
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/per_test_coverage_collector.rb +3 -1
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +148 -0
- data/lib/henitai/reporter.rb +96 -11
- data/lib/henitai/result.rb +49 -16
- data/lib/henitai/runner.rb +96 -30
- data/lib/henitai/scenario_execution_result.rb +16 -3
- data/lib/henitai/slot_scheduler/draining.rb +140 -0
- data/lib/henitai/slot_scheduler/process_control.rb +43 -0
- data/lib/henitai/slot_scheduler.rb +214 -0
- data/lib/henitai/static_filter.rb +10 -3
- data/lib/henitai/survivor_activation_cache.rb +81 -0
- data/lib/henitai/survivor_loader.rb +140 -0
- data/lib/henitai/survivor_rerun_strategy.rb +195 -0
- data/lib/henitai/survivor_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/unparse_helper.rb +5 -2
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +10 -0
- data/sig/configuration_validator.rbs +46 -22
- data/sig/henitai.rbs +329 -53
- metadata +46 -2
|
@@ -0,0 +1,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
|
-
|
|
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
|