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,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
@@ -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
- def self.run_all(names:, result:, config:)
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
- def initialize(config:)
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
- FileUtils.mkdir_p(File.dirname(report_path))
216
- File.write(report_path, JSON.pretty_generate(result.to_stryker_schema))
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 report_path
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
- path = File.join(config.reports_dir, Henitai::HISTORY_STORE_FILENAME)
228
- history_store = MutantHistoryStore.new(path:)
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(history_store.trend_report))
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
@@ -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
- def initialize(mutants:, started_at:, finished_at:, thresholds: nil)
19
- @mutants = mutants
20
- @started_at = started_at
21
- @finished_at = finished_at
22
- @thresholds = DEFAULT_THRESHOLDS.merge(thresholds || {})
23
- end
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
- private
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),
@@ -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 = config
33
- @subjects = subjects
34
- @since = 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
- # Coverage bootstrap (Gate 0) runs in a background thread so that Gate 1
40
- # (subject resolution) and Gate 2 (mutant generation) proceed concurrently.
41
- # The thread is joined before Gate 3 (static filtering), which is the first
42
- # phase that requires coverage data.
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
- source_files = self.source_files
48
- subjects = resolve_subjects(source_files)
49
- mutants = execute_mutants(mutants_for(subjects, source_files))
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
- filter_mutants(mutants)
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 ||= begin
169
- included_files = Array(config.includes).flat_map do |include_path|
170
- Dir.glob(File.join(include_path, "**", "*.rb"))
171
- end.uniq
172
-
173
- if @since
174
- changed_files = git_diff_analyzer.changed_files(from: @since, to: "HEAD")
175
- changed_file_set = changed_files.map { |path| normalize_path(path) }
176
-
177
- included_files.select do |path|
178
- changed_file_set.include?(normalize_path(path))
179
- end
180
- else
181
- included_files
182
- end
183
- end
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)