henitai 0.5.1 → 0.6.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +296 -1
  3. data/README.md +109 -16
  4. data/assets/schema/henitai.schema.json +20 -0
  5. data/lib/henitai/arid_node_filter.rb +4 -19
  6. data/lib/henitai/bootstrap_error_message.rb +20 -0
  7. data/lib/henitai/canonical_report_merger.rb +23 -2
  8. data/lib/henitai/cli/run_command.rb +25 -7
  9. data/lib/henitai/cli/run_options.rb +1 -1
  10. data/lib/henitai/cli.rb +4 -1
  11. data/lib/henitai/configuration.rb +4 -1
  12. data/lib/henitai/configuration_validator/rules.rb +8 -0
  13. data/lib/henitai/configuration_validator/scalars.rb +9 -0
  14. data/lib/henitai/configuration_validator.rb +2 -1
  15. data/lib/henitai/constant_path.rb +48 -0
  16. data/lib/henitai/control_run.rb +73 -0
  17. data/lib/henitai/coverage_bootstrapper.rb +57 -15
  18. data/lib/henitai/coverage_formatter.rb +26 -3
  19. data/lib/henitai/coverage_report_reader.rb +21 -0
  20. data/lib/henitai/equivalence_detector/operand_predicates.rb +16 -0
  21. data/lib/henitai/equivalence_detector.rb +46 -38
  22. data/lib/henitai/example_summary.rb +24 -0
  23. data/lib/henitai/execution_engine/fork_server_scope.rb +57 -0
  24. data/lib/henitai/execution_engine.rb +75 -60
  25. data/lib/henitai/fidelity_check.rb +173 -0
  26. data/lib/henitai/flaky_detection_baseline.rb +61 -0
  27. data/lib/henitai/flaky_mutant_retrier.rb +43 -0
  28. data/lib/henitai/flaky_retry_decision.rb +23 -0
  29. data/lib/henitai/flaky_test_registry.rb +63 -0
  30. data/lib/henitai/fork_server/client.rb +231 -0
  31. data/lib/henitai/fork_server/group_killer.rb +48 -0
  32. data/lib/henitai/fork_server/group_terminator.rb +47 -0
  33. data/lib/henitai/fork_server/launcher.rb +38 -0
  34. data/lib/henitai/fork_server/mutant_runner.rb +141 -0
  35. data/lib/henitai/fork_server/protocol.rb +73 -0
  36. data/lib/henitai/fork_server/reaper.rb +24 -0
  37. data/lib/henitai/fork_server/runtime.rb +109 -0
  38. data/lib/henitai/fork_server/server.rb +108 -0
  39. data/lib/henitai/fork_server/server_process.rb +73 -0
  40. data/lib/henitai/fork_server/status.rb +52 -0
  41. data/lib/henitai/fork_server.rb +43 -0
  42. data/lib/henitai/generated_artifacts.rb +3 -0
  43. data/lib/henitai/git_diff_analyzer.rb +28 -11
  44. data/lib/henitai/incremental_filter.rb +20 -10
  45. data/lib/henitai/integration/base.rb +72 -5
  46. data/lib/henitai/integration/capped_log_sink.rb +34 -0
  47. data/lib/henitai/integration/child_bootstrap.rb +7 -0
  48. data/lib/henitai/integration/child_report_store.rb +36 -0
  49. data/lib/henitai/integration/minitest.rb +17 -7
  50. data/lib/henitai/integration/minitest_autorun.rb +20 -0
  51. data/lib/henitai/integration/minitest_outcome_reporter.rb +28 -0
  52. data/lib/henitai/integration/minitest_preload.rb +30 -0
  53. data/lib/henitai/integration/minitest_runnable_filter.rb +61 -0
  54. data/lib/henitai/integration/minitest_test_runner.rb +32 -15
  55. data/lib/henitai/integration/mutant_run_support.rb +73 -13
  56. data/lib/henitai/integration/rspec_child_runner.rb +44 -9
  57. data/lib/henitai/integration/rspec_preload.rb +35 -0
  58. data/lib/henitai/integration/rspec_process_runner.rb +4 -1
  59. data/lib/henitai/integration/scenario_log_support.rb +31 -18
  60. data/lib/henitai/integration.rb +16 -3
  61. data/lib/henitai/minitest_coverage_reporter.rb +22 -6
  62. data/lib/henitai/minitest_result_file.rb +62 -0
  63. data/lib/henitai/mutant/activation.rb +32 -0
  64. data/lib/henitai/mutant/activator.rb +75 -55
  65. data/lib/henitai/mutant/def_injection.rb +136 -0
  66. data/lib/henitai/mutant/lexical_scope_check.rb +120 -0
  67. data/lib/henitai/mutant/lexical_target.rb +105 -0
  68. data/lib/henitai/mutant/replacement.rb +52 -0
  69. data/lib/henitai/mutant.rb +27 -8
  70. data/lib/henitai/mutant_generator.rb +62 -18
  71. data/lib/henitai/mutant_history_store/verdict_cache.rb +12 -11
  72. data/lib/henitai/mutant_history_store.rb +14 -4
  73. data/lib/henitai/mutant_identity.rb +5 -1
  74. data/lib/henitai/mutant_verdict.rb +71 -0
  75. data/lib/henitai/operators/array_declaration.rb +4 -0
  76. data/lib/henitai/operators/assignment_expression.rb +2 -0
  77. data/lib/henitai/operators/conditional_expression.rb +21 -1
  78. data/lib/henitai/operators/hash_literal.rb +7 -4
  79. data/lib/henitai/operators/method_expression.rb +14 -3
  80. data/lib/henitai/operators/regex_metacharacters.rb +89 -0
  81. data/lib/henitai/operators/regex_mutator.rb +23 -28
  82. data/lib/henitai/operators/return_value.rb +1 -1
  83. data/lib/henitai/operators.rb +1 -0
  84. data/lib/henitai/per_test_coverage.rb +11 -7
  85. data/lib/henitai/per_test_coverage_collector.rb +30 -31
  86. data/lib/henitai/per_test_coverage_delta.rb +60 -0
  87. data/lib/henitai/per_test_coverage_selector.rb +26 -13
  88. data/lib/henitai/process_wakeup.rb +8 -3
  89. data/lib/henitai/process_worker_runner.rb +19 -5
  90. data/lib/henitai/reporter.rb +15 -8
  91. data/lib/henitai/result.rb +32 -16
  92. data/lib/henitai/reuse_context.rb +50 -0
  93. data/lib/henitai/rspec_coverage_formatter.rb +4 -0
  94. data/lib/henitai/rspec_outcomes.rb +72 -0
  95. data/lib/henitai/runner.rb +58 -19
  96. data/lib/henitai/runner_dependencies.rb +22 -1
  97. data/lib/henitai/sampling_strategy.rb +36 -6
  98. data/lib/henitai/scenario_execution_result.rb +28 -5
  99. data/lib/henitai/signature_line.rb +21 -0
  100. data/lib/henitai/slot_scheduler/draining.rb +12 -2
  101. data/lib/henitai/slot_scheduler/process_control.rb +33 -0
  102. data/lib/henitai/slot_scheduler/retry_policy.rb +8 -6
  103. data/lib/henitai/slot_scheduler.rb +47 -8
  104. data/lib/henitai/source_file_selection.rb +1 -1
  105. data/lib/henitai/source_parser.rb +29 -9
  106. data/lib/henitai/static_filter.rb +45 -14
  107. data/lib/henitai/stillborn_filter.rb +70 -2
  108. data/lib/henitai/subject.rb +8 -3
  109. data/lib/henitai/subject_resolver.rb +65 -31
  110. data/lib/henitai/suite_boot_clock.rb +48 -0
  111. data/lib/henitai/survivor_activation_cache.rb +18 -11
  112. data/lib/henitai/survivor_rerun_strategy.rb +47 -43
  113. data/lib/henitai/test_file_outcomes.rb +79 -0
  114. data/lib/henitai/timeout_calibrator.rb +31 -9
  115. data/lib/henitai/timeout_ceiling.rb +37 -0
  116. data/lib/henitai/verdict_fingerprint.rb +29 -30
  117. data/lib/henitai/version.rb +1 -1
  118. data/lib/henitai.rb +17 -1
  119. data/sig/configuration_validator.rbs +1 -0
  120. data/sig/henitai.rbs +227 -45
  121. metadata +46 -3
  122. data/lib/henitai/survivor_test_filter.rb +0 -72
@@ -35,12 +35,7 @@ module Henitai
35
35
  # @return [Array<String>] sorted; empty when the test is unknown or the
36
36
  # map is unavailable.
37
37
  def source_files_covered_by(test_file)
38
- wanted = File.expand_path(test_file.to_s)
39
- map.select { |test, _| File.expand_path(test) == wanted }
40
- .values
41
- .flat_map(&:keys)
42
- .uniq
43
- .sort
38
+ Array(map_by_absolute_test_path[File.expand_path(test_file.to_s)]&.keys).sort
44
39
  end
45
40
 
46
41
  # True when the given test file's recorded coverage intersects the
@@ -63,11 +58,20 @@ module Henitai
63
58
  mutant.location[:end_line]
64
59
  end
65
60
 
61
+ # RSpec records test files as "./spec/x_spec.rb" while candidates arrive
62
+ # as "spec/x_spec.rb"; both resolve to the same absolute key.
66
63
  def coverage_lines_for(test, mutant)
67
- source_map = map[test.to_s] || {}
64
+ source_map = map_by_absolute_test_path[File.expand_path(test.to_s)] || {}
68
65
  Array(source_map[File.expand_path(mutant.location[:file])]).uniq
69
66
  end
70
67
 
68
+ def map_by_absolute_test_path
69
+ @map_by_absolute_test_path ||= map.each_with_object({}) do |(test, source_map), index|
70
+ merged = index[File.expand_path(test)] ||= {}
71
+ source_map.each { |file, lines| merged[file] = Array(merged[file]) + Array(lines) }
72
+ end
73
+ end
74
+
71
75
  def mutant_lines(mutant)
72
76
  (mutant.location[:start_line]..mutant.location[:end_line]).to_a
73
77
  end
@@ -3,9 +3,12 @@
3
3
  require "coverage"
4
4
  require "fileutils"
5
5
  require "json"
6
+ require_relative "per_test_coverage_delta"
7
+ require_relative "suite_boot_clock"
6
8
 
7
9
  module Henitai
8
10
  # Accumulates per-test line coverage deltas across test examples.
11
+ # {PerTestCoverageDelta} decides which lines a single test executed.
9
12
  #
10
13
  # Framework-agnostic core used by both the RSpec and Minitest adapters.
11
14
  # Callers invoke record_test after each test completes and write_report
@@ -13,16 +16,25 @@ module Henitai
13
16
  class PerTestCoverageCollector
14
17
  REPORT_DIR_ENV = "HENITAI_REPORTS_DIR"
15
18
  REPORT_FILE_NAME = "henitai_per_test.json"
19
+ BOOT_FILE_NAME = "henitai_suite_boot.json"
16
20
 
17
- def initialize
21
+ # @param boot_clock [SuiteBootClock] measures suite boot time
22
+ def initialize(boot_clock: SuiteBootClock.from_env(ENV))
23
+ @boot_clock = boot_clock
18
24
  @coverage_by_test = Hash.new do |hash, test_file|
19
25
  hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
20
26
  end
21
27
  @duration_by_test = Hash.new(0.0)
22
28
  @previous_snapshot = {}
29
+ @delta = PerTestCoverageDelta.new
23
30
  @warned_missing_coverage = false
24
31
  end
25
32
 
33
+ # Called when the first test of the suite starts; ends the boot phase.
34
+ def test_started
35
+ @boot_clock.mark_first_test_started
36
+ end
37
+
26
38
  def record_test(test_file, duration: nil)
27
39
  snapshot = current_snapshot
28
40
  return warn_missing_coverage unless snapshot
@@ -36,19 +48,34 @@ module Henitai
36
48
  @previous_snapshot = snapshot
37
49
  end
38
50
 
51
+ # Advances past a test whose result cannot be attributed to a file, so its
52
+ # coverage is not credited to the next test.
53
+ def skip_test
54
+ @previous_snapshot = current_snapshot || @previous_snapshot
55
+ end
56
+
39
57
  def write_report
40
58
  return if @coverage_by_test.empty?
41
59
 
42
- FileUtils.mkdir_p(File.dirname(report_path))
60
+ FileUtils.mkdir_p(reports_dir)
43
61
  File.write(report_path, JSON.pretty_generate(serializable_report))
62
+ write_boot_report if @boot_clock.measured?
44
63
  end
45
64
 
46
65
  private
47
66
 
67
+ def write_boot_report
68
+ File.write(boot_path, JSON.pretty_generate("boot_seconds" => @boot_clock.boot_seconds))
69
+ end
70
+
48
71
  def report_path
49
72
  File.join(reports_dir, REPORT_FILE_NAME)
50
73
  end
51
74
 
75
+ def boot_path
76
+ File.join(reports_dir, BOOT_FILE_NAME)
77
+ end
78
+
52
79
  def reports_dir
53
80
  ENV.fetch(REPORT_DIR_ENV, "coverage")
54
81
  end
@@ -81,39 +108,11 @@ module Henitai
81
108
  snapshot.each_with_object({}) do |(source_file, file_coverage), result|
82
109
  next unless source_file?(source_file)
83
110
 
84
- lines = new_line_numbers(
85
- file_coverage,
86
- previous_line_counts(source_file)
87
- )
111
+ lines = @delta.executed_lines(file_coverage, @previous_snapshot.fetch(source_file, []))
88
112
  result[source_file] = lines unless lines.empty?
89
113
  end
90
114
  end
91
115
 
92
- # Count-delta attribution: a line belongs to every test whose run
93
- # incremented its cumulative count, not just the first toucher. This keeps
94
- # the per-test map a complete over-approximation of reachability — the
95
- # soundness precondition for survivor verdict reuse (ADR-11).
96
- def new_line_numbers(file_coverage, previous_counts)
97
- line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
98
- next unless count.to_i > previous_counts.fetch(index, 0).to_i
99
-
100
- index + 1
101
- end
102
- end
103
-
104
- def previous_line_counts(source_file)
105
- line_counts_for(@previous_snapshot.fetch(source_file, []))
106
- end
107
-
108
- def line_counts_for(file_coverage)
109
- case file_coverage
110
- when Hash
111
- Array(file_coverage[:lines] || file_coverage["lines"])
112
- else
113
- Array(file_coverage)
114
- end
115
- end
116
-
117
116
  def source_file?(path)
118
117
  expanded = File.expand_path(path)
119
118
  prefix = "#{Dir.pwd}#{File::SEPARATOR}"
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Computes which source lines a test executed in one file, from two
5
+ # cumulative +Coverage.peek_result+ entries (before and after the test).
6
+ #
7
+ # Line coverage alone misses endless methods (+def x = expr+): calling
8
+ # one never increments any line count, so their body lines would only be
9
+ # attributed to the test that loaded the file. When method coverage is
10
+ # available, every called method also contributes its +def+ line and the
11
+ # lines inside its range that line coverage does not track.
12
+ class PerTestCoverageDelta
13
+ def executed_lines(current, previous)
14
+ (counted_lines(current, previous) | called_method_lines(current, previous)).sort
15
+ end
16
+
17
+ private
18
+
19
+ # Count-delta attribution: a line belongs to every test whose run
20
+ # incremented its cumulative count, not just the first toucher. This keeps
21
+ # the per-test map a complete over-approximation of reachability — the
22
+ # soundness precondition for survivor verdict reuse (ADR-11).
23
+ def counted_lines(current, previous)
24
+ previous_counts = line_counts(previous)
25
+ line_counts(current).each_with_index.filter_map do |count, index|
26
+ index + 1 if count.to_i > previous_counts.fetch(index, 0).to_i
27
+ end
28
+ end
29
+
30
+ def called_method_lines(current, previous)
31
+ counts = line_counts(current)
32
+ previous_calls = method_counts(previous)
33
+ method_counts(current).flat_map do |key, calls|
34
+ next [] unless calls.to_i > previous_calls.fetch(key, 0).to_i
35
+
36
+ untracked_method_lines(key, counts)
37
+ end
38
+ end
39
+
40
+ def untracked_method_lines(key, counts)
41
+ first_line = key[2]
42
+ last_line = key[4]
43
+ (first_line..last_line).select do |line|
44
+ line == first_line || counts[line - 1].nil?
45
+ end
46
+ end
47
+
48
+ def line_counts(file_coverage)
49
+ return Array(file_coverage) unless file_coverage.is_a?(Hash)
50
+
51
+ Array(file_coverage[:lines] || file_coverage["lines"])
52
+ end
53
+
54
+ def method_counts(file_coverage)
55
+ return {} unless file_coverage.is_a?(Hash)
56
+
57
+ file_coverage[:methods] || file_coverage["methods"] || {}
58
+ end
59
+ end
60
+ end
@@ -1,30 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Henitai
4
- # Narrows candidate test files using the per-test coverage report.
4
+ # Selects the test files to run for a mutant, coverage first.
5
5
  #
6
- # The intersection check itself lives in {PerTestCoverage}; this class only
7
- # adds the selection policy: when no candidate provably covers the mutant
8
- # (or the map / location is unavailable), it falls back to all candidates —
9
- # over-selection costs time, never correctness.
6
+ # The per-test coverage map is the primary source: every integration test
7
+ # file that provably reaches the mutant's lines is selected, whether or not
8
+ # its name matches the subject. Name matching ({fallback}) is only consulted
9
+ # when the map cannot decide — no map, no location, no covering test in the
10
+ # map, or no covering test left in the integration. Over-selection costs
11
+ # time; a missed covering test would produce a false survivor. Mutants on a
12
+ # method's def line additionally take the name-matched tests, because that
13
+ # line's coverage is only a load-time record.
14
+ #
15
+ # `test_files` and `fallback` are lambdas so that expensive name matching
16
+ # and file discovery run only when they are needed.
10
17
  class PerTestCoverageSelector
11
18
  def initialize(coverage_report_reader: CoverageReportReader.new)
12
19
  @coverage_report_reader = coverage_report_reader
13
20
  end
14
21
 
15
- def filter(tests, mutant, reports_dir:)
16
- candidates = Array(tests)
17
- return candidates if candidates.empty?
22
+ def select(mutant, reports_dir:, test_files:, fallback:)
23
+ covering = per_test_coverage(reports_dir).tests_covering(mutant)
24
+ return fallback.call if covering.empty?
18
25
 
19
- coverage = per_test_coverage(reports_dir)
20
- covered_tests = candidates.select do |test|
21
- coverage.covers?(test, mutant)
22
- end
23
- covered_tests.empty? ? candidates : covered_tests
26
+ covered = covered_integration_tests(test_files.call, covering)
27
+ return fallback.call if covered.empty?
28
+
29
+ SignatureLine.on?(mutant) ? covered | fallback.call : covered
24
30
  end
25
31
 
26
32
  private
27
33
 
34
+ def covered_integration_tests(integration_tests, covering)
35
+ covering_paths = covering.map { |test| File.expand_path(test) }
36
+ Array(integration_tests).select do |test|
37
+ covering_paths.include?(File.expand_path(test))
38
+ end
39
+ end
40
+
28
41
  def per_test_coverage(reports_dir)
29
42
  @per_test_coverage ||= {}
30
43
  @per_test_coverage[reports_dir] ||= PerTestCoverage.new(
@@ -13,10 +13,15 @@ module Henitai
13
13
  self
14
14
  end
15
15
 
16
+ # Also wake on these sockets; +provider+ is asked for them on every wait,
17
+ # so a connection that was lost can drop out of the set.
18
+ def watch(&provider)
19
+ @watched = provider
20
+ self
21
+ end
22
+
16
23
  def wait(timeout)
17
- # rubocop:disable Lint/IncompatibleIoSelectWithFiberScheduler
18
- IO.select([reader], nil, nil, timeout)
19
- # rubocop:enable Lint/IncompatibleIoSelectWithFiberScheduler
24
+ IO.select([reader, *@watched&.call], nil, nil, timeout)
20
25
  rescue Errno::EINTR
21
26
  nil
22
27
  end
@@ -27,6 +27,10 @@ module Henitai
27
27
  Process.wait(pid)
28
28
  end
29
29
 
30
+ def pause(seconds)
31
+ Kernel.sleep(seconds)
32
+ end
33
+
30
34
  def trap(signal, handler = nil, &block)
31
35
  Kernel.trap(signal, handler || block)
32
36
  end
@@ -35,10 +39,11 @@ module Henitai
35
39
  # Loop primitives the {SlotScheduler} reads back from its host.
36
40
  attr_reader :worker_count, :runtime, :wakeup
37
41
 
38
- def initialize(worker_count:, runtime: Runtime.new, wakeup: nil)
42
+ def initialize(worker_count:, runtime: Runtime.new, wakeup: nil, flaky_tests: nil)
39
43
  @worker_count = worker_count
40
44
  @runtime = runtime
41
45
  @wakeup = wakeup
46
+ @flaky_tests = flaky_tests
42
47
  @shutdown_requested = false
43
48
  end
44
49
 
@@ -85,18 +90,21 @@ module Henitai
85
90
  def prepare_run(mutants, integration, config, progress_reporter, options)
86
91
  @shutdown_requested = false
87
92
  @wakeup = Henitai::ProcessWakeup.new.install if @wakeup.nil?
93
+ @wakeup.watch { @runtime.wakeup_ios } if @runtime.respond_to?(:wakeup_ios)
88
94
  @scheduler = SlotScheduler.new(
89
95
  integration: integration,
90
96
  config: config,
91
97
  progress_reporter: progress_reporter,
92
98
  options: options,
93
- host: self
99
+ host: self,
100
+ flaky_tests: @flaky_tests
94
101
  )
95
102
  @scheduler.enqueue(mutants)
96
103
  end
97
104
 
98
105
  def event_loop
99
106
  saved_traps = install_signal_traps
107
+ @scheduler.fill_idle_slots
100
108
  loop do
101
109
  break if @scheduler.done?
102
110
 
@@ -108,7 +116,6 @@ module Henitai
108
116
  end
109
117
 
110
118
  def process_cycle
111
- @scheduler.fill_idle_slots unless @shutdown_requested
112
119
  @scheduler.reap_all_completed_children
113
120
  @scheduler.check_timeouts
114
121
  @scheduler.fill_idle_slots unless @shutdown_requested
@@ -129,14 +136,21 @@ module Henitai
129
136
  end
130
137
 
131
138
  def wait_for_next_event
132
- @wakeup&.wait(@scheduler.next_event_timeout)
139
+ @wakeup&.wait(next_wait_timeout)
133
140
  @wakeup&.drain
134
141
  end
135
142
 
143
+ # An event the runtime already read off its socket cannot wake the select.
144
+ def next_wait_timeout
145
+ return 0 if @runtime.respond_to?(:pending_events?) && @runtime.pending_events?
146
+
147
+ @scheduler.next_event_timeout
148
+ end
149
+
136
150
  def install_signal_traps
137
151
  saved = {}
138
152
  %w[INT TERM HUP].each do |sig|
139
- saved[sig] = @runtime.trap(sig) { @shutdown_requested = true }
153
+ saved[sig] = @runtime.trap(sig) { request_shutdown }
140
154
  end
141
155
  saved
142
156
  end
@@ -71,6 +71,15 @@ module Henitai
71
71
  def canonical_path
72
72
  File.join(config.reports_dir, "mutation-report.json")
73
73
  end
74
+
75
+ # Scoped runs upload the merged canonical report so that they do not
76
+ # replace the full report with a partial one.
77
+ def merged_schema_for(result)
78
+ schema = result.to_stryker_schema
79
+ return schema if authoritative?(result)
80
+
81
+ CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
82
+ end
74
83
  end
75
84
 
76
85
  # Terminal reporter.
@@ -395,6 +404,8 @@ module Henitai
395
404
 
396
405
  FileUtils.mkdir_p(File.dirname(history_report_path))
397
406
  File.write(history_report_path, JSON.pretty_generate(store.trend_report))
407
+ rescue StandardError => e
408
+ warn "henitai: could not export mutation history (#{e.class}: #{e.message})"
398
409
  end
399
410
 
400
411
  def default_history_store
@@ -450,10 +461,7 @@ module Henitai
450
461
  end
451
462
 
452
463
  def schema_for(result)
453
- schema = result.to_stryker_schema
454
- return schema if authoritative?(result)
455
-
456
- CanonicalReportMerger.merge(schema, canonical_path, prune_missing: true)
464
+ merged_schema_for(result)
457
465
  end
458
466
 
459
467
  def escaped_report_json(schema)
@@ -515,9 +523,8 @@ module Henitai
515
523
  end
516
524
 
517
525
  def ignore_reason_for(mutant)
518
- return nil unless mutant.respond_to?(:ignore_reason)
519
-
520
- reason = mutant.ignore_reason
526
+ reason = mutant.status_reason if mutant.respond_to?(:status_reason)
527
+ reason ||= mutant.ignore_reason if mutant.respond_to?(:ignore_reason)
521
528
  reason ? "(#{reason})" : nil
522
529
  end
523
530
 
@@ -609,7 +616,7 @@ module Henitai
609
616
 
610
617
  def build_request(result, uri)
611
618
  request = Net::HTTP::Put.new(uri.request_uri, request_headers)
612
- request.body = JSON.generate(result.to_stryker_schema)
619
+ request.body = JSON.generate(merged_schema_for(result))
613
620
  request
614
621
  end
615
622
 
@@ -26,9 +26,8 @@ module Henitai
26
26
  process_abort: true
27
27
  }.freeze
28
28
 
29
- # Maps each criterion to the mutant status it governs. process_abort has no
30
- # producer in lib/ yet -- nothing classifies a mutant as :runtime_error --
31
- # so that entry is forward-looking wiring rather than a live code path.
29
+ # Maps each criterion to the mutant status it governs. MutantVerdict emits
30
+ # :runtime_error for process aborts, signals, and unknown child outcomes.
32
31
  CRITERION_STATUSES = {
33
32
  test_result: :killed,
34
33
  timeout: :timeout,
@@ -36,7 +35,7 @@ module Henitai
36
35
  }.freeze
37
36
 
38
37
  attr_reader :mutants, :started_at, :finished_at, :thresholds, :survivor_stats,
39
- :session_id, :git_sha, :since, :coverage_criteria
38
+ :session_id, :git_sha, :since, :coverage_criteria, :complete_files, :sampling
40
39
 
41
40
  # @param source_provider [#call] maps a file path to its source string.
42
41
  # Injected so the domain object performs no disk IO; the caller (which
@@ -48,12 +47,16 @@ module Henitai
48
47
  # canonical report; non-authoritative (subject-scoped, --since,
49
48
  # --survivors-from) runs are merged into it instead — see
50
49
  # CanonicalReportMerger.
50
+ # @param complete_files [Array<String>, nil] source files this run mutated
51
+ # completely (every subject). The merger replaces a prior report's entry
52
+ # for such a file wholesale instead of merging by stableId, so stale
53
+ # mutants of changed or deleted code do not linger.
51
54
  # rubocop:disable Metrics/ParameterLists
52
55
  def initialize(mutants:, started_at:, finished_at:, thresholds: nil,
53
56
  partial_rerun: false, survivor_stats: nil,
54
57
  session_id: SecureRandom.uuid, git_sha: nil,
55
58
  source_provider: ->(_file) { "" }, authoritative: true,
56
- since: nil, coverage_criteria: nil)
59
+ since: nil, coverage_criteria: nil, complete_files: nil, sampling: nil)
57
60
  @mutants = mutants
58
61
  @started_at = started_at
59
62
  @finished_at = finished_at
@@ -65,7 +68,9 @@ module Henitai
65
68
  @source_provider = source_provider
66
69
  @authoritative = authoritative
67
70
  @since = since
71
+ @complete_files = Array(complete_files)
68
72
  @coverage_criteria = DEFAULT_COVERAGE_CRITERIA.merge(coverage_criteria || {})
73
+ @sampling = sampling
69
74
  end
70
75
  # rubocop:enable Metrics/ParameterLists
71
76
 
@@ -89,11 +94,12 @@ module Henitai
89
94
 
90
95
  # Mutation Score (MS) — Architektur-Formel aus Abschnitt 6.1:
91
96
  #
92
- # MS = detected / (total − ignored − no_coverage − compile_error − equivalent)
97
+ # MS = detected / (total − ignored − no_coverage − compile_error − equivalent − pending)
93
98
  #
94
99
  # Confirmed equivalent mutants werden aus BEIDEN Seiten der Gleichung entfernt:
95
100
  # Sie sind weder im Zähler (nicht detektierbar) noch im Nenner (nicht testbar).
96
- # Das ist der entscheidende Unterschied zum MSI.
101
+ # Das ist der entscheidende Unterschied zum MSI. Pending mutants (left over from
102
+ # an interrupted run, without a verdict) are excluded from both sides as well.
97
103
  #
98
104
  # @return [Float, nil] 0.0–100.0, nil wenn kein valider Mutant vorhanden
99
105
  def mutation_score
@@ -102,8 +108,9 @@ module Henitai
102
108
 
103
109
  # Mutation Score Indicator (MSI) — naive Berechnung ohne Äquivalenz-Bereinigung:
104
110
  #
105
- # MSI = killed / all_mutants
111
+ # MSI = killed / (total − pending)
106
112
  #
113
+ # Only mutants with a verdict count; pending mutants have none.
107
114
  # MSI ist immer ≤ MS. Der Unterschied kommuniziert die Äquivalenz-Unsicherheit.
108
115
  # Beide Werte MÜSSEN im Report zusammen ausgewiesen werden (Anti-Pattern: nur MS).
109
116
  #
@@ -151,6 +158,8 @@ module Henitai
151
158
  schema = base_schema
152
159
  sha = @git_sha
153
160
  schema[:gitSha] = sha if sha
161
+ schema[:completeFiles] = complete_files if complete_files.any? && !authoritative?
162
+ schema[:sampling] = @sampling if @sampling
154
163
  return schema unless partial_rerun?
155
164
 
156
165
  schema[:partialRerun] = true
@@ -171,7 +180,7 @@ module Henitai
171
180
  end
172
181
 
173
182
  def mutation_score_for(list)
174
- excluded = %i[ignored no_coverage compile_error equivalent]
183
+ excluded = %i[ignored no_coverage compile_error equivalent pending]
175
184
  valid = list.reject { |m| excluded.include?(m.status) }
176
185
  return nil if valid.empty?
177
186
 
@@ -179,9 +188,10 @@ module Henitai
179
188
  end
180
189
 
181
190
  def mutation_score_indicator_for(list)
182
- return nil if list.empty?
191
+ decided = list.reject { |m| m.status == :pending }
192
+ return nil if decided.empty?
183
193
 
184
- ((list.count(&:killed?).to_f / list.count) * 100.0).round(2).to_f
194
+ ((decided.count(&:killed?).to_f / decided.count) * 100.0).round(2).to_f
185
195
  end
186
196
 
187
197
  def base_schema
@@ -205,10 +215,16 @@ module Henitai
205
215
  language: "ruby",
206
216
  source: @source_provider.call(file_mutants.first.location[:file]),
207
217
  mutants: file_mutants.map { |m| mutant_to_schema(m) }
208
- }
218
+ }.merge(file_provenance)
209
219
  end
210
220
  end
211
221
 
222
+ # Vendor fields: which run produced this file entry. Kept per file so a
223
+ # merged report can tell which session snapshot holds its mutants.
224
+ def file_provenance
225
+ { sessionId: @session_id, gitSha: @git_sha }.compact
226
+ end
227
+
212
228
  def mutant_to_schema(mutant)
213
229
  {
214
230
  id: mutant.id,
@@ -261,11 +277,11 @@ module Henitai
261
277
  end
262
278
 
263
279
  # Serialized as the schema's statusReason so directive reasons show up
264
- # next to the Ignored status in the HTML report.
280
+ # next to the Ignored status, and harness reasons (a failed fidelity
281
+ # check, for example) next to CompileError, in the HTML report.
265
282
  def status_reason_for(mutant)
266
- return nil unless mutant.respond_to?(:ignore_reason)
267
-
268
- mutant.ignore_reason
283
+ reason = mutant.status_reason if mutant.respond_to?(:status_reason)
284
+ reason || (mutant.ignore_reason if mutant.respond_to?(:ignore_reason))
269
285
  end
270
286
 
271
287
  # Vendored extension field (like stableId): marks verdicts reused from
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ require_relative "verdict_fingerprint"
7
+
8
+ module Henitai
9
+ # Run-level fingerprint of everything outside a mutant and its covering
10
+ # tests that can change a verdict: the test dependency files, the
11
+ # verdict-relevant configuration and the henitai version. Recorded with every
12
+ # cached Killed/Survived verdict and required to match for reuse.
13
+ #
14
+ # Computed once per instance, so the filter that reads verdicts and the
15
+ # history store that writes them always agree. nil when the dependency files
16
+ # cannot be read — nothing is then reusable.
17
+ class ReuseContext
18
+ def initialize(config:, version: Henitai::VERSION, root: Dir.pwd)
19
+ @config = config
20
+ @version = version
21
+ @root = root
22
+ end
23
+
24
+ def fingerprint
25
+ return @fingerprint if defined?(@fingerprint)
26
+
27
+ @fingerprint = compute
28
+ end
29
+
30
+ private
31
+
32
+ def compute
33
+ dependencies = VerdictFingerprint.dependency_fingerprint(@root)
34
+ return nil unless dependencies
35
+
36
+ Digest::SHA256.hexdigest(JSON.generate(components(dependencies)))
37
+ end
38
+
39
+ def components(dependencies)
40
+ {
41
+ "dependencies" => dependencies,
42
+ "version" => @version,
43
+ "integration" => @config.integration.to_s,
44
+ "operators" => @config.operators.to_s,
45
+ "test_excludes" => Array(@config.test_excludes).map(&:to_s).sort,
46
+ "coverage_criteria" => @config.coverage_criteria.to_h.sort_by { |key, _| key.to_s }
47
+ }
48
+ end
49
+ end
50
+ end
@@ -5,6 +5,10 @@ require "henitai/coverage_formatter"
5
5
 
6
6
  RSpec::Core::Formatters.register(
7
7
  Henitai::CoverageFormatter,
8
+ :example_started,
8
9
  :example_finished,
10
+ :example_group_started,
11
+ :example_group_finished,
12
+ :message,
9
13
  :dump_summary
10
14
  )