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(branch) ⇒ Object



141
142
143
144
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 141

def branch_key(branch)
  hash = branch.is_a?(Hash) ? branch : {}
  [hash["type"] || hash[:type], hash["start_line"] || hash[:start_line], hash["end_line"] || hash[: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", root: nil, groups: nil, generated_at: Time.now) ⇒ Object

Standalone HTML report with summary, file table, and per-file source details. rubocop:disable Metrics/AbcSize -- linear assembly of overview, file table, sections, asset reads



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 14

def emit_html(coverage_blob, title: "Polyrun coverage", root: nil, groups: nil, generated_at: Time.now)
  files = coverage_blob.keys.sort.map { |path| html_file_payload(path, coverage_blob[path], root) }
  summary = html_summary(files)
  groups_html = render_html_partial("groups_table", group_rows_html: html_group_rows(groups).join("\n"))
  overview_html = render_html_partial(
    "overview",
    summary: summary,
    summary_badge_class: html_badge_class(summary[:line_percent]),
    groups_html: groups_html
  )
  file_list_html = render_html_partial("file_list", file_rows_html: files.map { |file| html_file_list_row(file) }.join("\n"))
  file_sections_html = files.map { |file| render_html_partial("file_section", file: file) }.join("\n")
  ERB.new(File.read(html_template_path, encoding: Encoding::UTF_8), trim_mode: "-").result_with_hash(
    title: CGI.escapeHTML(title.to_s),
    generated_label: html_generated_label(generated_at),
    overview_html: overview_html,
    file_list_html: file_list_html,
    file_sections_html: file_sections_html,
    stylesheet: File.read(html_stylesheet_path, encoding: Encoding::UTF_8),
    javascript: File.read(html_javascript_path, encoding: Encoding::UTF_8)
  )
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



178
179
180
181
182
183
184
# File 'lib/polyrun/coverage/merge/formatters.rb', line 178

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

.html_asset_dirObject

rubocop:enable Metrics/AbcSize



38
39
40
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 38

def html_asset_dir
  File.join(__dir__, "html")
end

.html_badge_class(percent) ⇒ Object



174
175
176
177
178
179
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 174

def html_badge_class(percent)
  return "green" if percent >= 90.0
  return "yellow" if percent >= 80.0

  "red"
end

.html_display_path(path, root) ⇒ Object



160
161
162
163
164
165
166
167
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 160

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

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

.html_file_list_row(file) ⇒ Object



121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 121

def html_file_list_row(file)
  <<~ROW.strip
    <tr class="t-file">
      <td class="strong t-file__name"><a href="##{file[:id]}" class="src_link" title="#{CGI.escapeHTML(file[:display_path])}">#{CGI.escapeHTML(file[:display_path])}</a></td>
      <td class="cell--number"><span class="badge #{html_badge_class(file[:line_percent])}">#{format("%.2f", file[:line_percent])}%</span></td>
      <td class="cell--number">#{file[:total_lines]}</td>
      <td class="cell--number">#{file[:relevant]}</td>
      <td class="cell--number">#{file[:covered]}</td>
      <td class="cell--number">#{file[:missed]}</td>
      <td class="cell--number">#{format("%.2f", file[:avg_hits])}</td>
    </tr>
  ROW
end

.html_file_payload(path, file, root) ⇒ Object



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 62

def html_file_payload(path, file, root)
  line_arr = line_array_from_file_entry(file) || []
  source_lines = html_source_lines(path, line_arr.length)
  counts = line_counts(file)
  relevant = counts[:relevant]
  covered = counts[:covered]
  total_hits = line_arr.sum { |hit| html_numeric_hit(hit) || 0 }
  pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
  {
    id: "file-#{Digest::SHA1.hexdigest(path.to_s)}",
    path: path.to_s,
    display_path: html_display_path(path, root),
    badge_class: html_badge_class(pct),
    total_lines: [source_lines.length, line_arr.length].max,
    relevant: relevant,
    covered: covered,
    missed: relevant - covered,
    avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0,
    line_percent: pct,
    source_rows: html_source_rows(source_lines, line_arr)
  }
end

.html_generated_label(generated_at) ⇒ Object



169
170
171
172
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 169

def html_generated_label(generated_at)
  t = generated_at.is_a?(Time) ? generated_at : Time.now
  t.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
end

.html_group_percent(data) ⇒ Object



113
114
115
116
117
118
119
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 113

def html_group_percent(data)
  return 0.0 unless data.is_a?(Hash)

  lines = data["lines"] || data[:lines]
  pct = lines.is_a?(Hash) ? (lines["covered_percent"] || lines[:covered_percent]) : nil
  pct.to_f
end

.html_group_rows(groups) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 99

def html_group_rows(groups)
  return [] unless groups.is_a?(Hash) && !groups.empty?

  groups.map do |name, data|
    pct = html_group_percent(data)
    <<~ROW.strip
      <tr>
        <td>#{CGI.escapeHTML(name.to_s)}</td>
        <td class="cell--number"><span class="badge #{html_badge_class(pct)}">#{format("%.2f", pct)}%</span></td>
      </tr>
    ROW
  end
end

.html_hit_label(hit) ⇒ Object



187
188
189
190
191
192
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 187

def html_hit_label(hit)
  return "" if hit.nil?
  return "ignored" if hit == "ignored"

  hit.to_i.to_s
end

.html_javascript_pathObject



50
51
52
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 50

def html_javascript_path
  File.join(html_asset_dir, "report.js")
end

.html_line_class(hit) ⇒ Object



194
195
196
197
198
199
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 194

def html_line_class(hit)
  return "line-none" if hit.nil?
  return "line-ignored" if hit == "ignored"

  hit.to_i.positive? ? "line-covered" : "line-missed"
end

.html_numeric_hit(hit) ⇒ Object



181
182
183
184
185
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 181

def html_numeric_hit(hit)
  return nil if hit.nil? || hit == "ignored"

  hit.to_i
end

.html_partial_path(name) ⇒ Object



54
55
56
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 54

def html_partial_path(name)
  File.join(html_asset_dir, "_#{name}.html.erb")
end

.html_source_lines(path, fallback_length) ⇒ Object



152
153
154
155
156
157
158
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 152

def html_source_lines(path, fallback_length)
  return Array.new(fallback_length, "") unless File.file?(path.to_s)

  File.readlines(path.to_s, chomp: true, encoding: Encoding::UTF_8)
rescue Errno::ENOENT, Errno::EACCES, ArgumentError
  Array.new(fallback_length, "")
end

.html_source_rows(source_lines, line_arr) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 135

def html_source_rows(source_lines, line_arr)
  max_len = [source_lines.length, line_arr.length].max
  Array.new(max_len) do |idx|
    source = source_lines[idx] || ""
    hit = line_arr[idx]
    css = html_line_class(hit)
    hits_label = html_hit_label(hit)
    <<~ROW.strip
      <tr class="#{css}">
        <td class="line-num">#{idx + 1}</td>
        <td class="line-hits">#{hits_label}</td>
        <td class="line-source"><code>#{source.empty? ? "&nbsp;" : CGI.escapeHTML(source)}</code></td>
      </tr>
    ROW
  end
end

.html_stylesheet_pathObject



46
47
48
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 46

def html_stylesheet_path
  File.join(html_asset_dir, "report.css")
end

.html_summary(files) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 85

def html_summary(files)
  relevant = files.sum { |file| file[:relevant] }
  covered = files.sum { |file| file[:covered] }
  total_hits = files.sum { |file| file[:avg_hits] * file[:relevant] }
  {
    files: files.length,
    lines_relevant: relevant,
    lines_covered: covered,
    lines_missed: relevant - covered,
    line_percent: relevant.positive? ? (100.0 * covered / relevant) : 0.0,
    avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0
  }
end

.html_template_pathObject



42
43
44
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 42

def html_template_path
  File.join(html_asset_dir, "template.html.erb")
end

.line_array_from_file_entry(file) ⇒ Object



50
51
52
53
54
55
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 50

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

  hash["lines"] || hash[: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
# File 'lib/polyrun/coverage/merge/formatters.rb', line 155

def line_counts(file_entry)
  if native_acceleration?
    MergeNative.line_counts(file_entry)
  else
    line_counts_ruby(file_entry)
  end
end

.line_counts_ruby(file_entry) ⇒ Object



163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/polyrun/coverage/merge/formatters.rb', line 163

def line_counts_ruby(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 |hit|
    next if hit.nil? || hit == "ignored"

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

.line_hit_to_i(value) ⇒ Object



110
111
112
113
114
115
116
117
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 110

def line_hit_to_i(value)
  case value
  when Integer then value
  when nil then nil
  else
    Integer(value, 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(left, right) ⇒ Object



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

def merge_branch_arrays(left, right)
  return nil if left.nil? && right.nil?
  return (left || right).dup if left.nil? || right.nil?

  index = {}
  [left, right].each do |array|
    array.each do |branch|
      next unless branch.is_a?(Hash)

      key = branch_key(branch)
      existing = index[key]
      index[key] =
        if existing
          merge_branch_entries(existing, branch)
        else
          branch.dup
        end
    end
  end
  index.values.sort_by { |branch| branch_key(branch) }
end

.merge_branch_arrays_for_native(left, right) ⇒ Object



146
147
148
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 146

def merge_branch_arrays_for_native(left, right)
  merge_branch_arrays(left, right)
end

.merge_branch_entries(left, right) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 154

def merge_branch_entries(left, right)
  out = left.is_a?(Hash) ? left.dup : {}
  left_count = (left["coverage"] || left[:coverage]).to_i
  right_count = (right["coverage"] || right[:coverage]).to_i
  out["coverage"] = left_count + right_count
  out
end

.merge_file_entry(left, right) ⇒ Object



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

def merge_file_entry(left, right)
  left = normalize_file_entry(left)
  right = normalize_file_entry(right)
  return right if left.nil?
  return left if right.nil?

  lines = merge_line_arrays(left["lines"] || left[:lines], right["lines"] || right[:lines])
  entry = {"lines" => lines}
  left_branches = left["branches"] || left[:branches]
  right_branches = right["branches"] || right[:branches]
  branches = merge_branch_arrays(left_branches, right_branches)
  entry["branches"] = branches if branches
  entry
end

.merge_files(paths) ⇒ Object

Merged coverage blob only (same as merge_fragments(paths)[:blob]). 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(left, right) ⇒ Object



72
73
74
75
76
77
78
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 72

def merge_line_arrays(left, right)
  if native_acceleration?
    MergeNative.merge_line_arrays(left, right)
  else
    merge_line_arrays_ruby(left, right)
  end
end

.merge_line_arrays_ruby(left, right) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 80

def merge_line_arrays_ruby(left, right)
  left ||= []
  right ||= []
  left_size = left.size
  right_size = right.size
  max_len = (left_size > right_size) ? left_size : right_size
  out = Array.new(max_len)
  index = 0
  while index < max_len
    out[index] = merge_line_hits(left[index], right[index])
    index += 1
  end
  out
end

.merge_line_hits(left, right) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 95

def merge_line_hits(left, right)
  return right if left.nil?
  return left if right.nil?
  return "ignored" if left == "ignored" || right == "ignored"

  left_i = line_hit_to_i(left)
  right_i = line_hit_to_i(right)
  return left_i + right_i if left_i && right_i

  return right_i if left_i.nil? && right_i
  return left_i if right_i.nil? && left_i

  left
end

.merge_two(a, b) ⇒ Object



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

def merge_two(a, b)
  if native_acceleration?
    MergeNative.merge_two(a, b)
  else
    merge_two_ruby(a, b)
  end
end

.merge_two_by_keys(primary, secondary) ⇒ Object



24
25
26
27
28
29
30
31
32
33
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 24

def merge_two_by_keys(primary, secondary)
  out = {}
  primary.each do |path, entry|
    out[path] = merge_file_entry(entry, secondary[path])
  end
  secondary.each do |path, entry|
    out[path] = entry unless out.key?(path)
  end
  out
end

.merge_two_ruby(a, b) ⇒ Object



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

def merge_two_ruby(a, b)
  a = {} if a.nil?
  b = {} if b.nil?
  if a.size >= b.size
    merge_two_by_keys(a, b)
  else
    merge_two_by_keys(b, a)
  end
end

.native_acceleration?Boolean

Returns:

  • (Boolean)


39
40
41
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 39

def native_acceleration?
  MergeNative.available?
end

.native_merge_line_arrays?Boolean

Returns:

  • (Boolean)


35
36
37
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 35

def native_merge_line_arrays?
  native_acceleration?
end

.normalize_file_entry(value) ⇒ Object



43
44
45
46
47
48
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 43

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

  value
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

.render_html_partial(name, locals = {}) ⇒ Object



58
59
60
# File 'lib/polyrun/coverage/merge/formatters_html.rb', line 58

def render_html_partial(name, locals = {})
  ERB.new(File.read(html_partial_path(name), encoding: Encoding::UTF_8), trim_mode: "-").result_with_hash(locals)
end

.sort_branches_for_native(branches) ⇒ Object



150
151
152
# File 'lib/polyrun/coverage/merge_merge_two.rb', line 150

def sort_branches_for_native(branches)
  branches.sort_by { |branch| branch_key(branch) }
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