henitai 0.2.0 → 0.3.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -1
  3. data/README.md +138 -5
  4. data/assets/schema/henitai.schema.json +41 -0
  5. data/lib/henitai/available_cpu_count.rb +16 -23
  6. data/lib/henitai/canonical_report_merger.rb +107 -0
  7. data/lib/henitai/canonical_report_writer.rb +22 -0
  8. data/lib/henitai/checkpoint_reporter.rb +79 -0
  9. data/lib/henitai/cli/clean_command.rb +54 -0
  10. data/lib/henitai/cli/command_support.rb +52 -0
  11. data/lib/henitai/cli/init_command.rb +64 -0
  12. data/lib/henitai/cli/operator_command.rb +96 -0
  13. data/lib/henitai/cli/options.rb +84 -0
  14. data/lib/henitai/cli/run_command.rb +136 -0
  15. data/lib/henitai/cli/run_options.rb +108 -0
  16. data/lib/henitai/cli.rb +27 -407
  17. data/lib/henitai/composite_progress_reporter.rb +42 -0
  18. data/lib/henitai/configuration.rb +42 -9
  19. data/lib/henitai/configuration_validator/rules.rb +161 -0
  20. data/lib/henitai/configuration_validator/scalars.rb +169 -0
  21. data/lib/henitai/configuration_validator.rb +20 -240
  22. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  23. data/lib/henitai/coverage_formatter.rb +5 -1
  24. data/lib/henitai/coverage_report_reader.rb +28 -5
  25. data/lib/henitai/eager_load.rb +36 -5
  26. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  27. data/lib/henitai/execution_engine.rb +115 -53
  28. data/lib/henitai/generated_artifacts.rb +58 -0
  29. data/lib/henitai/incremental_filter.rb +100 -0
  30. data/lib/henitai/integration/base.rb +171 -0
  31. data/lib/henitai/integration/child_debug_support.rb +119 -0
  32. data/lib/henitai/integration/child_runtime_control.rb +50 -0
  33. data/lib/henitai/integration/coverage_suppression.rb +52 -0
  34. data/lib/henitai/integration/minitest.rb +101 -0
  35. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  36. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  37. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  38. data/lib/henitai/integration/mutant_run_support.rb +77 -0
  39. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  40. data/lib/henitai/integration/rspec_child_runner.rb +61 -0
  41. data/lib/henitai/integration/rspec_test_selection.rb +138 -0
  42. data/lib/henitai/integration/scenario_log_support.rb +160 -0
  43. data/lib/henitai/integration.rb +22 -846
  44. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  45. data/lib/henitai/mutant/activator.rb +15 -79
  46. data/lib/henitai/mutant/parameter_source.rb +98 -0
  47. data/lib/henitai/mutant.rb +14 -6
  48. data/lib/henitai/mutant_history_store/sql.rb +90 -0
  49. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  50. data/lib/henitai/mutant_history_store.rb +67 -81
  51. data/lib/henitai/mutant_identity.rb +33 -2
  52. data/lib/henitai/mutation_skip_directives.rb +227 -0
  53. data/lib/henitai/operator.rb +3 -1
  54. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  55. data/lib/henitai/operators/equality_operator.rb +8 -3
  56. data/lib/henitai/operators.rb +1 -0
  57. data/lib/henitai/per_test_coverage.rb +66 -0
  58. data/lib/henitai/per_test_coverage_collector.rb +23 -6
  59. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  60. data/lib/henitai/process_worker_runner.rb +48 -334
  61. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  62. data/lib/henitai/reporter.rb +258 -125
  63. data/lib/henitai/reports_directory_lock.rb +76 -0
  64. data/lib/henitai/result.rb +88 -23
  65. data/lib/henitai/runner.rb +137 -218
  66. data/lib/henitai/scenario_execution_result.rb +12 -0
  67. data/lib/henitai/slot_scheduler/draining.rb +146 -0
  68. data/lib/henitai/slot_scheduler/process_control.rb +43 -0
  69. data/lib/henitai/slot_scheduler.rb +272 -0
  70. data/lib/henitai/static_filter.rb +16 -6
  71. data/lib/henitai/survivor_rerun_strategy.rb +195 -0
  72. data/lib/henitai/survivor_test_filter.rb +1 -1
  73. data/lib/henitai/test_prioritizer.rb +28 -2
  74. data/lib/henitai/timeout_calibrator.rb +44 -0
  75. data/lib/henitai/unparse_helper.rb +5 -2
  76. data/lib/henitai/verdict_fingerprint.rb +155 -0
  77. data/lib/henitai/version.rb +1 -1
  78. data/lib/henitai.rb +16 -1
  79. data/sig/configuration_validator.rbs +46 -22
  80. data/sig/henitai.rbs +386 -96
  81. metadata +45 -3
  82. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -0,0 +1,146 @@
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)
110
+ draining.each_value { |slot| reap_and_finalize_slot(slot) }
111
+ end
112
+
113
+ # One last WNOHANG before blocking: catches processes that exited
114
+ # between SIGKILL and here.
115
+ def reap_and_finalize_slot(slot)
116
+ _, final_status = wnohang_reap(slot.pid)
117
+ reap_pid(slot.pid) unless final_status
118
+
119
+ pid_to_slot.delete(slot.pid)
120
+ slots.delete(slot.slot_id)
121
+ Integration::SchedulerDiagnostics.child_ended(slot.pid)
122
+
123
+ return if slot.forced_outcome == :interrupted
124
+
125
+ record_drain_result(slot, final_status)
126
+ end
127
+
128
+ def record_drain_result(slot, final_status)
129
+ result = build_drain_result(slot, final_status)
130
+ slot.mutant.status = result.status
131
+ results << result
132
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
133
+ end
134
+
135
+ # Choose result: use real exit status only if observed before any parent
136
+ # signal was sent. After SIGTERM, the forced outcome is authoritative.
137
+ def build_drain_result(slot, final_status)
138
+ if final_status&.exited? && slot.term_sent_at_monotonic.nil?
139
+ integration.build_result(final_status, slot.log_paths)
140
+ else
141
+ integration.build_result(slot.forced_outcome || :timeout, slot.log_paths)
142
+ end
143
+ end
144
+ end
145
+ end
146
+ 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,272 @@
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
+ # Environment variable exposing a stable worker-slot index (0..jobs-1) to
27
+ # each forked child so test suites can isolate shared resources per slot
28
+ # (e.g. "myapp_test_#{ENV['HENITAI_WORKER_SLOT']}"). A flaky-retry respawn
29
+ # keeps the original attempt's value.
30
+ WORKER_SLOT_ENV = "HENITAI_WORKER_SLOT"
31
+
32
+ # Tracks one in-flight mutant child process.
33
+ Slot = Struct.new(
34
+ :slot_id, :mutant, :pid, :started_at_monotonic, :timeout,
35
+ :log_paths, :retry_count, :draining, :term_sent_at_monotonic,
36
+ :forced_outcome, :worker_index
37
+ )
38
+
39
+ # @return [Integer] mutants that required at least one retry during the run.
40
+ # @return [Array<ScenarioExecutionResult>] verdicts accumulated so far.
41
+ attr_reader :flaky_retry_count, :results
42
+
43
+ def initialize(integration:, config:, progress_reporter:, options:, host:)
44
+ @integration = integration
45
+ @config = config
46
+ @progress_reporter = progress_reporter
47
+ @options = options
48
+ @host = host
49
+ @pending = []
50
+ @slots = {}
51
+ @pid_to_slot = {}
52
+ @results = []
53
+ @flaky_retry_count = 0
54
+ @next_slot_id = 0
55
+ end
56
+
57
+ # Queues the mutants to be scheduled into worker slots.
58
+ def enqueue(mutants)
59
+ @pending = mutants.dup
60
+ end
61
+
62
+ def done?
63
+ pending.empty? && slots.empty?
64
+ end
65
+
66
+ def fill_idle_slots
67
+ while slots.size < worker_count && !pending.empty?
68
+ mutant = pending.shift
69
+ spawn_into_slot(mutant)
70
+ end
71
+ end
72
+
73
+ def reap_all_completed_children
74
+ loop do
75
+ pid, status = runtime.wait2(-1, Process::WNOHANG)
76
+ break unless pid
77
+
78
+ complete_slot(pid, status)
79
+ end
80
+ rescue Errno::ECHILD
81
+ nil
82
+ end
83
+
84
+ def next_event_timeout
85
+ now = monotonic_time
86
+ slot_timeouts = slots.each_value.filter_map do |slot|
87
+ remaining_slot_timeout(slot, now)
88
+ end
89
+
90
+ slot_timeouts.min
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :pending, :slots, :pid_to_slot, :integration, :config,
96
+ :progress_reporter, :options, :host
97
+
98
+ def worker_count = host.worker_count
99
+ def runtime = host.runtime
100
+ def wakeup = host.wakeup
101
+ def shutdown? = host.shutdown_requested?
102
+
103
+ def spawn_into_slot(mutant)
104
+ test_files = resolve_test_files(mutant)
105
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
106
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
107
+ return record_no_coverage(mutant) if resolved_selection_empty?(test_files)
108
+
109
+ worker_index = next_free_worker_index
110
+ with_worker_slot(worker_index) do
111
+ handle = integration.spawn_mutant(mutant: mutant, test_files: test_files)
112
+ register_slot(handle, mutant, worker_index, slot_timeout(mutant, test_files))
113
+ end
114
+ rescue StandardError => e
115
+ record_spawn_failure(mutant, e)
116
+ end
117
+
118
+ def register_slot(handle, mutant, worker_index, timeout)
119
+ slot_id = next_slot_id!
120
+ slot = build_slot(slot_id, mutant, handle, worker_index, timeout)
121
+ slots[slot_id] = slot
122
+ pid_to_slot[handle.pid] = slot_id
123
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
124
+ end
125
+
126
+ def build_slot(slot_id, mutant, handle, worker_index, timeout)
127
+ Slot.new(
128
+ slot_id, mutant, handle.pid,
129
+ monotonic_time,
130
+ timeout, handle.log_paths, 0, false, nil, nil,
131
+ worker_index
132
+ )
133
+ end
134
+
135
+ def slot_timeout(mutant, test_files)
136
+ resolver = options[:timeout_resolver]
137
+ resolver ? resolver.call(mutant, test_files) : config.timeout
138
+ end
139
+
140
+ # Smallest index in 0...worker_count not held by a live slot, so
141
+ # concurrently-running children always see distinct values and freed
142
+ # indices are reused. Slot ids themselves grow monotonically and are
143
+ # unsuitable as a resource token.
144
+ def next_free_worker_index
145
+ used = slots.each_value.map(&:worker_index)
146
+ (0...worker_count).find { |index| !used.include?(index) } || used.size
147
+ end
148
+
149
+ def complete_slot(pid, wait_result)
150
+ slot_id = pid_to_slot.delete(pid)
151
+ return unless slot_id
152
+
153
+ slot = slots[slot_id]
154
+ return unless slot
155
+
156
+ Integration::SchedulerDiagnostics.child_ended(pid)
157
+ result = integration.build_result(wait_result, slot.log_paths)
158
+ dispatch_slot_result(slot, result)
159
+ end
160
+
161
+ def dispatch_slot_result(slot, result)
162
+ if should_retry?(slot, result)
163
+ retry_slot(slot)
164
+ else
165
+ slots.delete(slot.slot_id)
166
+ slot.mutant.status = result.status
167
+ results << result
168
+ progress_reporter&.progress(slot.mutant, scenario_result: result)
169
+ result.release_output! if result.respond_to?(:release_output!)
170
+ end
171
+ end
172
+
173
+ def should_retry?(slot, result)
174
+ !shutdown? && result.survived? && slot.retry_count < config.max_flaky_retries.to_i
175
+ end
176
+
177
+ def retry_slot(slot)
178
+ test_files = resolve_test_files(slot.mutant)
179
+ with_worker_slot(slot.worker_index) do
180
+ handle = integration.spawn_mutant(mutant: slot.mutant, test_files: test_files)
181
+ finish_retry(slot, handle)
182
+ end
183
+ rescue StandardError => e
184
+ slots.delete(slot.slot_id)
185
+ record_spawn_failure(slot.mutant, e)
186
+ end
187
+
188
+ def finish_retry(slot, handle)
189
+ @flaky_retry_count += 1 if slot.retry_count.zero?
190
+ slot.retry_count += 1
191
+ reset_slot_for_retry(slot, handle)
192
+ pid_to_slot[handle.pid] = slot.slot_id
193
+ Integration::SchedulerDiagnostics.child_started(handle.pid)
194
+ end
195
+
196
+ def reset_slot_for_retry(slot, handle)
197
+ slot.pid = handle.pid
198
+ slot.log_paths = handle.log_paths
199
+ slot.started_at_monotonic = monotonic_time
200
+ slot.draining = false
201
+ slot.term_sent_at_monotonic = nil
202
+ slot.forced_outcome = nil
203
+ end
204
+
205
+ def record_spawn_failure(mutant, error)
206
+ result = ScenarioExecutionResult.new(
207
+ status: :compile_error,
208
+ stdout: "",
209
+ stderr: "spawn failed: #{error.message}",
210
+ log_path: "/dev/null",
211
+ exit_status: nil
212
+ )
213
+ mutant.status = result.status
214
+ results << result
215
+ progress_reporter&.progress(mutant, scenario_result: result)
216
+ end
217
+
218
+ def record_no_coverage(mutant)
219
+ mutant.status = :no_coverage
220
+ progress_reporter&.progress(mutant, scenario_result: nil)
221
+ end
222
+
223
+ def resolved_selection_empty?(test_files)
224
+ options.key?(:test_file_resolver) && test_files.empty?
225
+ end
226
+
227
+ def remaining_slot_timeout(slot, now)
228
+ # Invariant: drain_draining_slots runs (and removes draining slots) before
229
+ # the event wait, so next_event_timeout never observes a draining slot
230
+ # whose SIGTERM has not been sent. Guard term_sent_at_monotonic defensively
231
+ # against a future ordering change: an unsignalled draining slot is due now.
232
+ return 0.0 if slot.draining && slot.term_sent_at_monotonic.nil?
233
+
234
+ deadline =
235
+ if slot.draining
236
+ slot.term_sent_at_monotonic + PROCESS_DRAIN_WINDOW
237
+ else
238
+ slot.started_at_monotonic + slot.timeout
239
+ end
240
+ remaining = deadline - now
241
+ remaining.positive? ? remaining : 0.0
242
+ end
243
+
244
+ def resolve_test_files(mutant)
245
+ if @options.key?(:test_file_resolver)
246
+ @options[:test_file_resolver].call(mutant)
247
+ elsif @options.key?(:test_files)
248
+ @options[:test_files]
249
+ else
250
+ integration.select_tests(mutant.subject)
251
+ end
252
+ end
253
+
254
+ def next_slot_id!
255
+ id = @next_slot_id
256
+ @next_slot_id += 1
257
+ id
258
+ end
259
+
260
+ def with_worker_slot(worker_index)
261
+ previous = ENV.fetch(WORKER_SLOT_ENV, nil)
262
+ ENV[WORKER_SLOT_ENV] = worker_index.to_s
263
+ yield
264
+ ensure
265
+ if previous.nil?
266
+ ENV.delete(WORKER_SLOT_ENV)
267
+ else
268
+ ENV[WORKER_SLOT_ENV] = previous
269
+ end
270
+ end
271
+ end
272
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "coverage_report_reader"
4
+ require_relative "mutation_skip_directives"
4
5
 
5
6
  module Henitai
6
7
  # Applies static, pre-execution filtering to generated mutants.
@@ -8,8 +9,10 @@ module Henitai
8
9
  DEFAULT_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_COVERAGE_REPORT_PATH
9
10
  DEFAULT_PER_TEST_COVERAGE_REPORT_PATH = CoverageReportReader::DEFAULT_PER_TEST_COVERAGE_REPORT_PATH
10
11
 
11
- def initialize(coverage_report_reader: CoverageReportReader.new)
12
+ def initialize(coverage_report_reader: CoverageReportReader.new,
13
+ skip_directives: MutationSkipDirectives.new)
12
14
  @coverage_report_reader = coverage_report_reader
15
+ @skip_directives = skip_directives
13
16
  end
14
17
 
15
18
  # This method is the gate-level filter orchestrator.
@@ -18,7 +21,7 @@ module Henitai
18
21
  coverage_report_present = coverage_report_present?(config)
19
22
 
20
23
  Array(mutants).each do |mutant|
21
- next if ignored_mutant?(mutant, config)
24
+ next if ignored_mutant?(mutant, config) || skip_directive_mutant?(mutant)
22
25
 
23
26
  mark_equivalent_mutant(mutant)
24
27
  mark_no_coverage_mutant(
@@ -61,7 +64,7 @@ module Henitai
61
64
 
62
65
  private
63
66
 
64
- attr_reader :coverage_report_reader
67
+ attr_reader :coverage_report_reader, :skip_directives
65
68
 
66
69
  def ignored?(mutant, config)
67
70
  source = source_for(mutant)
@@ -79,6 +82,15 @@ module Henitai
79
82
  true
80
83
  end
81
84
 
85
+ def skip_directive_mutant?(mutant)
86
+ directive = skip_directives.directive_for(mutant)
87
+ return false unless directive
88
+
89
+ mutant.status = :ignored
90
+ mutant.ignore_reason = directive.reason if mutant.respond_to?(:ignore_reason=)
91
+ true
92
+ end
93
+
82
94
  def mark_equivalent_mutant(mutant)
83
95
  return unless mutant.pending?
84
96
 
@@ -96,9 +108,7 @@ module Henitai
96
108
  def covered?(mutant, coverage_lines)
97
109
  file = normalize_path(mutant.location[:file])
98
110
  covered = Array(coverage_lines[file])
99
- (mutant.location[:start_line]..mutant.location[:end_line]).any? do |line|
100
- covered.include?(line)
101
- end
111
+ covered.any? { |line| (mutant.location[:start_line]..mutant.location[:end_line]).cover?(line) }
102
112
  end
103
113
 
104
114
  def source_for(mutant)