polyrun 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,198 @@
1
1
  require "cgi"
2
+ require "digest/sha1"
3
+ require "erb"
4
+ require "pathname"
2
5
 
3
6
  module Polyrun
4
7
  module Coverage
5
8
  module Merge
6
9
  module_function
7
10
 
8
- # Minimal standalone HTML report (no extra gems), index listing similar to SimpleCov.
9
- def emit_html(coverage_blob, title: "Polyrun coverage")
10
- summary = console_summary(coverage_blob)
11
- rows = []
12
- coverage_blob.keys.sort.each do |path|
13
- file = coverage_blob[path]
14
- pct, rel, cov = file_line_stats(file)
15
- esc = CGI.escapeHTML(path.to_s)
16
- rows << "<tr><td class=\"path\">#{esc}</td><td class=\"pct\">#{format("%.2f", pct)}</td><td class=\"hits\">#{cov} / #{rel}</td></tr>"
11
+ # Standalone HTML report with summary, file table, and per-file source details.
12
+ def emit_html(coverage_blob, title: "Polyrun coverage", root: nil, groups: nil, generated_at: Time.now)
13
+ files = coverage_blob.keys.sort.map { |path| html_file_payload(path, coverage_blob[path], root) }
14
+ summary = html_summary(files)
15
+ groups_html = render_html_partial("groups_table", group_rows_html: html_group_rows(groups).join("\n"))
16
+ overview_html = render_html_partial(
17
+ "overview",
18
+ summary: summary,
19
+ summary_badge_class: html_badge_class(summary[:line_percent]),
20
+ groups_html: groups_html
21
+ )
22
+ file_list_html = render_html_partial("file_list", file_rows_html: files.map { |file| html_file_list_row(file) }.join("\n"))
23
+ file_sections_html = files.map { |file| render_html_partial("file_section", file: file) }.join("\n")
24
+ ERB.new(File.read(html_template_path), trim_mode: "-").result_with_hash(
25
+ title: CGI.escapeHTML(title.to_s),
26
+ generated_label: html_generated_label(generated_at),
27
+ overview_html: overview_html,
28
+ file_list_html: file_list_html,
29
+ file_sections_html: file_sections_html,
30
+ stylesheet: File.read(html_stylesheet_path),
31
+ javascript: File.read(html_javascript_path)
32
+ )
33
+ end
34
+
35
+ def html_asset_dir
36
+ File.join(__dir__, "html")
37
+ end
38
+
39
+ def html_template_path
40
+ File.join(html_asset_dir, "template.html.erb")
41
+ end
42
+
43
+ def html_stylesheet_path
44
+ File.join(html_asset_dir, "report.css")
45
+ end
46
+
47
+ def html_javascript_path
48
+ File.join(html_asset_dir, "report.js")
49
+ end
50
+
51
+ def html_partial_path(name)
52
+ File.join(html_asset_dir, "_#{name}.html.erb")
53
+ end
54
+
55
+ def render_html_partial(name, locals = {})
56
+ ERB.new(File.read(html_partial_path(name)), trim_mode: "-").result_with_hash(locals)
57
+ end
58
+
59
+ def html_file_payload(path, file, root)
60
+ line_arr = line_array_from_file_entry(file) || []
61
+ source_lines = html_source_lines(path, line_arr.length)
62
+ counts = line_counts(file)
63
+ relevant = counts[:relevant]
64
+ covered = counts[:covered]
65
+ total_hits = line_arr.sum { |hit| html_numeric_hit(hit) || 0 }
66
+ pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
67
+ {
68
+ id: "file-#{Digest::SHA1.hexdigest(path.to_s)}",
69
+ path: path.to_s,
70
+ display_path: html_display_path(path, root),
71
+ badge_class: html_badge_class(pct),
72
+ total_lines: [source_lines.length, line_arr.length].max,
73
+ relevant: relevant,
74
+ covered: covered,
75
+ missed: relevant - covered,
76
+ avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0,
77
+ line_percent: pct,
78
+ source_rows: html_source_rows(source_lines, line_arr)
79
+ }
80
+ end
81
+
82
+ def html_summary(files)
83
+ relevant = files.sum { |file| file[:relevant] }
84
+ covered = files.sum { |file| file[:covered] }
85
+ total_hits = files.sum { |file| file[:avg_hits] * file[:relevant] }
86
+ {
87
+ files: files.length,
88
+ lines_relevant: relevant,
89
+ lines_covered: covered,
90
+ lines_missed: relevant - covered,
91
+ line_percent: relevant.positive? ? (100.0 * covered / relevant) : 0.0,
92
+ avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0
93
+ }
94
+ end
95
+
96
+ def html_group_rows(groups)
97
+ return [] unless groups.is_a?(Hash) && !groups.empty?
98
+
99
+ groups.map do |name, data|
100
+ pct = html_group_percent(data)
101
+ <<~ROW.strip
102
+ <tr>
103
+ <td>#{CGI.escapeHTML(name.to_s)}</td>
104
+ <td class="cell--number"><span class="badge #{html_badge_class(pct)}">#{format("%.2f", pct)}%</span></td>
105
+ </tr>
106
+ ROW
17
107
  end
18
- esc_title = CGI.escapeHTML(title.to_s)
19
- <<~HTML
20
- <!DOCTYPE html>
21
- <html lang="en">
22
- <head>
23
- <meta charset="utf-8"/>
24
- <title>#{esc_title}</title>
25
- <style>
26
- body { font-family: system-ui, sans-serif; margin: 1.5rem; color: #1a1a1a; }
27
- h1 { font-size: 1.25rem; }
28
- .summary { margin: 1rem 0; }
29
- table { border-collapse: collapse; width: 100%; max-width: 56rem; }
30
- th, td { border: 1px solid #ccc; padding: 0.35rem 0.5rem; text-align: left; }
31
- th { background: #f4f4f4; }
32
- tr:nth-child(even) { background: #fafafa; }
33
- td.path { word-break: break-all; font-size: 0.9rem; }
34
- td.pct { white-space: nowrap; }
35
- </style>
36
- </head>
37
- <body>
38
- <h1>#{esc_title}</h1>
39
- <p class="summary">
40
- <strong>#{format("%.2f", summary[:line_percent])}%</strong> lines
41
- (#{summary[:lines_covered]} / #{summary[:lines_relevant]}) across #{summary[:files]} files
42
- </p>
43
- <table>
44
- <thead><tr><th>File</th><th>Coverage</th><th>Lines (covered / relevant)</th></tr></thead>
45
- <tbody>
46
- #{rows.join("\n")}
47
- </tbody>
48
- </table>
49
- </body>
50
- </html>
51
- HTML
108
+ end
109
+
110
+ def html_group_percent(data)
111
+ return 0.0 unless data.is_a?(Hash)
112
+
113
+ lines = data["lines"] || data[:lines]
114
+ pct = lines.is_a?(Hash) ? (lines["covered_percent"] || lines[:covered_percent]) : nil
115
+ pct.to_f
116
+ end
117
+
118
+ def html_file_list_row(file)
119
+ <<~ROW.strip
120
+ <tr class="t-file">
121
+ <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>
122
+ <td class="cell--number"><span class="badge #{html_badge_class(file[:line_percent])}">#{format("%.2f", file[:line_percent])}%</span></td>
123
+ <td class="cell--number">#{file[:total_lines]}</td>
124
+ <td class="cell--number">#{file[:relevant]}</td>
125
+ <td class="cell--number">#{file[:covered]}</td>
126
+ <td class="cell--number">#{file[:missed]}</td>
127
+ <td class="cell--number">#{format("%.2f", file[:avg_hits])}</td>
128
+ </tr>
129
+ ROW
130
+ end
131
+
132
+ def html_source_rows(source_lines, line_arr)
133
+ max_len = [source_lines.length, line_arr.length].max
134
+ Array.new(max_len) do |idx|
135
+ source = source_lines[idx] || ""
136
+ hit = line_arr[idx]
137
+ css = html_line_class(hit)
138
+ hits_label = html_hit_label(hit)
139
+ <<~ROW.strip
140
+ <tr class="#{css}">
141
+ <td class="line-num">#{idx + 1}</td>
142
+ <td class="line-hits">#{hits_label}</td>
143
+ <td class="line-source"><code>#{source.empty? ? "&nbsp;" : CGI.escapeHTML(source)}</code></td>
144
+ </tr>
145
+ ROW
146
+ end
147
+ end
148
+
149
+ def html_source_lines(path, fallback_length)
150
+ return Array.new(fallback_length, "") unless File.file?(path.to_s)
151
+
152
+ File.readlines(path.to_s, chomp: true)
153
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
154
+ Array.new(fallback_length, "")
155
+ end
156
+
157
+ def html_display_path(path, root)
158
+ p = File.expand_path(path.to_s)
159
+ return p if root.nil? || root.to_s.empty?
160
+
161
+ Pathname.new(p).relative_path_from(Pathname.new(File.expand_path(root.to_s))).to_s
162
+ rescue ArgumentError
163
+ p
164
+ end
165
+
166
+ def html_generated_label(generated_at)
167
+ t = generated_at.is_a?(Time) ? generated_at : Time.now
168
+ t.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
169
+ end
170
+
171
+ def html_badge_class(percent)
172
+ return "green" if percent >= 90.0
173
+ return "yellow" if percent >= 80.0
174
+
175
+ "red"
176
+ end
177
+
178
+ def html_numeric_hit(hit)
179
+ return nil if hit.nil? || hit == "ignored"
180
+
181
+ hit.to_i
182
+ end
183
+
184
+ def html_hit_label(hit)
185
+ return "" if hit.nil?
186
+ return "ignored" if hit == "ignored"
187
+
188
+ hit.to_i.to_s
189
+ end
190
+
191
+ def html_line_class(hit)
192
+ return "line-none" if hit.nil?
193
+ return "line-ignored" if hit == "ignored"
194
+
195
+ hit.to_i.positive? ? "line-covered" : "line-missed"
52
196
  end
53
197
  end
54
198
  end
@@ -0,0 +1,21 @@
1
+ <div class="panel file_list_container" id="file-list">
2
+ <h2>Files</h2>
3
+ <div class="file_list--responsive">
4
+ <table class="file_list" data-sortable="true">
5
+ <thead>
6
+ <tr>
7
+ <th>File</th>
8
+ <th class="cell--number">% covered</th>
9
+ <th class="cell--number">Lines</th>
10
+ <th class="cell--number">Relevant Lines</th>
11
+ <th class="cell--number">Lines covered</th>
12
+ <th class="cell--number">Lines missed</th>
13
+ <th class="cell--number">Avg. Hits / Line</th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ <%= file_rows_html %>
18
+ </tbody>
19
+ </table>
20
+ </div>
21
+ </div>
@@ -0,0 +1,26 @@
1
+ <div class="panel file-section source_file" id="<%= file[:id] %>">
2
+ <div class="file-heading">
3
+ <h2><%= CGI.escapeHTML(file[:display_path]) %></h2>
4
+ <a href="#overview">Back to summary</a>
5
+ </div>
6
+ <div class="file-meta">
7
+ <span class="badge <%= file[:badge_class] %>"><%= format("%.2f", file[:line_percent]) %>%</span>
8
+ <%= file[:covered] %> / <%= file[:relevant] %> relevant lines covered,
9
+ <%= file[:missed] %> missed,
10
+ <%= format("%.2f", file[:avg_hits]) %> avg. hits / line
11
+ </div>
12
+ <div class="file_list--responsive">
13
+ <table class="source_table">
14
+ <thead>
15
+ <tr>
16
+ <th class="line-num">Line</th>
17
+ <th class="line-hits">Hits</th>
18
+ <th class="line-source">Source</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <%= file[:source_rows].join("\n") %>
23
+ </tbody>
24
+ </table>
25
+ </div>
26
+ </div>
@@ -0,0 +1,18 @@
1
+ <% unless group_rows_html.empty? %>
2
+ <div class="group-summary">
3
+ <h3>Groups</h3>
4
+ <div class="file_list--responsive">
5
+ <table class="group-table" data-sortable="true">
6
+ <thead>
7
+ <tr>
8
+ <th>Group</th>
9
+ <th class="cell--number">% covered</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody>
13
+ <%= group_rows_html %>
14
+ </tbody>
15
+ </table>
16
+ </div>
17
+ </div>
18
+ <% end %>
@@ -0,0 +1,47 @@
1
+ <div class="panel file_list_container" id="overview">
2
+ <h2>
3
+ <span class="group_name">All Files</span>
4
+ (<span class="covered_percent"><span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:line_percent]) %>%</span></span>
5
+ covered at
6
+ <span class="covered_strength"><span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:avg_hits]) %></span></span>
7
+ hits/line)
8
+ </h2>
9
+
10
+ <div><b><%= summary[:files] %></b> files in total.</div>
11
+ <div class="t-line-summary">
12
+ <b><%= summary[:lines_relevant] %></b> relevant lines,
13
+ <span class="green"><b><%= summary[:lines_covered] %></b> lines covered</span> and
14
+ <span class="red"><b><%= summary[:lines_missed] %></b> lines missed</span>
15
+ (<span class="<%= summary_badge_class %>"><%= format("%.2f", summary[:line_percent]) %>%</span>)
16
+ </div>
17
+
18
+ <div class="metrics">
19
+ <div class="metric">
20
+ <div class="metric-label">Coverage</div>
21
+ <div class="metric-value"><%= format("%.2f", summary[:line_percent]) %>%</div>
22
+ </div>
23
+ <div class="metric">
24
+ <div class="metric-label">Relevant Lines</div>
25
+ <div class="metric-value"><%= summary[:lines_relevant] %></div>
26
+ </div>
27
+ <div class="metric">
28
+ <div class="metric-label">Lines Covered</div>
29
+ <div class="metric-value"><%= summary[:lines_covered] %></div>
30
+ </div>
31
+ <div class="metric">
32
+ <div class="metric-label">Lines Missed</div>
33
+ <div class="metric-value"><%= summary[:lines_missed] %></div>
34
+ </div>
35
+ <div class="metric">
36
+ <div class="metric-label">Avg. Hits / Line</div>
37
+ <div class="metric-value"><%= format("%.2f", summary[:avg_hits]) %></div>
38
+ </div>
39
+ </div>
40
+
41
+ <%= groups_html %>
42
+
43
+ <div class="nav-links">
44
+ <a href="#file-list">File List</a>
45
+ <a href="#source-files">Source Files</a>
46
+ </div>
47
+ </div>
@@ -0,0 +1,147 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #f5f7fb;
4
+ --surface: #ffffff;
5
+ --border: #d7ddea;
6
+ --text: #1f2937;
7
+ --muted: #6b7280;
8
+ --green: #0f766e;
9
+ --green-bg: #ecfdf5;
10
+ --yellow: #b45309;
11
+ --yellow-bg: #fffbeb;
12
+ --red: #b91c1c;
13
+ --red-bg: #fef2f2;
14
+ --gray-bg: #f8fafc;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+
19
+ html {
20
+ scroll-behavior: smooth;
21
+ }
22
+
23
+ body {
24
+ margin: 0;
25
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ line-height: 1.45;
29
+ }
30
+
31
+ a { color: #1d4ed8; text-decoration: none; }
32
+ a:hover { text-decoration: underline; }
33
+
34
+ #wrapper { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
35
+
36
+ .topbar {
37
+ display: flex;
38
+ justify-content: space-between;
39
+ gap: 1rem;
40
+ align-items: baseline;
41
+ margin-bottom: 1rem;
42
+ }
43
+
44
+ .timestamp { color: var(--muted); font-size: 0.9rem; }
45
+
46
+ .panel {
47
+ background: var(--surface);
48
+ border: 1px solid var(--border);
49
+ border-radius: 12px;
50
+ padding: 1rem 1.25rem;
51
+ margin-bottom: 1rem;
52
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
53
+ }
54
+
55
+ .metrics {
56
+ display: grid;
57
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
58
+ gap: 0.75rem;
59
+ margin-top: 1rem;
60
+ }
61
+
62
+ .metric {
63
+ border: 1px solid var(--border);
64
+ border-radius: 10px;
65
+ padding: 0.85rem 1rem;
66
+ background: var(--gray-bg);
67
+ }
68
+
69
+ .metric-label { color: var(--muted); font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.03em; }
70
+ .metric-value { font-size: 1.4rem; font-weight: 700; margin-top: 0.2rem; }
71
+ .summary-line, .t-line-summary { margin-top: 0.75rem; color: var(--muted); }
72
+ .group-summary { margin-top: 1rem; }
73
+
74
+ table { width: 100%; border-collapse: collapse; }
75
+
76
+ th, td { padding: 0.55rem 0.7rem; border-top: 1px solid var(--border); vertical-align: top; }
77
+
78
+ thead th {
79
+ background: var(--gray-bg);
80
+ color: var(--muted);
81
+ font-size: 0.82rem;
82
+ text-transform: uppercase;
83
+ letter-spacing: 0.03em;
84
+ border-top: none;
85
+ position: sticky;
86
+ top: 0;
87
+ }
88
+
89
+ th[data-sortable-column="true"] {
90
+ cursor: pointer;
91
+ user-select: none;
92
+ }
93
+
94
+ th[data-sort-direction="asc"]::after { content: " ↑"; }
95
+ th[data-sort-direction="desc"]::after { content: " ↓"; }
96
+
97
+ .cell--number { text-align: right; white-space: nowrap; }
98
+
99
+ .badge {
100
+ display: inline-block;
101
+ min-width: 72px;
102
+ text-align: center;
103
+ border-radius: 999px;
104
+ padding: 0.15rem 0.55rem;
105
+ font-weight: 700;
106
+ }
107
+
108
+ .green { color: var(--green); }
109
+ .yellow { color: var(--yellow); }
110
+ .red { color: var(--red); }
111
+ .badge.green { background: var(--green-bg); }
112
+ .badge.yellow { background: var(--yellow-bg); }
113
+ .badge.red { background: var(--red-bg); }
114
+
115
+ .file_list--responsive { overflow-x: auto; }
116
+ .file-section { margin-top: 1rem; }
117
+
118
+ .file-heading {
119
+ display: flex;
120
+ justify-content: space-between;
121
+ gap: 1rem;
122
+ align-items: baseline;
123
+ flex-wrap: wrap;
124
+ }
125
+
126
+ .file-meta { color: var(--muted); font-size: 0.95rem; }
127
+
128
+ .source_table td {
129
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
130
+ font-size: 0.83rem;
131
+ white-space: pre-wrap;
132
+ word-break: break-word;
133
+ }
134
+
135
+ .line-num, .line-hits {
136
+ width: 5rem;
137
+ color: var(--muted);
138
+ text-align: right;
139
+ white-space: nowrap;
140
+ }
141
+
142
+ .line-source { width: auto; }
143
+ .line-covered { background: rgba(16, 185, 129, 0.10); }
144
+ .line-missed { background: rgba(239, 68, 68, 0.10); }
145
+ .line-ignored, .line-none { background: transparent; }
146
+ .nav-links { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.75rem; }
147
+ .group-table td:first-child, .file_list td:first-child { width: 100%; }
@@ -0,0 +1,48 @@
1
+ (() => {
2
+ function cellValue(row, index) {
3
+ const cell = row.children[index];
4
+ return cell ? cell.textContent.trim() : "";
5
+ }
6
+
7
+ function sortableValue(text) {
8
+ const numeric = Number.parseFloat(text.replace(/[% ,]/g, ""));
9
+ return Number.isNaN(numeric) ? text.toLowerCase() : numeric;
10
+ }
11
+
12
+ function sortTable(table, index, direction) {
13
+ const body = table.tBodies[0];
14
+ if (!body) return;
15
+
16
+ const rows = Array.from(body.rows);
17
+ rows.sort((left, right) => {
18
+ const a = sortableValue(cellValue(left, index));
19
+ const b = sortableValue(cellValue(right, index));
20
+ if (a < b) return direction === "asc" ? -1 : 1;
21
+ if (a > b) return direction === "asc" ? 1 : -1;
22
+ return 0;
23
+ });
24
+
25
+ rows.forEach((row) => body.appendChild(row));
26
+ }
27
+
28
+ function installSortableTables() {
29
+ document.querySelectorAll("table[data-sortable='true']").forEach((table) => {
30
+ const headers = Array.from(table.tHead ? table.tHead.rows[0].cells : []);
31
+ headers.forEach((header, index) => {
32
+ header.dataset.sortableColumn = "true";
33
+ header.addEventListener("click", () => {
34
+ const nextDirection = header.dataset.sortDirection === "asc" ? "desc" : "asc";
35
+ headers.forEach((cell) => delete cell.dataset.sortDirection);
36
+ header.dataset.sortDirection = nextDirection;
37
+ sortTable(table, index, nextDirection);
38
+ });
39
+ });
40
+ });
41
+ }
42
+
43
+ if (document.readyState === "loading") {
44
+ document.addEventListener("DOMContentLoaded", installSortableTables);
45
+ } else {
46
+ installSortableTables();
47
+ }
48
+ })();
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <title><%= title %></title>
6
+ <style>
7
+ <%= stylesheet %>
8
+ </style>
9
+ </head>
10
+ <body>
11
+ <div id="wrapper">
12
+ <div class="topbar">
13
+ <h1><%= title %></h1>
14
+ <div class="timestamp">Generated <%= generated_label %></div>
15
+ </div>
16
+
17
+ <%= overview_html %>
18
+
19
+ <%= file_list_html %>
20
+
21
+ <div id="source-files">
22
+ <%= file_sections_html %>
23
+ </div>
24
+ </div>
25
+
26
+ <script>
27
+ <%= javascript %>
28
+ </script>
29
+ </body>
30
+ </html>
@@ -22,6 +22,15 @@ module Polyrun
22
22
  end.map { |rel| File.expand_path(rel, root) }.uniq
23
23
  end
24
24
 
25
+ # Keeps only loaded files that match configured +track_files+ globs.
26
+ def keep_tracked_files(blob, root, track_files)
27
+ tracked = expand_globs(root, track_files).each_with_object({}) { |abs, acc| acc[abs] = true }
28
+ blob.each_with_object({}) do |(path, entry), out|
29
+ abs = File.expand_path(path.to_s)
30
+ out[abs] = entry if tracked[abs]
31
+ end
32
+ end
33
+
25
34
  # Adds tracked files that were never required, with simulated line arrays (blank/comment => nil, else 0).
26
35
  # Matches SimpleCov +add_not_loaded_files+ behavior for coverage completeness.
27
36
  def merge_untracked_into_blob(blob, root, track_files)