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
@@ -7,12 +7,19 @@ require "sqlite3"
7
7
  require "time"
8
8
  require_relative "mutant_identity"
9
9
  require_relative "mutant_history_store/sql"
10
+ require_relative "mutant_history_store/verdict_cache"
10
11
 
11
12
  module Henitai
12
13
  # Persists mutant outcomes across runs in a lightweight SQLite database.
13
14
  class MutantHistoryStore
14
- 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)
15
21
  @path = path
22
+ @per_test_coverage = per_test_coverage
16
23
  end
17
24
 
18
25
  attr_reader :path
@@ -31,6 +38,27 @@ module Henitai
31
38
  end
32
39
  end
33
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
+
34
62
  def trend_report
35
63
  with_database do |db|
36
64
  ensure_schema(db)
@@ -44,6 +72,19 @@ module Henitai
44
72
 
45
73
  private
46
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
+
47
88
  def partial_rerun?(result)
48
89
  result.respond_to?(:partial_rerun?) && result.partial_rerun?
49
90
  end
@@ -59,6 +100,19 @@ module Henitai
59
100
  def ensure_schema(db)
60
101
  db.execute_batch(Sql::RUNS_TABLE)
61
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
62
116
  end
63
117
 
64
118
  def insert_run(db, result, version, recorded_at)
@@ -94,18 +148,12 @@ module Henitai
94
148
  def mutant_history_data(db, mutant, version, recorded_at)
95
149
  mutant_id = stable_mutant_id(mutant)
96
150
  existing = existing_mutant_row(db, mutant_id)
97
- history = existing_status_history(existing)
98
- history << mutation_history_entry(mutant, version, recorded_at)
151
+ history = existing_status_history(existing) << mutation_history_entry(mutant, version, recorded_at)
99
152
  first_seen = first_seen_metadata(existing, version, recorded_at)
100
153
 
101
154
  {
102
- mutant_id: mutant_id,
103
- first_seen_version: first_seen[:version],
104
- first_seen_at: first_seen[:at],
105
- version: version,
106
- recorded_at: recorded_at,
107
- mutant: mutant,
108
- 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],
109
157
  days_alive: days_alive_since(first_seen[:at], recorded_at)
110
158
  }
111
159
  end
@@ -185,15 +233,17 @@ module Henitai
185
233
  end
186
234
 
187
235
  def upsert_mutant_bindings(data)
236
+ mutant = data.fetch(:mutant)
188
237
  [
189
238
  data.fetch(:mutant_id),
190
239
  data.fetch(:first_seen_version),
191
240
  data.fetch(:first_seen_at),
192
241
  data.fetch(:version),
193
242
  data.fetch(:recorded_at).iso8601,
194
- data.fetch(:mutant).status.to_s,
243
+ mutant.status.to_s,
195
244
  JSON.generate(data.fetch(:history)),
196
- data.fetch(:days_alive)
245
+ data.fetch(:days_alive),
246
+ *verdict_cache_bindings(mutant, data[:existing_row])
197
247
  ]
198
248
  end
199
249
  end
@@ -10,10 +10,21 @@ module Henitai
10
10
  # session UUID or source coordinates, so it survives ordinary line shifts.
11
11
  module MutantIdentity
12
12
  def self.stable_id(mutant)
13
- Digest::SHA256.hexdigest(identity_components(mutant).join("\0"))
13
+ digest(identity_components(mutant))
14
+ end
15
+
16
+ # Stable id formula used before site offsets were introduced. Emitted as a
17
+ # temporary report alias so scoped runs can replace pre-migration entries.
18
+ def self.legacy_stable_id(mutant)
19
+ digest(legacy_identity_components(mutant))
14
20
  end
15
21
 
16
22
  def self.identity_components(mutant)
23
+ legacy_identity_components(mutant) + [site_offset(mutant)]
24
+ end
25
+ private_class_method :identity_components
26
+
27
+ def self.legacy_identity_components(mutant)
17
28
  [
18
29
  mutant.subject.expression,
19
30
  mutant.operator,
@@ -22,7 +33,27 @@ module Henitai
22
33
  mutation_signature(mutant)
23
34
  ]
24
35
  end
25
- private_class_method :identity_components
36
+ private_class_method :legacy_identity_components
37
+
38
+ def self.digest(components)
39
+ Digest::SHA256.hexdigest(components.join("\0"))
40
+ end
41
+ private_class_method :digest
42
+
43
+ # Position of the mutation site relative to its subject's own start,
44
+ # not the file's absolute line/column — this still disambiguates two
45
+ # call sites that produce the same operator/description/signature
46
+ # within one subject (e.g. two `MethodExpression — replaced with nil`
47
+ # mutants on different lines of a method), while surviving line drift
48
+ # elsewhere in the file, which would shift absolute coordinates but
49
+ # not this offset.
50
+ def self.site_offset(mutant)
51
+ subject_start = mutant.subject.source_range&.begin
52
+ return mutant.location[:start_col].to_s unless subject_start
53
+
54
+ "#{mutant.location[:start_line] - subject_start}:#{mutant.location[:start_col]}"
55
+ end
56
+ private_class_method :site_offset
26
57
 
27
58
  def self.mutation_signature(mutant)
28
59
  Unparser.unparse(mutant.mutated_node)
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Henitai
6
+ # Reads `# henitai:disable` magic comments from subject source files and
7
+ # decides whether a mutant is excluded from the run.
8
+ #
9
+ # Grammar (backward compatible — a bare directive means "all operators"):
10
+ #
11
+ # # henitai:disable all operators, current scope
12
+ # # henitai:disable -- prose same; `--` introduces prose
13
+ # # henitai:disable OpA, OpB only the named operators
14
+ # # henitai:disable OpA: reason reason lands in the report
15
+ # # henitai:disable: reason all operators, with reason
16
+ # # henitai:disable-start [ops][: reason] region begin
17
+ # # henitai:disable-end region end
18
+ #
19
+ # Scopes: trailing comment (line), standalone comment directly above a
20
+ # `def` (method), and disable-start/disable-end pairs (region, no nesting).
21
+ # Operator names must exactly match the canonical registry names
22
+ # (`henitai operator list`); unknown names, unmatched or nested region
23
+ # directives raise Henitai::ConfigurationError with file:line.
24
+ #
25
+ # Matching mutants are reported as ignored by {StaticFilter}, not dropped.
26
+ class MutationSkipDirectives
27
+ DIRECTIVE = /\A#\s*henitai:disable(?<kind>-start|-end)?(?<rest>[:\s].*)?\z/
28
+ VALID_OPERATOR_NAMES = Operator::FULL_SET
29
+
30
+ # A parsed directive: +operators+ is nil (all) or a Set of canonical
31
+ # operator names; +reason+ is optional free text shown in reports.
32
+ Directive = Data.define(:operators, :reason) do
33
+ def match?(operator)
34
+ operators.nil? || operators.include?(operator.to_s)
35
+ end
36
+ end
37
+
38
+ # Per-file directives, classified by scope.
39
+ Index = Data.define(:trailing, :standalone, :regions, :standalone_comment_lines)
40
+ EMPTY_INDEX = Index.new(
41
+ trailing: {}.freeze,
42
+ standalone: {}.freeze,
43
+ regions: [].freeze,
44
+ standalone_comment_lines: Set.new.freeze
45
+ ).freeze
46
+
47
+ def initialize
48
+ @index_cache = {}
49
+ end
50
+
51
+ def skip?(mutant)
52
+ !directive_for(mutant).nil?
53
+ end
54
+
55
+ # @return [Directive, nil] the directive excluding this mutant, if any.
56
+ def directive_for(mutant)
57
+ line_directive_for(mutant) || region_directive_for(mutant) || method_directive_for(mutant)
58
+ end
59
+
60
+ private
61
+
62
+ def line_directive_for(mutant)
63
+ directive = index_for(mutant.location[:file]).trailing[mutant.location[:start_line]]
64
+ directive&.match?(mutant.operator) ? directive : nil
65
+ end
66
+
67
+ def region_directive_for(mutant)
68
+ index = index_for(mutant.location[:file])
69
+ line = mutant.location[:start_line]
70
+ _range, directive = index.regions.find do |range, region_directive|
71
+ range.cover?(line) && region_directive.match?(mutant.operator)
72
+ end
73
+ directive
74
+ end
75
+
76
+ def method_directive_for(mutant)
77
+ range = mutant.subject&.source_range
78
+ return nil unless range
79
+
80
+ index = index_for(mutant.subject.source_file)
81
+ line = range.begin - 1
82
+ while index.standalone_comment_lines.include?(line)
83
+ directive = index.standalone[line]
84
+ return directive if directive&.match?(mutant.operator)
85
+
86
+ line -= 1
87
+ end
88
+ nil
89
+ end
90
+
91
+ def index_for(path)
92
+ mtime = File.mtime(path)
93
+ cached_mtime, cached_index = @index_cache[path]
94
+ return cached_index if cached_mtime == mtime
95
+
96
+ index = build_index(path)
97
+ @index_cache[path] = [mtime, index]
98
+ index
99
+ rescue Errno::ENOENT, Errno::EACCES
100
+ EMPTY_INDEX
101
+ end
102
+
103
+ def build_index(path)
104
+ source = File.read(path)
105
+ lines = source.lines
106
+ builder = IndexBuilder.new(path)
107
+
108
+ Prism.parse(source).comments.sort_by { |comment| comment.location.start_line }.each do |comment|
109
+ builder.record(comment, standalone: standalone?(comment, lines))
110
+ end
111
+
112
+ builder.index
113
+ end
114
+
115
+ def standalone?(comment, lines)
116
+ prefix = lines[comment.location.start_line - 1].to_s[0...comment.location.start_column]
117
+ prefix.strip.empty?
118
+ end
119
+
120
+ # Accumulates classified directives for one file, tracking open
121
+ # disable-start regions and raising on malformed directive usage.
122
+ class IndexBuilder
123
+ def initialize(path)
124
+ @path = path
125
+ @trailing = {}
126
+ @standalone = {}
127
+ @regions = []
128
+ @standalone_comment_lines = Set.new
129
+ @open_region = nil
130
+ end
131
+
132
+ def record(comment, standalone:)
133
+ line = comment.location.start_line
134
+ @standalone_comment_lines << line if standalone
135
+ match = DIRECTIVE.match(comment.slice)
136
+ return unless match
137
+
138
+ dispatch(match, line, standalone)
139
+ end
140
+
141
+ def index
142
+ ensure_all_regions_closed!
143
+
144
+ Index.new(
145
+ trailing: @trailing.freeze,
146
+ standalone: @standalone.freeze,
147
+ regions: @regions.freeze,
148
+ standalone_comment_lines: @standalone_comment_lines.freeze
149
+ )
150
+ end
151
+
152
+ private
153
+
154
+ def ensure_all_regions_closed!
155
+ return unless @open_region
156
+
157
+ error("unclosed `henitai:disable-start` (opened at line #{@open_region.fetch(:line)})")
158
+ end
159
+
160
+ def dispatch(match, line, standalone)
161
+ case match[:kind]
162
+ when "-start" then open_region(parse_payload(match[:rest], line), line)
163
+ when "-end" then close_region(line)
164
+ else
165
+ bucket = standalone ? @standalone : @trailing
166
+ bucket[line] = parse_payload(match[:rest], line)
167
+ end
168
+ end
169
+
170
+ def open_region(directive, line)
171
+ error("nested `henitai:disable-start` at line #{line}") if @open_region
172
+
173
+ @open_region = { line:, directive: }
174
+ end
175
+
176
+ def close_region(line)
177
+ error("`henitai:disable-end` without a matching start at line #{line}") unless @open_region
178
+
179
+ @regions << [(@open_region.fetch(:line)..line), @open_region.fetch(:directive)]
180
+ @open_region = nil
181
+ end
182
+
183
+ def parse_payload(rest, line)
184
+ text = rest.to_s.strip
185
+ return Directive.new(operators: nil, reason: nil) if prose?(text)
186
+ return Directive.new(operators: nil, reason: presence(text[1..])) if text.start_with?(":")
187
+
188
+ operators_part, reason = text.split(":", 2)
189
+ Directive.new(
190
+ operators: parse_operator_names(operators_part, line),
191
+ reason: presence(reason)
192
+ )
193
+ end
194
+
195
+ # Free-form rationale after a bare directive stays valid (pre-grammar
196
+ # behavior): empty, `-- prose`, or anything whose first token cannot be
197
+ # an operator name (operators are CamelCase). Only CamelCase-looking
198
+ # tokens are validated against the registry and can raise.
199
+ def prose?(text)
200
+ text.empty? || text.start_with?("--") ||
201
+ (!text.start_with?(":") && !text.match?(/\A[A-Z]/))
202
+ end
203
+
204
+ def parse_operator_names(operators_part, line)
205
+ names = operators_part.split(",", -1).map(&:strip)
206
+ names.each do |name|
207
+ next if VALID_OPERATOR_NAMES.include?(name)
208
+
209
+ error(
210
+ "unknown operator #{name.inspect} in `henitai:disable` directive at line #{line} " \
211
+ "(valid names: `henitai operator list`)"
212
+ )
213
+ end
214
+ names.to_set
215
+ end
216
+
217
+ def presence(text)
218
+ stripped = text.to_s.strip
219
+ stripped.empty? ? nil : stripped
220
+ end
221
+
222
+ def error(message)
223
+ raise Henitai::ConfigurationError, "#{@path}: #{message}"
224
+ end
225
+ end
226
+ end
227
+ end
@@ -14,7 +14,8 @@ module Henitai
14
14
  # Additional operators (full set):
15
15
  # ArrayDeclaration, HashLiteral, RangeLiteral, SafeNavigation,
16
16
  # PatternMatch, BlockStatement, MethodExpression, AssignmentExpression,
17
- # UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap
17
+ # UnaryOperator, UpdateOperator, RegexMutator, MethodChainUnwrap,
18
+ # EqualityIdentityOperator
18
19
  #
19
20
  # Each operator subclass must implement:
20
21
  # - .node_types → Array<Symbol> AST node types this operator handles
@@ -43,6 +44,7 @@ module Henitai
43
44
  AssignmentExpression
44
45
  UnaryOperator
45
46
  UpdateOperator
47
+ EqualityIdentityOperator
46
48
  ]).freeze
47
49
 
48
50
  # @param set [Symbol] :light or :full
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parser_current"
4
+
5
+ module Henitai
6
+ module Operators
7
+ # Replaces relational operators with identity methods (`eql?`, `equal?`)
8
+ # and vice versa.
9
+ #
10
+ # This is the noisy half of the equality/identity pairing split out of
11
+ # EqualityOperator: most Ruby objects don't observably distinguish `==`
12
+ # from `eql?`/`equal?`, so these mutations are frequently unkillable by
13
+ # ordinary tests. They stay available in the full operator set rather
14
+ # than the default light set.
15
+ class EqualityIdentityOperator < Henitai::Operator
16
+ NODE_TYPES = [:send].freeze
17
+ RELATIONAL = %i[== != < > <= >= <=>].freeze
18
+ IDENTITY = %i[eql? equal?].freeze
19
+ OPERATORS = (RELATIONAL + IDENTITY).freeze
20
+
21
+ def self.node_types
22
+ NODE_TYPES
23
+ end
24
+
25
+ def mutate(node, subject:)
26
+ method_name = node.children[1]
27
+ return [] unless OPERATORS.include?(method_name)
28
+
29
+ OPERATORS.each_with_object([]) do |replacement, mutants|
30
+ next if replacement == method_name
31
+ next if RELATIONAL.include?(method_name) && RELATIONAL.include?(replacement)
32
+
33
+ mutants << build_mutant(
34
+ subject:,
35
+ original_node: node,
36
+ mutated_node: mutated_node(node, replacement),
37
+ description: "replaced #{method_name} with #{replacement}"
38
+ )
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def mutated_node(node, replacement)
45
+ receiver = node.children[0]
46
+ arguments = node.children[2..]
47
+ Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
48
+ end
49
+ end
50
+ end
51
+ end
@@ -4,10 +4,15 @@ require_relative "../parser_current"
4
4
 
5
5
  module Henitai
6
6
  module Operators
7
- # Replaces comparison operators with the other comparison operators.
7
+ # Replaces relational/equality operators with the other relational operators.
8
+ #
9
+ # Identity-method comparisons (`eql?`, `equal?`) are handled separately by
10
+ # EqualityIdentityOperator: that pairing is the hardest to kill in practice
11
+ # (most objects don't observably distinguish `==` from `eql?`/`equal?`), so
12
+ # it is kept out of the default light set.
8
13
  class EqualityOperator < Henitai::Operator
9
14
  NODE_TYPES = [:send].freeze
10
- OPERATORS = %i[== != < > <= >= <=> eql? equal?].freeze
15
+ OPERATORS = %i[== != < > <= >= <=>].freeze
11
16
 
12
17
  def self.node_types
13
18
  NODE_TYPES
@@ -33,7 +38,7 @@ module Henitai
33
38
 
34
39
  def mutated_node(node, replacement)
35
40
  receiver = node.children[0]
36
- arguments = node.children[2..] || []
41
+ arguments = node.children[2..]
37
42
  Parser::AST::Node.new(:send, [receiver, replacement, *arguments])
38
43
  end
39
44
  end
@@ -8,6 +8,7 @@ module Henitai
8
8
  module Operators
9
9
  autoload :ArithmeticOperator, "henitai/operators/arithmetic_operator"
10
10
  autoload :EqualityOperator, "henitai/operators/equality_operator"
11
+ autoload :EqualityIdentityOperator, "henitai/operators/equality_identity_operator"
11
12
  autoload :LogicalOperator, "henitai/operators/logical_operator"
12
13
  autoload :BooleanLiteral, "henitai/operators/boolean_literal"
13
14
  autoload :ConditionalExpression, "henitai/operators/conditional_expression"
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Henitai
4
+ # Read-side view of the per-test coverage map (henitai_per_test.json):
5
+ # which test files reach which source lines. The single implementation of
6
+ # the line-intersection check, shared by test selection
7
+ # (PerTestCoverageSelector) and survivor verdict reuse (IncrementalFilter),
8
+ # so both callers always agree on what "covers" means.
9
+ class PerTestCoverage
10
+ def initialize(reports_dir:, coverage_report_reader: CoverageReportReader.new)
11
+ @reports_dir = reports_dir
12
+ @coverage_report_reader = coverage_report_reader
13
+ end
14
+
15
+ def available?
16
+ !map.empty?
17
+ end
18
+
19
+ # The full-map intersection set: every test file whose covered lines
20
+ # intersect the mutant's current line range.
21
+ #
22
+ # @return [Array<String>] sorted test file paths; empty when the map or
23
+ # the mutant's location is unavailable — callers must treat that as
24
+ # "unknown", never as "not covered".
25
+ def tests_covering(mutant)
26
+ return [] unless location_available?(mutant)
27
+
28
+ map.keys.select { |test| covers?(test, mutant) }.sort
29
+ end
30
+
31
+ # True when the given test file's recorded coverage intersects the
32
+ # mutant's current line range.
33
+ def covers?(test, mutant)
34
+ return false unless location_available?(mutant)
35
+
36
+ coverage_lines_for(test, mutant).intersect?(mutant_lines(mutant))
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :reports_dir, :coverage_report_reader
42
+
43
+ def location_available?(mutant)
44
+ mutant.respond_to?(:location) &&
45
+ mutant.location.is_a?(Hash) &&
46
+ mutant.location[:file] &&
47
+ mutant.location[:start_line] &&
48
+ mutant.location[:end_line]
49
+ end
50
+
51
+ def coverage_lines_for(test, mutant)
52
+ source_map = map[test.to_s] || {}
53
+ Array(source_map[File.expand_path(mutant.location[:file])]).uniq
54
+ end
55
+
56
+ def mutant_lines(mutant)
57
+ (mutant.location[:start_line]..mutant.location[:end_line]).to_a
58
+ end
59
+
60
+ def map
61
+ @map ||= coverage_report_reader.test_lines_by_file(
62
+ File.join(reports_dir, PerTestCoverageCollector::REPORT_FILE_NAME)
63
+ )
64
+ end
65
+ end
66
+ end
@@ -18,11 +18,12 @@ module Henitai
18
18
  @coverage_by_test = Hash.new do |hash, test_file|
19
19
  hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
20
20
  end
21
+ @duration_by_test = Hash.new(0.0)
21
22
  @previous_snapshot = {}
22
23
  @warned_missing_coverage = false
23
24
  end
24
25
 
25
- def record_test(test_file)
26
+ def record_test(test_file, duration: nil)
26
27
  snapshot = current_snapshot
27
28
  return warn_missing_coverage unless snapshot
28
29
 
@@ -31,6 +32,7 @@ module Henitai
31
32
  @coverage_by_test[test_file][source_file].uniq!
32
33
  @coverage_by_test[test_file][source_file].sort!
33
34
  end
35
+ @duration_by_test[test_file] += duration.to_f
34
36
  @previous_snapshot = snapshot
35
37
  end
36
38
 
@@ -61,11 +63,20 @@ module Henitai
61
63
 
62
64
  def warn_missing_coverage
63
65
  return if @warned_missing_coverage
66
+ return if coverage_suppressed?
64
67
 
65
68
  warn "Per-test coverage unavailable; skipping coverage formatter output"
66
69
  @warned_missing_coverage = true
67
70
  end
68
71
 
72
+ # Mutant children deliberately suppress coverage startup
73
+ # (CoverageRuntimeSuppressors), so missing coverage is expected there and
74
+ # warning on every child run is pure noise.
75
+ def coverage_suppressed?
76
+ defined?(Henitai::Integration::CoverageRuntimeSuppressors) &&
77
+ Henitai::Integration::CoverageRuntimeSuppressors.active?
78
+ end
79
+
69
80
  def new_lines(snapshot)
70
81
  snapshot.each_with_object({}) do |(source_file, file_coverage), result|
71
82
  next unless source_file?(source_file)
@@ -78,10 +89,13 @@ module Henitai
78
89
  end
79
90
  end
80
91
 
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).
81
96
  def new_line_numbers(file_coverage, previous_counts)
82
97
  line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
83
- next unless count.to_i.positive?
84
- next if previous_counts.fetch(index, 0).to_i.positive?
98
+ next unless count.to_i > previous_counts.fetch(index, 0).to_i
85
99
 
86
100
  index + 1
87
101
  end
@@ -111,10 +125,11 @@ module Henitai
111
125
  end
112
126
 
113
127
  def serializable_report
114
- @coverage_by_test.transform_values do |source_map|
115
- source_map.to_h do |source_file, lines|
128
+ @coverage_by_test.to_h do |test_file, source_map|
129
+ coverage = source_map.to_h do |source_file, lines|
116
130
  [File.expand_path(source_file), lines.uniq.sort]
117
131
  end
132
+ [test_file, { "coverage" => coverage, "duration" => @duration_by_test[test_file] }]
118
133
  end
119
134
  end
120
135
  end