henitai 0.1.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +182 -0
  5. data/assets/schema/henitai.schema.json +123 -0
  6. data/exe/henitai +6 -0
  7. data/lib/henitai/arid_node_filter.rb +97 -0
  8. data/lib/henitai/cli.rb +341 -0
  9. data/lib/henitai/configuration.rb +132 -0
  10. data/lib/henitai/configuration_validator.rb +293 -0
  11. data/lib/henitai/coverage_bootstrapper.rb +75 -0
  12. data/lib/henitai/coverage_formatter.rb +112 -0
  13. data/lib/henitai/equivalence_detector.rb +85 -0
  14. data/lib/henitai/execution_engine.rb +174 -0
  15. data/lib/henitai/git_diff_analyzer.rb +82 -0
  16. data/lib/henitai/integration.rb +417 -0
  17. data/lib/henitai/mutant/activator.rb +234 -0
  18. data/lib/henitai/mutant.rb +68 -0
  19. data/lib/henitai/mutant_generator.rb +158 -0
  20. data/lib/henitai/mutant_history_store.rb +279 -0
  21. data/lib/henitai/operator.rb +96 -0
  22. data/lib/henitai/operators/arithmetic_operator.rb +46 -0
  23. data/lib/henitai/operators/array_declaration.rb +52 -0
  24. data/lib/henitai/operators/assignment_expression.rb +78 -0
  25. data/lib/henitai/operators/block_statement.rb +31 -0
  26. data/lib/henitai/operators/boolean_literal.rb +70 -0
  27. data/lib/henitai/operators/conditional_expression.rb +184 -0
  28. data/lib/henitai/operators/equality_operator.rb +41 -0
  29. data/lib/henitai/operators/hash_literal.rb +66 -0
  30. data/lib/henitai/operators/logical_operator.rb +84 -0
  31. data/lib/henitai/operators/method_expression.rb +56 -0
  32. data/lib/henitai/operators/pattern_match.rb +66 -0
  33. data/lib/henitai/operators/range_literal.rb +40 -0
  34. data/lib/henitai/operators/return_value.rb +105 -0
  35. data/lib/henitai/operators/safe_navigation.rb +34 -0
  36. data/lib/henitai/operators/string_literal.rb +64 -0
  37. data/lib/henitai/operators.rb +25 -0
  38. data/lib/henitai/parser_current.rb +7 -0
  39. data/lib/henitai/reporter.rb +432 -0
  40. data/lib/henitai/result.rb +170 -0
  41. data/lib/henitai/runner.rb +183 -0
  42. data/lib/henitai/sampling_strategy.rb +33 -0
  43. data/lib/henitai/scenario_execution_result.rb +71 -0
  44. data/lib/henitai/source_parser.rb +41 -0
  45. data/lib/henitai/static_filter.rb +186 -0
  46. data/lib/henitai/stillborn_filter.rb +34 -0
  47. data/lib/henitai/subject.rb +71 -0
  48. data/lib/henitai/subject_resolver.rb +232 -0
  49. data/lib/henitai/syntax_validator.rb +16 -0
  50. data/lib/henitai/test_prioritizer.rb +55 -0
  51. data/lib/henitai/unparse_helper.rb +24 -0
  52. data/lib/henitai/version.rb +5 -0
  53. data/lib/henitai/warning_silencer.rb +16 -0
  54. data/lib/henitai.rb +51 -0
  55. data/sig/configuration_validator.rbs +29 -0
  56. data/sig/henitai.rbs +594 -0
  57. data/sig/unparser.rbs +3 -0
  58. metadata +153 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Henitai
6
+ # Runs pending mutants through the selected integration.
7
+ class ExecutionEngine
8
+ def run(mutants, integration, config, progress_reporter: nil)
9
+ with_reports_dir(config) do
10
+ with_coverage_dir(config) do
11
+ @flaky_retry_count = 0
12
+ pending_mutants = Array(mutants).select(&:pending?)
13
+ mutex = Mutex.new
14
+ if parallel_execution?(config, pending_mutants)
15
+ run_parallel(pending_mutants, integration, config, progress_reporter, mutex)
16
+ else
17
+ run_linear(pending_mutants, integration, config, progress_reporter, mutex)
18
+ end
19
+
20
+ warn_flaky_mutants(pending_mutants.size)
21
+ mutants
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def parallel_execution?(config, mutants)
29
+ worker_count(config) > 1 && mutants.size > 1
30
+ end
31
+
32
+ def worker_count(config)
33
+ configured_jobs = config.respond_to?(:jobs) ? config.jobs : nil
34
+ configured_jobs || Etc.nprocessors
35
+ end
36
+
37
+ def run_linear(mutants, integration, config, progress_reporter, mutex)
38
+ mutants.each do |mutant|
39
+ process_mutant(mutant, integration, config, progress_reporter, mutex)
40
+ end
41
+ end
42
+
43
+ def run_parallel(mutants, integration, config, progress_reporter, mutex)
44
+ queue = Queue.new
45
+ mutants.each { |mutant| queue << mutant }
46
+
47
+ Array.new(worker_count(config)) do
48
+ Thread.new do
49
+ loop do
50
+ mutant = queue.pop(true)
51
+ process_mutant(mutant, integration, config, progress_reporter, mutex)
52
+ rescue ThreadError
53
+ break
54
+ end
55
+ end
56
+ end.each(&:join)
57
+ end
58
+
59
+ def process_mutant(mutant, integration, config, progress_reporter, mutex)
60
+ test_files = prioritized_tests_for(mutant, integration, config)
61
+ scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
62
+ mutant.status = scenario_status(scenario_result)
63
+
64
+ if mutex
65
+ mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
66
+ else
67
+ progress_reporter&.progress(mutant, scenario_result:)
68
+ end
69
+ end
70
+
71
+ def prioritized_tests_for(mutant, integration, config)
72
+ test_prioritizer.sort(
73
+ integration.select_tests(mutant.subject),
74
+ mutant,
75
+ test_history(config)
76
+ )
77
+ end
78
+
79
+ def test_prioritizer
80
+ @test_prioritizer ||= TestPrioritizer.new
81
+ end
82
+
83
+ def test_history(config)
84
+ return {} unless config.respond_to?(:history)
85
+
86
+ config.history || {}
87
+ end
88
+
89
+ # Retry logic is kept in one place to preserve the status transition flow.
90
+ # The retry budget is configurable because repeated survivors can multiply
91
+ # runtime on real CI workloads.
92
+ # rubocop:disable Metrics/MethodLength
93
+ def run_with_flaky_retry(mutant, integration, config, test_files, mutex)
94
+ scenario_result = integration.run_mutant(
95
+ mutant:,
96
+ test_files:,
97
+ timeout: config.timeout
98
+ )
99
+ return scenario_result unless scenario_status(scenario_result) == :survived
100
+
101
+ retries = 0
102
+ max_flaky_retries(config).times do
103
+ retries += 1
104
+ scenario_result = integration.run_mutant(
105
+ mutant:,
106
+ test_files:,
107
+ timeout: config.timeout
108
+ )
109
+ break unless scenario_status(scenario_result) == :survived
110
+ end
111
+
112
+ mutex.synchronize { @flaky_retry_count += 1 } if retries.positive?
113
+ scenario_result
114
+ end
115
+ # rubocop:enable Metrics/MethodLength
116
+
117
+ def scenario_status(result)
118
+ return result if result.is_a?(Symbol)
119
+
120
+ result.status
121
+ end
122
+
123
+ def warn_flaky_mutants(total_mutants)
124
+ return if total_mutants.zero?
125
+
126
+ flaky_ratio = @flaky_retry_count.to_f / total_mutants
127
+ return unless flaky_ratio > 0.05
128
+
129
+ warn format(
130
+ "Flaky-test mitigation: %<flaky>d/%<total>d mutants required retries (%<ratio>.2f%%)",
131
+ flaky: @flaky_retry_count,
132
+ total: total_mutants,
133
+ ratio: flaky_ratio * 100.0
134
+ )
135
+ end
136
+
137
+ def with_reports_dir(config)
138
+ original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
139
+ ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
140
+ yield
141
+ ensure
142
+ if original_reports_dir.nil?
143
+ ENV.delete("HENITAI_REPORTS_DIR")
144
+ else
145
+ ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
146
+ end
147
+ end
148
+
149
+ def with_coverage_dir(config)
150
+ original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
151
+ ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
152
+ yield
153
+ ensure
154
+ if original_coverage_dir.nil?
155
+ ENV.delete("HENITAI_COVERAGE_DIR")
156
+ else
157
+ ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
158
+ end
159
+ end
160
+
161
+ def mutation_coverage_dir(config)
162
+ base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
163
+ base_dir = "reports" if base_dir.nil? || base_dir.empty?
164
+
165
+ File.join(base_dir, "mutation-coverage")
166
+ end
167
+
168
+ def max_flaky_retries(config)
169
+ return 3 unless config.respond_to?(:max_flaky_retries)
170
+
171
+ config.max_flaky_retries || 3
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "subject_resolver"
5
+
6
+ module Henitai
7
+ class GitDiffError < StandardError; end
8
+
9
+ # Shells out to git to discover changed files between two refs.
10
+ #
11
+ # By default the analyzer runs in the current working directory. Callers can
12
+ # pass dir: to point it at another repository root without changing cwd.
13
+ class GitDiffAnalyzer
14
+ def changed_files(from:, to:, dir: Dir.pwd)
15
+ stdout, stderr, status = git_diff(dir, "--name-only", from, to)
16
+
17
+ raise GitDiffError, stderr.strip unless status.success?
18
+
19
+ stdout.split("\n").reject(&:empty?)
20
+ end
21
+
22
+ def changed_methods(from:, to:, dir: Dir.pwd)
23
+ changed_files(from:, to:, dir:).flat_map do |path|
24
+ changed_methods_in_file(path, from:, to:, dir:)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def changed_methods_in_file(path, from:, to:, dir:)
31
+ subjects = SubjectResolver.new.resolve_from_files([File.expand_path(path, dir)])
32
+ changed_ranges = changed_line_ranges(path, from:, to:, dir:)
33
+
34
+ subjects.select do |subject|
35
+ subject.source_range &&
36
+ changed_ranges.any? do |range|
37
+ ranges_overlap?(subject.source_range, range)
38
+ end
39
+ end
40
+ end
41
+
42
+ def changed_line_ranges(path, from:, to:, dir:)
43
+ stdout, stderr, status = git_diff(dir, "--unified=0", from, to, "--", path)
44
+
45
+ raise GitDiffError, stderr.strip unless status.success?
46
+
47
+ stdout.each_line.filter_map { |line| changed_range_from_hunk(line) }
48
+ end
49
+
50
+ def changed_range_from_hunk(line)
51
+ match = line.match(/\A@@ -\d+(?:,\d+)? \+(?<start>\d+)(?:,(?<count>\d+))? @@/)
52
+ return unless match
53
+
54
+ start_line = match[:start].to_i
55
+ line_count = hunk_line_count(match)
56
+
57
+ start_line..(start_line + line_count - 1)
58
+ end
59
+
60
+ def hunk_line_count(match)
61
+ line_count = match[:count].nil? ? 1 : match[:count].to_i
62
+ # Git uses `+start` for a one-line hunk and `+start,0` for a pure
63
+ # deletion. We still anchor both at the reported start line so the
64
+ # current subject range can absorb the change point.
65
+ line_count = 1 if line_count.zero?
66
+ line_count
67
+ end
68
+
69
+ def ranges_overlap?(left, right)
70
+ left.begin <= right.end && right.begin <= left.end
71
+ end
72
+
73
+ def git_diff(dir, *git_args)
74
+ command = ["git"]
75
+ command += ["-C", dir] if dir
76
+ command << "diff"
77
+ command.concat(git_args)
78
+
79
+ Open3.capture3(*command)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,417 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "minitest"
5
+ require "rspec/core"
6
+
7
+ module Henitai
8
+ # Namespace for test-framework integrations.
9
+ #
10
+ # An Integration is responsible for:
11
+ # 1. Discovering test files relevant to a Subject (test selection)
12
+ # 2. Running the selected tests in a child process with a mutant injected
13
+ # 3. Reporting pass/fail/timeout to the runner
14
+ #
15
+ # Test selection uses longest-prefix matching:
16
+ # Subject expression "Foo::Bar#method" matches example groups whose
17
+ # description contains "Foo::Bar" or "Foo::Bar#method".
18
+ #
19
+ # Built-in integrations:
20
+ # rspec — RSpec 3.x
21
+ module Integration
22
+ # Shared helpers for capturing stdout/stderr from child test processes.
23
+ class ScenarioLogSupport
24
+ def capture_child_output(log_paths)
25
+ output_files = open_child_output(log_paths)
26
+ yield
27
+ ensure
28
+ close_child_output(output_files)
29
+ end
30
+
31
+ def with_coverage_dir(mutant_id)
32
+ original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
33
+ ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
34
+ yield
35
+ ensure
36
+ if original_coverage_dir.nil?
37
+ ENV.delete("HENITAI_COVERAGE_DIR")
38
+ else
39
+ ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
40
+ end
41
+ end
42
+
43
+ def open_child_output(log_paths)
44
+ FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
45
+ output_files = build_child_output_files(log_paths)
46
+ sync_child_output_files(output_files)
47
+ redirect_child_output(output_files)
48
+ output_files
49
+ end
50
+
51
+ def close_child_output(output_files)
52
+ return unless output_files
53
+
54
+ restore_child_output(output_files)
55
+ close_child_output_files(output_files)
56
+ end
57
+
58
+ def build_child_output_files(log_paths)
59
+ {
60
+ original_stdout: $stdout.dup,
61
+ original_stderr: $stderr.dup,
62
+ stdout_file: File.new(log_paths[:stdout_path], "w"),
63
+ stderr_file: File.new(log_paths[:stderr_path], "w")
64
+ }
65
+ end
66
+
67
+ def sync_child_output_files(output_files)
68
+ output_files[:stdout_file].sync = true
69
+ output_files[:stderr_file].sync = true
70
+ end
71
+
72
+ def redirect_child_output(output_files)
73
+ $stdout.reopen(output_files[:stdout_file])
74
+ $stderr.reopen(output_files[:stderr_file])
75
+ end
76
+
77
+ def restore_child_output(output_files)
78
+ reopen_child_output_stream($stdout, output_files[:original_stdout])
79
+ reopen_child_output_stream($stderr, output_files[:original_stderr])
80
+ end
81
+
82
+ def reopen_child_output_stream(stream, original_stream)
83
+ stream.reopen(original_stream) if original_stream
84
+ end
85
+
86
+ def close_child_output_files(output_files)
87
+ %i[stdout_file stderr_file original_stdout original_stderr].each do |key|
88
+ output_files[key]&.close
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def mutation_coverage_dir(mutant_id)
95
+ reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
96
+ File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
97
+ end
98
+ end
99
+
100
+ # Integration adapter for RSpec.
101
+ #
102
+ # This class exists as the stable public entry point for the RSpec
103
+ # integration, even though the concrete behavior is not implemented yet.
104
+ # @param name [String] integration name, e.g. "rspec"
105
+ # @return [Class] integration class
106
+ def self.for(name)
107
+ const_get(name.capitalize)
108
+ rescue NameError
109
+ raise ArgumentError, "Unknown integration: #{name}. Available: rspec"
110
+ end
111
+
112
+ # Base class for all integrations.
113
+ class Base
114
+ # @param subject [Subject]
115
+ # @return [Array<String>] paths to test files that cover this subject
116
+ def select_tests(subject)
117
+ raise NotImplementedError
118
+ end
119
+
120
+ # @return [Array<String>] all test files for the configured framework
121
+ def test_files
122
+ raise NotImplementedError
123
+ end
124
+
125
+ # Run test files in a child process with the mutant active.
126
+ #
127
+ # @param mutant [Mutant]
128
+ # @param test_files [Array<String>]
129
+ # @param timeout [Float] seconds
130
+ # @return [ScenarioExecutionResult]
131
+ def run_mutant(mutant:, test_files:, timeout:)
132
+ raise NotImplementedError
133
+ end
134
+ end
135
+
136
+ # RSpec integration adapter.
137
+ class Rspec < Base
138
+ DEFAULT_SUITE_TIMEOUT = 300.0
139
+ REQUIRE_DIRECTIVE_PATTERN = /
140
+ \A\s*
141
+ (require|require_relative)
142
+ \s*
143
+ (?:\(\s*)?
144
+ ["']([^"']+)["']
145
+ \s*\)?
146
+ /x
147
+
148
+ def select_tests(subject)
149
+ matches = spec_files.select do |path|
150
+ content = File.read(path)
151
+ selection_patterns(subject).any? { |pattern| content.include?(pattern) }
152
+ rescue StandardError
153
+ false
154
+ end
155
+
156
+ return matches unless matches.empty?
157
+
158
+ fallback_spec_files(subject)
159
+ end
160
+
161
+ def test_files
162
+ spec_files
163
+ end
164
+
165
+ def run_mutant(mutant:, test_files:, timeout:)
166
+ log_paths = scenario_log_paths("mutant-#{mutant.id}")
167
+ pid = Process.fork do
168
+ ENV["HENITAI_MUTANT_ID"] = mutant.id
169
+ Process.exit(run_in_child(mutant:, test_files:, log_paths:))
170
+ end
171
+
172
+ build_result(wait_with_timeout(pid, timeout), log_paths)
173
+ end
174
+
175
+ def run_suite(test_files, timeout: DEFAULT_SUITE_TIMEOUT)
176
+ log_paths = scenario_log_paths("baseline")
177
+ FileUtils.mkdir_p(File.dirname(log_paths[:stdout_path]))
178
+ pid = File.open(log_paths[:stdout_path], "w") do |stdout_file|
179
+ File.open(log_paths[:stderr_path], "w") do |stderr_file|
180
+ Process.spawn(*suite_command(test_files), out: stdout_file, err: stderr_file)
181
+ end
182
+ end
183
+ build_result(wait_with_timeout(pid, timeout), log_paths)
184
+ end
185
+
186
+ private
187
+
188
+ def run_in_child(mutant:, test_files:, log_paths:)
189
+ scenario_log_support.with_coverage_dir(mutant.id) do
190
+ scenario_log_support.capture_child_output(log_paths) do
191
+ return 2 if Mutant::Activator.activate!(mutant) == :compile_error
192
+
193
+ run_tests(test_files)
194
+ end
195
+ end
196
+ end
197
+
198
+ def suite_command(test_files)
199
+ ["bundle", "exec", "rspec", *test_files]
200
+ end
201
+
202
+ def wait_with_timeout(pid, timeout)
203
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
204
+
205
+ loop do
206
+ return Process.last_status if Process.wait(pid, Process::WNOHANG)
207
+ return handle_timeout(pid) if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
208
+
209
+ pause(0.01)
210
+ end
211
+ end
212
+
213
+ def handle_timeout(pid)
214
+ begin
215
+ Process.kill(:SIGTERM, pid)
216
+ pause(2.0)
217
+ Process.kill(:SIGKILL, pid)
218
+ rescue Errno::ESRCH
219
+ # The child may exit after SIGTERM but before SIGKILL.
220
+ ensure
221
+ reap_child(pid)
222
+ end
223
+ :timeout
224
+ end
225
+
226
+ def run_tests(test_files)
227
+ status = RSpec::Core::Runner.run(test_files + rspec_options)
228
+ return status if status.is_a?(Integer)
229
+
230
+ status == true ? 0 : 1
231
+ end
232
+
233
+ def rspec_options
234
+ ["--require", "henitai/coverage_formatter"]
235
+ end
236
+
237
+ def pause(seconds)
238
+ sleep(seconds)
239
+ end
240
+
241
+ def scenario_log_support
242
+ @scenario_log_support ||= ScenarioLogSupport.new
243
+ end
244
+
245
+ def read_log_file(path)
246
+ return "" unless File.exist?(path)
247
+
248
+ File.read(path)
249
+ end
250
+
251
+ def write_combined_log(path, stdout, stderr)
252
+ FileUtils.mkdir_p(File.dirname(path))
253
+ File.write(path, combined_log(stdout, stderr))
254
+ end
255
+
256
+ def combined_log(stdout, stderr)
257
+ [
258
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
259
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
260
+ ].compact.join("\n")
261
+ end
262
+
263
+ def scenario_log_paths(name)
264
+ reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
265
+ log_dir = File.join(reports_dir, "mutation-logs")
266
+ {
267
+ stdout_path: File.join(log_dir, "#{name}.stdout.log"),
268
+ stderr_path: File.join(log_dir, "#{name}.stderr.log"),
269
+ log_path: File.join(log_dir, "#{name}.log")
270
+ }
271
+ end
272
+
273
+ def build_result(wait_result, log_paths)
274
+ status = scenario_status(wait_result)
275
+ stdout = read_log_file(log_paths[:stdout_path])
276
+ stderr = read_log_file(log_paths[:stderr_path])
277
+ write_combined_log(log_paths[:log_path], stdout, stderr)
278
+
279
+ ScenarioExecutionResult.new(
280
+ status:,
281
+ stdout:,
282
+ stderr:,
283
+ log_path: log_paths[:log_path],
284
+ exit_status: exit_status_for(wait_result)
285
+ )
286
+ end
287
+
288
+ def scenario_status(wait_result)
289
+ return :timeout if wait_result == :timeout
290
+ return :compile_error if exit_status_for(wait_result) == 2
291
+ return :survived if wait_result.respond_to?(:success?) && wait_result.success?
292
+
293
+ :killed
294
+ end
295
+
296
+ def exit_status_for(wait_result)
297
+ return nil if wait_result == :timeout
298
+ return nil unless wait_result.respond_to?(:exitstatus)
299
+
300
+ wait_result.exitstatus
301
+ end
302
+
303
+ def spec_files
304
+ Dir.glob("spec/**/*_spec.rb")
305
+ end
306
+
307
+ def fallback_spec_files(subject)
308
+ return [] unless subject.source_file
309
+
310
+ matches = spec_files.select do |path|
311
+ requires_source_file_transitively?(path, subject.source_file)
312
+ rescue StandardError
313
+ false
314
+ end
315
+
316
+ matches.empty? ? spec_files : matches
317
+ end
318
+
319
+ def selection_patterns(subject)
320
+ [
321
+ subject.expression,
322
+ subject.namespace
323
+ ].compact.uniq.sort_by(&:length).reverse
324
+ end
325
+
326
+ def requires_source_file?(spec_file, source_file)
327
+ content = File.read(spec_file)
328
+ basename = File.basename(source_file, ".rb")
329
+ content.include?(basename) || content.include?(source_file)
330
+ end
331
+
332
+ def requires_source_file_transitively?(spec_file, source_file, visited = [])
333
+ normalized_spec_file = File.expand_path(spec_file)
334
+ return false if visited.include?(normalized_spec_file)
335
+
336
+ visited << normalized_spec_file
337
+ return true if requires_source_file?(spec_file, source_file)
338
+
339
+ required_files(spec_file).any? do |required_file|
340
+ requires_source_file_transitively?(required_file, source_file, visited)
341
+ end
342
+ end
343
+
344
+ def required_files(spec_file)
345
+ File.read(spec_file).lines.filter_map do |line|
346
+ match = line.match(REQUIRE_DIRECTIVE_PATTERN)
347
+ next unless match
348
+
349
+ resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
350
+ end
351
+ end
352
+
353
+ def resolve_required_file(spec_file, method_name, required_path)
354
+ candidates =
355
+ if method_name == "require_relative"
356
+ relative_candidates(spec_file, required_path)
357
+ else
358
+ require_candidates(spec_file, required_path)
359
+ end
360
+
361
+ candidates.find { |candidate| File.file?(candidate) }
362
+ end
363
+
364
+ def relative_candidates(spec_file, required_path)
365
+ expand_candidates(File.dirname(spec_file), required_path)
366
+ end
367
+
368
+ def require_candidates(spec_file, required_path)
369
+ ([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
370
+ expand_candidates(base_path, required_path)
371
+ end
372
+ end
373
+
374
+ def expand_candidates(base_path, required_path)
375
+ [
376
+ File.expand_path(required_path, base_path),
377
+ File.expand_path("#{required_path}.rb", base_path)
378
+ ].uniq
379
+ end
380
+
381
+ def reap_child(pid)
382
+ Process.wait(pid)
383
+ rescue Errno::ECHILD, Errno::ESRCH
384
+ nil
385
+ end
386
+ end
387
+
388
+ # Minitest integration adapter.
389
+ #
390
+ # Coverage formatter injection remains implemented in the RSpec child
391
+ # runner. Minitest shares selection and execution semantics, but per-test
392
+ # coverage collection is not yet wired into this path.
393
+ class Minitest < Rspec
394
+ private
395
+
396
+ def suite_command(test_files)
397
+ ["bundle", "exec", "ruby", "-I", "test",
398
+ "-e", "ARGV.each { |f| require File.expand_path(f) }",
399
+ *test_files]
400
+ end
401
+
402
+ def run_tests(test_files)
403
+ test_files.each { |file| require File.expand_path(file) }
404
+ # @type var empty_args: Array[String]
405
+ empty_args = []
406
+ status = ::Minitest.run(empty_args)
407
+ return status if status.is_a?(Integer)
408
+
409
+ status == true ? 0 : 1
410
+ end
411
+
412
+ def spec_files
413
+ Dir.glob("test/**/*_test.rb") + Dir.glob("test/**/*_spec.rb")
414
+ end
415
+ end
416
+ end
417
+ end