henitai 0.2.1 → 0.3.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +125 -3
  4. data/assets/schema/henitai.schema.json +35 -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 +14 -8
  10. data/lib/henitai/cli/command_support.rb +2 -1
  11. data/lib/henitai/cli/operator_command.rb +2 -1
  12. data/lib/henitai/cli/options.rb +21 -57
  13. data/lib/henitai/cli/run_command.rb +36 -3
  14. data/lib/henitai/cli/run_options.rb +108 -0
  15. data/lib/henitai/cli.rb +12 -4
  16. data/lib/henitai/composite_progress_reporter.rb +42 -0
  17. data/lib/henitai/configuration.rb +41 -9
  18. data/lib/henitai/configuration_validator/rules.rb +18 -0
  19. data/lib/henitai/configuration_validator/scalars.rb +46 -0
  20. data/lib/henitai/configuration_validator.rb +8 -1
  21. data/lib/henitai/coverage_bootstrapper.rb +54 -5
  22. data/lib/henitai/coverage_formatter.rb +5 -1
  23. data/lib/henitai/coverage_report_reader.rb +28 -5
  24. data/lib/henitai/execution_engine/env_scope.rb +67 -0
  25. data/lib/henitai/execution_engine.rb +111 -50
  26. data/lib/henitai/generated_artifacts.rb +58 -0
  27. data/lib/henitai/incremental_filter.rb +100 -0
  28. data/lib/henitai/integration/child_debug_support.rb +6 -2
  29. data/lib/henitai/integration/coverage_suppression.rb +9 -0
  30. data/lib/henitai/integration/minitest.rb +11 -43
  31. data/lib/henitai/integration/minitest_load_path.rb +14 -0
  32. data/lib/henitai/integration/minitest_suite_command.rb +17 -0
  33. data/lib/henitai/integration/minitest_test_runner.rb +39 -0
  34. data/lib/henitai/integration/rails_environment_preloader.rb +14 -0
  35. data/lib/henitai/integration/rspec_child_runner.rb +3 -3
  36. data/lib/henitai/integration/rspec_test_selection.rb +3 -0
  37. data/lib/henitai/integration/scenario_log_support.rb +67 -20
  38. data/lib/henitai/minitest_coverage_reporter.rb +4 -1
  39. data/lib/henitai/mutant/activator.rb +14 -0
  40. data/lib/henitai/mutant.rb +13 -6
  41. data/lib/henitai/mutant_history_store/sql.rb +21 -3
  42. data/lib/henitai/mutant_history_store/verdict_cache.rb +84 -0
  43. data/lib/henitai/mutant_history_store.rb +62 -12
  44. data/lib/henitai/mutant_identity.rb +33 -2
  45. data/lib/henitai/mutation_skip_directives.rb +227 -0
  46. data/lib/henitai/operator.rb +3 -1
  47. data/lib/henitai/operators/equality_identity_operator.rb +51 -0
  48. data/lib/henitai/operators/equality_operator.rb +8 -3
  49. data/lib/henitai/operators.rb +1 -0
  50. data/lib/henitai/per_test_coverage.rb +81 -0
  51. data/lib/henitai/per_test_coverage_collector.rb +20 -5
  52. data/lib/henitai/per_test_coverage_selector.rb +11 -33
  53. data/lib/henitai/reporter/dashboard_metadata_provider.rb +113 -0
  54. data/lib/henitai/reporter.rb +248 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +75 -10
  57. data/lib/henitai/runner.rb +102 -43
  58. data/lib/henitai/scenario_execution_result.rb +12 -0
  59. data/lib/henitai/slot_scheduler/draining.rb +21 -15
  60. data/lib/henitai/slot_scheduler.rb +73 -15
  61. data/lib/henitai/static_filter.rb +16 -6
  62. data/lib/henitai/survivor_rerun_strategy.rb +1 -1
  63. data/lib/henitai/survivor_test_filter.rb +1 -1
  64. data/lib/henitai/test_prioritizer.rb +28 -2
  65. data/lib/henitai/timeout_calibrator.rb +44 -0
  66. data/lib/henitai/verdict_fingerprint.rb +155 -0
  67. data/lib/henitai/version.rb +1 -1
  68. data/lib/henitai.rb +14 -1
  69. data/sig/henitai.rbs +235 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -1,12 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ require_relative "verdict_fingerprint"
7
+
3
8
  module Henitai
4
9
  # Ensures coverage data exists before the mutation pipeline starts.
5
10
  class CoverageBootstrapper
11
+ # Sidecar recording which dependency files existed when the coverage
12
+ # artifacts were produced. Deletions drop a path from the current set but
13
+ # leave every surviving file's mtime untouched, so freshness must compare
14
+ # the recorded path set, not just watch existing files.
15
+ DEPENDENCY_MANIFEST_FILE = "henitai_dependency_manifest.json"
16
+
6
17
  def initialize(static_filter: StaticFilter.new)
7
18
  @static_filter = static_filter
8
19
  end
9
20
 
21
+ # Writes the current dependency path set next to the coverage artifacts.
22
+ # Called after every bootstrap; exposed so tests can seed a manifest.
23
+ def record_dependency_manifest(config)
24
+ FileUtils.mkdir_p(reports_dir(config))
25
+ File.write(dependency_manifest_path(config), JSON.generate(current_dependency_paths))
26
+ end
27
+
10
28
  # Runs the test suite to collect coverage, unless a fresh report already
11
29
  # exists.
12
30
  #
@@ -62,14 +80,38 @@ module Henitai
62
80
  Array(source_files).map { |path| File.expand_path(path) }
63
81
  end
64
82
 
65
- # Returns true when a coverage report already exists and is newer than
66
- # every watched source and test file. Stale or absent reports return false.
83
+ # Returns true when a coverage report already exists, is newer than every
84
+ # watched source and test file, and the dependency path set is unchanged
85
+ # since the report was produced. Stale or absent reports return false.
67
86
  def coverage_fresh?(source_files, config, test_files)
68
87
  watched_files_fresh?(
69
88
  coverage_report_path(config),
70
89
  source_files,
71
90
  test_files
72
- )
91
+ ) && dependency_manifest_current?(config)
92
+ end
93
+
94
+ # False when the manifest is missing, unreadable, or lists a different
95
+ # path set than the files currently on disk — conservative: a deleted
96
+ # dependency invalidates the coverage artifacts just like an edit.
97
+ def dependency_manifest_current?(config)
98
+ recorded = JSON.parse(File.read(dependency_manifest_path(config)))
99
+ recorded == current_dependency_paths
100
+ rescue StandardError
101
+ false
102
+ end
103
+
104
+ def dependency_manifest_path(config)
105
+ File.join(reports_dir(config), DEPENDENCY_MANIFEST_FILE)
106
+ end
107
+
108
+ # Paths stored relative to the working directory so the manifest survives
109
+ # a repository move.
110
+ def current_dependency_paths
111
+ root = Dir.pwd
112
+ VerdictFingerprint.dependency_files(root).map do |path|
113
+ path.delete_prefix("#{root}#{File::SEPARATOR}")
114
+ end
73
115
  end
74
116
 
75
117
  def coverage_report_path(config)
@@ -86,7 +128,10 @@ module Henitai
86
128
  with_reports_dir(config) do
87
129
  with_coverage_dir(config) do
88
130
  result = integration.run_suite(test_files)
89
- return if result == :survived
131
+ if result == :survived
132
+ record_dependency_manifest(config)
133
+ return
134
+ end
90
135
 
91
136
  raise CoverageError, build_bootstrap_error(result)
92
137
  end
@@ -172,8 +217,12 @@ module Henitai
172
217
  end
173
218
  end
174
219
 
220
+ # Dependency files (helpers, support, fixtures, lockfile, tool config)
221
+ # are watched alongside sources and tests: they shape which tests cover
222
+ # what, so a stale per-test map after a dependency edit would let the
223
+ # test selector omit a newly covering test (ADR-11).
175
224
  def watched_files(source_files, test_files)
176
- Array(source_files) + Array(test_files)
225
+ Array(source_files) + Array(test_files) + VerdictFingerprint.dependency_files
177
226
  end
178
227
 
179
228
  def resolve_test_files(integration, test_files)
@@ -13,7 +13,11 @@ module Henitai
13
13
  end
14
14
 
15
15
  def example_finished(notification)
16
- @collector.record_test(notification.example.metadata[:file_path])
16
+ example = notification.example
17
+ @collector.record_test(
18
+ example.metadata[:file_path],
19
+ duration: example.execution_result.run_time
20
+ )
17
21
  end
18
22
 
19
23
  def dump_summary(_summary)
@@ -22,17 +22,40 @@ module Henitai
22
22
  end
23
23
 
24
24
  def test_lines_by_file(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
25
+ per_test_report(path).transform_values do |entry|
26
+ normalize_test_coverage(unwrap_coverage(entry))
27
+ end
28
+ end
29
+
30
+ # Wall-clock seconds per test file, from reports whose entries carry a
31
+ # "duration" field. Legacy reports (plain source-map entries) yield {}.
32
+ def durations_by_test(path = DEFAULT_PER_TEST_COVERAGE_REPORT_PATH)
33
+ per_test_report(path).filter_map do |test_file, entry|
34
+ next unless wrapped_entry?(entry) && entry.key?("duration")
35
+
36
+ [test_file, entry.fetch("duration").to_f]
37
+ end.to_h
38
+ end
39
+
40
+ private
41
+
42
+ def per_test_report(path)
25
43
  return {} unless File.exist?(path)
26
44
 
27
45
  parsed = JSON.parse(File.read(path))
28
- return {} unless parsed.is_a?(Hash)
46
+ parsed.is_a?(Hash) ? parsed : {}
47
+ end
29
48
 
30
- parsed.transform_values do |coverage|
31
- normalize_test_coverage(coverage)
32
- end
49
+ # New-format entries wrap the source map under "coverage" (with a sibling
50
+ # "duration"); legacy entries are the source map itself. Source-map keys
51
+ # are absolute file paths, so a plain "coverage" key is unambiguous.
52
+ def wrapped_entry?(entry)
53
+ entry.is_a?(Hash) && entry["coverage"].is_a?(Hash)
33
54
  end
34
55
 
35
- private
56
+ def unwrap_coverage(entry)
57
+ wrapped_entry?(entry) ? entry.fetch("coverage") : entry
58
+ end
36
59
 
37
60
  def covered_lines(file_coverage)
38
61
  Array(file_coverage["lines"]).each_with_index.filter_map do |count, index|
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class ExecutionEngine
5
+ # Run-scoped ENV/working-directory setup for {ExecutionEngine}: exposes the
6
+ # reports dir, mutation-coverage dir, per-stream log cap and worker-slot
7
+ # index to forked children via ENV, restoring the prior values afterwards.
8
+ # Extracted so the engine class stays focused on execution scheduling.
9
+ module EnvScope
10
+ private
11
+
12
+ def with_reports_dir(config)
13
+ original = ENV.fetch("HENITAI_REPORTS_DIR", nil)
14
+ ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
15
+ yield
16
+ ensure
17
+ restore_env("HENITAI_REPORTS_DIR", original)
18
+ end
19
+
20
+ def with_coverage_dir(config)
21
+ original = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
22
+ ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
23
+ yield
24
+ ensure
25
+ restore_env("HENITAI_COVERAGE_DIR", original)
26
+ end
27
+
28
+ def with_max_log_bytes(config)
29
+ cap = config.respond_to?(:max_log_bytes) ? config.max_log_bytes : nil
30
+ return yield if cap.nil?
31
+
32
+ env_key = Integration::ScenarioLogSupport::MAX_LOG_BYTES_ENV
33
+ original = ENV.fetch(env_key, nil)
34
+ ENV[env_key] = cap.to_s
35
+ yield
36
+ ensure
37
+ restore_env(env_key, original) if cap
38
+ end
39
+
40
+ # Linear-path children inherit slot 0 so suite-side isolation code works
41
+ # identically in both execution modes; the parallel scheduler overwrites
42
+ # the value per spawn. Restored afterwards like the other run-scoped vars.
43
+ def with_worker_slot
44
+ original = ENV.fetch(SlotScheduler::WORKER_SLOT_ENV, nil)
45
+ ENV[SlotScheduler::WORKER_SLOT_ENV] = "0"
46
+ yield
47
+ ensure
48
+ restore_env(SlotScheduler::WORKER_SLOT_ENV, original)
49
+ end
50
+
51
+ def restore_env(key, original)
52
+ if original.nil?
53
+ ENV.delete(key)
54
+ else
55
+ ENV[key] = original
56
+ end
57
+ end
58
+
59
+ def mutation_coverage_dir(config)
60
+ base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
61
+ base_dir = "reports" if base_dir.nil? || base_dir.empty?
62
+
63
+ File.join(base_dir, "mutation-coverage")
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,31 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "parallel_execution_runner"
4
3
  require_relative "process_worker_runner"
4
+ require_relative "execution_engine/env_scope"
5
5
 
6
6
  module Henitai
7
7
  # Runs pending mutants through the selected integration.
8
8
  class ExecutionEngine
9
+ include EnvScope
10
+
9
11
  def run(mutants, integration, config, progress_reporter: nil)
10
12
  with_reports_dir(config) do
11
13
  with_coverage_dir(config) do
12
- @flaky_retry_count = 0
13
- pending_mutants = Array(mutants).select(&:pending?)
14
- mutex = Mutex.new
15
- if parallel_execution?(config, pending_mutants)
16
- run_parallel(pending_mutants, integration, config, progress_reporter)
17
- else
18
- run_linear(pending_mutants, integration, config, progress_reporter, mutex)
14
+ with_max_log_bytes(config) do
15
+ with_worker_slot do
16
+ execute(mutants, integration, config, progress_reporter)
17
+ end
19
18
  end
20
-
21
- warn_flaky_mutants(pending_mutants.size)
22
- mutants
23
19
  end
24
20
  end
25
21
  end
26
22
 
27
23
  private
28
24
 
25
+ def execute(mutants, integration, config, progress_reporter)
26
+ @flaky_retry_count = 0
27
+ pending_mutants = Array(mutants).select(&:pending?)
28
+ mutex = Mutex.new
29
+ if parallel_execution?(config, pending_mutants)
30
+ run_parallel(pending_mutants, integration, config, progress_reporter)
31
+ else
32
+ run_linear(pending_mutants, integration, config, progress_reporter, mutex)
33
+ end
34
+
35
+ warn_flaky_mutants(pending_mutants.size)
36
+ mutants
37
+ end
38
+
29
39
  def parallel_execution?(config, mutants)
30
40
  worker_count(config) > 1 && mutants.size > 1
31
41
  end
@@ -53,7 +63,8 @@ module Henitai
53
63
  integration,
54
64
  config,
55
65
  progress_reporter,
56
- test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) }
66
+ test_file_resolver: ->(mutant) { prioritized_tests_for(mutant, integration, config) },
67
+ timeout_resolver: ->(_mutant, test_files) { resolved_timeout(test_files, config) }
57
68
  )
58
69
  @flaky_retry_count = runner.flaky_retry_count
59
70
  results
@@ -61,11 +72,26 @@ module Henitai
61
72
 
62
73
  def process_mutant(mutant, integration, config, progress_reporter, mutex)
63
74
  test_files = prioritized_tests_for(mutant, integration, config)
64
- mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
65
- mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
75
+ record_test_files(mutant, test_files)
76
+ return record_no_coverage(mutant, progress_reporter, mutex) if test_files.empty?
77
+
66
78
  scenario_result = run_with_flaky_retry(mutant, integration, config, test_files, mutex)
67
79
  mutant.status = scenario_status(scenario_result)
68
80
 
81
+ report_progress(mutant, scenario_result, progress_reporter, mutex)
82
+ end
83
+
84
+ def record_test_files(mutant, test_files)
85
+ mutant.covered_by = test_files if mutant.respond_to?(:covered_by=)
86
+ mutant.tests_completed = test_files.size if mutant.respond_to?(:tests_completed=)
87
+ end
88
+
89
+ def record_no_coverage(mutant, progress_reporter, mutex)
90
+ mutant.status = :no_coverage
91
+ report_progress(mutant, nil, progress_reporter, mutex)
92
+ end
93
+
94
+ def report_progress(mutant, scenario_result, progress_reporter, mutex)
69
95
  if mutex
70
96
  mutex.synchronize { progress_reporter&.progress(mutant, scenario_result:) }
71
97
  else
@@ -74,16 +100,81 @@ module Henitai
74
100
  end
75
101
 
76
102
  def prioritized_tests_for(mutant, integration, config)
77
- tests = integration.select_tests(mutant.subject)
103
+ tests = reject_excluded_tests(integration.select_tests(mutant.subject), config)
78
104
  tests = per_test_coverage_selector.filter(
79
105
  tests,
80
106
  mutant,
81
107
  reports_dir: config.reports_dir
82
108
  )
83
- test_prioritizer.sort(tests, mutant, test_history(config))
109
+ test_prioritizer(config).sort(tests, mutant, test_history(config))
84
110
  end
85
111
 
86
- def test_prioritizer = @test_prioritizer ||= TestPrioritizer.new
112
+ # Drops test files matching any config.test_excludes glob. Used to keep a
113
+ # mutant child from re-running tests that themselves spawn henitai/forked
114
+ # subprocesses (e.g. the CLI and process-scheduler specs when dogfooding
115
+ # henitai on itself), which otherwise multiplies processes and log noise.
116
+ def reject_excluded_tests(tests, config)
117
+ patterns = config.respond_to?(:test_excludes) ? Array(config.test_excludes) : []
118
+ return tests if patterns.empty?
119
+
120
+ expanded = patterns.map { |pattern| File.expand_path(pattern) }
121
+ tests.reject do |path|
122
+ candidate = File.expand_path(path)
123
+ expanded.any? { |pattern| File.fnmatch?(pattern, candidate, File::FNM_PATHNAME) }
124
+ end
125
+ end
126
+
127
+ def test_prioritizer(config)
128
+ @test_prioritizer ||= TestPrioritizer.new(timing_source: timing_source(config))
129
+ end
130
+
131
+ def timing_source(config)
132
+ path = File.join(config.reports_dir, PerTestCoverageCollector::REPORT_FILE_NAME)
133
+ -> { CoverageReportReader.new.durations_by_test(path) }
134
+ end
135
+
136
+ # Fixed `mutation.timeout` wins untouched; when unset, the timeout is
137
+ # calibrated per mutant from its selected tests' measured durations, with
138
+ # a single warning per run when no timing data is available.
139
+ def resolved_timeout(test_files, config)
140
+ return config.timeout unless calibration_enabled?(config)
141
+
142
+ calibrated = timeout_calibrator(config).timeout_for(test_files)
143
+ return [calibrated, max_timeout(config)].min if calibrated
144
+
145
+ warn_timeout_fallback_once
146
+ Configuration::DEFAULT_TIMEOUT
147
+ end
148
+
149
+ # Ceiling on the auto-calibrated timeout so a runaway mutant is killed in
150
+ # seconds instead of running for minutes when the calibrated value (derived
151
+ # from a slow baseline) is large. A fixed mutation.timeout bypasses this.
152
+ def max_timeout(config)
153
+ return config.max_timeout if config.respond_to?(:max_timeout) && config.max_timeout
154
+
155
+ Configuration::DEFAULT_MAX_TIMEOUT
156
+ end
157
+
158
+ def calibration_enabled?(config)
159
+ config.respond_to?(:timeout_configured?) && !config.timeout_configured?
160
+ end
161
+
162
+ def timeout_calibrator(config)
163
+ @timeout_calibrator ||= TimeoutCalibrator.new(
164
+ timing_source: timing_source(config),
165
+ multiplier: config.timeout_multiplier
166
+ )
167
+ end
168
+
169
+ def warn_timeout_fallback_once
170
+ return if @warned_timeout_fallback
171
+
172
+ @warned_timeout_fallback = true
173
+ warn(
174
+ "Timeout calibration unavailable (no per-test timing data); " \
175
+ "falling back to the default timeout of #{Configuration::DEFAULT_TIMEOUT}s"
176
+ )
177
+ end
87
178
 
88
179
  def per_test_coverage_selector = @per_test_coverage_selector ||= PerTestCoverageSelector.new
89
180
 
@@ -98,10 +189,11 @@ module Henitai
98
189
  # runtime on real CI workloads.
99
190
  # rubocop:disable Metrics/MethodLength
100
191
  def run_with_flaky_retry(mutant, integration, config, test_files, mutex)
192
+ timeout = resolved_timeout(test_files, config)
101
193
  scenario_result = integration.run_mutant(
102
194
  mutant:,
103
195
  test_files:,
104
- timeout: config.timeout
196
+ timeout: timeout
105
197
  )
106
198
  return scenario_result unless scenario_status(scenario_result) == :survived
107
199
 
@@ -111,7 +203,7 @@ module Henitai
111
203
  scenario_result = integration.run_mutant(
112
204
  mutant:,
113
205
  test_files:,
114
- timeout: config.timeout
206
+ timeout: timeout
115
207
  )
116
208
  break unless scenario_status(scenario_result) == :survived
117
209
  end
@@ -141,37 +233,6 @@ module Henitai
141
233
  )
142
234
  end
143
235
 
144
- def with_reports_dir(config)
145
- original_reports_dir = ENV.fetch("HENITAI_REPORTS_DIR", nil)
146
- ENV["HENITAI_REPORTS_DIR"] = config.reports_dir
147
- yield
148
- ensure
149
- if original_reports_dir.nil?
150
- ENV.delete("HENITAI_REPORTS_DIR")
151
- else
152
- ENV["HENITAI_REPORTS_DIR"] = original_reports_dir
153
- end
154
- end
155
-
156
- def with_coverage_dir(config)
157
- original_coverage_dir = ENV.fetch("HENITAI_COVERAGE_DIR", nil)
158
- ENV["HENITAI_COVERAGE_DIR"] = mutation_coverage_dir(config)
159
- yield
160
- ensure
161
- if original_coverage_dir.nil?
162
- ENV.delete("HENITAI_COVERAGE_DIR")
163
- else
164
- ENV["HENITAI_COVERAGE_DIR"] = original_coverage_dir
165
- end
166
- end
167
-
168
- def mutation_coverage_dir(config)
169
- base_dir = config.respond_to?(:reports_dir) ? config.reports_dir : nil
170
- base_dir = "reports" if base_dir.nil? || base_dir.empty?
171
-
172
- File.join(base_dir, "mutation-coverage")
173
- end
174
-
175
236
  def max_flaky_retries(config)
176
237
  return 3 unless config.respond_to?(:max_flaky_retries)
177
238
 
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Recognizes directories holding henitai/SimpleCov *output* so dependency
5
+ # scans can skip them. Detection requires artifact evidence — known output
6
+ # files inside the directory — not just a conventional name: a directory
7
+ # merely named reports/ or coverage/ may hold legitimate fixture input,
8
+ # and wrongly excluding a real dependency would let an obsolete Survived
9
+ # verdict be reused (unsound). Wrongly including junk merely re-runs.
10
+ module GeneratedArtifacts
11
+ # Evidence that a coverage/ directory is SimpleCov output.
12
+ COVERAGE_MARKERS = %w[.resultset.json .last_run.json].freeze
13
+
14
+ # Evidence that a reports/ directory is a henitai reports_dir.
15
+ REPORT_MARKERS = %w[
16
+ mutation-report.json
17
+ mutation-report.html
18
+ mutation-history.sqlite3
19
+ mutation-history.json
20
+ henitai_per_test.json
21
+ henitai_dependency_manifest.json
22
+ .henitai-run.lock
23
+ mutation-logs
24
+ mutation-coverage
25
+ ].freeze
26
+
27
+ module_function
28
+
29
+ # True when the directory carries a generated-artifact name AND contains
30
+ # evidence of actual output. Unreadable directories count as evidence-
31
+ # free — they stay in the scan (conservative direction).
32
+ def generated_dir?(path)
33
+ case File.basename(path)
34
+ when "coverage" then evidence?(path, COVERAGE_MARKERS)
35
+ when "reports" then evidence?(path, REPORT_MARKERS)
36
+ when "mutation-logs", "mutation-coverage" then per_mutant_evidence?(path)
37
+ else false
38
+ end
39
+ end
40
+
41
+ # Dir.each_child returns an Enumerator, which has no #intersect? —
42
+ # Style/ArrayIntersect's autocorrect would produce a NoMethodError here.
43
+ def evidence?(dir, markers)
44
+ Dir.each_child(dir).any? { |entry| markers.include?(entry) } # rubocop:disable Style/ArrayIntersect
45
+ rescue SystemCallError
46
+ false
47
+ end
48
+
49
+ # Per-mutant scratch trees hold one entry per mutant (or baseline);
50
+ # checked lazily — these directories can contain tens of thousands of
51
+ # files, so never materialize the full listing.
52
+ def per_mutant_evidence?(dir)
53
+ Dir.each_child(dir).any? { |entry| entry.start_with?("mutant-", "baseline") }
54
+ rescue SystemCallError
55
+ false
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "verdict_fingerprint"
4
+
5
+ module Henitai
6
+ # Marks mutants whose verdict from a prior run is still valid so
7
+ # `--incremental` runs skip re-executing them (Gate 3.5, opt-in).
8
+ #
9
+ # Reuse rules (conservative — any doubt resolves to re-execution):
10
+ #
11
+ # * Killed: the stored verdict must carry both fingerprints, and both the
12
+ # subject's source and every recorded covering test file must be
13
+ # byte-identical to what was recorded. Killed is monotone, so no live
14
+ # coverage is needed.
15
+ # * Survived: additionally the LIVE covering set (the full-map
16
+ # intersection computed via {PerTestCoverage}) must equal the recorded
17
+ # set in membership, and the run-level dependency fingerprint must be
18
+ # unchanged — a new or edited test reaching the mutant always forces a
19
+ # re-run (ADR-11). Without a live per-test map or dependency
20
+ # fingerprint, survivors always re-execute.
21
+ #
22
+ # Timeouts, errors, unknown ids and legacy rows always re-execute.
23
+ class IncrementalFilter
24
+ def initialize(history_store:, per_test_coverage: nil, dependency_fingerprint: nil)
25
+ @history_store = history_store
26
+ @per_test_coverage = per_test_coverage
27
+ @dependency_fingerprint = dependency_fingerprint
28
+ end
29
+
30
+ # @return [Array<Mutant>] the same collection; cache hits get the stored
31
+ # status (:killed or :survived) and from_cache = true.
32
+ def apply(mutants)
33
+ collection = Array(mutants)
34
+ ambiguous = ambiguous_stable_ids(collection)
35
+ collection.each do |mutant|
36
+ next unless mutant.pending?
37
+ next if ambiguous.include?(mutant.stable_id)
38
+
39
+ status = reusable_status(mutant)
40
+ next unless status
41
+
42
+ mutant.status = status
43
+ mutant.from_cache = true if mutant.respond_to?(:from_cache=)
44
+ end
45
+
46
+ mutants
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :history_store, :per_test_coverage, :dependency_fingerprint
52
+
53
+ # MutantIdentity deliberately omits source coordinates (line-drift
54
+ # tolerance), so distinct mutants inside one subject can share a stable
55
+ # id. A shared id makes the stored verdict ambiguous — one colliding
56
+ # mutant may have been killed while another errored — so reuse is skipped
57
+ # for every mutant whose id appears more than once in this run.
58
+ def ambiguous_stable_ids(mutants)
59
+ mutants.group_by(&:stable_id).filter_map do |stable_id, group|
60
+ stable_id if group.size > 1
61
+ end.to_set
62
+ end
63
+
64
+ def reusable_status(mutant)
65
+ verdict = history_store.verdict_for(mutant.stable_id)
66
+ return nil unless verdict
67
+
68
+ case verdict.fetch(:status)
69
+ when :killed
70
+ :killed if killed_reusable?(mutant, verdict)
71
+ when :survived
72
+ :survived if survived_reusable?(mutant, verdict)
73
+ end
74
+ end
75
+
76
+ def killed_reusable?(mutant, verdict)
77
+ subject_source_unchanged?(mutant, verdict) &&
78
+ VerdictFingerprint.tests_fingerprint_current?(verdict.fetch(:covered_tests_fingerprint))
79
+ end
80
+
81
+ # The filter checks per-test map availability itself instead of trusting
82
+ # the bootstrap's readiness skip: integrations without per-test support
83
+ # or a missing/empty map mean the live covering set is unknowable, and
84
+ # unknowable means re-execute.
85
+ def survived_reusable?(mutant, verdict)
86
+ return false unless per_test_coverage&.available?
87
+ return false unless subject_source_unchanged?(mutant, verdict)
88
+
89
+ VerdictFingerprint.survivor_fingerprint_current?(
90
+ verdict.fetch(:covered_tests_fingerprint),
91
+ live_paths: per_test_coverage.tests_covering(mutant),
92
+ dependency_sha: dependency_fingerprint
93
+ )
94
+ end
95
+
96
+ def subject_source_unchanged?(mutant, verdict)
97
+ VerdictFingerprint.subject_source_hash(mutant) == verdict.fetch(:subject_source_hash)
98
+ end
99
+ end
100
+ end
@@ -10,7 +10,11 @@ module Henitai
10
10
 
11
11
  def debug_child? = ENV["HENITAI_DEBUG_CHILD"] == "1"
12
12
 
13
+ # Gated here (not only at call sites) so no unguarded caller can leak
14
+ # debug lines into every child log by default.
13
15
  def debug_child_puts(message)
16
+ return unless debug_child?
17
+
14
18
  $stdout.puts(message)
15
19
  $stdout.flush
16
20
  end
@@ -79,7 +83,7 @@ module Henitai
79
83
 
80
84
  def debug_child_activation_check
81
85
  location = begin
82
- Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":")
86
+ Henitai::Runner.instance_method(:resolve_subjects).source_location&.join(":") # henitai:disable
83
87
  rescue StandardError
84
88
  nil
85
89
  end
@@ -105,7 +109,7 @@ module Henitai
105
109
  end
106
110
 
107
111
  def rspec_world_example_count # steep:ignore Ruby::UndeclaredMethodDefinition
108
- world = ::RSpec.__send__(:world)
112
+ world = ::RSpec.world
109
113
  world.example_count
110
114
  rescue StandardError
111
115
  nil
@@ -18,7 +18,15 @@ module Henitai
18
18
  # mutant child runs. Coverage artifacts are only required during the
19
19
  # dedicated bootstrap phase.
20
20
  module CoverageRuntimeSuppressors
21
+ # True once coverage startup has been suppressed in this process —
22
+ # consumers (e.g. PerTestCoverageCollector) can then treat missing
23
+ # coverage as expected instead of warning about it.
24
+ def self.active? = @active ? true : false
25
+
26
+ def self.activate! = @active = true
27
+
21
28
  def self.suppress_simplecov!
29
+ activate!
22
30
  require "simplecov"
23
31
  sc = Object.const_get(:SimpleCov) # steep:ignore Ruby::UnknownConstant
24
32
  sc.external_at_exit = true if sc.respond_to?(:external_at_exit=)
@@ -30,6 +38,7 @@ module Henitai
30
38
  end
31
39
 
32
40
  def self.suppress_coverage!
41
+ activate!
33
42
  require "coverage"
34
43
  cov = Object.const_get(:Coverage) # steep:ignore Ruby::UnknownConstant
35
44
  return if cov.singleton_class.ancestors.include?(CoverageStartSuppressor)