Class: Henitai::PerTestCoverageCollector

Inherits:
Object
  • Object
show all
Defined in:
lib/henitai/per_test_coverage_collector.rb,
sig/henitai.rbs

Overview

Accumulates per-test line coverage deltas across test examples.

Framework-agnostic core used by both the RSpec and Minitest adapters. Callers invoke record_test after each test completes and write_report once the full suite has finished.

Constant Summary collapse

REPORT_DIR_ENV =

Returns:

  • (String)
"HENITAI_REPORTS_DIR"
REPORT_FILE_NAME =

Returns:

  • (String)
"henitai_per_test.json"

Instance Method Summary collapse

Constructor Details

#initializePerTestCoverageCollector

Returns a new instance of PerTestCoverageCollector.



17
18
19
20
21
22
23
24
# File 'lib/henitai/per_test_coverage_collector.rb', line 17

def initialize
  @coverage_by_test = Hash.new do |hash, test_file|
    hash[test_file] = Hash.new { |nested, source_file| nested[source_file] = [] }
  end
  @duration_by_test = Hash.new(0.0)
  @previous_snapshot = {}
  @warned_missing_coverage = false
end

Instance Method Details

#current_snapshotObject

Returns:

  • (Object)


56
57
58
59
60
61
62
# File 'lib/henitai/per_test_coverage_collector.rb', line 56

def current_snapshot
  Coverage.peek_result
rescue RuntimeError
  # Raised when coverage measurement is not running; caller warns and skips.
  # Other errors are genuine bugs and must surface.
  nil
end

#line_counts_for(file_coverage) ⇒ Array[untyped]

Parameters:

  • (Object)

Returns:

  • (Array[untyped])


108
109
110
111
112
113
114
115
# File 'lib/henitai/per_test_coverage_collector.rb', line 108

def line_counts_for(file_coverage)
  case file_coverage
  when Hash
    Array(file_coverage[:lines] || file_coverage["lines"])
  else
    Array(file_coverage)
  end
end

#new_line_numbers(file_coverage, previous_counts) ⇒ Array[Integer]

Count-delta attribution: a line belongs to every test whose run incremented its cumulative count, not just the first toucher. This keeps the per-test map a complete over-approximation of reachability — the soundness precondition for survivor verdict reuse (ADR-11).

Parameters:

  • (Object)
  • (Object)

Returns:

  • (Array[Integer])


96
97
98
99
100
101
102
# File 'lib/henitai/per_test_coverage_collector.rb', line 96

def new_line_numbers(file_coverage, previous_counts)
  line_counts_for(file_coverage).each_with_index.filter_map do |count, index|
    next unless count.to_i > previous_counts.fetch(index, 0).to_i

    index + 1
  end
end

#new_lines(snapshot) ⇒ Hash[String, Array[Integer]]

Parameters:

  • (Object)

Returns:

  • (Hash[String, Array[Integer]])


80
81
82
83
84
85
86
87
88
89
90
# File 'lib/henitai/per_test_coverage_collector.rb', line 80

def new_lines(snapshot)
  snapshot.each_with_object({}) do |(source_file, file_coverage), result|
    next unless source_file?(source_file)

    lines = new_line_numbers(
      file_coverage,
      previous_line_counts(source_file)
    )
    result[source_file] = lines unless lines.empty?
  end
end

#previous_line_counts(source_file) ⇒ Array[untyped]

Parameters:

  • (String)

Returns:

  • (Array[untyped])


104
105
106
# File 'lib/henitai/per_test_coverage_collector.rb', line 104

def previous_line_counts(source_file)
  line_counts_for(@previous_snapshot.fetch(source_file, []))
end

#record_test(test_file, duration: nil) ⇒ void

This method returns an undefined value.

Parameters:

  • (String)
  • duration: (Float, nil) (defaults to: nil)


26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/henitai/per_test_coverage_collector.rb', line 26

def record_test(test_file, duration: nil)
  snapshot = current_snapshot
  return warn_missing_coverage unless snapshot

  new_lines(snapshot).each do |source_file, lines|
    @coverage_by_test[test_file][source_file].concat(lines)
    @coverage_by_test[test_file][source_file].uniq!
    @coverage_by_test[test_file][source_file].sort!
  end
  @duration_by_test[test_file] += duration.to_f
  @previous_snapshot = snapshot
end

#report_pathString

Returns:

  • (String)


48
49
50
# File 'lib/henitai/per_test_coverage_collector.rb', line 48

def report_path
  File.join(reports_dir, REPORT_FILE_NAME)
end

#reports_dirString

Returns:

  • (String)


52
53
54
# File 'lib/henitai/per_test_coverage_collector.rb', line 52

def reports_dir
  ENV.fetch(REPORT_DIR_ENV, "coverage")
end

#serializable_reportHash[String, Hash[String, Array[Integer]]]

Returns:

  • (Hash[String, Hash[String, Array[Integer]]])


127
128
129
130
131
132
133
134
# File 'lib/henitai/per_test_coverage_collector.rb', line 127

def serializable_report
  @coverage_by_test.to_h do |test_file, source_map|
    coverage = source_map.to_h do |source_file, lines|
      [File.expand_path(source_file), lines.uniq.sort]
    end
    [test_file, { "coverage" => coverage, "duration" => @duration_by_test[test_file] }]
  end
end

#source_file?(path) ⇒ Boolean

Parameters:

  • (String)

Returns:

  • (Boolean)


117
118
119
120
121
122
123
124
125
# File 'lib/henitai/per_test_coverage_collector.rb', line 117

def source_file?(path)
  expanded = File.expand_path(path)
  prefix = "#{Dir.pwd}#{File::SEPARATOR}"
  return false unless expanded.start_with?(prefix)

  relative = expanded.sub(prefix, "")
  !relative.start_with?("spec#{File::SEPARATOR}") &&
    !relative.start_with?("test#{File::SEPARATOR}")
end

#warn_missing_coveragevoid

This method returns an undefined value.



64
65
66
67
68
69
70
# File 'lib/henitai/per_test_coverage_collector.rb', line 64

def warn_missing_coverage
  return if @warned_missing_coverage
  return if coverage_suppressed?

  warn "Per-test coverage unavailable; skipping coverage formatter output"
  @warned_missing_coverage = true
end

#write_reportvoid

This method returns an undefined value.



39
40
41
42
43
44
# File 'lib/henitai/per_test_coverage_collector.rb', line 39

def write_report
  return if @coverage_by_test.empty?

  FileUtils.mkdir_p(File.dirname(report_path))
  File.write(report_path, JSON.pretty_generate(serializable_report))
end