polyrun 1.4.1 → 1.5.0

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/lib/polyrun/cli/ci_shard_hooks.rb +12 -4
  4. data/lib/polyrun/cli/ci_shard_run_command.rb +3 -1
  5. data/lib/polyrun/cli/help.rb +3 -0
  6. data/lib/polyrun/cli/helpers.rb +22 -0
  7. data/lib/polyrun/cli/run_shards_parallel_children.rb +26 -34
  8. data/lib/polyrun/cli/run_shards_parallel_wait.rb +267 -0
  9. data/lib/polyrun/cli/run_shards_plan_boot_phases.rb +34 -1
  10. data/lib/polyrun/cli/run_shards_plan_options.rb +6 -2
  11. data/lib/polyrun/cli/run_shards_run.rb +7 -33
  12. data/lib/polyrun/cli/run_shards_worker_interrupt.rb +75 -0
  13. data/lib/polyrun/coverage/collector_finish.rb +3 -2
  14. data/lib/polyrun/coverage/formatter.rb +2 -1
  15. data/lib/polyrun/coverage/merge/formatters_html.rb +191 -43
  16. data/lib/polyrun/coverage/merge/html/_file_list.html.erb +21 -0
  17. data/lib/polyrun/coverage/merge/html/_file_section.html.erb +26 -0
  18. data/lib/polyrun/coverage/merge/html/_groups_table.html.erb +18 -0
  19. data/lib/polyrun/coverage/merge/html/_overview.html.erb +47 -0
  20. data/lib/polyrun/coverage/merge/html/report.css +147 -0
  21. data/lib/polyrun/coverage/merge/html/report.js +48 -0
  22. data/lib/polyrun/coverage/merge/html/template.html.erb +30 -0
  23. data/lib/polyrun/coverage/track_files.rb +9 -0
  24. data/lib/polyrun/hooks.rb +9 -1
  25. data/lib/polyrun/log.rb +16 -0
  26. data/lib/polyrun/minitest.rb +34 -0
  27. data/lib/polyrun/quick/example_runner.rb +11 -0
  28. data/lib/polyrun/rspec.rb +18 -0
  29. data/lib/polyrun/version.rb +1 -1
  30. data/lib/polyrun/worker_ping.rb +74 -0
  31. data/sig/polyrun/minitest.rbs +2 -0
  32. data/sig/polyrun/rspec.rbs +4 -0
  33. data/sig/polyrun/worker_ping.rbs +10 -0
  34. metadata +12 -1
@@ -0,0 +1,75 @@
1
+ module Polyrun
2
+ class CLI
3
+ # SIGINT/SIGTERM handling and non-blocking reap for parallel worker PIDs (used by run-shards / ci-shard fan-out).
4
+ module RunShardsWorkerInterrupt
5
+ private
6
+
7
+ def run_shards_log_interrupt_workers(pids, _ctx)
8
+ parts = pids.map { |h| "shard=#{h[:shard]} pid=#{h[:pid]}" }
9
+ Polyrun::Log.orchestration_warn "polyrun run-shards: SIGINT/SIGTERM while waiting on workers — stopping: #{parts.join(", ")}"
10
+ Polyrun::Log.warn "polyrun run-shards: search this log for each shard's started … pid= line and RSpec output; repeat SIGINT during cleanup escalates to SIGKILL"
11
+ end
12
+
13
+ # Best-effort worker teardown then exit. Does not return.
14
+ def run_shards_shutdown_on_signal!(pids, code)
15
+ run_shards_log_interrupt_workers(pids, nil)
16
+ run_shards_terminate_children!(pids)
17
+ exit(code)
18
+ rescue Interrupt
19
+ run_shards_signal_workers_kill(pids)
20
+ run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
21
+ exit(code)
22
+ end
23
+
24
+ # Send SIGTERM to each worker PID and wait so Ctrl+C / SIGTERM does not leave orphans.
25
+ def run_shards_terminate_children!(pids)
26
+ run_shards_signal_workers_term(pids)
27
+ run_shards_reap_worker_pids_interruptible(pids.map { |h| h[:pid] })
28
+ end
29
+
30
+ def run_shards_signal_workers_term(pids)
31
+ pids.each do |h|
32
+ Process.kill(:TERM, h[:pid])
33
+ rescue Errno::ESRCH
34
+ end
35
+ end
36
+
37
+ def run_shards_signal_workers_kill(pids)
38
+ pids.each do |h|
39
+ Process.kill(:KILL, h[:pid])
40
+ rescue Errno::ESRCH
41
+ end
42
+ end
43
+
44
+ # Reap child PIDs without blocking uninterruptibly on one stuck zombie (avoids noisy stacks on repeat Ctrl+C).
45
+ def run_shards_reap_worker_pids_interruptible(pids)
46
+ pending = pids.compact.uniq
47
+ force_note = false
48
+ until pending.empty?
49
+ pending.reject! do |pid|
50
+ w = Process.wait(pid, Process::WNOHANG)
51
+ next true if w == pid
52
+
53
+ false
54
+ rescue Errno::ECHILD
55
+ true
56
+ end
57
+ break if pending.empty?
58
+
59
+ begin
60
+ sleep(0.05)
61
+ rescue Interrupt
62
+ unless force_note
63
+ force_note = true
64
+ Polyrun::Log.orchestration_warn "polyrun run-shards: repeated SIGINT during worker cleanup — SIGKILL to #{pending.size} process(es)"
65
+ end
66
+ pending.each do |pid|
67
+ Process.kill(:KILL, pid)
68
+ rescue Errno::ESRCH
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -41,9 +41,10 @@ module Polyrun
41
41
  def self.track_blob_for_finish(cfg, blob)
42
42
  sharded = ENV["POLYRUN_SHARD_TOTAL"].to_i > 1
43
43
  if cfg[:track_files]
44
- return Collector.keep_under_root(blob, cfg[:root], cfg[:track_under]) if sharded
44
+ filtered = TrackFiles.keep_tracked_files(blob, cfg[:root], cfg[:track_files])
45
+ return filtered if sharded
45
46
 
46
- TrackFiles.merge_untracked_into_blob(blob, cfg[:root], cfg[:track_files])
47
+ TrackFiles.merge_untracked_into_blob(filtered, cfg[:root], cfg[:track_files])
47
48
  else
48
49
  Collector.keep_under_root(blob, cfg[:root], cfg[:track_under])
49
50
  end
@@ -106,7 +106,8 @@ module Polyrun
106
106
  def write_files(result, output_dir, basename)
107
107
  path = File.join(output_dir, "#{basename}.html")
108
108
  title = (result.meta && result.meta["title"]) || (result.meta && result.meta[:title]) || "Polyrun coverage"
109
- File.write(path, Merge.emit_html(result.coverage_blob, title: title))
109
+ root = result.meta && (result.meta["polyrun_coverage_root"] || result.meta[:polyrun_coverage_root])
110
+ File.write(path, Merge.emit_html(result.coverage_blob, title: title, root: root, groups: result.groups))
110
111
  {html: path}
111
112
  end
112
113
  end
@@ -1,55 +1,203 @@
1
+ # rubocop:disable Polyrun/FileLength -- HTML merge formatter + helpers in one file
1
2
  require "cgi"
3
+ require "digest/sha1"
4
+ require "erb"
5
+ require "pathname"
2
6
 
3
7
  module Polyrun
4
8
  module Coverage
5
9
  module Merge
6
10
  module_function
7
11
 
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>"
12
+ # Standalone HTML report with summary, file table, and per-file source details.
13
+ # rubocop:disable Metrics/AbcSize -- linear assembly of overview, file table, sections, asset reads
14
+ def emit_html(coverage_blob, title: "Polyrun coverage", root: nil, groups: nil, generated_at: Time.now)
15
+ files = coverage_blob.keys.sort.map { |path| html_file_payload(path, coverage_blob[path], root) }
16
+ summary = html_summary(files)
17
+ groups_html = render_html_partial("groups_table", group_rows_html: html_group_rows(groups).join("\n"))
18
+ overview_html = render_html_partial(
19
+ "overview",
20
+ summary: summary,
21
+ summary_badge_class: html_badge_class(summary[:line_percent]),
22
+ groups_html: groups_html
23
+ )
24
+ file_list_html = render_html_partial("file_list", file_rows_html: files.map { |file| html_file_list_row(file) }.join("\n"))
25
+ file_sections_html = files.map { |file| render_html_partial("file_section", file: file) }.join("\n")
26
+ ERB.new(File.read(html_template_path), trim_mode: "-").result_with_hash(
27
+ title: CGI.escapeHTML(title.to_s),
28
+ generated_label: html_generated_label(generated_at),
29
+ overview_html: overview_html,
30
+ file_list_html: file_list_html,
31
+ file_sections_html: file_sections_html,
32
+ stylesheet: File.read(html_stylesheet_path),
33
+ javascript: File.read(html_javascript_path)
34
+ )
35
+ end
36
+ # rubocop:enable Metrics/AbcSize
37
+
38
+ def html_asset_dir
39
+ File.join(__dir__, "html")
40
+ end
41
+
42
+ def html_template_path
43
+ File.join(html_asset_dir, "template.html.erb")
44
+ end
45
+
46
+ def html_stylesheet_path
47
+ File.join(html_asset_dir, "report.css")
48
+ end
49
+
50
+ def html_javascript_path
51
+ File.join(html_asset_dir, "report.js")
52
+ end
53
+
54
+ def html_partial_path(name)
55
+ File.join(html_asset_dir, "_#{name}.html.erb")
56
+ end
57
+
58
+ def render_html_partial(name, locals = {})
59
+ ERB.new(File.read(html_partial_path(name)), trim_mode: "-").result_with_hash(locals)
60
+ end
61
+
62
+ def html_file_payload(path, file, root)
63
+ line_arr = line_array_from_file_entry(file) || []
64
+ source_lines = html_source_lines(path, line_arr.length)
65
+ counts = line_counts(file)
66
+ relevant = counts[:relevant]
67
+ covered = counts[:covered]
68
+ total_hits = line_arr.sum { |hit| html_numeric_hit(hit) || 0 }
69
+ pct = relevant.positive? ? (100.0 * covered / relevant) : 0.0
70
+ {
71
+ id: "file-#{Digest::SHA1.hexdigest(path.to_s)}",
72
+ path: path.to_s,
73
+ display_path: html_display_path(path, root),
74
+ badge_class: html_badge_class(pct),
75
+ total_lines: [source_lines.length, line_arr.length].max,
76
+ relevant: relevant,
77
+ covered: covered,
78
+ missed: relevant - covered,
79
+ avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0,
80
+ line_percent: pct,
81
+ source_rows: html_source_rows(source_lines, line_arr)
82
+ }
83
+ end
84
+
85
+ def html_summary(files)
86
+ relevant = files.sum { |file| file[:relevant] }
87
+ covered = files.sum { |file| file[:covered] }
88
+ total_hits = files.sum { |file| file[:avg_hits] * file[:relevant] }
89
+ {
90
+ files: files.length,
91
+ lines_relevant: relevant,
92
+ lines_covered: covered,
93
+ lines_missed: relevant - covered,
94
+ line_percent: relevant.positive? ? (100.0 * covered / relevant) : 0.0,
95
+ avg_hits: relevant.positive? ? (total_hits.to_f / relevant) : 0.0
96
+ }
97
+ end
98
+
99
+ def html_group_rows(groups)
100
+ return [] unless groups.is_a?(Hash) && !groups.empty?
101
+
102
+ groups.map do |name, data|
103
+ pct = html_group_percent(data)
104
+ <<~ROW.strip
105
+ <tr>
106
+ <td>#{CGI.escapeHTML(name.to_s)}</td>
107
+ <td class="cell--number"><span class="badge #{html_badge_class(pct)}">#{format("%.2f", pct)}%</span></td>
108
+ </tr>
109
+ ROW
17
110
  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
111
+ end
112
+
113
+ def html_group_percent(data)
114
+ return 0.0 unless data.is_a?(Hash)
115
+
116
+ lines = data["lines"] || data[:lines]
117
+ pct = lines.is_a?(Hash) ? (lines["covered_percent"] || lines[:covered_percent]) : nil
118
+ pct.to_f
119
+ end
120
+
121
+ def html_file_list_row(file)
122
+ <<~ROW.strip
123
+ <tr class="t-file">
124
+ <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>
125
+ <td class="cell--number"><span class="badge #{html_badge_class(file[:line_percent])}">#{format("%.2f", file[:line_percent])}%</span></td>
126
+ <td class="cell--number">#{file[:total_lines]}</td>
127
+ <td class="cell--number">#{file[:relevant]}</td>
128
+ <td class="cell--number">#{file[:covered]}</td>
129
+ <td class="cell--number">#{file[:missed]}</td>
130
+ <td class="cell--number">#{format("%.2f", file[:avg_hits])}</td>
131
+ </tr>
132
+ ROW
133
+ end
134
+
135
+ def html_source_rows(source_lines, line_arr)
136
+ max_len = [source_lines.length, line_arr.length].max
137
+ Array.new(max_len) do |idx|
138
+ source = source_lines[idx] || ""
139
+ hit = line_arr[idx]
140
+ css = html_line_class(hit)
141
+ hits_label = html_hit_label(hit)
142
+ <<~ROW.strip
143
+ <tr class="#{css}">
144
+ <td class="line-num">#{idx + 1}</td>
145
+ <td class="line-hits">#{hits_label}</td>
146
+ <td class="line-source"><code>#{source.empty? ? "&nbsp;" : CGI.escapeHTML(source)}</code></td>
147
+ </tr>
148
+ ROW
149
+ end
150
+ end
151
+
152
+ def html_source_lines(path, fallback_length)
153
+ return Array.new(fallback_length, "") unless File.file?(path.to_s)
154
+
155
+ File.readlines(path.to_s, chomp: true)
156
+ rescue Errno::ENOENT, Errno::EACCES, ArgumentError
157
+ Array.new(fallback_length, "")
158
+ end
159
+
160
+ def html_display_path(path, root)
161
+ p = File.expand_path(path.to_s)
162
+ return p if root.nil? || root.to_s.empty?
163
+
164
+ Pathname.new(p).relative_path_from(Pathname.new(File.expand_path(root.to_s))).to_s
165
+ rescue ArgumentError
166
+ p
167
+ end
168
+
169
+ def html_generated_label(generated_at)
170
+ t = generated_at.is_a?(Time) ? generated_at : Time.now
171
+ t.utc.strftime("%Y-%m-%d %H:%M:%S UTC")
172
+ end
173
+
174
+ def html_badge_class(percent)
175
+ return "green" if percent >= 90.0
176
+ return "yellow" if percent >= 80.0
177
+
178
+ "red"
179
+ end
180
+
181
+ def html_numeric_hit(hit)
182
+ return nil if hit.nil? || hit == "ignored"
183
+
184
+ hit.to_i
185
+ end
186
+
187
+ def html_hit_label(hit)
188
+ return "" if hit.nil?
189
+ return "ignored" if hit == "ignored"
190
+
191
+ hit.to_i.to_s
192
+ end
193
+
194
+ def html_line_class(hit)
195
+ return "line-none" if hit.nil?
196
+ return "line-ignored" if hit == "ignored"
197
+
198
+ hit.to_i.positive? ? "line-covered" : "line-missed"
52
199
  end
53
200
  end
54
201
  end
55
202
  end
203
+ # rubocop:enable Polyrun/FileLength
@@ -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
+ })();