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
- .apply_track_files_once_after_merge(blob, merged_meta) ⇒ Object
- .branch_key(br) ⇒ Object
-
.cobertura_display_path(path, root) ⇒ Object
rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity.
-
.console_summary(coverage_blob) ⇒ Object
Aggregate stats for a SimpleCov-compatible coverage blob (lines arrays only).
-
.emit_cobertura(coverage_blob, root: nil) ⇒ Object
Cobertura XML (no extra gems).
-
.emit_html(coverage_blob, title: "Polyrun coverage") ⇒ Object
Minimal standalone HTML report (no extra gems), index listing similar to SimpleCov.
- .emit_lcov(coverage_blob) ⇒ Object
-
.extract_coverage_blob(data) ⇒ Object
Picks top-level export ‘coverage`, merges all suite entries (e.g. RSpec + Minitest), and combines both when present.
- .extract_doc_meta(d) ⇒ Object
- .file_line_stats(file) ⇒ Object
- .format_console_summary(summary) ⇒ Object
- .line_array_from_file_entry(file) ⇒ Object
-
.line_counts(file_entry) ⇒ Object
Per-file line stats for HTML and other formatters.
- .line_hit_to_i(v) ⇒ Object
-
.merge_blob_tree(blobs) ⇒ Object
Balanced reduction: same total
merge_twowork as a left fold, shallower call stack. - .merge_branch_arrays(a, b) ⇒ Object
- .merge_branch_entries(x, y) ⇒ Object
- .merge_file_entry(x, y) ⇒ Object
-
.merge_files(paths) ⇒ Object
Merged coverage blob only (same as merge_fragments(paths)).
- .merge_fragment_meta_warn_groups!(grs) ⇒ Object
- .merge_fragment_meta_warn_root!(roots) ⇒ Object
- .merge_fragment_meta_warn_track_files!(tfs) ⇒ Object
- .merge_fragment_metas(docs) ⇒ Object
-
.merge_fragments(paths) ⇒ Object
Returns { blob:, meta:, groups: } where
groupsis recomputed from merged blob when fragments includemeta.polyrun_coverage_rootandmeta.polyrun_coverage_groups(emitted by Collector). - .merge_line_arrays(a, b) ⇒ Object
- .merge_line_hits(x, y) ⇒ Object
- .merge_two(a, b) ⇒ Object
- .normalize_file_entry(v) ⇒ Object
- .normalize_track_files_meta(tf) ⇒ Object
- .parse_file(path) ⇒ Object
- .recompute_groups_from_meta(blob, merged_meta) ⇒ Object
- .stringify_keys_deep(obj) ⇒ Object
- .to_simplecov_json(coverage_blob, meta: {}, groups: nil, strip_internal_meta: true) ⇒ Object
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, ) return blob unless .is_a?(Hash) tf = ["polyrun_track_files"] root = ["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.(p) r = File.(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("'", "'") 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 (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 (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 (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 (tfs) return if tfs.map { |tf| JSON.generate((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 (docs) = docs.map { |d| (d) } base = .first.dup roots = .map { |m| m["polyrun_coverage_root"] }.compact grs = .map { |m| m["polyrun_coverage_groups"] }.compact tfs = .map { |m| m["polyrun_track_files"] }.compact (roots) (grs) (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) = (docs) merged_blob = apply_track_files_once_after_merge(merged_blob, ) groups_payload = (merged_blob, ) {blob: merged_blob, 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 (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 (blob, ) return nil unless .is_a?(Hash) r = ["polyrun_coverage_root"] g = ["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 = .is_a?(Hash) ? : {} = {} m.each { |k, v| [k.to_s] = v } if INTERNAL_META_KEYS.each { |k| .delete(k) } end ["simplecov_version"] ||= Polyrun::VERSION g = if groups.nil? {} else stringify_keys_deep(groups) end { "meta" => , "coverage" => stringify_keys_deep(coverage_blob), "groups" => g } end |