Module: Moult::Coverage

Defined in:
lib/moult/coverage.rb,
lib/moult/coverage/resolver.rb

Overview

Ingests line-keyed code coverage from a LOCAL FILE and normalises it into one Moult-owned value object (Dataset) the Resolver can read. This is the runtime-layer analogue of Index: external formats (SimpleCov, stdlib Coverage) come in, only Moult types go out, so the input is swappable.

Two on-disk formats are understood (auto-detected, or forced via format:):

  • :simplecov — SimpleCov's coverage/.resultset.json: {command => {"coverage" => {abs_path => {"lines" => [...]}}, "timestamp" => epoch}}. Multiple command runs are merged element-wise.
  • :coverage — a JSON dump of stdlib Coverage.result(lines: true): {abs_path => {"lines" => [...]}} or the legacy bare {abs_path => [...]}.

Line arrays are 0-indexed (index 0 = line 1) with the shared convention: nil = non-executable, 0 = executable but never run, N = hit count. oneshot_lines is intentionally unsupported: it cannot distinguish 0 from nil, so runtime-cold could not be detected.

Defined Under Namespace

Modules: Resolver Classes: Dataset, Source

Class Method Summary collapse

Class Method Details

.canonicalize(p) ⇒ Object

realpath resolves /tmp -> /private/tmp style symlinks so coverage paths line up with rubydex's canonical paths; falls back when the file is absent locally (coverage collected on another machine).



168
169
170
171
172
# File 'lib/moult/coverage.rb', line 168

def canonicalize(p)
  File.realpath(p)
rescue
  File.expand_path(p)
end

.detect_format(raw) ⇒ Object

SimpleCov nests file coverage under a command name and a "coverage" key; stdlib dumps key files at the top level. The presence of "coverage" on the first value is the unambiguous discriminator.

Raises:



75
76
77
78
79
80
81
82
83
84
85
# File 'lib/moult/coverage.rb', line 75

def detect_format(raw)
  raise Moult::Error, "coverage file is not a JSON object" unless raw.is_a?(Hash)
  sample = raw.values.first
  if sample.is_a?(Hash) && sample.key?("coverage")
    :simplecov
  elsif sample.is_a?(Array) || (sample.is_a?(Hash) && sample.key?("lines"))
    :coverage
  else
    raise Moult::Error, "could not auto-detect coverage format; pass --coverage-format simplecov|coverage"
  end
end

.extract_lines(data) ⇒ Object

Accepts both the wrapped (=> [...]) and legacy bare-array forms; ignores sibling :methods/:branches data.



126
127
128
129
130
131
# File 'lib/moult/coverage.rb', line 126

def extract_lines(data)
  case data
  when Array then data
  when Hash then data["lines"]
  end
end

.from_coverage(raw, path) ⇒ [Hash{String=>Array}, Source]

Returns abs-path line arrays + provenance.

Returns:

  • ([Hash{String=>Array}, Source])

    abs-path line arrays + provenance



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/moult/coverage.rb', line 108

def from_coverage(raw, path)
  entries = {}
  raw.each do |file, data|
    lines = extract_lines(data)
    entries[file] = lines if lines
  end
  # The raw dump carries no timestamp, so the file mtime is the best-effort
  # collected_at (noted as a fallback; only matters for deferred staleness).
  source = Source.new(
    backend: "coverage",
    version: RUBY_VERSION,
    collected_at: File.mtime(path).utc.iso8601
  )
  [entries, source]
end

.from_simplecov(raw, _path) ⇒ [Hash{String=>Array}, Source]

Returns abs-path line arrays + provenance.

Returns:

  • ([Hash{String=>Array}, Source])

    abs-path line arrays + provenance



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/moult/coverage.rb', line 88

def from_simplecov(raw, _path)
  merged = {}
  timestamps = []
  raw.each_value do |run|
    next unless run.is_a?(Hash)
    timestamps << run["timestamp"] if run["timestamp"]
    (run["coverage"] || {}).each do |file, data|
      merged[file] = merge_lines(merged[file], extract_lines(data))
    end
  end
  collected = timestamps.compact.max
  source = Source.new(
    backend: "simplecov",
    version: nil, # not recorded in the resultset
    collected_at: collected && Time.at(collected).utc.iso8601
  )
  [merged, source]
end

.load(path, root:, format: :auto) ⇒ Dataset

Parameters:

  • path (String)

    path to the coverage file

  • root (String)

    absolute analysis root (findings are relative to it)

  • format (Symbol) (defaults to: :auto)

    :auto, :simplecov, or :coverage

Returns:



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/moult/coverage.rb', line 56

def load(path, root:, format: :auto)
  raw = JSON.parse(File.read(path))
  fmt = (format == :auto) ? detect_format(raw) : format
  abs_entries, source = case fmt
  when :simplecov then from_simplecov(raw, path)
  when :coverage then from_coverage(raw, path)
  else raise Moult::Error, "unknown coverage format: #{fmt}"
  end
  entries, unmatched = relativize(abs_entries, root)
  Dataset.new(entries: entries, source: source, unmatched_count: unmatched)
rescue JSON::ParserError => e
  raise Moult::Error, "could not parse coverage file #{path}: #{e.message}"
rescue Errno::ENOENT
  raise Moult::Error, "no such coverage file: #{path}"
end

.merge_lines(a, b) ⇒ Object

Element-wise merge of two coverage runs: a value is hit if hit in either run (max of the non-nil values), non-executable only if nil in both.



135
136
137
138
139
140
141
142
143
144
145
# File 'lib/moult/coverage.rb', line 135

def merge_lines(a, b)
  return b if a.nil?
  return a if b.nil?
  Array.new([a.length, b.length].max) do |i|
    x, y = a[i], b[i]
    if x.nil? then y
    elsif y.nil? then x
    else [x, y].max
    end
  end
end

.relativize(abs_entries, root) ⇒ Object

Map absolute coverage paths to the root-relative paths Phase 2 emits, so the join lands on the same symbol_id components. Files outside the root are dropped and counted (a different checkout layout, vendored code, etc.).



150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/moult/coverage.rb', line 150

def relativize(abs_entries, root)
  real_root = canonicalize(root)
  entries = {}
  unmatched = 0
  abs_entries.each do |abs, lines|
    full = canonicalize(abs)
    if full == real_root || full.start_with?(real_root + File::SEPARATOR)
      entries[SymbolId.relative_path(full, real_root)] = lines
    else
      unmatched += 1
    end
  end
  [entries, unmatched]
end