Module: Polyrun::Coverage::Merge

Defined in:
lib/polyrun/coverage/merge.rb,
lib/polyrun/coverage/merge_merge_two.rb,
lib/polyrun/coverage/merge/formatters.rb,
lib/polyrun/coverage/merge_fragment_meta.rb,
lib/polyrun/coverage/merge/formatters_html.rb

Overview

Merges SimpleCov-compatible coverage blobs (line arrays and optional branches). Intended to be replaced or accelerated by a native extension for large suites.

Complexity: merge_two is linear in the number of file keys in its operands. Shards are combined with merge_blob_tree (pairwise rounds), so total work stays linear in the sum of blob sizes across shards (same asymptotic cost as a left fold; shallower call depth). Group recomputation after merge is O(files x groups) with one pass over files (TrackFiles.group_summaries).

Constant Summary collapse

INTERNAL_META_KEYS =
%w[polyrun_coverage_root polyrun_coverage_groups polyrun_track_files].freeze

Class Method Summary collapse

Class Method Details

.apply_track_files_once_after_merge(blob, merged_meta) ⇒ Object



38
39
40
41
42
43
44
45
46
47
# File 'lib/polyrun/coverage/merge.rb', line 38

def apply_track_files_once_after_merge(blob, merged_meta)
  return blob unless merged_meta.is_a?(Hash)

  tf = merged_meta["polyrun_track_files"]
  root = merged_meta["polyrun_coverage_root"]
  return blob if tf.nil? || root.nil?

  require_relative "track_files"
  TrackFiles.merge_untracked_into_blob(blob, root, tf)
end

.branch_key(br) ⇒ Object



103
104
105
106
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 103

def branch_key(br)
  h = br.is_a?(Hash) ? br : {}
  [h["type"] || h[:type], h["start_line"] || h[:start_line], h["end_line"] || h[:end_line]]
end

.cobertura_display_path(path, root) ⇒ Object

rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity



105
106
107
108
109
110
111
112
113
114
# File 'lib/polyrun/coverage/merge/formatters.rb', line 105

def cobertura_display_path(path, root)
  p = path.to_s
  return p if root.nil? || root.to_s.empty?

  abs = File.expand_path(p)
  r = File.expand_path(root.to_s)
  Pathname.new(abs).relative_path_from(Pathname.new(r)).to_s
rescue ArgumentError
  abs
end

.console_summary(coverage_blob) ⇒ Object

Aggregate stats for a SimpleCov-compatible coverage blob (lines arrays only).



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/polyrun/coverage/merge/formatters.rb', line 117

def console_summary(coverage_blob)
  files = 0
  relevant = 0
  covered = 0
  coverage_blob.each_value do |file|
    line_arr = line_array_from_file_entry(file)
    next unless line_arr.is_a?(Array)

    files += 1
    line_arr.each do |h|
      next if h.nil? || h == "ignored"

      relevant += 1
      covered += 1 if h.to_i > 0
    end
  end
  pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
  {
    files: files,
    lines_relevant: relevant,
    lines_covered: covered,
    line_percent: pct
  }
end

.emit_cobertura(coverage_blob, root: nil) ⇒ Object

Cobertura XML (no extra gems). Root metrics match common consumers (spec3.md). When root is set, filename on each class is relative to that directory (for tools that expect lib/...). rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity – linear XML assembly



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/polyrun/coverage/merge/formatters.rb', line 64

def emit_cobertura(coverage_blob, root: nil)
  lines_valid = 0
  lines_covered = 0
  coverage_blob.each_value do |file|
    line_arr = line_array_from_file_entry(file)
    next unless line_arr.is_a?(Array)

    line_arr.each do |hit|
      next if hit.nil? || hit == "ignored"

      lines_valid += 1
      lines_covered += 1 if hit.to_i > 0
    end
  end
  line_rate = lines_valid.positive? ? (lines_covered.to_f / lines_valid) : 0.0
  ts = Time.now.to_i

  lines = []
  lines << '<?xml version="1.0" encoding="UTF-8"?>'
  lines << '<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">'
  lines << %(<coverage line-rate="#{line_rate}" branch-rate="0" lines-covered="#{lines_covered}" lines-valid="#{lines_valid}" branches-covered="0" branches-valid="0" complexity="0" timestamp="#{ts}" version="1">)
  lines << '<packages><package name="app"><classes>'
  coverage_blob.each do |path, file|
    line_arr = line_array_from_file_entry(file)
    next unless line_arr.is_a?(Array)

    fname = CGI.escapeHTML(cobertura_display_path(path, root)).gsub("'", "&#39;")
    lines << %(<class name="#{fname}" filename="#{fname}"><lines>)
    line_arr.each_with_index do |hit, i|
      next if hit.nil? || hit == "ignored"

      n = hit.to_i
      lines << %(<line number="#{i + 1}" hits="#{n}"/>)
    end
    lines << "</lines></class>"
  end
  lines << "</classes></package></packages></coverage>\n"
  lines.join
end

.emit_html(coverage_blob, title: "Polyrun coverage") ⇒ Object

Minimal standalone HTML report (no extra gems), index listing similar to SimpleCov.



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 9

def emit_html(coverage_blob, title: "Polyrun coverage")
  summary = console_summary(coverage_blob)
  rows = []
  coverage_blob.keys.sort.each do |path|
    file = coverage_blob[path]
    pct, rel, cov = file_line_stats(file)
    esc = CGI.escapeHTML(path.to_s)
    rows << "<tr><td class=\"path\">#{esc}</td><td class=\"pct\">#{format("%.2f", pct)}</td><td class=\"hits\">#{cov} / #{rel}</td></tr>"
  end
  esc_title = CGI.escapeHTML(title.to_s)
  <<~HTML
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="utf-8"/>
      <title>#{esc_title}</title>
      <style>
        body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #1a1a1a; }
        h1 { font-size: 1.25rem; }
        .summary { margin: 1rem 0; }
        table { border-collapse: collapse; width: 100%; max-width: 56rem; }
        th, td { border: 1px solid #ccc; padding: 0.35rem 0.5rem; text-align: left; }
        th { background: #f4f4f4; }
        tr:nth-child(even) { background: #fafafa; }
        td.path { word-break: break-all; font-size: 0.9rem; }
        td.pct { white-space: nowrap; }
      </style>
    </head>
    <body>
      <h1>#{esc_title}</h1>
      <p class="summary">
        <strong>#{format("%.2f", summary[:line_percent])}%</strong> lines
        (#{summary[:lines_covered]} / #{summary[:lines_relevant]}) across #{summary[:files]} files
      </p>
      <table>
        <thead><tr><th>File</th><th>Coverage</th><th>Lines (covered / relevant)</th></tr></thead>
        <tbody>
        #{rows.join("\n")}
        </tbody>
      </table>
    </body>
    </html>
  HTML
end

.emit_lcov(coverage_blob) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/polyrun/coverage/merge/formatters.rb', line 41

def emit_lcov(coverage_blob)
  lines = []
  coverage_blob.each do |path, file|
    phys = path.to_s
    lines << "TN:polyrun"
    lines << "SF:#{phys}"
    line_arr = line_array_from_file_entry(file)
    next unless line_arr.is_a?(Array)

    line_arr.each_with_index do |hit, i|
      next if hit.nil? || hit == "ignored"

      n = hit.to_i
      lines << "DA:#{i + 1},#{n}" if n >= 0
    end
    lines << "end_of_record"
  end
  lines.join("\n") + "\n"
end

.extract_coverage_blob(data) ⇒ Object

Picks top-level export ‘coverage`, merges all suite entries (e.g. RSpec + Minitest), and combines both when present.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/polyrun/coverage/merge.rb', line 100

def extract_coverage_blob(data)
  return {} unless data.is_a?(Hash)

  top = data["coverage"]
  nested = []
  data.each do |k, v|
    next if k == "coverage"
    next unless v.is_a?(Hash) && v["coverage"].is_a?(Hash)

    nested << v["coverage"]
  end

  if nested.empty?
    return top if top.is_a?(Hash)

    return {}
  end

  merged = nested.reduce { |acc, el| merge_two(acc, el) }
  top.is_a?(Hash) ? merge_two(top, merged) : merged
end

.extract_doc_meta(d) ⇒ Object



24
25
26
# File 'lib/polyrun/coverage/merge_fragment_meta.rb', line 24

def extract_doc_meta(d)
  (d.is_a?(Hash) && d["meta"].is_a?(Hash)) ? d["meta"].transform_keys(&:to_s) : {}
end

.file_line_stats(file) ⇒ Object



170
171
172
173
174
175
176
# File 'lib/polyrun/coverage/merge/formatters.rb', line 170

def file_line_stats(file)
  c = line_counts(file)
  rel = c[:relevant]
  cov = c[:covered]
  pct = rel.positive? ? (100.0 * cov / rel) : 0.0
  [pct, rel, cov]
end

.format_console_summary(summary) ⇒ Object



142
143
144
145
146
147
148
149
150
151
# File 'lib/polyrun/coverage/merge/formatters.rb', line 142

def format_console_summary(summary)
  s = summary.is_a?(Hash) ? summary : console_summary(summary)
  format(
    "Polyrun coverage summary: %.2f%% lines (%d / %d) across %d files\n",
    s[:line_percent] || s["line_percent"],
    s[:lines_covered] || s["lines_covered"],
    s[:lines_relevant] || s["lines_relevant"],
    s[:files] || s["files"]
  )
end

.line_array_from_file_entry(file) ⇒ Object



22
23
24
25
26
27
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 22

def line_array_from_file_entry(file)
  h = normalize_file_entry(file)
  return nil unless h.is_a?(Hash)

  h["lines"] || h[:lines]
end

.line_counts(file_entry) ⇒ Object

Per-file line stats for HTML and other formatters. Integer line counts for one file entry (for O(files x groups) group aggregation).



155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/polyrun/coverage/merge/formatters.rb', line 155

def line_counts(file_entry)
  line_arr = line_array_from_file_entry(file_entry)
  return {relevant: 0, covered: 0} unless line_arr.is_a?(Array)

  relevant = 0
  covered = 0
  line_arr.each do |h|
    next if h.nil? || h == "ignored"

    relevant += 1
    covered += 1 if h.to_i > 0
  end
  {relevant: relevant, covered: covered}
end

.line_hit_to_i(v) ⇒ Object



74
75
76
77
78
79
80
81
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 74

def line_hit_to_i(v)
  case v
  when Integer then v
  when nil then nil
  else
    Integer(v, exception: false)
  end
end

.merge_blob_tree(blobs) ⇒ Object

Balanced reduction: same total merge_two work as a left fold, shallower call stack.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/polyrun/coverage/merge.rb', line 50

def merge_blob_tree(blobs)
  return {} if blobs.empty?
  return blobs.first if blobs.size == 1

  list = blobs.dup
  while list.size > 1
    nxt = []
    i = 0
    while i < list.size
      if i + 1 < list.size
        nxt << merge_two(list[i], list[i + 1])
        i += 2
      else
        nxt << list[i]
        i += 1
      end
    end
    list = nxt
  end
  list.first
end

.merge_branch_arrays(a, b) ⇒ Object



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 83

def merge_branch_arrays(a, b)
  return nil if a.nil? && b.nil?
  return (a || b).dup if a.nil? || b.nil?

  index = {}
  [a, b].each do |arr|
    arr.each do |br|
      k = branch_key(br)
      existing = index[k]
      index[k] =
        if existing
          merge_branch_entries(existing, br)
        else
          br.dup
        end
    end
  end
  index.values.sort_by { |br| branch_key(br) }
end

.merge_branch_entries(x, y) ⇒ Object



108
109
110
111
112
113
114
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 108

def merge_branch_entries(x, y)
  out = x.is_a?(Hash) ? x.dup : {}
  xc = (x["coverage"] || x[:coverage]).to_i
  yc = (y["coverage"] || y[:coverage]).to_i
  out["coverage"] = xc + yc
  out
end

.merge_file_entry(x, y) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 29

def merge_file_entry(x, y)
  x = normalize_file_entry(x)
  y = normalize_file_entry(y)
  return y if x.nil?
  return x if y.nil?

  lines = merge_line_arrays(x["lines"] || x[:lines], y["lines"] || y[:lines])
  entry = {"lines" => lines}
  bx = x["branches"] || x[:branches]
  by = y["branches"] || y[:branches]
  br = merge_branch_arrays(bx, by)
  entry["branches"] = br if br
  entry
end

.merge_files(paths) ⇒ Object

Merged coverage blob only (same as merge_fragments(paths)). Uses a balanced binary tree of merge_two calls (depth O(log k) for k shards) so work stays linear in total key count across merges; merge_two is associative.



18
19
20
# File 'lib/polyrun/coverage/merge.rb', line 18

def merge_files(paths)
  merge_fragments(paths)[:blob]
end

.merge_fragment_meta_warn_groups!(grs) ⇒ Object



34
35
36
37
38
# File 'lib/polyrun/coverage/merge_fragment_meta.rb', line 34

def merge_fragment_meta_warn_groups!(grs)
  return if grs.uniq.size <= 1

  Polyrun::Log.warn "Polyrun merge-coverage: polyrun_coverage_groups differs across fragments; using first."
end

.merge_fragment_meta_warn_root!(roots) ⇒ Object



28
29
30
31
32
# File 'lib/polyrun/coverage/merge_fragment_meta.rb', line 28

def merge_fragment_meta_warn_root!(roots)
  return if roots.uniq.size <= 1

  Polyrun::Log.warn "Polyrun merge-coverage: polyrun_coverage_root differs across fragments; using first."
end

.merge_fragment_meta_warn_track_files!(tfs) ⇒ Object



40
41
42
43
44
# File 'lib/polyrun/coverage/merge_fragment_meta.rb', line 40

def merge_fragment_meta_warn_track_files!(tfs)
  return if tfs.map { |tf| JSON.generate(normalize_track_files_meta(tf)) }.uniq.size <= 1

  Polyrun::Log.warn "Polyrun merge-coverage: polyrun_track_files differs across fragments; using first."
end

.merge_fragment_metas(docs) ⇒ Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# File 'lib/polyrun/coverage/merge_fragment_meta.rb', line 6

def merge_fragment_metas(docs)
  metas = docs.map { |d| extract_doc_meta(d) }
  base = metas.first.dup
  roots = metas.map { |m| m["polyrun_coverage_root"] }.compact
  grs = metas.map { |m| m["polyrun_coverage_groups"] }.compact
  tfs = metas.map { |m| m["polyrun_track_files"] }.compact
  merge_fragment_meta_warn_root!(roots)
  merge_fragment_meta_warn_groups!(grs)
  merge_fragment_meta_warn_track_files!(tfs)
  root = roots.first
  groups_cfg = grs.first
  track_files_cfg = tfs.first
  base["polyrun_coverage_root"] = root if root
  base["polyrun_coverage_groups"] = groups_cfg if groups_cfg
  base["polyrun_track_files"] = track_files_cfg if track_files_cfg
  base
end

.merge_fragments(paths) ⇒ Object

Returns { blob:, meta:, groups: } where groups is recomputed from merged blob when fragments include meta.polyrun_coverage_root and meta.polyrun_coverage_groups (emitted by Collector). When meta.polyrun_track_files is present (sharded runs defer per-shard untracked expansion), applies TrackFiles.merge_untracked_into_blob once on the merged blob so totals match serial.



26
27
28
29
30
31
32
33
34
35
36
# File 'lib/polyrun/coverage/merge.rb', line 26

def merge_fragments(paths)
  return {blob: {}, meta: {}, groups: nil} if paths.empty?

  docs = paths.map { |p| JSON.parse(File.read(p)) }
  blobs = docs.map { |d| extract_coverage_blob(d) }
  merged_blob = merge_blob_tree(blobs)
  merged_meta = merge_fragment_metas(docs)
  merged_blob = apply_track_files_once_after_merge(merged_blob, merged_meta)
  groups_payload = recompute_groups_from_meta(merged_blob, merged_meta)
  {blob: merged_blob, meta: merged_meta, groups: groups_payload}
end

.merge_line_arrays(a, b) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 44

def merge_line_arrays(a, b)
  a ||= []
  b ||= []
  na = a.size
  nb = b.size
  max_len = (na > nb) ? na : nb
  out = Array.new(max_len)
  i = 0
  while i < max_len
    out[i] = merge_line_hits(a[i], b[i])
    i += 1
  end
  out
end

.merge_line_hits(x, y) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 59

def merge_line_hits(x, y)
  return y if x.nil?
  return x if y.nil?
  return "ignored" if x == "ignored" || y == "ignored"

  xi = line_hit_to_i(x)
  yi = line_hit_to_i(y)
  return xi + yi if xi && yi

  return yi if xi.nil? && yi
  return xi if yi.nil? && xi

  x
end

.merge_two(a, b) ⇒ Object



6
7
8
9
10
11
12
13
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 6

def merge_two(a, b)
  keys = a.keys | b.keys
  out = {}
  keys.each do |path|
    out[path] = merge_file_entry(a[path], b[path])
  end
  out
end

.normalize_file_entry(v) ⇒ Object



15
16
17
18
19
20
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 15

def normalize_file_entry(v)
  return nil if v.nil?
  return {"lines" => v} if v.is_a?(Array)

  v
end

.normalize_track_files_meta(tf) ⇒ Object



74
75
76
77
78
79
# File 'lib/polyrun/coverage/merge.rb', line 74

def normalize_track_files_meta(tf)
  case tf
  when Array then tf.map(&:to_s).sort
  else [tf.to_s]
  end
end

.parse_file(path) ⇒ Object



92
93
94
95
96
# File 'lib/polyrun/coverage/merge.rb', line 92

def parse_file(path)
  text = File.read(path)
  data = JSON.parse(text)
  extract_coverage_blob(data)
end

.recompute_groups_from_meta(blob, merged_meta) ⇒ Object



81
82
83
84
85
86
87
88
89
90
# File 'lib/polyrun/coverage/merge.rb', line 81

def recompute_groups_from_meta(blob, merged_meta)
  return nil unless merged_meta.is_a?(Hash)

  r = merged_meta["polyrun_coverage_root"]
  g = merged_meta["polyrun_coverage_groups"]
  return nil if r.nil? || g.nil? || g.empty?

  require_relative "track_files"
  TrackFiles.group_summaries(blob, r, g)
end

.stringify_keys_deep(obj) ⇒ Object



30
31
32
33
34
35
36
37
38
39
# File 'lib/polyrun/coverage/merge/formatters.rb', line 30

def stringify_keys_deep(obj)
  case obj
  when Hash
    obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
  when Array
    obj.map { |e| stringify_keys_deep(e) }
  else
    obj
  end
end

.to_simplecov_json(coverage_blob, meta: {}, groups: nil, strip_internal_meta: true) ⇒ Object



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# File 'lib/polyrun/coverage/merge/formatters.rb', line 9

def to_simplecov_json(coverage_blob, meta: {}, groups: nil, strip_internal_meta: true)
  m = meta.is_a?(Hash) ? meta : {}
  meta_out = {}
  m.each { |k, v| meta_out[k.to_s] = v }
  if strip_internal_meta
    INTERNAL_META_KEYS.each { |k| meta_out.delete(k) }
  end
  meta_out["simplecov_version"] ||= Polyrun::VERSION
  g =
    if groups.nil?
      {}
    else
      stringify_keys_deep(groups)
    end
  {
    "meta" => meta_out,
    "coverage" => stringify_keys_deep(coverage_blob),
    "groups" => g
  }
end