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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Drives an in-process RSpec run inside the mutant child. Kept separate
6
+ # from the framework-agnostic debug helpers so the RSpec-specific runner
7
+ # wiring stays in one place.
8
+ module RspecChildRunner
9
+ private
10
+
11
+ def run_rspec_runner(test_files)
12
+ debug_child_puts("[henitai-debug-child] build_rspec_runner_start")
13
+ runner = build_rspec_runner
14
+ debug_child_puts("[henitai-debug-child] build_rspec_runner_return")
15
+ debug_child_puts("[henitai-debug-child] configure_rspec_runner_start")
16
+ configure_rspec_runner(runner)
17
+ debug_child_puts("[henitai-debug-child] configure_rspec_runner_return")
18
+ load_rspec_spec_files(test_files)
19
+ run_rspec_specs(runner)
20
+ rescue SystemExit => e
21
+ debug_child_puts("[henitai-debug-child] runner_run_system_exit status=#{e.status.inspect}")
22
+ raise
23
+ ensure
24
+ debug_child_puts("[henitai-debug-child] runner_run_ensure")
25
+ end
26
+
27
+ def build_rspec_runner
28
+ # @type var empty_args: Array[String]
29
+ empty_args = []
30
+ configuration_options = ::RSpec::Core.const_get(:ConfigurationOptions).new(empty_args)
31
+ ::RSpec::Core::Runner.__send__(:new, configuration_options)
32
+ end
33
+
34
+ def configure_rspec_runner(runner)
35
+ debug_child_puts("[henitai-debug-child] trap_interrupt_start")
36
+ ::RSpec::Core::Runner.__send__(:trap_interrupt)
37
+ debug_child_puts("[henitai-debug-child] trap_interrupt_return")
38
+ debug_child_puts("[henitai-debug-child] runner_configure_start")
39
+ runner.send(:configure, $stderr, $stdout)
40
+ debug_child_puts("[henitai-debug-child] runner_configure_return")
41
+ end
42
+
43
+ def load_rspec_spec_files(test_files)
44
+ debug_child_puts("[henitai-debug-child] load_spec_files_start")
45
+ ::RSpec.configuration.files_to_run = test_files.map do |file|
46
+ File.expand_path(file)
47
+ end
48
+ ::RSpec.configuration.load_spec_files
49
+ debug_child_example_count("after_load")
50
+ debug_child_puts("[henitai-debug-child] load_spec_files_return")
51
+ end
52
+
53
+ def run_rspec_specs(runner)
54
+ debug_child_puts("[henitai-debug-child] run_specs_start")
55
+ result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
56
+ debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
+ result
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,138 @@
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
+ # rubocop:disable Style/ArrayIntersect -- content is a String (substring
22
+ # search), not a collection; `intersect?` would raise a TypeError.
23
+ selection_patterns(subject).any? { |pattern| content.include?(pattern) }
24
+ # rubocop:enable Style/ArrayIntersect
25
+ rescue StandardError
26
+ false
27
+ end
28
+
29
+ return matches unless matches.empty?
30
+
31
+ fallback_spec_files(subject)
32
+ end
33
+
34
+ private
35
+
36
+ def spec_files
37
+ @spec_files ||= begin
38
+ paths = Dir.glob("spec/**/*_spec.rb")
39
+ paths - excluded_spec_files
40
+ end
41
+ end
42
+
43
+ def fallback_spec_files(subject)
44
+ return [] unless subject.source_file
45
+
46
+ matches = spec_files.select do |path|
47
+ requires_source_file_transitively?(path, subject.source_file)
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ matches.empty? ? spec_files : matches
53
+ end
54
+
55
+ def excluded_spec_files
56
+ @excluded_spec_files ||= rspec_exclude_patterns.flat_map { |pattern| Dir.glob(pattern) }.uniq
57
+ end
58
+
59
+ def rspec_exclude_patterns
60
+ rspec_config_lines.filter_map do |line|
61
+ line[/\A--exclude-pattern\s+(.+)\z/, 1]
62
+ end
63
+ end
64
+
65
+ def rspec_config_lines
66
+ return [] unless File.exist?(rspec_config_path)
67
+
68
+ File.readlines(rspec_config_path, chomp: true).map(&:strip)
69
+ end
70
+
71
+ def rspec_config_path
72
+ ".rspec"
73
+ end
74
+
75
+ def selection_patterns(subject)
76
+ [
77
+ subject.expression,
78
+ subject.namespace
79
+ ].compact.uniq.sort_by(&:length).reverse
80
+ end
81
+
82
+ def requires_source_file?(spec_file, source_file)
83
+ content = File.read(spec_file)
84
+ basename = File.basename(source_file, ".rb")
85
+ content.include?(basename) || content.include?(source_file)
86
+ end
87
+
88
+ def requires_source_file_transitively?(spec_file, source_file, visited = [])
89
+ normalized_spec_file = File.expand_path(spec_file)
90
+ return false if visited.include?(normalized_spec_file)
91
+
92
+ visited << normalized_spec_file
93
+ return true if requires_source_file?(spec_file, source_file)
94
+
95
+ required_files(spec_file).any? do |required_file|
96
+ requires_source_file_transitively?(required_file, source_file, visited)
97
+ end
98
+ end
99
+
100
+ def required_files(spec_file)
101
+ File.read(spec_file).lines.filter_map do |line|
102
+ match = line.match(REQUIRE_DIRECTIVE_PATTERN)
103
+ next unless match
104
+
105
+ resolve_required_file(spec_file, match[1].to_s, match[2].to_s)
106
+ end
107
+ end
108
+
109
+ def resolve_required_file(spec_file, method_name, required_path)
110
+ candidates =
111
+ if method_name == "require_relative"
112
+ relative_candidates(spec_file, required_path)
113
+ else
114
+ require_candidates(spec_file, required_path)
115
+ end
116
+
117
+ candidates.find { |candidate| File.file?(candidate) }
118
+ end
119
+
120
+ def relative_candidates(spec_file, required_path)
121
+ expand_candidates(File.dirname(spec_file), required_path)
122
+ end
123
+
124
+ def require_candidates(spec_file, required_path)
125
+ ([File.dirname(spec_file), Dir.pwd] + $LOAD_PATH).flat_map do |base_path|
126
+ expand_candidates(base_path, required_path)
127
+ end
128
+ end
129
+
130
+ def expand_candidates(base_path, required_path)
131
+ [
132
+ File.expand_path(required_path, base_path),
133
+ File.expand_path("#{required_path}.rb", base_path)
134
+ ].uniq
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,160 @@
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
+ # Env var carrying the per-stream capture cap (bytes) into forked
11
+ # children. Set by the execution engine from Configuration#max_log_bytes.
12
+ MAX_LOG_BYTES_ENV = "HENITAI_MAX_LOG_BYTES"
13
+ DEFAULT_MAX_LOG_BYTES = 5_000_000
14
+ DRAIN_CHUNK_BYTES = 65_536
15
+
16
+ def capture_child_output(log_paths)
17
+ output_files = open_child_output(log_paths)
18
+ yield
19
+ ensure
20
+ close_child_output(output_files)
21
+ end
22
+
23
+ def with_coverage_dir(mutant_id)
24
+ original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
25
+ ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(mutant_id)
26
+ yield
27
+ ensure
28
+ if original_coverage_dir.nil?
29
+ ENV.delete("HENITAI_COVERAGE_DIR")
30
+ else
31
+ ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
32
+ end
33
+ end
34
+
35
+ # Captures via a pipe drained by a background thread that writes to the
36
+ # log file only up to +max_log_bytes+ and then discards the overflow.
37
+ # A runaway mutant (e.g. one that recurses henitai into itself and spews
38
+ # backtraces) can otherwise fill hundreds of MB per child; draining past
39
+ # the cap keeps the writer from blocking on a full pipe.
40
+ def open_child_output(log_paths)
41
+ FileUtils.mkdir_p(File.dirname(log_paths[:log_path]))
42
+ cap = max_log_bytes
43
+ output_files = {
44
+ original_stdout: stdout_stream.dup,
45
+ original_stderr: stderr_stream.dup,
46
+ stdout: open_capped_stream(log_paths[:stdout_path], cap),
47
+ stderr: open_capped_stream(log_paths[:stderr_path], cap)
48
+ }
49
+ redirect_child_output(output_files)
50
+ output_files
51
+ end
52
+
53
+ def close_child_output(output_files)
54
+ return unless output_files
55
+
56
+ restore_child_output(output_files)
57
+ finish_capped_stream(output_files[:stdout])
58
+ finish_capped_stream(output_files[:stderr])
59
+ output_files[:original_stdout]&.close
60
+ output_files[:original_stderr]&.close
61
+ end
62
+
63
+ # One capture channel: a pipe whose read end is drained (capped) into the
64
+ # log file by a dedicated thread. Returns the pieces close needs.
65
+ def open_capped_stream(path, cap)
66
+ file = File.new(path, "w")
67
+ file.sync = true
68
+ reader, writer = IO.pipe
69
+ thread = Thread.new { drain_capped(reader, file, cap) }
70
+ { file:, reader:, writer:, thread: }
71
+ end
72
+
73
+ def drain_capped(reader, file, cap)
74
+ written = 0
75
+ truncated = false
76
+ loop do
77
+ chunk = reader.readpartial(DRAIN_CHUNK_BYTES)
78
+ next unless written < cap
79
+
80
+ slice = chunk.byteslice(0, cap - written)
81
+ file.write(slice)
82
+ written += slice.bytesize
83
+ next unless written >= cap && !truncated
84
+
85
+ file.write("\n[henitai] output truncated at #{cap} bytes\n")
86
+ truncated = true
87
+ end
88
+ rescue IOError
89
+ # EOFError (a subclass) on writer close, or a closed pipe: draining done.
90
+ nil
91
+ end
92
+
93
+ def finish_capped_stream(stream)
94
+ return unless stream
95
+
96
+ stream[:writer].close
97
+ stream[:thread].join
98
+ stream[:reader].close
99
+ stream[:file].close
100
+ end
101
+
102
+ def redirect_child_output(output_files)
103
+ reopen_child_output_stream(stdout_stream, output_files[:stdout][:writer])
104
+ reopen_child_output_stream(stderr_stream, output_files[:stderr][:writer])
105
+ $stdout = stdout_stream
106
+ $stderr = stderr_stream
107
+ end
108
+
109
+ def restore_child_output(output_files)
110
+ reopen_child_output_stream(stdout_stream, output_files[:original_stdout])
111
+ reopen_child_output_stream(stderr_stream, output_files[:original_stderr])
112
+ $stdout = stdout_stream
113
+ $stderr = stderr_stream
114
+ end
115
+
116
+ def reopen_child_output_stream(stream, original_stream)
117
+ stream.reopen(original_stream) if original_stream
118
+ end
119
+
120
+ def max_log_bytes
121
+ raw = ENV.fetch(MAX_LOG_BYTES_ENV, nil)
122
+ value = raw.to_i
123
+ value.positive? ? value : DEFAULT_MAX_LOG_BYTES
124
+ end
125
+
126
+ def read_log_file(path)
127
+ return "" unless File.exist?(path)
128
+
129
+ File.read(path)
130
+ end
131
+
132
+ def write_combined_log(path, stdout, stderr)
133
+ FileUtils.mkdir_p(File.dirname(path))
134
+ File.write(path, combined_log(stdout, stderr))
135
+ end
136
+
137
+ def combined_log(stdout, stderr)
138
+ [
139
+ (stdout.empty? ? nil : "stdout:\n#{stdout}"),
140
+ (stderr.empty? ? nil : "stderr:\n#{stderr}")
141
+ ].compact.join("\n")
142
+ end
143
+
144
+ private
145
+
146
+ def mutation_coverage_dir(mutant_id)
147
+ reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", "reports")
148
+ File.join(reports_dir, "mutation-coverage", mutant_id.to_s)
149
+ end
150
+
151
+ def stdout_stream
152
+ @stdout_stream ||= IO.for_fd(1)
153
+ end
154
+
155
+ def stderr_stream
156
+ @stderr_stream ||= IO.for_fd(2)
157
+ end
158
+ end
159
+ end
160
+ end