henitai 0.2.1 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/README.md +123 -2
  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 +64 -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 +66 -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 +238 -117
  55. data/lib/henitai/reports_directory_lock.rb +76 -0
  56. data/lib/henitai/result.rb +72 -9
  57. data/lib/henitai/runner.rb +82 -40
  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 +230 -25
  70. metadata +22 -3
  71. data/lib/henitai/parallel_execution_runner.rb +0 -153
@@ -4,6 +4,10 @@ require "fileutils"
4
4
  # Guarded to avoid a circular require: integration.rb requires this file at its
5
5
  # tail, by which point Base is already defined, so the require is skipped.
6
6
  require_relative "../integration" unless defined?(Henitai::Integration::Base)
7
+ require_relative "minitest_load_path"
8
+ require_relative "minitest_suite_command"
9
+ require_relative "minitest_test_runner"
10
+ require_relative "rails_environment_preloader"
7
11
 
8
12
  module Henitai
9
13
  module Integration
@@ -26,18 +30,18 @@ module Henitai
26
30
  end
27
31
 
28
32
  def run_mutant(mutant:, test_files:, timeout:)
29
- setup_load_path
33
+ MinitestLoadPath.ensure!
30
34
  super
31
35
  end
32
36
 
33
37
  def spawn_mutant(mutant:, test_files:)
34
- setup_load_path
38
+ MinitestLoadPath.ensure!
35
39
  super
36
40
  end
37
41
 
38
42
  def run_in_child(mutant:, test_files:, log_paths:)
39
43
  ENV["RAILS_ENV"] = "test" unless ENV["RAILS_ENV"] == "test"
40
- preload_environment
44
+ RailsEnvironmentPreloader.new.call
41
45
  super
42
46
  end
43
47
 
@@ -50,50 +54,14 @@ module Henitai
50
54
  cleanup_suite_process(pid, wait_result)
51
55
  end
52
56
 
53
- private
54
-
55
57
  def suite_command(test_files)
56
- ["bundle", "exec", "ruby", "-I", "test",
57
- "-r", "henitai/minitest_simplecov",
58
- "-r", "henitai/minitest_coverage_hook",
59
- "-e", "ARGV.each { |f| require File.expand_path(f) }",
60
- *test_files]
61
- end
62
-
63
- def run_tests(test_files)
64
- suppress_simplecov!
65
- suppress_minitest_autorun!
66
- test_files.each { |file| require File.expand_path(file) }
67
- # @type var empty_args: Array[String]
68
- empty_args = []
69
- status = ::Minitest.run(empty_args)
70
- return status if status.is_a?(Integer)
71
-
72
- status == true ? 0 : 1
73
- end
74
-
75
- def preload_environment
76
- env_file = File.expand_path("config/environment.rb")
77
- require env_file if File.exist?(env_file)
78
- end
79
-
80
- def setup_load_path
81
- test_dir = File.expand_path("test")
82
- $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
58
+ MinitestSuiteCommand.new.build(test_files)
83
59
  end
84
60
 
85
- def suppress_minitest_autorun!
86
- require "minitest"
87
- singleton_class = ::Minitest.singleton_class
88
- suppressor = @minitest_autorun_suppressor ||= Module.new.tap do |mod|
89
- mod.define_method(:autorun) { nil }
90
- end
91
- return if singleton_class.ancestors.include?(suppressor)
61
+ private
92
62
 
93
- singleton_class.prepend(suppressor)
94
- nil
95
- rescue LoadError, NameError
96
- nil
63
+ def run_tests(test_files)
64
+ MinitestTestRunner.new.call(test_files)
97
65
  end
98
66
 
99
67
  def subprocess_env
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Ensures the current project's test/ directory is on $LOAD_PATH, for
6
+ # Minitest#run_mutant/#spawn_mutant. Idempotent: safe to call repeatedly.
7
+ module MinitestLoadPath
8
+ def self.ensure!
9
+ test_dir = File.expand_path("test")
10
+ $LOAD_PATH.unshift(test_dir) unless $LOAD_PATH.include?(test_dir)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Builds the argv used to run Minitest test files in an external Ruby
6
+ # process, for Minitest#spawn_suite_process's baseline coverage bootstrap.
7
+ class MinitestSuiteCommand
8
+ def build(test_files)
9
+ ["bundle", "exec", "ruby", "-I", "test",
10
+ "-r", "henitai/minitest_simplecov",
11
+ "-r", "henitai/minitest_coverage_hook",
12
+ "-e", "ARGV.each { |f| require File.expand_path(f) }",
13
+ *test_files]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "coverage_suppression"
4
+
5
+ module Henitai
6
+ module Integration
7
+ # Runs Minitest test files in-process and normalizes the result to an
8
+ # exit code, for Minitest#run_tests during a single mutant activation.
9
+ class MinitestTestRunner
10
+ def call(test_files)
11
+ CoverageRuntimeSuppressors.suppress_simplecov!
12
+ suppress_autorun!
13
+ test_files.each { |file| require File.expand_path(file) }
14
+ # @type var empty_args: Array[String]
15
+ empty_args = []
16
+ status = ::Minitest.run(empty_args)
17
+ return status if status.is_a?(Integer)
18
+
19
+ status == true ? 0 : 1
20
+ end
21
+
22
+ private
23
+
24
+ def suppress_autorun!
25
+ require "minitest"
26
+ singleton_class = ::Minitest.singleton_class
27
+ suppressor = @autorun_suppressor ||= Module.new.tap do |mod|
28
+ mod.define_method(:autorun) { nil }
29
+ end
30
+ return if singleton_class.ancestors.include?(suppressor)
31
+
32
+ singleton_class.prepend(suppressor)
33
+ nil
34
+ rescue LoadError, NameError
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ module Integration
5
+ # Loads a Rails app's config/environment.rb if present, for
6
+ # Minitest#run_in_child. A no-op outside a Rails project.
7
+ class RailsEnvironmentPreloader
8
+ def call
9
+ env_file = File.expand_path("config/environment.rb")
10
+ require env_file if File.exist?(env_file)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -42,17 +42,17 @@ module Henitai
42
42
 
43
43
  def load_rspec_spec_files(test_files)
44
44
  debug_child_puts("[henitai-debug-child] load_spec_files_start")
45
- ::RSpec.__send__(:configuration).files_to_run = test_files.map do |file|
45
+ ::RSpec.configuration.files_to_run = test_files.map do |file|
46
46
  File.expand_path(file)
47
47
  end
48
- ::RSpec.__send__(:configuration).load_spec_files
48
+ ::RSpec.configuration.load_spec_files
49
49
  debug_child_example_count("after_load")
50
50
  debug_child_puts("[henitai-debug-child] load_spec_files_return")
51
51
  end
52
52
 
53
53
  def run_rspec_specs(runner)
54
54
  debug_child_puts("[henitai-debug-child] run_specs_start")
55
- result = runner.send(:run_specs, ::RSpec.__send__(:world).ordered_example_groups)
55
+ result = runner.send(:run_specs, ::RSpec.world.ordered_example_groups)
56
56
  debug_child_puts("[henitai-debug-child] run_specs_return result=#{result.inspect}")
57
57
  result
58
58
  end
@@ -18,7 +18,10 @@ module Henitai
18
18
  def select_tests(subject)
19
19
  matches = spec_files.select do |path|
20
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.
21
23
  selection_patterns(subject).any? { |pattern| content.include?(pattern) }
24
+ # rubocop:enable Style/ArrayIntersect
22
25
  rescue StandardError
23
26
  false
24
27
  end
@@ -7,6 +7,12 @@ module Henitai
7
7
  # Shared helpers for capturing stdout/stderr from child test processes and
8
8
  # for reading and combining the captured log files afterwards.
9
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
+
10
16
  def capture_child_output(log_paths)
11
17
  output_files = open_child_output(log_paths)
12
18
  yield
@@ -26,10 +32,20 @@ module Henitai
26
32
  end
27
33
  end
28
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.
29
40
  def open_child_output(log_paths)
30
41
  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)
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
+ }
33
49
  redirect_child_output(output_files)
34
50
  output_files
35
51
  end
@@ -38,26 +54,54 @@ module Henitai
38
54
  return unless output_files
39
55
 
40
56
  restore_child_output(output_files)
41
- close_child_output_files(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
42
91
  end
43
92
 
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
93
+ def finish_capped_stream(stream)
94
+ return unless stream
52
95
 
53
- def sync_child_output_files(output_files)
54
- output_files[:stdout_file].sync = true
55
- output_files[:stderr_file].sync = true
96
+ stream[:writer].close
97
+ stream[:thread].join
98
+ stream[:reader].close
99
+ stream[:file].close
56
100
  end
57
101
 
58
102
  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])
103
+ reopen_child_output_stream(stdout_stream, output_files[:stdout][:writer])
104
+ reopen_child_output_stream(stderr_stream, output_files[:stderr][:writer])
61
105
  $stdout = stdout_stream
62
106
  $stderr = stderr_stream
63
107
  end
@@ -73,10 +117,10 @@ module Henitai
73
117
  stream.reopen(original_stream) if original_stream
74
118
  end
75
119
 
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
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
80
124
  end
81
125
 
82
126
  def read_log_file(path)
@@ -18,7 +18,10 @@ module Henitai
18
18
 
19
19
  def record(result)
20
20
  super
21
- @collector.record_test(relative_to_pwd(result.source_location.first))
21
+ @collector.record_test(
22
+ relative_to_pwd(result.source_location.first),
23
+ duration: result.time
24
+ )
22
25
  end
23
26
 
24
27
  def report
@@ -57,15 +57,27 @@ module Henitai
57
57
  subject.method_type == :class ? target.singleton_class : target
58
58
  end
59
59
 
60
+ # The trailing module_function re-copy handles module_function subjects:
61
+ # module_function copies the (private) instance method onto the module's
62
+ # singleton, and define_method only replaces the instance side — without
63
+ # the re-copy, callers of Module.method keep running the original code
64
+ # and every mutant in such modules falsely survives. The guard checks
65
+ # the pre-injection shape (private instance + singleton method on a
66
+ # non-Class module) so a class-level method that merely shares an
67
+ # instance method's name is never clobbered.
60
68
  def method_source(mutant)
61
69
  method_name = mutant.subject.method_name
62
70
  parameters = parameter_source(mutant)
63
71
  replacement = body_source(mutant)
64
72
 
65
73
  <<~RUBY
74
+ __henitai_module_function__ = !is_a?(Class) &&
75
+ private_method_defined?(:#{method_name}) &&
76
+ singleton_class.method_defined?(:#{method_name})
66
77
  define_method(:#{method_name}) do |#{parameters}|
67
78
  #{replacement}
68
79
  end
80
+ module_function :#{method_name} if __henitai_module_function__
69
81
  RUBY
70
82
  end
71
83
 
@@ -164,6 +176,8 @@ module Henitai
164
176
 
165
177
  Thread.current[:henitai_filter_const_warnings] = true
166
178
  load(source_file)
179
+ loaded_feature = File.expand_path(source_file)
180
+ $LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
167
181
  ensure
168
182
  Thread.current[:henitai_filter_const_warnings] = false
169
183
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "securerandom"
4
4
  require_relative "mutant_identity"
5
+ require_relative "unparse_helper"
5
6
 
6
7
  module Henitai
7
8
  # Represents a single syntactic mutation applied to a Subject.
@@ -16,6 +17,8 @@ module Henitai
16
17
  # :pending, :killed, :survived, :timeout, :compile_error, :runtime_error,
17
18
  # :ignored, :no_coverage
18
19
  class Mutant
20
+ include UnparseHelper
21
+
19
22
  autoload :Activator, "henitai/mutant/activator"
20
23
  autoload :ParameterSource, "henitai/mutant/parameter_source"
21
24
 
@@ -38,7 +41,8 @@ module Henitai
38
41
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
39
42
  :mutation_type, :description, :location,
40
43
  :precomputed_stable_id, :precomputed_activation_source
41
- attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed
44
+ attr_accessor :status, :killing_test, :duration, :covered_by, :tests_completed,
45
+ :ignore_reason, :from_cache
42
46
 
43
47
  # @param subject [Subject] the subject being mutated
44
48
  # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
@@ -57,11 +61,9 @@ module Henitai
57
61
  @location = location
58
62
  @precomputed_stable_id = precomputed_stable_id
59
63
  @precomputed_activation_source = precomputed_activation_source
60
- @status = :pending
61
- @killing_test = nil
62
- @duration = nil
63
- @covered_by = nil
64
- @tests_completed = nil
64
+ @status = :pending
65
+ @killing_test = @duration = @covered_by = @tests_completed = @ignore_reason = nil
66
+ @from_cache = false
65
67
  end
66
68
  # rubocop:enable Metrics/ParameterLists
67
69
 
@@ -69,7 +71,12 @@ module Henitai
69
71
  @stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
70
72
  end
71
73
 
74
+ # Unparsing is expensive and the value is needed by both the JSON and
75
+ # the terminal reporter — compute once per mutant.
76
+ def mutated_source = @mutated_source ||= safe_unparse(mutated_node)
77
+
72
78
  def killed? = @status == :killed
79
+ def from_cache? = @from_cache == true
73
80
  def survived? = @status == :survived
74
81
  def pending? = @status == :pending
75
82
  def ignored? = @status == :ignored
@@ -49,6 +49,14 @@ module Henitai
49
49
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
50
50
  SQL
51
51
 
52
+ # Nullable verdict-cache columns added after the initial release; see
53
+ # MIGRATION_COLUMNS. Old rows keep NULL there, which the incremental
54
+ # filter treats as never-reusable — the correct conservative behavior.
55
+ MIGRATION_COLUMNS = {
56
+ "subject_source_hash" => "TEXT",
57
+ "covered_tests_fingerprint" => "TEXT"
58
+ }.freeze
59
+
52
60
  UPSERT_MUTANT = <<~SQL
53
61
  INSERT INTO mutants (
54
62
  mutant_id,
@@ -58,14 +66,24 @@ module Henitai
58
66
  last_seen_at,
59
67
  current_status,
60
68
  status_history,
61
- days_alive
62
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
69
+ days_alive,
70
+ subject_source_hash,
71
+ covered_tests_fingerprint
72
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
73
  ON CONFLICT(mutant_id) DO UPDATE SET
64
74
  last_seen_version = excluded.last_seen_version,
65
75
  last_seen_at = excluded.last_seen_at,
66
76
  current_status = excluded.current_status,
67
77
  status_history = excluded.status_history,
68
- days_alive = excluded.days_alive
78
+ days_alive = excluded.days_alive,
79
+ subject_source_hash = excluded.subject_source_hash,
80
+ covered_tests_fingerprint = excluded.covered_tests_fingerprint
81
+ SQL
82
+
83
+ VERDICT_LOOKUP = <<~SQL
84
+ SELECT current_status, subject_source_hash, covered_tests_fingerprint
85
+ FROM mutants
86
+ WHERE mutant_id = ?
69
87
  SQL
70
88
  end
71
89
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../verdict_fingerprint"
4
+
5
+ module Henitai
6
+ class MutantHistoryStore
7
+ # Verdict-cache column handling for the incremental (`--incremental`)
8
+ # reuse feature. Mixed into {MutantHistoryStore}.
9
+ module VerdictCache
10
+ # The reusable verdicts: hashes are persisted for killed and survived
11
+ # mutants only; every other status stays NULL and always re-executes.
12
+ CACHEABLE_STATUSES = %w[killed survived].freeze
13
+
14
+ private
15
+
16
+ # Cache-hit mutants (from_cache) were never executed this run, so their
17
+ # covered tests are unknown — carry the stored fingerprints forward
18
+ # instead of recomputing them from empty data (which would wipe them
19
+ # and silently disable reuse after one cached run).
20
+ def verdict_cache_bindings(mutant, existing_row)
21
+ status = mutant.status.to_s
22
+ return [nil, nil] unless CACHEABLE_STATUSES.include?(status)
23
+ return existing_cache_bindings(existing_row) if from_cache?(mutant)
24
+ return killed_cache_bindings(mutant) if status == "killed"
25
+
26
+ survived_cache_bindings(mutant, existing_row)
27
+ end
28
+
29
+ def killed_cache_bindings(mutant)
30
+ [
31
+ VerdictFingerprint.subject_source_hash(mutant),
32
+ VerdictFingerprint.tests_fingerprint(covered_tests_for(mutant))
33
+ ]
34
+ end
35
+
36
+ # Survived rows fingerprint the full-map intersection set — the same
37
+ # set the incremental filter recomputes live — never Mutant#covered_by,
38
+ # which reflects selector fallback and test_excludes and would
39
+ # permanently mismatch the live set. When the fingerprint cannot be
40
+ # recomputed (no per-test map, recipe stubs without a source range),
41
+ # the previously stored bindings are kept: the filter re-validates
42
+ # everything live before reuse, so a carried-forward fingerprint can
43
+ # never validate a changed state.
44
+ def survived_cache_bindings(mutant, existing_row)
45
+ bindings = [
46
+ VerdictFingerprint.subject_source_hash(mutant),
47
+ survived_tests_fingerprint(mutant)
48
+ ]
49
+ return bindings unless bindings.any?(&:nil?)
50
+
51
+ existing_cache_bindings(existing_row)
52
+ end
53
+
54
+ def survived_tests_fingerprint(mutant)
55
+ return nil unless per_test_coverage
56
+
57
+ VerdictFingerprint.survivor_tests_fingerprint(
58
+ per_test_coverage.tests_covering(mutant),
59
+ dependency_sha: recording_dependency_sha
60
+ )
61
+ end
62
+
63
+ # One dependency sha per record call set, computed lazily so runs
64
+ # without survivors never touch the dependency files.
65
+ def recording_dependency_sha
66
+ @recording_dependency_sha ||= VerdictFingerprint.dependency_fingerprint
67
+ end
68
+
69
+ def existing_cache_bindings(existing_row)
70
+ return [nil, nil] unless existing_row
71
+
72
+ [existing_row["subject_source_hash"], existing_row["covered_tests_fingerprint"]]
73
+ end
74
+
75
+ def from_cache?(mutant)
76
+ mutant.respond_to?(:from_cache?) && mutant.from_cache?
77
+ end
78
+
79
+ def covered_tests_for(mutant)
80
+ mutant.respond_to?(:covered_by) ? mutant.covered_by : nil
81
+ end
82
+ end
83
+ end
84
+ end