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
@@ -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
 
@@ -53,17 +55,28 @@ module Henitai
53
55
 
54
56
  def current_snapshot
55
57
  Coverage.peek_result
56
- rescue StandardError
58
+ rescue RuntimeError
59
+ # Raised when coverage measurement is not running; caller warns and skips.
60
+ # Other errors are genuine bugs and must surface.
57
61
  nil
58
62
  end
59
63
 
60
64
  def warn_missing_coverage
61
65
  return if @warned_missing_coverage
66
+ return if coverage_suppressed?
62
67
 
63
68
  warn "Per-test coverage unavailable; skipping coverage formatter output"
64
69
  @warned_missing_coverage = true
65
70
  end
66
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
+
67
80
  def new_lines(snapshot)
68
81
  snapshot.each_with_object({}) do |(source_file, file_coverage), result|
69
82
  next unless source_file?(source_file)
@@ -76,10 +89,13 @@ module Henitai
76
89
  end
77
90
  end
78
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).
79
96
  def new_line_numbers(file_coverage, previous_counts)
80
97
  line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
81
- next unless count.to_i.positive?
82
- next if previous_counts.fetch(index, 0).to_i.positive?
98
+ next unless count.to_i > previous_counts.fetch(index, 0).to_i
83
99
 
84
100
  index + 1
85
101
  end
@@ -109,10 +125,11 @@ module Henitai
109
125
  end
110
126
 
111
127
  def serializable_report
112
- @coverage_by_test.transform_values do |source_map|
113
- 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|
114
130
  [File.expand_path(source_file), lines.uniq.sort]
115
131
  end
132
+ [test_file, { "coverage" => coverage, "duration" => @duration_by_test[test_file] }]
116
133
  end
117
134
  end
118
135
  end
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Henitai
4
4
  # Narrows candidate test files using the per-test coverage report.
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.
5
10
  class PerTestCoverageSelector
6
11
  def initialize(coverage_report_reader: CoverageReportReader.new)
7
12
  @coverage_report_reader = coverage_report_reader
@@ -10,49 +15,22 @@ module Henitai
10
15
  def filter(tests, mutant, reports_dir:)
11
16
  candidates = Array(tests)
12
17
  return candidates if candidates.empty?
13
- return candidates unless location_available?(mutant)
14
- return candidates unless per_test_coverage_available?(reports_dir)
15
18
 
19
+ coverage = per_test_coverage(reports_dir)
16
20
  covered_tests = candidates.select do |test|
17
- covers_mutant?(test, mutant, reports_dir)
21
+ coverage.covers?(test, mutant)
18
22
  end
19
23
  covered_tests.empty? ? candidates : covered_tests
20
24
  end
21
25
 
22
26
  private
23
27
 
24
- def location_available?(mutant)
25
- mutant.respond_to?(:location) &&
26
- mutant.location.is_a?(Hash) &&
27
- mutant.location[:file] &&
28
- mutant.location[:start_line] &&
29
- mutant.location[:end_line]
30
- end
31
-
32
- def covers_mutant?(test, mutant, reports_dir)
33
- covered_lines = coverage_lines_for(test, mutant, reports_dir)
34
- mutant_lines(mutant).any? { |line| covered_lines.include?(line) }
35
- end
36
-
37
- def coverage_lines_for(test, mutant, reports_dir)
38
- source_map = per_test_coverage(reports_dir)[test.to_s] || {}
39
- Array(source_map[File.expand_path(mutant.location[:file])]).uniq
40
- end
41
-
42
- def mutant_lines(mutant)
43
- (mutant.location[:start_line]..mutant.location[:end_line]).to_a
44
- end
45
-
46
28
  def per_test_coverage(reports_dir)
47
29
  @per_test_coverage ||= {}
48
- @per_test_coverage[reports_dir] ||= begin
49
- path = File.join(reports_dir, "henitai_per_test.json")
50
- coverage_report_reader.test_lines_by_file(path)
51
- end
52
- end
53
-
54
- def per_test_coverage_available?(reports_dir)
55
- !per_test_coverage(reports_dir).empty?
30
+ @per_test_coverage[reports_dir] ||= PerTestCoverage.new(
31
+ reports_dir:,
32
+ coverage_report_reader:
33
+ )
56
34
  end
57
35
 
58
36
  attr_reader :coverage_report_reader