henitai 0.1.8 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +86 -1
- data/README.md +18 -4
- data/lib/henitai/cli.rb +81 -3
- data/lib/henitai/configuration.rb +24 -11
- data/lib/henitai/coverage_bootstrapper.rb +24 -24
- data/lib/henitai/execution_engine.rb +3 -9
- data/lib/henitai/git_diff_analyzer.rb +34 -0
- data/lib/henitai/integration/rspec_process_runner.rb +66 -13
- data/lib/henitai/integration.rb +403 -38
- data/lib/henitai/mutant/activator.rb +14 -2
- data/lib/henitai/mutant.rb +13 -2
- data/lib/henitai/mutant_generator.rb +21 -2
- data/lib/henitai/mutant_history_store.rb +7 -22
- data/lib/henitai/mutant_identity.rb +34 -0
- data/lib/henitai/parallel_execution_runner.rb +29 -11
- data/lib/henitai/process_wakeup.rb +49 -0
- data/lib/henitai/process_worker_runner.rb +434 -0
- data/lib/henitai/reporter.rb +76 -3
- data/lib/henitai/result.rb +39 -8
- data/lib/henitai/runner.rb +203 -14
- data/lib/henitai/scenario_execution_result.rb +16 -3
- 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_selector.rb +36 -0
- data/lib/henitai/survivor_test_filter.rb +72 -0
- data/lib/henitai/version.rb +1 -1
- data/lib/henitai.rb +8 -0
- data/sig/henitai.rbs +205 -9
- metadata +23 -2
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Flat, single-threaded process-slot scheduler for parallel mutation runs.
|
|
5
|
+
#
|
|
6
|
+
# Owns the process table: it is the sole caller of Process.wait* so there
|
|
7
|
+
# are no race conditions between threads reaping the same child.
|
|
8
|
+
class ProcessWorkerRunner # rubocop:disable Metrics/ClassLength
|
|
9
|
+
PROCESS_DRAIN_WINDOW = 0.2
|
|
10
|
+
|
|
11
|
+
# Default bridge to process and signal primitives used by the scheduler.
|
|
12
|
+
class Runtime
|
|
13
|
+
def clock_gettime(clock_id)
|
|
14
|
+
Process.clock_gettime(clock_id)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def wait2(pid, flags = nil)
|
|
18
|
+
Process.wait2(pid, flags)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def kill(signal, pid)
|
|
22
|
+
Process.kill(signal, pid)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def wait(pid)
|
|
26
|
+
Process.wait(pid)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def trap(signal, handler = nil, &block)
|
|
30
|
+
Kernel.trap(signal, handler || block)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Tracks one in-flight mutant child process.
|
|
35
|
+
Slot = Struct.new(
|
|
36
|
+
:slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
|
|
37
|
+
:log_paths, :retry_count, :draining, :term_sent_at_monotonic,
|
|
38
|
+
:forced_outcome
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
|
|
42
|
+
@worker_count = worker_count
|
|
43
|
+
@runtime = runtime
|
|
44
|
+
@wakeup = wakeup
|
|
45
|
+
@shutdown_requested = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Trigger a graceful shutdown from outside the event loop.
|
|
49
|
+
# Safe to call from any thread. The loop observes the flag on its next tick.
|
|
50
|
+
def request_shutdown
|
|
51
|
+
@shutdown_requested = true
|
|
52
|
+
@wakeup&.signal
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Runs all mutants and returns an array of ScenarioExecutionResult.
|
|
56
|
+
#
|
|
57
|
+
# @param mutants [Array<Mutant>]
|
|
58
|
+
# @param integration [Integration::Base]
|
|
59
|
+
# @param config [Configuration]
|
|
60
|
+
# @param progress_reporter [#progress, nil]
|
|
61
|
+
# @param options [Hash]
|
|
62
|
+
# @return [Array<ScenarioExecutionResult>]
|
|
63
|
+
def run(mutants, integration, config, progress_reporter, options = {})
|
|
64
|
+
Integration::SchedulerDiagnostics.reset! if Integration::SchedulerDiagnostics.enabled?
|
|
65
|
+
prepare_run(mutants, integration, config, progress_reporter, options)
|
|
66
|
+
|
|
67
|
+
event_loop
|
|
68
|
+
@results
|
|
69
|
+
ensure
|
|
70
|
+
@wakeup&.close
|
|
71
|
+
@wakeup = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
attr_reader :worker_count, :pending, :slots, :pid_to_slot, :results,
|
|
77
|
+
:integration, :config, :progress_reporter, :runtime
|
|
78
|
+
|
|
79
|
+
def event_loop
|
|
80
|
+
saved_traps = install_signal_traps
|
|
81
|
+
loop do
|
|
82
|
+
break if done?
|
|
83
|
+
|
|
84
|
+
break if process_cycle == :shutdown
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
restore_signal_traps(saved_traps)
|
|
88
|
+
raise Interrupt if @shutdown_requested
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def process_cycle
|
|
92
|
+
fill_idle_slots unless @shutdown_requested
|
|
93
|
+
reap_all_completed_children
|
|
94
|
+
check_timeouts
|
|
95
|
+
fill_idle_slots unless @shutdown_requested
|
|
96
|
+
return handle_shutdown if @shutdown_requested
|
|
97
|
+
|
|
98
|
+
drain_draining_slots if draining_slots?
|
|
99
|
+
fill_idle_slots unless @shutdown_requested
|
|
100
|
+
return :done if done?
|
|
101
|
+
|
|
102
|
+
wait_for_next_event
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_shutdown
|
|
107
|
+
interrupt_active_slots
|
|
108
|
+
drain_draining_slots
|
|
109
|
+
:shutdown
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def done?
|
|
113
|
+
pending.empty? && slots.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def fill_idle_slots
|
|
117
|
+
while slots.size < worker_count && !pending.empty?
|
|
118
|
+
mutant = pending.shift
|
|
119
|
+
spawn_into_slot(mutant)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def spawn_into_slot(mutant)
|
|
124
|
+
test_files = resolve_test_files(mutant)
|
|
125
|
+
mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
|
|
126
|
+
mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
|
|
127
|
+
handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
|
|
128
|
+
register_slot(handle, mutant)
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
record_spawn_failure(mutant, e)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def register_slot(handle, mutant)
|
|
134
|
+
slot_id = next_slot_id!
|
|
135
|
+
slot = build_slot(slot_id, mutant, handle)
|
|
136
|
+
slots[slot_id] = slot
|
|
137
|
+
pid_to_slot[handle.pid] = slot_id
|
|
138
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def build_slot(slot_id, mutant, handle)
|
|
142
|
+
Slot.new(
|
|
143
|
+
slot_id, mutant, handle.pid,
|
|
144
|
+
monotonic_time,
|
|
145
|
+
config.timeout, handle.log_paths, 0, false, nil, nil
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reap_all_completed_children
|
|
150
|
+
loop do
|
|
151
|
+
pid, status = runtime.wait2(-1, Process::WNOHANG)
|
|
152
|
+
break unless pid
|
|
153
|
+
|
|
154
|
+
complete_slot(pid, status)
|
|
155
|
+
end
|
|
156
|
+
rescue Errno::ECHILD
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def complete_slot(pid, wait_result)
|
|
161
|
+
slot_id = pid_to_slot.delete(pid)
|
|
162
|
+
return unless slot_id
|
|
163
|
+
|
|
164
|
+
slot = slots[slot_id]
|
|
165
|
+
return unless slot
|
|
166
|
+
|
|
167
|
+
Integration::SchedulerDiagnostics.child_ended(pid)
|
|
168
|
+
result = integration.build_result(wait_result, slot.log_paths)
|
|
169
|
+
dispatch_slot_result(slot, result)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def dispatch_slot_result(slot, result)
|
|
173
|
+
if should_retry?(slot, result)
|
|
174
|
+
retry_slot(slot)
|
|
175
|
+
else
|
|
176
|
+
slots.delete(slot.slot_id)
|
|
177
|
+
slot.mutant.status = result.status
|
|
178
|
+
results << result
|
|
179
|
+
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Per-slot timeout check. Must be called after reap_all_completed_children
|
|
184
|
+
# so that naturally-exited processes are already removed from slots.
|
|
185
|
+
def check_timeouts
|
|
186
|
+
now = monotonic_time
|
|
187
|
+
slots.each_value do |slot|
|
|
188
|
+
next if slot.draining
|
|
189
|
+
next unless now >= slot.started_at_monotonic + slot.timeout
|
|
190
|
+
|
|
191
|
+
# Final targeted reap: if the child already exited, classify it normally.
|
|
192
|
+
pid, status = runtime.wait2(slot.pid, Process::WNOHANG)
|
|
193
|
+
if pid
|
|
194
|
+
complete_slot(pid, status)
|
|
195
|
+
else
|
|
196
|
+
slot.forced_outcome = :timeout
|
|
197
|
+
slot.draining = true
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def draining_slots?
|
|
203
|
+
slots.any? { |_, slot| slot.draining }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Two-phase broadcast cleanup for all slots that are in draining state.
|
|
207
|
+
#
|
|
208
|
+
# Precision rule: before signalling, do one final WNOHANG pass to catch
|
|
209
|
+
# processes that exited naturally in the window between check_timeouts and
|
|
210
|
+
# now. If SIGTERM gets ESRCH, the process is already gone — we must not
|
|
211
|
+
# force-label those as :timeout.
|
|
212
|
+
def drain_draining_slots
|
|
213
|
+
draining = draining_slots
|
|
214
|
+
return if draining.empty?
|
|
215
|
+
|
|
216
|
+
prune_raced_draining_slots(draining)
|
|
217
|
+
|
|
218
|
+
return if draining.empty?
|
|
219
|
+
|
|
220
|
+
broadcast_term(draining)
|
|
221
|
+
wait_for_drain_window
|
|
222
|
+
signal_draining_slots(draining)
|
|
223
|
+
reap_and_remove_draining(draining)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def draining_slots
|
|
227
|
+
slots.select { |_, slot| slot.draining }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def prune_raced_draining_slots(draining)
|
|
231
|
+
draining.reject! do |_, slot|
|
|
232
|
+
pid, status = wnohang_reap(slot.pid)
|
|
233
|
+
next false unless pid
|
|
234
|
+
|
|
235
|
+
complete_slot(pid, status)
|
|
236
|
+
true
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def wait_for_drain_window
|
|
241
|
+
@wakeup&.wait(PROCESS_DRAIN_WINDOW)
|
|
242
|
+
@wakeup&.drain
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def signal_draining_slots(draining)
|
|
246
|
+
draining.each_value { |slot| signal_process_group(slot.pid, :SIGKILL) }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def broadcast_term(draining)
|
|
250
|
+
now = monotonic_time
|
|
251
|
+
draining.each_value do |slot|
|
|
252
|
+
slot.term_sent_at_monotonic = now
|
|
253
|
+
signal_process_group(slot.pid, :SIGTERM)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# After SIGKILL window: blocking reap each slot, then build its result.
|
|
258
|
+
#
|
|
259
|
+
# Interrupted slots are cleaned up but produce no result — the scheduler
|
|
260
|
+
# is shutting down and does not emit verdicts for in-flight mutants.
|
|
261
|
+
#
|
|
262
|
+
# For timeout slots: a real exit status only wins if observed before any
|
|
263
|
+
# parent signal was sent. Once SIGTERM has been dispatched, the forced
|
|
264
|
+
# outcome is authoritative — a child handling SIGTERM and exiting 0 must
|
|
265
|
+
# not be misclassified as :survived.
|
|
266
|
+
def reap_and_remove_draining(draining) # rubocop:disable Metrics/AbcSize
|
|
267
|
+
draining.each_value do |slot|
|
|
268
|
+
# One last WNOHANG before blocking: catches processes that exited
|
|
269
|
+
# between SIGKILL and here.
|
|
270
|
+
_, final_status = wnohang_reap(slot.pid)
|
|
271
|
+
reap_pid(slot.pid) unless final_status
|
|
272
|
+
|
|
273
|
+
pid_to_slot.delete(slot.pid)
|
|
274
|
+
slots.delete(slot.slot_id)
|
|
275
|
+
Integration::SchedulerDiagnostics.child_ended(slot.pid)
|
|
276
|
+
|
|
277
|
+
next if slot.forced_outcome == :interrupted
|
|
278
|
+
|
|
279
|
+
result = build_drain_result(slot, final_status)
|
|
280
|
+
slot.mutant.status = result.status
|
|
281
|
+
results << result
|
|
282
|
+
progress_reporter&.progress(slot.mutant, scenario_result: result)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Choose result: use real exit status only if observed before any parent
|
|
287
|
+
# signal was sent. After SIGTERM, the forced outcome is authoritative.
|
|
288
|
+
def build_drain_result(slot, final_status)
|
|
289
|
+
if final_status&.exited? && slot.term_sent_at_monotonic.nil?
|
|
290
|
+
integration.build_result(final_status, slot.log_paths)
|
|
291
|
+
else
|
|
292
|
+
integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def install_signal_traps
|
|
297
|
+
saved = {}
|
|
298
|
+
%w[INT TERM HUP].each do |sig|
|
|
299
|
+
saved[sig] = runtime.trap(sig) { @shutdown_requested = true }
|
|
300
|
+
end
|
|
301
|
+
saved
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def restore_signal_traps(saved)
|
|
305
|
+
saved&.each { |sig, handler| runtime.trap(sig, handler) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def interrupt_active_slots
|
|
309
|
+
slots.each_value do |slot|
|
|
310
|
+
next if slot.draining
|
|
311
|
+
|
|
312
|
+
slot.forced_outcome = :interrupted
|
|
313
|
+
slot.draining = true
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def should_retry?(slot, result)
|
|
318
|
+
!@shutdown_requested && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def prepare_run(mutants, integration, config, progress_reporter, options)
|
|
322
|
+
@pending = mutants.dup
|
|
323
|
+
@slots = {}
|
|
324
|
+
@pid_to_slot = {}
|
|
325
|
+
@results = []
|
|
326
|
+
@next_slot_id = 0
|
|
327
|
+
@integration = integration
|
|
328
|
+
@config = config
|
|
329
|
+
@progress_reporter = progress_reporter
|
|
330
|
+
@options = options
|
|
331
|
+
@wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def next_event_timeout
|
|
335
|
+
now = monotonic_time
|
|
336
|
+
slot_timeouts = slots.each_value.filter_map do |slot|
|
|
337
|
+
remaining_slot_timeout(slot, now)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
slot_timeouts.min
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def remaining_slot_timeout(slot, now)
|
|
344
|
+
deadline =
|
|
345
|
+
if slot.draining
|
|
346
|
+
slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
|
|
347
|
+
else
|
|
348
|
+
slot.started_at_monotonic + slot.timeout
|
|
349
|
+
end
|
|
350
|
+
remaining = deadline - now
|
|
351
|
+
remaining.positive? ? remaining : 0.0
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def wait_for_next_event
|
|
355
|
+
@wakeup&.wait(next_event_timeout)
|
|
356
|
+
@wakeup&.drain
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def retry_slot(slot) # rubocop:disable Metrics/AbcSize
|
|
360
|
+
slot.retry_count += 1
|
|
361
|
+
test_files = resolve_test_files(slot.mutant)
|
|
362
|
+
handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
|
|
363
|
+
slot.pid = handle.pid
|
|
364
|
+
slot.log_paths = handle.log_paths
|
|
365
|
+
slot.started_at_monotonic = monotonic_time
|
|
366
|
+
slot.draining = false
|
|
367
|
+
slot.term_sent_at_monotonic = nil
|
|
368
|
+
slot.forced_outcome = nil
|
|
369
|
+
pid_to_slot[handle.pid] = slot.slot_id
|
|
370
|
+
Integration::SchedulerDiagnostics.child_started(handle.pid)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
slots.delete(slot.slot_id)
|
|
373
|
+
record_spawn_failure(slot.mutant, e)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def record_spawn_failure(mutant, error)
|
|
377
|
+
result = ScenarioExecutionResult.new(
|
|
378
|
+
status: :compile_error,
|
|
379
|
+
stdout: "",
|
|
380
|
+
stderr: "spawn failed: #{error.message}",
|
|
381
|
+
log_path: "/dev/null",
|
|
382
|
+
exit_status: nil
|
|
383
|
+
)
|
|
384
|
+
mutant.status = result.status
|
|
385
|
+
results << result
|
|
386
|
+
progress_reporter&.progress(mutant, scenario_result: result)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def wnohang_reap(pid)
|
|
390
|
+
runtime.wait2(pid, Process::WNOHANG)
|
|
391
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def signal_process_group(pid, signal)
|
|
396
|
+
runtime.kill(signal, -pid)
|
|
397
|
+
rescue Errno::ESRCH
|
|
398
|
+
nil
|
|
399
|
+
rescue Errno::EPERM
|
|
400
|
+
# Process group not yet established; fall back to signalling the pid.
|
|
401
|
+
begin
|
|
402
|
+
runtime.kill(signal, pid)
|
|
403
|
+
rescue Errno::ESRCH
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def reap_pid(pid)
|
|
409
|
+
runtime.wait(pid)
|
|
410
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def monotonic_time
|
|
415
|
+
runtime.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def resolve_test_files(mutant)
|
|
419
|
+
if @options.key?(:test_file_resolver)
|
|
420
|
+
@options[:test_file_resolver].call(mutant)
|
|
421
|
+
elsif @options.key?(:test_files)
|
|
422
|
+
@options[:test_files]
|
|
423
|
+
else
|
|
424
|
+
integration.select_tests(mutant.subject)
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def next_slot_id!
|
|
429
|
+
id = @next_slot_id
|
|
430
|
+
@next_slot_id += 1
|
|
431
|
+
id
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
end
|
data/lib/henitai/reporter.rb
CHANGED
|
@@ -90,6 +90,14 @@ module Henitai
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def summary_lines(result)
|
|
93
|
+
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
94
|
+
partial_summary_lines(result)
|
|
95
|
+
else
|
|
96
|
+
full_summary_lines(result)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def full_summary_lines(result)
|
|
93
101
|
[
|
|
94
102
|
"Mutation testing summary",
|
|
95
103
|
score_line(result),
|
|
@@ -101,6 +109,28 @@ module Henitai
|
|
|
101
109
|
]
|
|
102
110
|
end
|
|
103
111
|
|
|
112
|
+
def partial_summary_lines(result)
|
|
113
|
+
lines = [
|
|
114
|
+
"Partial survivor rerun",
|
|
115
|
+
format_row("Survived", count_status(result, :survived)),
|
|
116
|
+
format_row("Duration", format_duration(result.duration))
|
|
117
|
+
]
|
|
118
|
+
append_survivor_stats(lines, result)
|
|
119
|
+
lines
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def append_survivor_stats(lines, result)
|
|
123
|
+
return unless result.respond_to?(:survivor_stats)
|
|
124
|
+
|
|
125
|
+
stats = result.survivor_stats # : Hash[Symbol, untyped]?
|
|
126
|
+
return unless stats
|
|
127
|
+
|
|
128
|
+
lines << format_row("Matched", stats.fetch(:matched))
|
|
129
|
+
lines << format_row("Skipped", stats.fetch(:skipped_count, 0))
|
|
130
|
+
lines << format_row("Unmatched", stats.fetch(:unmatched_count))
|
|
131
|
+
lines << format_row("Drift warning", stats.fetch(:drift_warning) ? "yes" : "no")
|
|
132
|
+
end
|
|
133
|
+
|
|
104
134
|
def survived_detail_lines(result)
|
|
105
135
|
survivors = result.mutants.select(&:survived?)
|
|
106
136
|
return [] if survivors.empty?
|
|
@@ -212,14 +242,57 @@ module Henitai
|
|
|
212
242
|
# JSON reporter.
|
|
213
243
|
class Json < Base
|
|
214
244
|
def report(result)
|
|
215
|
-
|
|
216
|
-
|
|
245
|
+
schema = result.to_stryker_schema
|
|
246
|
+
write_canonical(schema)
|
|
247
|
+
write_session_snapshot(schema)
|
|
248
|
+
write_activation_recipes(result)
|
|
217
249
|
write_history_report
|
|
218
250
|
end
|
|
219
251
|
|
|
220
252
|
private
|
|
221
253
|
|
|
222
|
-
def
|
|
254
|
+
def write_canonical(schema)
|
|
255
|
+
FileUtils.mkdir_p(File.dirname(canonical_path))
|
|
256
|
+
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def write_session_snapshot(schema)
|
|
260
|
+
session_id = schema[:sessionId]
|
|
261
|
+
return unless session_id
|
|
262
|
+
|
|
263
|
+
path = session_snapshot_path(session_id)
|
|
264
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
265
|
+
File.write(path, JSON.pretty_generate(schema))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def write_activation_recipes(result)
|
|
269
|
+
session_id = result.session_id if result.respond_to?(:session_id)
|
|
270
|
+
return unless session_id
|
|
271
|
+
|
|
272
|
+
survived = survived_mutants_for(result)
|
|
273
|
+
return if survived.empty?
|
|
274
|
+
|
|
275
|
+
recipes = SurvivorActivationCache.compute(survived)
|
|
276
|
+
return if recipes.empty?
|
|
277
|
+
|
|
278
|
+
SurvivorActivationCache.write(session_recipe_path(session_id), recipes)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def survived_mutants_for(result)
|
|
282
|
+
return [] unless result.respond_to?(:mutants)
|
|
283
|
+
|
|
284
|
+
result.mutants.select(&:survived?)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def session_snapshot_path(session_id)
|
|
288
|
+
File.join(config.reports_dir, "sessions", session_id, "mutation-report.json")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def session_recipe_path(session_id)
|
|
292
|
+
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def canonical_path
|
|
223
296
|
File.join(config.reports_dir, "mutation-report.json")
|
|
224
297
|
end
|
|
225
298
|
|
data/lib/henitai/result.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
3
4
|
require_relative "unparse_helper"
|
|
4
5
|
|
|
5
6
|
module Henitai
|
|
@@ -13,14 +14,25 @@ module Henitai
|
|
|
13
14
|
SCHEMA_VERSION = "1.0"
|
|
14
15
|
DEFAULT_THRESHOLDS = { high: 80, low: 60 }.freeze
|
|
15
16
|
|
|
16
|
-
attr_reader :mutants, :started_at, :finished_at, :thresholds
|
|
17
|
+
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
|
+
:session_id, :git_sha
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@
|
|
20
|
+
# rubocop:disable Metrics/ParameterLists
|
|
21
|
+
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
22
|
+
partial_rerun: false, survivor_stats: nil,
|
|
23
|
+
session_id: SecureRandom.uuid, git_sha: nil)
|
|
24
|
+
@mutants = mutants
|
|
25
|
+
@started_at = started_at
|
|
26
|
+
@finished_at = finished_at
|
|
27
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
28
|
+
@partial_rerun = partial_rerun
|
|
29
|
+
@survivor_stats = survivor_stats
|
|
30
|
+
@session_id = session_id
|
|
31
|
+
@git_sha = git_sha
|
|
23
32
|
end
|
|
33
|
+
# rubocop:enable Metrics/ParameterLists
|
|
34
|
+
|
|
35
|
+
def partial_rerun? = @partial_rerun
|
|
24
36
|
|
|
25
37
|
# @return [Integer] number of killed mutants
|
|
26
38
|
def killed = mutants.count(&:killed?)
|
|
@@ -88,14 +100,32 @@ module Henitai
|
|
|
88
100
|
# Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).
|
|
89
101
|
# @return [Hash]
|
|
90
102
|
def to_stryker_schema
|
|
91
|
-
|
|
103
|
+
schema = base_schema
|
|
104
|
+
sha = @git_sha
|
|
105
|
+
schema[:gitSha] = sha if sha
|
|
106
|
+
return schema unless partial_rerun?
|
|
107
|
+
|
|
108
|
+
schema[:partialRerun] = true
|
|
109
|
+
schema[:unmatchedSurvivorIds] = unmatched_survivor_ids
|
|
110
|
+
schema
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def base_schema
|
|
116
|
+
{ # : Hash[Symbol, untyped]
|
|
92
117
|
schemaVersion: SCHEMA_VERSION,
|
|
118
|
+
sessionId: @session_id,
|
|
93
119
|
thresholds: thresholds,
|
|
94
120
|
files: build_files_section
|
|
95
121
|
}
|
|
96
122
|
end
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
def unmatched_survivor_ids
|
|
125
|
+
return survivor_stats.fetch(:unmatched_ids) if survivor_stats
|
|
126
|
+
|
|
127
|
+
[] # : Array[String]
|
|
128
|
+
end
|
|
99
129
|
|
|
100
130
|
def build_files_section
|
|
101
131
|
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
@@ -115,6 +145,7 @@ module Henitai
|
|
|
115
145
|
def mutant_to_schema(mutant)
|
|
116
146
|
{
|
|
117
147
|
id: mutant.id,
|
|
148
|
+
stableId: mutant.stable_id,
|
|
118
149
|
mutatorName: mutant.operator,
|
|
119
150
|
replacement: replacement_for(mutant),
|
|
120
151
|
location: location_for(mutant),
|