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,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Wakeup pipe used to interrupt child-process wait loops when CHLD arrives.
|
|
5
|
+
class ProcessWakeup
|
|
6
|
+
def initialize(signal_name: "CHLD")
|
|
7
|
+
@signal_name = signal_name
|
|
8
|
+
@reader, @writer = IO.pipe
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def install
|
|
12
|
+
@previous_handler = Signal.trap(signal_name) { signal }
|
|
13
|
+
self
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def wait(timeout)
|
|
17
|
+
# rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
|
|
18
|
+
IO.select([reader], nil, nil, timeout)
|
|
19
|
+
# rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
|
|
20
|
+
rescue Errno::EINTR
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def drain
|
|
25
|
+
loop do
|
|
26
|
+
reader.read_nonblock(4096)
|
|
27
|
+
end
|
|
28
|
+
rescue IO::WaitReadable, EOFError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def signal
|
|
33
|
+
writer.write_nonblock(".")
|
|
34
|
+
rescue IO::WaitWritable, IOError, Errno::EPIPE
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
Signal.trap(signal_name, previous_handler) if previous_handler
|
|
40
|
+
ensure
|
|
41
|
+
reader.close unless reader.closed?
|
|
42
|
+
writer.close unless writer.closed?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :previous_handler, :reader, :signal_name, :writer
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Henitai
|
|
4
|
+
# Flat, single-threaded driver for parallel mutation runs.
|
|
5
|
+
#
|
|
6
|
+
# Owns the event loop and OS signal handling, and delegates the slot
|
|
7
|
+
# lifecycle (spawning, reaping, timeout detection and the drain/broadcast
|
|
8
|
+
# state machine) to a {SlotScheduler}. Because the scheduler is the sole
|
|
9
|
+
# caller of Process.wait*, there are no races between threads reaping the
|
|
10
|
+
# same child.
|
|
11
|
+
class ProcessWorkerRunner
|
|
12
|
+
# Default bridge to process and signal primitives used by the scheduler.
|
|
13
|
+
class Runtime
|
|
14
|
+
def clock_gettime(clock_id)
|
|
15
|
+
Process.clock_gettime(clock_id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def wait2(pid, flags = nil)
|
|
19
|
+
Process.wait2(pid, flags)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def kill(signal, pid)
|
|
23
|
+
Process.kill(signal, pid)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wait(pid)
|
|
27
|
+
Process.wait(pid)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def trap(signal, handler = nil, &block)
|
|
31
|
+
Kernel.trap(signal, handler || block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Loop primitives the {SlotScheduler} reads back from its host.
|
|
36
|
+
attr_reader :worker_count, :runtime, :wakeup
|
|
37
|
+
|
|
38
|
+
def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
|
|
39
|
+
@worker_count = worker_count
|
|
40
|
+
@runtime = runtime
|
|
41
|
+
@wakeup = wakeup
|
|
42
|
+
@shutdown_requested = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Number of mutants that required at least one retry during the run.
|
|
46
|
+
# Mirrors the linear path's per-mutant flaky semantics so the engine can
|
|
47
|
+
# report a single, mode-agnostic flaky statistic.
|
|
48
|
+
def flaky_retry_count
|
|
49
|
+
@scheduler ? @scheduler.flaky_retry_count : 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# True once a graceful shutdown has been requested; read by the scheduler.
|
|
53
|
+
def shutdown_requested?
|
|
54
|
+
@shutdown_requested
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Trigger a graceful shutdown from outside the event loop.
|
|
58
|
+
# Safe to call from any thread. The loop observes the flag on its next tick.
|
|
59
|
+
def request_shutdown
|
|
60
|
+
@shutdown_requested = true
|
|
61
|
+
@wakeup&.signal
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Runs all mutants and returns an array of ScenarioExecutionResult.
|
|
65
|
+
#
|
|
66
|
+
# @param mutants [Array<Mutant>]
|
|
67
|
+
# @param integration [Integration::Base]
|
|
68
|
+
# @param config [Configuration]
|
|
69
|
+
# @param progress_reporter [#progress, nil]
|
|
70
|
+
# @param options [Hash]
|
|
71
|
+
# @return [Array<ScenarioExecutionResult>]
|
|
72
|
+
def run(mutants, integration, config, progress_reporter, options = {})
|
|
73
|
+
Integration::SchedulerDiagnostics.reset! if Integration::SchedulerDiagnostics.enabled?
|
|
74
|
+
prepare_run(mutants, integration, config, progress_reporter, options)
|
|
75
|
+
|
|
76
|
+
event_loop
|
|
77
|
+
@scheduler.results
|
|
78
|
+
ensure
|
|
79
|
+
@wakeup&.close
|
|
80
|
+
@wakeup = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def prepare_run(mutants, integration, config, progress_reporter, options)
|
|
86
|
+
@shutdown_requested = false
|
|
87
|
+
@wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
|
|
88
|
+
@scheduler = SlotScheduler.new(
|
|
89
|
+
integration: integration,
|
|
90
|
+
config: config,
|
|
91
|
+
progress_reporter: progress_reporter,
|
|
92
|
+
options: options,
|
|
93
|
+
host: self
|
|
94
|
+
)
|
|
95
|
+
@scheduler.enqueue(mutants)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def event_loop
|
|
99
|
+
saved_traps = install_signal_traps
|
|
100
|
+
loop do
|
|
101
|
+
break if @scheduler.done?
|
|
102
|
+
|
|
103
|
+
break if process_cycle == :shutdown
|
|
104
|
+
end
|
|
105
|
+
ensure
|
|
106
|
+
restore_signal_traps(saved_traps)
|
|
107
|
+
raise Interrupt if @shutdown_requested
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def process_cycle
|
|
111
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
112
|
+
@scheduler.reap_all_completed_children
|
|
113
|
+
@scheduler.check_timeouts
|
|
114
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
115
|
+
return handle_shutdown if @shutdown_requested
|
|
116
|
+
|
|
117
|
+
@scheduler.drain_draining_slots if @scheduler.draining_slots?
|
|
118
|
+
@scheduler.fill_idle_slots unless @shutdown_requested
|
|
119
|
+
return :done if @scheduler.done?
|
|
120
|
+
|
|
121
|
+
wait_for_next_event
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def handle_shutdown
|
|
126
|
+
@scheduler.interrupt_active_slots
|
|
127
|
+
@scheduler.drain_draining_slots
|
|
128
|
+
:shutdown
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def wait_for_next_event
|
|
132
|
+
@wakeup&.wait(@scheduler.next_event_timeout)
|
|
133
|
+
@wakeup&.drain
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def install_signal_traps
|
|
137
|
+
saved = {}
|
|
138
|
+
%w[INT TERM HUP].each do |sig|
|
|
139
|
+
saved[sig] = @runtime.trap(sig) { @shutdown_requested = true }
|
|
140
|
+
end
|
|
141
|
+
saved
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def restore_signal_traps(saved)
|
|
145
|
+
saved&.each { |sig, handler| @runtime.trap(sig, handler) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/henitai/reporter.rb
CHANGED
|
@@ -22,9 +22,12 @@ module Henitai
|
|
|
22
22
|
# @param names [Array<String>] reporter names from configuration
|
|
23
23
|
# @param result [Result]
|
|
24
24
|
# @param config [Configuration]
|
|
25
|
-
|
|
25
|
+
# @param history_store [MutantHistoryStore, nil] persistence store the JSON
|
|
26
|
+
# reporter reads trend data from; supplied by the composition root so the
|
|
27
|
+
# reporter does not build infrastructure itself.
|
|
28
|
+
def self.run_all(names:, result:, config:, history_store: nil)
|
|
26
29
|
names.each do |name|
|
|
27
|
-
reporter_class(name).new(config:).report(result)
|
|
30
|
+
reporter_class(name).new(config:, history_store:).report(result)
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -36,8 +39,12 @@ module Henitai
|
|
|
36
39
|
|
|
37
40
|
# Base class for all reporters.
|
|
38
41
|
class Base
|
|
39
|
-
|
|
42
|
+
# @param history_store [MutantHistoryStore, nil] accepted by every
|
|
43
|
+
# reporter for a uniform factory signature; only the JSON reporter uses
|
|
44
|
+
# it. Ignored elsewhere.
|
|
45
|
+
def initialize(config:, history_store: nil)
|
|
40
46
|
@config = config
|
|
47
|
+
@history_store = history_store
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# @param result [Result]
|
|
@@ -47,7 +54,7 @@ module Henitai
|
|
|
47
54
|
|
|
48
55
|
private
|
|
49
56
|
|
|
50
|
-
attr_reader :config
|
|
57
|
+
attr_reader :config, :history_store
|
|
51
58
|
end
|
|
52
59
|
|
|
53
60
|
# Terminal reporter.
|
|
@@ -90,6 +97,14 @@ module Henitai
|
|
|
90
97
|
end
|
|
91
98
|
|
|
92
99
|
def summary_lines(result)
|
|
100
|
+
if result.respond_to?(:partial_rerun?) && result.partial_rerun?
|
|
101
|
+
partial_summary_lines(result)
|
|
102
|
+
else
|
|
103
|
+
full_summary_lines(result)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def full_summary_lines(result)
|
|
93
108
|
[
|
|
94
109
|
"Mutation testing summary",
|
|
95
110
|
score_line(result),
|
|
@@ -101,6 +116,28 @@ module Henitai
|
|
|
101
116
|
]
|
|
102
117
|
end
|
|
103
118
|
|
|
119
|
+
def partial_summary_lines(result)
|
|
120
|
+
lines = [
|
|
121
|
+
"Partial survivor rerun",
|
|
122
|
+
format_row("Survived", count_status(result, :survived)),
|
|
123
|
+
format_row("Duration", format_duration(result.duration))
|
|
124
|
+
]
|
|
125
|
+
append_survivor_stats(lines, result)
|
|
126
|
+
lines
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def append_survivor_stats(lines, result)
|
|
130
|
+
return unless result.respond_to?(:survivor_stats)
|
|
131
|
+
|
|
132
|
+
stats = result.survivor_stats # : Hash[Symbol, untyped]?
|
|
133
|
+
return unless stats
|
|
134
|
+
|
|
135
|
+
lines << format_row("Matched", stats.fetch(:matched))
|
|
136
|
+
lines << format_row("Skipped", stats.fetch(:skipped_count, 0))
|
|
137
|
+
lines << format_row("Unmatched", stats.fetch(:unmatched_count))
|
|
138
|
+
lines << format_row("Drift warning", stats.fetch(:drift_warning) ? "yes" : "no")
|
|
139
|
+
end
|
|
140
|
+
|
|
104
141
|
def survived_detail_lines(result)
|
|
105
142
|
survivors = result.mutants.select(&:survived?)
|
|
106
143
|
return [] if survivors.empty?
|
|
@@ -212,24 +249,72 @@ module Henitai
|
|
|
212
249
|
# JSON reporter.
|
|
213
250
|
class Json < Base
|
|
214
251
|
def report(result)
|
|
215
|
-
|
|
216
|
-
|
|
252
|
+
schema = result.to_stryker_schema
|
|
253
|
+
write_canonical(schema)
|
|
254
|
+
write_session_snapshot(schema)
|
|
255
|
+
write_activation_recipes(result)
|
|
217
256
|
write_history_report
|
|
218
257
|
end
|
|
219
258
|
|
|
220
259
|
private
|
|
221
260
|
|
|
222
|
-
def
|
|
261
|
+
def write_canonical(schema)
|
|
262
|
+
FileUtils.mkdir_p(File.dirname(canonical_path))
|
|
263
|
+
File.write(canonical_path, JSON.pretty_generate(schema))
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def write_session_snapshot(schema)
|
|
267
|
+
session_id = schema[:sessionId]
|
|
268
|
+
return unless session_id
|
|
269
|
+
|
|
270
|
+
path = session_snapshot_path(session_id)
|
|
271
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
272
|
+
File.write(path, JSON.pretty_generate(schema))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def write_activation_recipes(result)
|
|
276
|
+
session_id = result.session_id if result.respond_to?(:session_id)
|
|
277
|
+
return unless session_id
|
|
278
|
+
|
|
279
|
+
survived = survived_mutants_for(result)
|
|
280
|
+
return if survived.empty?
|
|
281
|
+
|
|
282
|
+
recipes = SurvivorActivationCache.compute(survived)
|
|
283
|
+
return if recipes.empty?
|
|
284
|
+
|
|
285
|
+
SurvivorActivationCache.write(session_recipe_path(session_id), recipes)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def survived_mutants_for(result)
|
|
289
|
+
return [] unless result.respond_to?(:mutants)
|
|
290
|
+
|
|
291
|
+
result.mutants.select(&:survived?)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def session_snapshot_path(session_id)
|
|
295
|
+
File.join(config.reports_dir, "sessions", session_id, "mutation-report.json")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def session_recipe_path(session_id)
|
|
299
|
+
File.join(config.reports_dir, "sessions", session_id, SurvivorActivationCache::FILENAME)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def canonical_path
|
|
223
303
|
File.join(config.reports_dir, "mutation-report.json")
|
|
224
304
|
end
|
|
225
305
|
|
|
226
306
|
def write_history_report
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return unless File.exist?(path)
|
|
307
|
+
store = history_store || default_history_store
|
|
308
|
+
return unless File.exist?(store.path)
|
|
230
309
|
|
|
231
310
|
FileUtils.mkdir_p(File.dirname(history_report_path))
|
|
232
|
-
File.write(history_report_path, JSON.pretty_generate(
|
|
311
|
+
File.write(history_report_path, JSON.pretty_generate(store.trend_report))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def default_history_store
|
|
315
|
+
MutantHistoryStore.new(
|
|
316
|
+
path: File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
|
|
317
|
+
)
|
|
233
318
|
end
|
|
234
319
|
|
|
235
320
|
def history_report_path
|
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,32 @@ 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
|
|
18
|
+
:session_id, :git_sha
|
|
19
|
+
|
|
20
|
+
# @param source_provider [#call] maps a file path to its source string.
|
|
21
|
+
# Injected so the domain object performs no disk IO; the caller (which
|
|
22
|
+
# already knows the file paths) supplies the contents. The default
|
|
23
|
+
# provider returns "" for every file — callers that need real source in
|
|
24
|
+
# the schema (e.g. the runner) inject a provider primed with file content.
|
|
25
|
+
# rubocop:disable Metrics/ParameterLists
|
|
26
|
+
def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
|
|
27
|
+
partial_rerun: false, survivor_stats: nil,
|
|
28
|
+
session_id: SecureRandom.uuid, git_sha: nil,
|
|
29
|
+
source_provider: ->(_file) { "" })
|
|
30
|
+
@mutants = mutants
|
|
31
|
+
@started_at = started_at
|
|
32
|
+
@finished_at = finished_at
|
|
33
|
+
@thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
|
|
34
|
+
@partial_rerun = partial_rerun
|
|
35
|
+
@survivor_stats = survivor_stats
|
|
36
|
+
@session_id = session_id
|
|
37
|
+
@git_sha = git_sha
|
|
38
|
+
@source_provider = source_provider
|
|
39
|
+
end
|
|
40
|
+
# rubocop:enable Metrics/ParameterLists
|
|
41
|
+
|
|
42
|
+
def partial_rerun? = @partial_rerun
|
|
24
43
|
|
|
25
44
|
# @return [Integer] number of killed mutants
|
|
26
45
|
def killed = mutants.count(&:killed?)
|
|
@@ -88,25 +107,38 @@ module Henitai
|
|
|
88
107
|
# Serialise to Stryker mutation-testing-report-schema JSON (schema 1.0).
|
|
89
108
|
# @return [Hash]
|
|
90
109
|
def to_stryker_schema
|
|
91
|
-
|
|
110
|
+
schema = base_schema
|
|
111
|
+
sha = @git_sha
|
|
112
|
+
schema[:gitSha] = sha if sha
|
|
113
|
+
return schema unless partial_rerun?
|
|
114
|
+
|
|
115
|
+
schema[:partialRerun] = true
|
|
116
|
+
schema[:unmatchedSurvivorIds] = unmatched_survivor_ids
|
|
117
|
+
schema
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def base_schema
|
|
123
|
+
{ # : Hash[Symbol, untyped]
|
|
92
124
|
schemaVersion: SCHEMA_VERSION,
|
|
125
|
+
sessionId: @session_id,
|
|
93
126
|
thresholds: thresholds,
|
|
94
127
|
files: build_files_section
|
|
95
128
|
}
|
|
96
129
|
end
|
|
97
130
|
|
|
98
|
-
|
|
131
|
+
def unmatched_survivor_ids
|
|
132
|
+
return survivor_stats.fetch(:unmatched_ids) if survivor_stats
|
|
133
|
+
|
|
134
|
+
[] # : Array[String]
|
|
135
|
+
end
|
|
99
136
|
|
|
100
137
|
def build_files_section
|
|
101
138
|
mutants.group_by { |m| m.location[:file] }.transform_values do |file_mutants|
|
|
102
|
-
source = begin
|
|
103
|
-
File.read(file_mutants.first.location[:file])
|
|
104
|
-
rescue StandardError
|
|
105
|
-
""
|
|
106
|
-
end
|
|
107
139
|
{
|
|
108
140
|
language: "ruby",
|
|
109
|
-
source
|
|
141
|
+
source: @source_provider.call(file_mutants.first.location[:file]),
|
|
110
142
|
mutants: file_mutants.map { |m| mutant_to_schema(m) }
|
|
111
143
|
}
|
|
112
144
|
end
|
|
@@ -115,6 +147,7 @@ module Henitai
|
|
|
115
147
|
def mutant_to_schema(mutant)
|
|
116
148
|
{
|
|
117
149
|
id: mutant.id,
|
|
150
|
+
stableId: mutant.stable_id,
|
|
118
151
|
mutatorName: mutant.operator,
|
|
119
152
|
replacement: replacement_for(mutant),
|
|
120
153
|
location: location_for(mutant),
|
data/lib/henitai/runner.rb
CHANGED
|
@@ -28,25 +28,35 @@ module Henitai
|
|
|
28
28
|
class Runner
|
|
29
29
|
attr_reader :config, :result
|
|
30
30
|
|
|
31
|
-
def initialize(config: Configuration.load, subjects: nil, since: nil)
|
|
32
|
-
@config
|
|
33
|
-
@subjects
|
|
34
|
-
@since
|
|
31
|
+
def initialize(config: Configuration.load, subjects: nil, since: nil, survivors_from: nil)
|
|
32
|
+
@config = config
|
|
33
|
+
@subjects = subjects
|
|
34
|
+
@since = since
|
|
35
|
+
@survivors_from = survivors_from
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
# Entry point — runs the full pipeline and returns a Result.
|
|
38
39
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# Fast path (recipe rerun): when +--survivors-from+ is given and an
|
|
41
|
+
# +activation-recipes.json+ file exists beside the report with entries for
|
|
42
|
+
# all survivor IDs, stub Mutants are built from the recipes and the full
|
|
43
|
+
# source-parse / mutant-generation pipeline is skipped entirely.
|
|
44
|
+
#
|
|
45
|
+
# Normal path: Coverage bootstrap (Gate 0) runs in a background thread so
|
|
46
|
+
# that Gate 1 (subject resolution) and Gate 2 (mutant generation) proceed
|
|
47
|
+
# concurrently. The thread is joined before Gate 3 (static filtering).
|
|
43
48
|
#
|
|
44
49
|
# @return [Result]
|
|
45
50
|
def run
|
|
46
51
|
started_at = Time.now
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
|
|
53
|
+
mutants = if survivor_rerun? && (fast_mutants = survivor_strategy.try_recipe_run)
|
|
54
|
+
execute_mutants(fast_mutants)
|
|
55
|
+
else
|
|
56
|
+
source_files = self.source_files
|
|
57
|
+
subjects = resolve_subjects(source_files)
|
|
58
|
+
execute_mutants(mutants_for(subjects, source_files))
|
|
59
|
+
end
|
|
50
60
|
|
|
51
61
|
build_result(mutants, started_at, Time.now)
|
|
52
62
|
end
|
|
@@ -75,8 +85,10 @@ module Henitai
|
|
|
75
85
|
bootstrap_thread = bootstrap_mutants(source_files)
|
|
76
86
|
mutants = generate_mutants(subjects)
|
|
77
87
|
bootstrap_thread.value
|
|
88
|
+
filtered = filter_mutants(mutants)
|
|
89
|
+
return filtered unless survivor_rerun?
|
|
78
90
|
|
|
79
|
-
|
|
91
|
+
survivor_strategy.apply_selection(filtered)
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
def bootstrap_mutants(source_files)
|
|
@@ -93,7 +105,7 @@ module Henitai
|
|
|
93
105
|
end
|
|
94
106
|
|
|
95
107
|
def report(result)
|
|
96
|
-
Reporter.run_all(names: config.reporters, result:, config:)
|
|
108
|
+
Reporter.run_all(names: config.reporters, result:, config:, history_store:)
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
def persist_history(result, recorded_at)
|
|
@@ -109,13 +121,39 @@ module Henitai
|
|
|
109
121
|
mutants:,
|
|
110
122
|
started_at:,
|
|
111
123
|
finished_at:,
|
|
112
|
-
thresholds: result_thresholds
|
|
124
|
+
thresholds: result_thresholds,
|
|
125
|
+
partial_rerun: survivor_rerun?,
|
|
126
|
+
survivor_stats: survivor_strategy.survivor_stats,
|
|
127
|
+
git_sha: safe_head_sha,
|
|
128
|
+
source_provider: source_provider
|
|
113
129
|
)
|
|
114
130
|
persist_history(@result, finished_at)
|
|
115
131
|
report(@result)
|
|
116
132
|
@result
|
|
117
133
|
end
|
|
118
134
|
|
|
135
|
+
# Reads each source file once and caches it, so Result consumes source
|
|
136
|
+
# content while performing no disk IO of its own. Returns "" for files that
|
|
137
|
+
# cannot be read (e.g. recipe stubs with synthetic locations).
|
|
138
|
+
def source_provider
|
|
139
|
+
cache = {} # : Hash[String, String]
|
|
140
|
+
lambda do |file|
|
|
141
|
+
cache[file] ||= begin
|
|
142
|
+
File.read(file)
|
|
143
|
+
rescue StandardError
|
|
144
|
+
""
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def safe_head_sha
|
|
150
|
+
git_diff_analyzer.head_sha
|
|
151
|
+
rescue StandardError
|
|
152
|
+
# `head_sha` rescues Errno::ENOENT. This extra rescue is defensive for
|
|
153
|
+
# unexpected Open3/git runtime errors; conservative fallback is `nil`.
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
119
157
|
def bootstrap_coverage(source_files, test_files = nil)
|
|
120
158
|
coverage_bootstrapper.ensure!(source_files:, config:, integration:, test_files:)
|
|
121
159
|
end
|
|
@@ -165,22 +203,38 @@ module Henitai
|
|
|
165
203
|
end
|
|
166
204
|
|
|
167
205
|
def source_files
|
|
168
|
-
@source_files ||=
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
206
|
+
@source_files ||= filter_changed(reject_excluded(included_source_files))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def included_source_files
|
|
210
|
+
Array(config.includes).flat_map do |include_path|
|
|
211
|
+
Dir.glob(File.join(include_path, "**", "*.rb"))
|
|
212
|
+
end.uniq
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Drops files matched by any `excludes:` glob (e.g. standalone entry points
|
|
216
|
+
# that cannot be mutation-tested in-process). Excludes apply regardless of
|
|
217
|
+
# the --since filter.
|
|
218
|
+
def reject_excluded(files)
|
|
219
|
+
excluded = excluded_source_files
|
|
220
|
+
return files if excluded.empty?
|
|
221
|
+
|
|
222
|
+
files.reject { |path| excluded.include?(normalize_path(path)) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def excluded_source_files
|
|
226
|
+
Array(config.excludes)
|
|
227
|
+
.flat_map { |pattern| Dir.glob(pattern) }
|
|
228
|
+
.map { |path| normalize_path(path) }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def filter_changed(files)
|
|
232
|
+
return files unless @since
|
|
233
|
+
|
|
234
|
+
changed_file_set = git_diff_analyzer
|
|
235
|
+
.changed_files(from: @since, to: "HEAD")
|
|
236
|
+
.map { |path| normalize_path(path) }
|
|
237
|
+
files.select { |path| changed_file_set.include?(normalize_path(path)) }
|
|
184
238
|
end
|
|
185
239
|
|
|
186
240
|
def pattern_subjects
|
|
@@ -200,5 +254,17 @@ module Henitai
|
|
|
200
254
|
|
|
201
255
|
config.thresholds
|
|
202
256
|
end
|
|
257
|
+
|
|
258
|
+
def survivor_rerun?
|
|
259
|
+
!@survivors_from.nil?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def survivor_strategy
|
|
263
|
+
@survivor_strategy ||= SurvivorRerunStrategy.new(
|
|
264
|
+
survivors_from: @survivors_from,
|
|
265
|
+
config:,
|
|
266
|
+
git_diff_analyzer:
|
|
267
|
+
)
|
|
268
|
+
end
|
|
203
269
|
end
|
|
204
270
|
end
|
|
@@ -7,7 +7,7 @@ module Henitai
|
|
|
7
7
|
|
|
8
8
|
def self.build(wait_result:, stdout:, stderr:, log_path:)
|
|
9
9
|
new(
|
|
10
|
-
status: status_for(wait_result),
|
|
10
|
+
status: status_for(wait_result, stdout:, stderr:),
|
|
11
11
|
stdout: stdout,
|
|
12
12
|
stderr: stderr,
|
|
13
13
|
log_path: log_path,
|
|
@@ -62,7 +62,7 @@ module Henitai
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def should_show_logs?(all_logs: nil)
|
|
65
|
-
all_logs || timeout?
|
|
65
|
+
all_logs || timeout? || status == :compile_error
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def failure_tail(all_logs: nil, lines: 12)
|
|
@@ -77,8 +77,10 @@ module Henitai
|
|
|
77
77
|
class << self
|
|
78
78
|
private
|
|
79
79
|
|
|
80
|
-
def status_for(wait_result)
|
|
80
|
+
def status_for(wait_result, stdout:, stderr:)
|
|
81
81
|
return :timeout if wait_result == :timeout
|
|
82
|
+
return :compile_error if no_examples_found?(stdout, stderr)
|
|
83
|
+
return :compile_error if zero_examples_failure?(wait_result, stdout, stderr)
|
|
82
84
|
return :compile_error if exit_status_for(wait_result) == 2
|
|
83
85
|
return :survived if wait_result.respond_to?(:success?) && wait_result.success?
|
|
84
86
|
|
|
@@ -91,6 +93,17 @@ module Henitai
|
|
|
91
93
|
|
|
92
94
|
wait_result.exitstatus
|
|
93
95
|
end
|
|
96
|
+
|
|
97
|
+
def no_examples_found?(stdout, stderr)
|
|
98
|
+
stdout.to_s.include?("No examples found.") || stderr.to_s.include?("No examples found.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def zero_examples_failure?(wait_result, stdout, stderr)
|
|
102
|
+
return false unless exit_status_for(wait_result) == 1
|
|
103
|
+
|
|
104
|
+
output = [stdout.to_s, stderr.to_s].join("\n")
|
|
105
|
+
output.include?("0 examples, 0 failures")
|
|
106
|
+
end
|
|
94
107
|
end
|
|
95
108
|
|
|
96
109
|
def stream_section(name, content)
|