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
@@ -2,15 +2,68 @@
2
2
 
3
3
  module Henitai
4
4
  module Integration
5
+ # Tracks real OS child pids for scheduler observability.
6
+ # Gated on HENITAI_DEBUG_SCHEDULER=1. Thread-safe.
7
+ module SchedulerDiagnostics
8
+ @mutex = Mutex.new
9
+ @intervals = []
10
+ @live_count = 0
11
+ @max_concurrent = 0
12
+
13
+ class << self
14
+ def enabled?
15
+ ENV["HENITAI_DEBUG_SCHEDULER"] == "1"
16
+ end
17
+
18
+ def child_started(pid)
19
+ return unless enabled?
20
+
21
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ @mutex.synchronize do
23
+ @live_count += 1
24
+ @max_concurrent = [@max_concurrent, @live_count].max
25
+ @intervals << { pid: pid, started_at: started_at, ended_at: nil }
26
+ end
27
+ end
28
+
29
+ def child_ended(pid)
30
+ return if pid.nil? || !enabled?
31
+
32
+ ended_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ @mutex.synchronize do
34
+ @live_count -= 1
35
+ entry = @intervals.rfind { |i| i[:pid] == pid && i[:ended_at].nil? }
36
+ entry[:ended_at] = ended_at if entry
37
+ end
38
+ end
39
+
40
+ def summary
41
+ @mutex.synchronize { { max_concurrent: @max_concurrent, intervals: @intervals.dup } }
42
+ end
43
+
44
+ def reset!
45
+ @mutex.synchronize do
46
+ @intervals = []
47
+ @live_count = 0
48
+ @max_concurrent = 0
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Captures the PID and log paths for a spawned mutant child process.
55
+ ChildHandle = Struct.new(:pid, :log_paths)
56
+
5
57
  # Runs RSpec child and suite processes on behalf of the integration.
6
58
  class RspecProcessRunner
7
59
  def run_mutant(integration, mutant:, test_files:, timeout:)
8
- log_paths = integration.scenario_log_paths(mutant_log_name(mutant))
9
- pid = fork_mutant_process(integration, mutant, test_files, log_paths)
10
- wait_result = integration.wait_with_timeout(pid, timeout)
11
- integration.build_result(wait_result, log_paths)
60
+ handle = integration.spawn_mutant(mutant:, test_files:)
61
+ SchedulerDiagnostics.child_started(handle.pid)
62
+ wait_result = integration.wait_with_timeout(handle.pid, timeout)
63
+ integration.build_result(wait_result, handle.log_paths)
12
64
  ensure
13
- finalize_mutant_run(integration, pid, wait_result)
65
+ SchedulerDiagnostics.child_ended(handle&.pid)
66
+ finalize_mutant_run(integration, handle&.pid, wait_result)
14
67
  end
15
68
 
16
69
  def run_suite(integration, test_files, timeout:)
@@ -27,10 +80,11 @@ module Henitai
27
80
  end
28
81
  end
29
82
 
30
- private
31
-
32
- def fork_mutant_process(integration, mutant, test_files, log_paths)
33
- Process.fork do
83
+ # Called from Integration::Rspec#spawn_mutant (and Minitest#spawn_mutant).
84
+ # Forks a child, sets process group, activates the mutant, runs tests.
85
+ # Returns a ChildHandle with the forked pid and log_paths.
86
+ def spawn_mutant(integration, mutant:, test_files:, log_paths:)
87
+ pid = Process.fork do
34
88
  Process.setpgid(0, 0)
35
89
  ENV["HENITAI_MUTANT_ID"] = mutant.id
36
90
  Process.exit(
@@ -41,18 +95,17 @@ module Henitai
41
95
  )
42
96
  )
43
97
  end
98
+ ChildHandle.new(pid:, log_paths:)
44
99
  end
45
100
 
101
+ private
102
+
46
103
  def finalize_mutant_run(integration, pid, wait_result)
47
104
  return unless pid
48
105
 
49
106
  integration.cleanup_process_group(pid) unless wait_result == :timeout
50
107
  integration.reap_child(pid) if wait_result.nil?
51
108
  end
52
-
53
- def mutant_log_name(mutant)
54
- "mutant-#{mutant.id}"
55
- end
56
109
  end
57
110
  end
58
111
  end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Spec-file discovery and subject-to-spec matching for the RSpec
6
+ # integration. Selection uses longest-prefix matching against the subject
7
+ # expression and namespace, with a transitive require-based fallback.
8
+ module RspecTestSelection
9
+ REQUIRE_DIRECTIVE_PATTERN = /
10
+ \A\s*
11
+ (require|require_relative)
12
+ \s*
13
+ (?:\(\s*)?
14
+ ["']([^"']+)["']
15
+ \s*\)?
16
+ /x
17
+
18
+ def select_tests(subject)
19
+ matches = spec_files.select do |path|
20
+ content = File.read(path)
21
+ selection_patterns(subject).any? { |pattern| content.include?(pattern) }
22
+ rescue StandardError
23
+ false
24
+ end
25
+
26
+ return matches unless matches.empty?
27
+
28
+ fallback_spec_files(subject)
29
+ end
30
+
31
+ private
32
+
33
+ def spec_files
34
+ @spec_files ||= begin
35
+ paths = Dir.glob("spec/**/*_spec.rb")
36
+ paths - excluded_spec_files
37
+ end
38
+ end
39
+
40
+ def fallback_spec_files(subject)
41
+ return [] unless subject.source_file
42
+
43
+ matches = spec_files.select do |path|
44
+ requires_source_file_transitively?(path, subject.source_file)
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ matches.empty? ? spec_files : matches
50
+ end
51
+
52
+ def excluded_spec_files
53
+ @excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
54
+ end
55
+
56
+ def rspec_exclude_patterns
57
+ rspec_config_lines.filter_map do |line|
58
+ line[/\A--exclude-pattern\s+(.+)\z/, 1]
59
+ end
60
+ end
61
+
62
+ def rspec_config_lines
63
+ return [] unless File.exist?(rspec_config_path)
64
+
65
+ File.readlines(rspec_config_path, chomp: true).map(&:strip)
66
+ end
67
+
68
+ def rspec_config_path
69
+ ".rspec"
70
+ end
71
+
72
+ def selection_patterns(subject)
73
+ [
74
+ subject.expression,
75
+ subject.namespace
76
+ ].compact.uniq.sort_by(&:length).reverse
77
+ end
78
+
79
+ def requires_source_file?(spec_file, source_file)
80
+ content = File.read(spec_file)
81
+ basename = File.basename(source_file, ".rb")
82
+ content.include?(basename) || content.include?(source_file)
83
+ end
84
+
85
+ def requires_source_file_transitively?(spec_file, source_file, visited = [])
86
+ normalized_spec_file = File.expand_path(spec_file)
87
+ return false if visited.include?(normalized_spec_file)
88
+
89
+ visited << normalized_spec_file
90
+ return true if requires_source_file?(spec_file, source_file)
91
+
92
+ required_files(spec_file).any? do |required_file|
93
+ requires_source_file_transitively?(required_file, source_file, visited)
94
+ end
95
+ end
96
+
97
+ def required_files(spec_file)
98
+ File.read(spec_file).lines.filter_map do |line|
99
+ match = line.match(REQUIRE_DIRECTIVE_PATTERN)
100
+ next unless match
101
+
102
+ resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
103
+ end
104
+ end
105
+
106
+ def resolve_required_file(spec_file, method_name, required_path)
107
+ candidates =
108
+ if method_name == "require_relative"
109
+ relative_candidates(spec_file, required_path)
110
+ else
111
+ require_candidates(spec_file, required_path)
112
+ end
113
+
114
+ candidates.find { |candidate| File.file?(candidate) }
115
+ end
116
+
117
+ def relative_candidates(spec_file, required_path)
118
+ expand_candidates(File.dirname(spec_file), required_path)
119
+ end
120
+
121
+ def require_candidates(spec_file, required_path)
122
+ ([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
123
+ expand_candidates(base_path, required_path)
124
+ end
125
+ end
126
+
127
+ def expand_candidates(base_path, required_path)
128
+ [
129
+ File.expand_path(required_path, base_path),
130
+ File.expand_path("#{required_path}.rb", base_path)
131
+ ].uniq
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Shared helpers for capturing stdout/stderr from child test processes and
8
+ # for reading and combining the captured log files afterwards.
9
+ class ScenarioLogSupport
10
+ def capture_child_output(log_paths)
11
+ output_files = open_child_output(log_paths)
12
+ yield
13
+ ensure
14
+ close_child_output(output_files)
15
+ end
16
+
17
+ def with_coverage_dir(mutant_id)
18
+ original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
19
+ ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
20
+ yield
21
+ ensure
22
+ if original_coverage_dir.nil?
23
+ ENV.delete("HENITAI_COVERAGE_DIR")
24
+ else
25
+ ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
26
+ end
27
+ end
28
+
29
+ def open_child_output(log_paths)
30
+ FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
31
+ output_files = build_child_output_files(log_paths)
32
+ sync_child_output_files(output_files)
33
+ redirect_child_output(output_files)
34
+ output_files
35
+ end
36
+
37
+ def close_child_output(output_files)
38
+ return unless output_files
39
+
40
+ restore_child_output(output_files)
41
+ close_child_output_files(output_files)
42
+ end
43
+
44
+ def build_child_output_files(log_paths)
45
+ {
46
+ original_stdout: stdout_stream.dup,
47
+ original_stderr: stderr_stream.dup,
48
+ stdout_file: File.new(log_paths[:stdout_path], "w"),
49
+ stderr_file: File.new(log_paths[:stderr_path], "w")
50
+ }
51
+ end
52
+
53
+ def sync_child_output_files(output_files)
54
+ output_files[:stdout_file].sync = true
55
+ output_files[:stderr_file].sync = true
56
+ end
57
+
58
+ def redirect_child_output(output_files)
59
+ reopen_child_output_stream(stdout_stream, output_files[:stdout_file])
60
+ reopen_child_output_stream(stderr_stream, output_files[:stderr_file])
61
+ $stdout = stdout_stream
62
+ $stderr = stderr_stream
63
+ end
64
+
65
+ def restore_child_output(output_files)
66
+ reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
67
+ reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
68
+ $stdout = stdout_stream
69
+ $stderr = stderr_stream
70
+ end
71
+
72
+ def reopen_child_output_stream(stream, original_stream)
73
+ stream.reopen(original_stream) if original_stream
74
+ end
75
+
76
+ def close_child_output_files(output_files)
77
+ %i[stdout_file stderr_file original_stdout original_stderr].each do |key|
78
+ output_files[key]&.close
79
+ end
80
+ end
81
+
82
+ def read_log_file(path)
83
+ return "" unless File.exist?(path)
84
+
85
+ File.read(path)
86
+ end
87
+
88
+ def write_combined_log(path, stdout, stderr)
89
+ FileUtils.mkdir_p(File.dirname(path))
90
+ File.write(path, combined_log(stdout, stderr))
91
+ end
92
+
93
+ def combined_log(stdout, stderr)
94
+ [
95
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
96
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
97
+ ].compact.join("\n")
98
+ end
99
+
100
+ private
101
+
102
+ def mutation_coverage_dir(mutant_id)
103
+ reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
104
+ File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
105
+ end
106
+
107
+ def stdout_stream
108
+ @stdout_stream ||= IO.for_fd(1)
109
+ end
110
+
111
+ def stderr_stream
112
+ @stderr_stream ||= IO.for_fd(2)
113
+ end
114
+ end
115
+ end
116
+ end