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
@@ -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
@@ -6,7 +6,6 @@ require "unparser"
6
6
  module Henitai
7
7
  class Mutant
8
8
  # Activates a mutant inside the forked child process.
9
- # rubocop:disable Metrics/ClassLength
10
9
  class Activator
11
10
  # Filters "already initialized constant" C-level warnings that fire when
12
11
  # a source file is loaded into a process that already has the constant
@@ -24,17 +23,6 @@ module Henitai
24
23
  end
25
24
  Warning.singleton_class.prepend(ConstantRedefinitionFilter)
26
25
 
27
- SERIALIZER_METHODS = {
28
- arg: :argument_parameter_fragment,
29
- optarg: :optional_parameter_fragment,
30
- restarg: :rest_parameter_fragment,
31
- kwarg: :keyword_parameter_fragment,
32
- kwoptarg: :optional_keyword_parameter_fragment,
33
- kwrestarg: :keyword_rest_parameter_fragment,
34
- blockarg: :block_parameter_fragment,
35
- forward_arg: :forward_parameter_fragment
36
- }.freeze
37
-
38
26
  def self.activate!(mutant)
39
27
  new.activate!(mutant)
40
28
  end
@@ -69,15 +57,27 @@ module Henitai
69
57
  subject.method_type == :class ? target.singleton_class : target
70
58
  end
71
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.
72
68
  def method_source(mutant)
73
69
  method_name = mutant.subject.method_name
74
70
  parameters = parameter_source(mutant)
75
71
  replacement = body_source(mutant)
76
72
 
77
73
  <<~RUBY
74
+ __henitai_module_function__ = !is_a?(Class) &&
75
+ private_method_defined?(:#{method_name}) &&
76
+ singleton_class.method_defined?(:#{method_name})
78
77
  define_method(:#{method_name}) do |#{parameters}|
79
78
  #{replacement}
80
79
  end
80
+ module_function :#{method_name} if __henitai_module_function__
81
81
  RUBY
82
82
  end
83
83
 
@@ -124,78 +124,13 @@ module Henitai
124
124
  end
125
125
 
126
126
  def parameter_source(mutant)
127
- args_node = method_arguments(mutant.subject.ast_node)
128
- return "" unless args_node
129
- return forward_parameter_fragment(nil) if args_node.type == :forward_args
130
-
131
- args_node.children.filter_map do |argument|
132
- parameter_fragment(argument)
133
- end.join(", ")
134
- end
135
-
136
- def method_arguments(subject_node)
137
- case subject_node&.type
138
- when :def
139
- subject_node.children[1]
140
- when :defs
141
- subject_node.children[2]
142
- when :block
143
- block_arguments(subject_node)
144
- end
145
- end
146
-
147
- def parameter_fragment(argument)
148
- method_name = SERIALIZER_METHODS[argument&.type]
149
- return unless method_name
150
-
151
- send(method_name, argument)
152
- end
153
-
154
- def argument_parameter_fragment(argument)
155
- argument.children[0].to_s
156
- end
157
-
158
- def optional_parameter_fragment(argument)
159
- "#{argument.children[0]} = #{compile_safe_unparse(argument.children[1])}"
160
- end
161
-
162
- def rest_parameter_fragment(argument)
163
- prefixed_parameter(argument, "*")
164
- end
165
-
166
- def keyword_parameter_fragment(argument)
167
- "#{argument.children[0]}:"
168
- end
169
-
170
- def optional_keyword_parameter_fragment(argument)
171
- "#{argument.children[0]}: #{compile_safe_unparse(argument.children[1])}"
172
- end
173
-
174
- def keyword_rest_parameter_fragment(argument)
175
- prefixed_parameter(argument, "**")
176
- end
177
-
178
- def block_parameter_fragment(argument)
179
- "&#{argument.children[0]}"
180
- end
181
-
182
- def forward_parameter_fragment(_argument)
183
- "*args, **kwargs, &block"
184
- end
185
-
186
- def prefixed_parameter(argument, prefix)
187
- name = argument.children[0]
188
- name ? "#{prefix}#{name}" : prefix
127
+ ParameterSource.new.build(mutant.subject.ast_node)
189
128
  end
190
129
 
191
130
  def block_body(subject_node)
192
131
  subject_node.children[2]
193
132
  end
194
133
 
195
- def block_arguments(subject_node)
196
- subject_node.children[1]
197
- end
198
-
199
134
  def heredoc_location?(location)
200
135
  location.respond_to?(:heredoc_body) && location.heredoc_body
201
136
  end
@@ -241,6 +176,8 @@ module Henitai
241
176
 
242
177
  Thread.current[:henitai_filter_const_warnings] = true
243
178
  load(source_file)
179
+ loaded_feature = File.expand_path(source_file)
180
+ $LOADED_FEATURES << loaded_feature unless $LOADED_FEATURES.include?(loaded_feature)
244
181
  ensure
245
182
  Thread.current[:henitai_filter_const_warnings] = false
246
183
  end
@@ -277,6 +214,5 @@ module Henitai
277
214
  raise Unparser::UnsupportedNodeError, e.message
278
215
  end
279
216
  end
280
- # rubocop:enable Metrics/ClassLength
281
217
  end
282
218
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+ require "unparser"
5
+
6
+ module Henitai
7
+ class Mutant
8
+ # Builds the parameter-list fragment of a +define_method+ block from a
9
+ # subject's method-definition AST node.
10
+ #
11
+ # Given the +def+/+defs+/+block+ node for a method, {#build} returns the
12
+ # comma-separated parameter source (e.g. "a, b = 1, *rest, key:, &blk")
13
+ # suitable for splicing into a +define_method(:name) do |...| ...+ template.
14
+ class ParameterSource
15
+ SERIALIZER_METHODS = {
16
+ arg: :argument_parameter_fragment,
17
+ optarg: :optional_parameter_fragment,
18
+ restarg: :rest_parameter_fragment,
19
+ kwarg: :keyword_parameter_fragment,
20
+ kwoptarg: :optional_keyword_parameter_fragment,
21
+ kwrestarg: :keyword_rest_parameter_fragment,
22
+ blockarg: :block_parameter_fragment,
23
+ forward_arg: :forward_parameter_fragment
24
+ }.freeze
25
+
26
+ def build(subject_node)
27
+ args_node = method_arguments(subject_node)
28
+ return "" unless args_node
29
+ return forward_parameter_fragment(nil) if args_node.type == :forward_args
30
+
31
+ args_node.children.filter_map do |argument|
32
+ parameter_fragment(argument)
33
+ end.join(", ")
34
+ end
35
+
36
+ private
37
+
38
+ def method_arguments(subject_node)
39
+ case subject_node&.type
40
+ when :def, :block
41
+ subject_node.children[1]
42
+ when :defs
43
+ subject_node.children[2]
44
+ end
45
+ end
46
+
47
+ def parameter_fragment(argument)
48
+ method_name = SERIALIZER_METHODS[argument&.type]
49
+ return unless method_name
50
+
51
+ send(method_name, argument)
52
+ end
53
+
54
+ def argument_parameter_fragment(argument)
55
+ argument.children[0].to_s
56
+ end
57
+
58
+ def optional_parameter_fragment(argument)
59
+ "#{argument.children[0]} = #{compile_safe_unparse(argument.children[1])}"
60
+ end
61
+
62
+ def rest_parameter_fragment(argument)
63
+ prefixed_parameter(argument, "*")
64
+ end
65
+
66
+ def keyword_parameter_fragment(argument)
67
+ "#{argument.children[0]}:"
68
+ end
69
+
70
+ def optional_keyword_parameter_fragment(argument)
71
+ "#{argument.children[0]}: #{compile_safe_unparse(argument.children[1])}"
72
+ end
73
+
74
+ def keyword_rest_parameter_fragment(argument)
75
+ prefixed_parameter(argument, "**")
76
+ end
77
+
78
+ def block_parameter_fragment(argument)
79
+ "&#{argument.children[0]}"
80
+ end
81
+
82
+ def forward_parameter_fragment(_argument)
83
+ "*args, **kwargs, &block"
84
+ end
85
+
86
+ def prefixed_parameter(argument, prefix)
87
+ name = argument.children[0]
88
+ name ? "#{prefix}#{name}" : prefix
89
+ end
90
+
91
+ def compile_safe_unparse(node)
92
+ Unparser.unparse(node)
93
+ rescue StandardError => e
94
+ raise Unparser::UnsupportedNodeError, e.message
95
+ end
96
+ end
97
+ end
98
+ 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,7 +17,10 @@ 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"
23
+ autoload :ParameterSource, "henitai/mutant/parameter_source"
20
24
 
21
25
  # Status-Vokabular folgt dem Stryker mutation-testing-report-schema.
22
26
  # :equivalent ist ein Henitai-interner Status (wird im JSON als "Ignored" serialisiert,
@@ -37,7 +41,8 @@ module Henitai
37
41
  attr_reader :id, :subject, :operator, :original_node, :mutated_node,
38
42
  :mutation_type, :description, :location,
39
43
  :precomputed_stable_id, :precomputed_activation_source
40
- 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
41
46
 
42
47
  # @param subject [Subject] the subject being mutated
43
48
  # @param operator [Symbol] operator name, e.g. :ArithmeticOperator
@@ -56,11 +61,9 @@ module Henitai
56
61
  @location = location
57
62
  @precomputed_stable_id = precomputed_stable_id
58
63
  @precomputed_activation_source = precomputed_activation_source
59
- @status = :pending
60
- @killing_test = nil
61
- @duration = nil
62
- @covered_by = nil
63
- @tests_completed = nil
64
+ @status = :pending
65
+ @killing_test = @duration = @covered_by = @tests_completed = @ignore_reason = nil
66
+ @from_cache = false
64
67
  end
65
68
  # rubocop:enable Metrics/ParameterLists
66
69
 
@@ -68,7 +71,12 @@ module Henitai
68
71
  @stable_id ||= @precomputed_stable_id || MutantIdentity.stable_id(self)
69
72
  end
70
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
+
71
78
  def killed? = @status == :killed
79
+ def from_cache? = @from_cache == true
72
80
  def survived? = @status == :survived
73
81
  def pending? = @status == :pending
74
82
  def ignored? = @status == :ignored
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ class MutantHistoryStore
5
+ # SQL statements used by {MutantHistoryStore} to create the schema and
6
+ # persist run/mutant rows in the SQLite history database.
7
+ module Sql
8
+ RUNS_TABLE = <<~SQL
9
+ CREATE TABLE IF NOT EXISTS runs (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ version TEXT NOT NULL,
12
+ recorded_at TEXT NOT NULL,
13
+ mutation_score REAL,
14
+ mutation_score_indicator REAL,
15
+ equivalence_uncertainty TEXT,
16
+ total_mutants INTEGER NOT NULL,
17
+ killed_mutants INTEGER NOT NULL,
18
+ survived_mutants INTEGER NOT NULL,
19
+ timeout_mutants INTEGER NOT NULL,
20
+ equivalent_mutants INTEGER NOT NULL
21
+ );
22
+ SQL
23
+
24
+ MUTANTS_TABLE = <<~SQL
25
+ CREATE TABLE IF NOT EXISTS mutants (
26
+ mutant_id TEXT PRIMARY KEY,
27
+ first_seen_version TEXT NOT NULL,
28
+ first_seen_at TEXT NOT NULL,
29
+ last_seen_version TEXT NOT NULL,
30
+ last_seen_at TEXT NOT NULL,
31
+ current_status TEXT NOT NULL,
32
+ status_history TEXT NOT NULL,
33
+ days_alive INTEGER NOT NULL
34
+ );
35
+ SQL
36
+
37
+ INSERT_RUN = <<~SQL
38
+ INSERT INTO runs (
39
+ version,
40
+ recorded_at,
41
+ mutation_score,
42
+ mutation_score_indicator,
43
+ equivalence_uncertainty,
44
+ total_mutants,
45
+ killed_mutants,
46
+ survived_mutants,
47
+ timeout_mutants,
48
+ equivalent_mutants
49
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
50
+ SQL
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
+
60
+ UPSERT_MUTANT = <<~SQL
61
+ INSERT INTO mutants (
62
+ mutant_id,
63
+ first_seen_version,
64
+ first_seen_at,
65
+ last_seen_version,
66
+ last_seen_at,
67
+ current_status,
68
+ status_history,
69
+ days_alive,
70
+ subject_source_hash,
71
+ covered_tests_fingerprint
72
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ ON CONFLICT(mutant_id) DO UPDATE SET
74
+ last_seen_version = excluded.last_seen_version,
75
+ last_seen_at = excluded.last_seen_at,
76
+ current_status = excluded.current_status,
77
+ status_history = excluded.status_history,
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 = ?
87
+ SQL
88
+ end
89
+ end
90
+ 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
@@ -6,76 +6,20 @@ require "json"
6
6
  require "sqlite3"
7
7
  require "time"
8
8
  require_relative "mutant_identity"
9
+ require_relative "mutant_history_store/sql"
10
+ require_relative "mutant_history_store/verdict_cache"
9
11
 
10
12
  module Henitai
11
13
  # Persists mutant outcomes across runs in a lightweight SQLite database.
12
- # rubocop:disable Metrics/ClassLength
13
14
  class MutantHistoryStore
14
- RUNS_TABLE_SQL = <<~SQL
15
- CREATE TABLE IF NOT EXISTS runs (
16
- id INTEGER PRIMARY KEY AUTOINCREMENT,
17
- version TEXT NOT NULL,
18
- recorded_at TEXT NOT NULL,
19
- mutation_score REAL,
20
- mutation_score_indicator REAL,
21
- equivalence_uncertainty TEXT,
22
- total_mutants INTEGER NOT NULL,
23
- killed_mutants INTEGER NOT NULL,
24
- survived_mutants INTEGER NOT NULL,
25
- timeout_mutants INTEGER NOT NULL,
26
- equivalent_mutants INTEGER NOT NULL
27
- );
28
- SQL
29
-
30
- MUTANTS_TABLE_SQL = <<~SQL
31
- CREATE TABLE IF NOT EXISTS mutants (
32
- mutant_id TEXT PRIMARY KEY,
33
- first_seen_version TEXT NOT NULL,
34
- first_seen_at TEXT NOT NULL,
35
- last_seen_version TEXT NOT NULL,
36
- last_seen_at TEXT NOT NULL,
37
- current_status TEXT NOT NULL,
38
- status_history TEXT NOT NULL,
39
- days_alive INTEGER NOT NULL
40
- );
41
- SQL
42
-
43
- INSERT_RUN_SQL = <<~SQL
44
- INSERT INTO runs (
45
- version,
46
- recorded_at,
47
- mutation_score,
48
- mutation_score_indicator,
49
- equivalence_uncertainty,
50
- total_mutants,
51
- killed_mutants,
52
- survived_mutants,
53
- timeout_mutants,
54
- equivalent_mutants
55
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
56
- SQL
57
-
58
- UPSERT_MUTANT_SQL = <<~SQL
59
- INSERT INTO mutants (
60
- mutant_id,
61
- first_seen_version,
62
- first_seen_at,
63
- last_seen_version,
64
- last_seen_at,
65
- current_status,
66
- status_history,
67
- days_alive
68
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
69
- ON CONFLICT(mutant_id) DO UPDATE SET
70
- last_seen_version = excluded.last_seen_version,
71
- last_seen_at = excluded.last_seen_at,
72
- current_status = excluded.current_status,
73
- status_history = excluded.status_history,
74
- days_alive = excluded.days_alive
75
- SQL
76
-
77
- def initialize(path:)
15
+ include VerdictCache
16
+
17
+ # @param per_test_coverage [PerTestCoverage, nil] live per-test coverage
18
+ # view used to record the full-map intersection set for survived
19
+ # verdicts; without it survived rows stay NULL (never reusable).
20
+ def initialize(path:, per_test_coverage: nil)
78
21
  @path = path
22
+ @per_test_coverage = per_test_coverage
79
23
  end
80
24
 
81
25
  attr_reader :path
@@ -94,6 +38,27 @@ module Henitai
94
38
  end
95
39
  end
96
40
 
41
+ # Verdict-cache lookup for `--incremental`: returns the stored hashes for
42
+ # the mutant's LATEST verdict when it is Killed or Survived — the upsert
43
+ # keeps exactly one row per stable id, so current_status is always the
44
+ # most recent outcome. nil for every other status, unknown ids and legacy
45
+ # rows without hashes (recorded before the cache columns existed).
46
+ def verdict_for(stable_id)
47
+ return nil unless File.exist?(path)
48
+
49
+ with_database do |db|
50
+ ensure_schema(db)
51
+ verdict_from_row(db.get_first_row(Sql::VERDICT_LOOKUP, stable_id))
52
+ end
53
+ end
54
+
55
+ # Killed-only view of {#verdict_for}, kept for the original killed reuse
56
+ # path.
57
+ def killed_verdict_for(stable_id)
58
+ verdict = verdict_for(stable_id)
59
+ verdict if verdict && verdict[:status] == :killed
60
+ end
61
+
97
62
  def trend_report
98
63
  with_database do |db|
99
64
  ensure_schema(db)
@@ -107,6 +72,19 @@ module Henitai
107
72
 
108
73
  private
109
74
 
75
+ attr_reader :per_test_coverage
76
+
77
+ def verdict_from_row(row)
78
+ return nil unless row && VerdictCache::CACHEABLE_STATUSES.include?(row["current_status"])
79
+ return nil if row["subject_source_hash"].nil? || row["covered_tests_fingerprint"].nil?
80
+
81
+ {
82
+ status: row["current_status"].to_sym,
83
+ subject_source_hash: row["subject_source_hash"],
84
+ covered_tests_fingerprint: row["covered_tests_fingerprint"]
85
+ }
86
+ end
87
+
110
88
  def partial_rerun?(result)
111
89
  result.respond_to?(:partial_rerun?) && result.partial_rerun?
112
90
  end
@@ -120,17 +98,30 @@ module Henitai
120
98
  end
121
99
 
122
100
  def ensure_schema(db)
123
- db.execute_batch(RUNS_TABLE_SQL)
124
- db.execute_batch(MUTANTS_TABLE_SQL)
101
+ db.execute_batch(Sql::RUNS_TABLE)
102
+ db.execute_batch(Sql::MUTANTS_TABLE)
103
+ migrate_mutants_table(db)
104
+ end
105
+
106
+ # Additive, idempotent in-place migration: databases created before the
107
+ # verdict-cache columns existed gain them on first contact, with existing
108
+ # rows left intact (NULL hashes — valid but never reusable).
109
+ def migrate_mutants_table(db)
110
+ existing = db.execute("PRAGMA table_info(mutants)").map { |row| row["name"] }
111
+ Sql::MIGRATION_COLUMNS.each do |column, type|
112
+ next if existing.include?(column)
113
+
114
+ db.execute("ALTER TABLE mutants ADD COLUMN #{column} #{type}")
115
+ end
125
116
  end
126
117
 
127
118
  def insert_run(db, result, version, recorded_at)
128
- db.execute(INSERT_RUN_SQL, insert_run_bindings(result, version, recorded_at))
119
+ db.execute(Sql::INSERT_RUN, insert_run_bindings(result, version, recorded_at))
129
120
  end
130
121
 
131
122
  def upsert_mutant(db, mutant, version, recorded_at)
132
123
  db.execute(
133
- UPSERT_MUTANT_SQL,
124
+ Sql::UPSERT_MUTANT,
134
125
  upsert_mutant_bindings(mutant_history_data(db, mutant, version, recorded_at))
135
126
  )
136
127
  end
@@ -157,18 +148,12 @@ module Henitai
157
148
  def mutant_history_data(db, mutant, version, recorded_at)
158
149
  mutant_id = stable_mutant_id(mutant)
159
150
  existing = existing_mutant_row(db, mutant_id)
160
- history = existing_status_history(existing)
161
- history << mutation_history_entry(mutant, version, recorded_at)
151
+ history = existing_status_history(existing) << mutation_history_entry(mutant, version, recorded_at)
162
152
  first_seen = first_seen_metadata(existing, version, recorded_at)
163
153
 
164
154
  {
165
- mutant_id: mutant_id,
166
- first_seen_version: first_seen[:version],
167
- first_seen_at: first_seen[:at],
168
- version: version,
169
- recorded_at: recorded_at,
170
- mutant: mutant,
171
- history: history,
155
+ mutant_id:, version:, recorded_at:, mutant:, history:, existing_row: existing,
156
+ first_seen_version: first_seen[:version], first_seen_at: first_seen[:at],
172
157
  days_alive: days_alive_since(first_seen[:at], recorded_at)
173
158
  }
174
159
  end
@@ -248,17 +233,18 @@ module Henitai
248
233
  end
249
234
 
250
235
  def upsert_mutant_bindings(data)
236
+ mutant = data.fetch(:mutant)
251
237
  [
252
238
  data.fetch(:mutant_id),
253
239
  data.fetch(:first_seen_version),
254
240
  data.fetch(:first_seen_at),
255
241
  data.fetch(:version),
256
242
  data.fetch(:recorded_at).iso8601,
257
- data.fetch(:mutant).status.to_s,
243
+ mutant.status.to_s,
258
244
  JSON.generate(data.fetch(:history)),
259
- data.fetch(:days_alive)
245
+ data.fetch(:days_alive),
246
+ *verdict_cache_bindings(mutant, data[:existing_row])
260
247
  ]
261
248
  end
262
249
  end
263
250
  end
264
- # rubocop:enable Metrics/ClassLength