@vibecodeqa/cli 0.14.0 → 0.16.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.
@@ -51,12 +51,22 @@ export const CHECK_META = {
51
51
  risk: "Large files are hard to review and test. console.log in production leaks internal data. var causes hoisting bugs. == causes type coercion surprises. eval/innerHTML are security vulnerabilities. Inconsistent naming makes the codebase harder to navigate.",
52
52
  recommendation: "Split files over 300 lines. Replace console.log with a proper logger or remove it. Use const/let, ===, and safe DOM APIs. Enable TypeScript strict mode.",
53
53
  },
54
+ "error-handling": {
55
+ name: "error-handling",
56
+ label: "Error Handling",
57
+ category: "Quality",
58
+ priority: "high",
59
+ weight: 2,
60
+ description: "Detects poor error handling: empty catch blocks, throw with string literals, catch-and-rethrow without context, Promise.then() without .catch(), missing React Error Boundaries.",
61
+ risk: "Empty catch blocks silently swallow errors. throw 'string' loses stack traces. Missing Error Boundaries in React cause the entire app to crash on render errors.",
62
+ recommendation: "Handle or log every catch. Use throw new Error() for stack traces. Add Error Boundaries in React. Chain .catch() on promises.",
63
+ },
54
64
  complexity: {
55
65
  name: "complexity",
56
66
  label: "Complexity",
57
67
  category: "Quality",
58
68
  priority: "high",
59
- weight: 7,
69
+ weight: 5,
60
70
  description: "Measures cognitive complexity of each function: how many branches (if/else/switch/for/while/ternary/&&/||) and how many lines. Functions over 60 lines or with complexity over 15 are flagged.",
61
71
  risk: "Complex functions are the #1 source of bugs. Research shows defect density increases exponentially with cyclomatic complexity above 10 (McCabe, 1976). Complex code is also harder to review, test, and modify safely.",
62
72
  recommendation: "Extract complex functions into smaller ones. Use early returns to reduce nesting. Replace conditional chains with lookup tables or strategy patterns. Aim for functions under 30 lines with complexity under 10.",
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { runContext } from "./runners/context.js";
11
11
  import { runDependencies } from "./runners/dependencies.js";
12
12
  import { runDocs } from "./runners/docs.js";
13
13
  import { runDuplication } from "./runners/duplication.js";
14
+ import { runErrorHandling } from "./runners/error-handling.js";
14
15
  import { runLint } from "./runners/lint.js";
15
16
  import { runSecrets } from "./runners/secrets.js";
16
17
  import { runSecurity } from "./runners/security.js";
@@ -65,6 +66,7 @@ async function main() {
65
66
  // Quality
66
67
  { name: "complexity", fn: () => runComplexity(cwd) },
67
68
  { name: "duplication", fn: () => runDuplication(cwd) },
69
+ { name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
68
70
  { name: "docs", fn: () => runDocs(cwd) },
69
71
  // Testing
70
72
  { name: "testing", fn: () => runTesting(cwd, stack, skipTests) },
@@ -0,0 +1,14 @@
1
+ /** Read report history from .vibe-check/history/ and return sorted snapshots. */
2
+ export interface HistoryEntry {
3
+ timestamp: string;
4
+ score: number;
5
+ checkScores: Map<string, number>;
6
+ }
7
+ /** Load history entries from historyDir, sorted oldest-first. Returns last 30 max. */
8
+ export declare function loadHistory(historyDir: string): HistoryEntry[];
9
+ /** Compute a human-friendly delta badge like "up 3 from last week" or "down 5 from yesterday". */
10
+ export declare function scoreDeltaBadge(entries: HistoryEntry[]): {
11
+ arrow: string;
12
+ delta: number;
13
+ label: string;
14
+ } | null;
@@ -0,0 +1,51 @@
1
+ /** Read report history from .vibe-check/history/ and return sorted snapshots. */
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** Load history entries from historyDir, sorted oldest-first. Returns last 30 max. */
5
+ export function loadHistory(historyDir) {
6
+ if (!existsSync(historyDir))
7
+ return [];
8
+ const files = readdirSync(historyDir)
9
+ .filter((f) => f.endsWith(".json"))
10
+ .sort(); // filenames are timestamp-based, so lexicographic = chronological
11
+ const entries = [];
12
+ for (const file of files) {
13
+ try {
14
+ const raw = JSON.parse(readFileSync(join(historyDir, file), "utf-8"));
15
+ if (raw.score == null || !raw.checks)
16
+ continue;
17
+ const checkScores = new Map();
18
+ for (const c of raw.checks) {
19
+ checkScores.set(c.name, c.score);
20
+ }
21
+ entries.push({ timestamp: raw.timestamp, score: raw.score, checkScores });
22
+ }
23
+ catch {
24
+ // skip corrupt files
25
+ }
26
+ }
27
+ return entries;
28
+ }
29
+ /** Compute a human-friendly delta badge like "up 3 from last week" or "down 5 from yesterday". */
30
+ export function scoreDeltaBadge(entries) {
31
+ if (entries.length < 2)
32
+ return null;
33
+ const current = entries[entries.length - 1];
34
+ const prev = entries[entries.length - 2];
35
+ const delta = current.score - prev.score;
36
+ const arrow = delta > 0 ? "\u2191" : delta < 0 ? "\u2193" : "=";
37
+ const now = new Date(current.timestamp);
38
+ const then = new Date(prev.timestamp);
39
+ const diffMs = now.getTime() - then.getTime();
40
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
41
+ let timeLabel;
42
+ if (diffDays === 0)
43
+ timeLabel = "earlier today";
44
+ else if (diffDays === 1)
45
+ timeLabel = "yesterday";
46
+ else if (diffDays <= 7)
47
+ timeLabel = "last week";
48
+ else
49
+ timeLabel = `${diffDays}d ago`;
50
+ return { arrow, delta, label: `${arrow}${Math.abs(delta)} from ${timeLabel}` };
51
+ }
@@ -0,0 +1,10 @@
1
+ /** Reusable HTML components and helpers for the report. */
2
+ import type { Priority } from "../check-meta.js";
3
+ /** HTML-escape a string. */
4
+ export declare function e(s: string): string;
5
+ /** Make a file path a clickable GitHub link if repoUrl is available. */
6
+ export declare function fileLink(path: string, line: number | undefined, repoUrl: string | null, branch: string): string;
7
+ /** Grade color. */
8
+ export declare function gc(grade: string): string;
9
+ /** Priority color. */
10
+ export declare function pc(p: Priority): string;
@@ -0,0 +1,21 @@
1
+ /** Reusable HTML components and helpers for the report. */
2
+ /** HTML-escape a string. */
3
+ export function e(s) {
4
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5
+ }
6
+ /** Make a file path a clickable GitHub link if repoUrl is available. */
7
+ export function fileLink(path, line, repoUrl, branch) {
8
+ const clean = path.split(":")[0];
9
+ if (!repoUrl || !/^https?:\/\//.test(repoUrl))
10
+ return e(path);
11
+ const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
12
+ return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
13
+ }
14
+ /** Grade color. */
15
+ export function gc(grade) {
16
+ return { A: "#22c55e", B: "#84cc16", C: "#eab308", D: "#f97316", F: "#ef4444" }[grade] || "#6b7280";
17
+ }
18
+ /** Priority color. */
19
+ export function pc(p) {
20
+ return { critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280" }[p];
21
+ }
@@ -9,27 +9,12 @@
9
9
  * All in one self-contained HTML file using hash routing + show/hide.
10
10
  */
11
11
  import { getCheckMeta } from "../check-meta.js";
12
- import { generateArchSVG } from "../runners/architecture.js";
13
- function e(s) {
14
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15
- }
16
- /** Make a file path a clickable GitHub link if repoUrl is available. */
17
- function fileLink(path, line, repoUrl, branch) {
18
- const clean = path.split(":")[0];
19
- if (!repoUrl || !/^https?:\/\//.test(repoUrl))
20
- return e(path);
21
- const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
22
- return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
23
- }
24
- function gc(grade) {
25
- return { A: "#22c55e", B: "#84cc16", C: "#eab308", D: "#f97316", F: "#ef4444" }[grade] || "#6b7280";
26
- }
27
- function pc(p) {
28
- return { critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280" }[p];
29
- }
12
+ import { e, fileLink, gc } from "./components.js";
13
+ import { categoryPages, filesPage, heatmapPage, issuesPage, overviewPage } from "./pages.js";
14
+ import { CSS } from "./styles.js";
30
15
  const GROUPS = [
31
16
  { id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
32
- { id: "quality", label: "Quality", checks: ["complexity", "duplication", "docs"] },
17
+ { id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "docs"] },
33
18
  { id: "testing", label: "Testing", checks: ["testing"] },
34
19
  { id: "arch", label: "Architecture", checks: ["architecture"] },
35
20
  { id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
@@ -71,8 +56,7 @@ export function generateHTML(report) {
71
56
  const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
72
57
  return { ...g, avg, checks };
73
58
  });
74
- // ── Build pages ──
75
- // Top nav
59
+ // ── Top nav ──
76
60
  const topNavItems = [
77
61
  { id: "overview", label: "Overview" },
78
62
  ...GROUPS.map((g) => ({ id: g.id, label: g.label })),
@@ -81,296 +65,32 @@ export function generateHTML(report) {
81
65
  { id: "heatmap", label: "Heatmap" },
82
66
  ];
83
67
  const topNav = topNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
84
- // Overview page
85
- const ringPct = report.score;
86
- const barChart = active
87
- .sort((a, b) => a.score - b.score)
88
- .map((c) => {
89
- return `<div class="brow"><span class="bl">${e(c.name)}</span><div class="bb"><div class="bf" style="width:${c.score}%;background:${gc(c.grade)}"></div></div><span class="bv" style="color:${gc(c.grade)}">${c.grade} ${c.score}</span></div>`;
90
- })
91
- .join("");
92
- const catCards = catScores
68
+ // ── Sidebar ──
69
+ const sidebar = catScores
93
70
  .map((cs) => {
94
71
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
95
- const mini = cs.checks
72
+ return `<div class="side-section"><a class="side-cat" onclick="go('${cs.id}')">${cs.label} <span style="color:${clr}">${cs.avg}</span></a>${cs.checks
96
73
  .map((c) => {
97
74
  const sk = c.details.skipped;
98
- return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "—" : c.grade}</span>`;
99
- })
100
- .join("");
101
- return `<div class="cc" onclick="go('${cs.id}')"><div class="cc-s" style="color:${clr}">${cs.avg}</div><div class="cc-l">${cs.label}</div><div class="cc-m">${mini}</div></div>`;
102
- })
103
- .join("");
104
- const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
105
- const overviewPage = `<div id="p-overview" class="page active">
106
- <div class="dash">
107
- <div class="hero">
108
- ${buildRing(ringPct, gc(report.grade))}
109
- <div class="hc"><span class="hg" style="color:${gc(report.grade)}">${report.grade}</span><span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span><span class="hd">${active.length} checks · ${totalIssues} issues · ${report.meta.duration}ms</span></div>
110
- </div>
111
- <div class="radar">${radarSvg}</div>
112
- </div>
113
- <div class="cats">${catCards}</div>
114
- <h3>All Checks</h3>
115
- <div class="bars">${barChart}</div>
116
- <div class="stack">${Object.entries(report.meta.stack)
117
- .filter(([, v]) => v !== "none" && v !== "unknown")
118
- .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
119
- .join("")}</div>
120
- </div>`;
121
- // Category pages (with sub-nav tabs for each check)
122
- let catPages = "";
123
- for (const cs of catScores) {
124
- const subNav = cs.checks
125
- .map((c, i) => {
126
- const sk = c.details.skipped;
127
- return `<a class="sn${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span></a>`;
128
- })
129
- .join("");
130
- const subPages = cs.checks
131
- .map((c, i) => {
132
75
  const meta = getCheckMeta(c.name);
133
- const sk = c.details.skipped;
134
- const detailsFiltered = Object.entries(c.details)
135
- .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
136
- .map(([k, v]) => {
137
- const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
138
- return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
139
- })
140
- .join("");
141
- // Group issues by file
142
- const byFile = new Map();
143
- const noFile = [];
144
- for (const iss of c.issues) {
145
- const f = iss.file?.split(":")[0];
146
- if (f) {
147
- const arr = byFile.get(f) || [];
148
- arr.push(iss);
149
- byFile.set(f, arr);
150
- }
151
- else {
152
- noFile.push(iss);
153
- }
154
- }
155
- let issuesHtml = "";
156
- for (const [file, issues] of byFile) {
157
- issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
158
- for (const iss of issues) {
159
- const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
160
- issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span>${iss.line ? `<span class="il">${iss.line}</span>` : ""}<span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}<button class="cp-btn" data-prompt="${e(prompt)}" title="Copy fix prompt">📋</button></div>`;
161
- }
162
- issuesHtml += `</div>`;
163
- }
164
- if (noFile.length > 0) {
165
- issuesHtml += `<div class="fg"><div class="fn">General</div>`;
166
- for (const iss of noFile) {
167
- issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span><span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}</div>`;
168
- }
169
- issuesHtml += `</div>`;
170
- }
171
- return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
172
- <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} · weight ${meta.weight}% · ${c.duration}ms · ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
173
- ${meta.description ? `<div class="info-panel"><div class="ip-row"><span class="ip-label">What</span><span>${e(meta.description)}</span></div><div class="ip-row"><span class="ip-label">Risk</span><span>${e(meta.risk)}</span></div><div class="ip-row"><span class="ip-label">Fix</span><span>${e(meta.recommendation)}</span></div></div>` : ""}
174
- ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
175
- ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
176
- ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
177
- ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
178
- </div>`;
76
+ return `<a class="side-check" onclick="go('${cs.id}')" title="${e(meta.label)}"><span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span> ${e(meta.label)}</a>`;
179
77
  })
180
- .join("");
181
- const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
182
- catPages += `<div id="p-${cs.id}" class="page">
183
- <div class="cat-head"><span style="color:${clr};font-size:1.8rem;font-weight:900">${cs.avg}</span><span style="color:${clr}">/100</span><span style="color:var(--muted);margin-left:0.5rem">${cs.label}</span></div>
184
- <div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
185
- <div class="sub-nav">${subNav}</div>
186
- ${subPages}
187
- </div>`;
188
- }
189
- // All Issues page
190
- const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
191
- const issueRows = allIssues
192
- .slice(0, 200)
193
- .map((i) => {
194
- const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
195
- return `<tr class="${i.severity}"><td class="is2">${i.severity[0].toUpperCase()}</td><td class="ic2">${e(i.check)}</td><td class="il2">${loc}</td><td>${e(i.message)}</td><td class="iru2">${e(i.rule || "")}</td></tr>`;
196
- })
197
- .join("");
198
- const issuesPage = `<div id="p-issues" class="page">
199
- <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
200
- <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors · ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
201
- <table class="it"><thead><tr><th></th><th>Check</th><th>Location</th><th>Message</th><th>Rule</th></tr></thead><tbody>${issueRows}</tbody></table>
202
- ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
203
- </div>`;
204
- // File heatmap page
205
- const fileRows = topFiles
206
- .map((f) => {
207
- const pct = Math.min(100, f.total * 5);
208
- return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
78
+ .join("")}</div>`;
209
79
  })
210
80
  .join("");
211
- const filesPage = `<div id="p-files" class="page">
212
- <h2>File Heatmap</h2>
213
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
214
- ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
215
- </div>`;
216
- // Codebase heatmap — each file = row of pixels, color = issue density
217
- const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
218
- let heatmapHtml = "";
219
- if (heatmapFiles.length > 0) {
220
- const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
221
- heatmapHtml = heatmapFiles
222
- .map(([file, d]) => {
223
- const total = d.errors + d.warnings;
224
- const intensity = maxIssues > 0 ? total / maxIssues : 0;
225
- const r = Math.round(239 * intensity); // red channel
226
- const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
227
- const color = `rgb(${r},${g},30)`;
228
- const barW = Math.max(4, Math.round(intensity * 200));
229
- const checks = [...d.checks].join(", ");
230
- return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
231
- })
232
- .join("");
233
- }
234
- const heatmapPage = `<div id="p-heatmap" class="page">
235
- <h2>Code Heatmap</h2>
236
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Visual density of issues per file. Red = errors, orange = warnings. Bar width = relative issue count.</p>
237
- ${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
238
- </div>`;
81
+ // ── Assemble pages ──
82
+ const overview = overviewPage(report, active, totalIssues, catScores);
83
+ const catPages = categoryPages(catScores, fl);
84
+ const issues = issuesPage(allChecks, totalIssues, fl);
85
+ const files = filesPage(topFiles, fl);
86
+ const heatmap = heatmapPage(fileIssues, fl);
239
87
  return `<!DOCTYPE html>
240
88
  <html lang="en">
241
89
  <head>
242
90
  <meta charset="utf-8">
243
91
  <meta name="viewport" content="width=device-width,initial-scale=1">
244
- <title>VibeCode QA ${e(proj)}</title>
245
- <style>
246
- :root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}
247
- *{margin:0;padding:0;box-sizing:border-box}
248
- body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
249
- code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
250
-
251
- /* Top nav */
252
- .top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}
253
- .logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}
254
- .logo span{color:var(--accent)}
255
- .tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
256
- .tn:hover{color:var(--text)}
257
- .tn.active{color:var(--text);border-bottom-color:var(--accent)}
258
-
259
- /* Sidebar */
260
- .side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}
261
- .side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
262
- .side-section:last-child{border-bottom:none}
263
- .side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
264
- .side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
265
- .side-cat:hover{background:#14141a}
266
- .side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}
267
- .side-check:hover{color:var(--text);background:#14141a}
268
- .side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
269
-
270
- /* Content */
271
- .content{max-width:900px;margin-left:200px;padding:2rem}
272
- .page{display:none;animation:fadeIn 0.15s}
273
- .page.active{display:block}
274
- @keyframes fadeIn{from{opacity:0}to{opacity:1}}
275
-
276
- /* Overview */
277
- .dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
278
- .hero{display:flex;align-items:center;gap:1rem}
279
- .hero svg{width:100px;height:100px}
280
- .hc{display:flex;flex-direction:column}
281
- .hg{font-size:2.5rem;font-weight:900;line-height:1}
282
- .hs{font-size:1rem;font-weight:600}
283
- .hd{font-size:0.68rem;color:var(--muted)}
284
- .radar{flex:1;display:flex;justify-content:center}
285
- .radar svg{max-width:240px;width:100%}
286
- .cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
287
- .cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
288
- .cc:hover{border-color:var(--accent)}
289
- .cc-s{font-size:1.8rem;font-weight:900}
290
- .cc-l{font-size:0.75rem;color:var(--muted)}
291
- .cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
292
- .mc{font-size:0.65rem;font-weight:800}
293
- h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
294
- .bars{margin-bottom:1.5rem}
295
- .brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
296
- .bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}
297
- .bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
298
- .bf{height:100%;border-radius:2px}
299
- .bv{width:36px;font-weight:700;font-size:0.68rem}
300
- .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
301
- .stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}
302
-
303
- /* Category pages */
304
- .cat-head{margin-bottom:0.3rem}
305
- .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
306
- .bf2{height:100%;border-radius:2px}
307
- .sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
308
- .sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
309
- .sn:hover{color:var(--text)}
310
- .sn.active{color:var(--text);border-bottom-color:var(--accent)}
311
- .sp{display:none}.sp.active{display:block}
312
-
313
- /* Check detail */
314
- .ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
315
- .ch-g{font-size:2rem;font-weight:900}
316
- .ch-s{display:block;font-size:0.7rem;color:var(--muted)}
317
- .pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}
318
- .info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}
319
- .ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
320
- .ip-row:last-child{margin-bottom:0}
321
- .ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
322
- .skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
323
- .kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
324
- .kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
325
- .k{color:var(--muted);margin-right:0.3rem}
326
- .v{font-weight:600}
327
-
328
- /* Issue list grouped by file */
329
- .iss-list{margin-top:1rem}
330
- .fg{margin-bottom:0.8rem}
331
- .fn{font-size:0.72rem;font-weight:600;font-family:"SF Mono",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}
332
- .fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
333
- .ir{font-size:0.65rem;font-family:"SF Mono",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}
334
- .is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
335
- .ir.error .is{color:var(--fail);background:#ef444418}
336
- .ir.warning .is{color:var(--warn);background:#eab30818}
337
- .il{color:var(--accent);min-width:2rem;flex-shrink:0}
338
- .im{flex:1;word-break:break-word}
339
- .iru{color:#555;font-size:0.55rem}
340
-
341
- /* All issues table */
342
- .isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
343
- .it{width:100%;border-collapse:collapse;font-size:0.68rem}
344
- .it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}
345
- .it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
346
- .it tr.error .is2{color:var(--fail)}
347
- .it tr.warning .is2{color:var(--warn)}
348
- .is2{font-weight:800;width:1rem}
349
- .ic2{color:var(--muted);width:70px}
350
- .il2{color:var(--muted)}
351
- .iru2{color:#555;font-size:0.58rem}
352
-
353
- /* File heatmap */
354
- .fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
355
- .ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
356
- .fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
357
- .fbf{height:100%;border-radius:2px}
358
- .fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
359
- .fcs{font-size:0.6rem;color:#555}
360
-
361
- .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
362
- .footer a{color:var(--muted)}
363
- .flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
364
- .arch-svg{margin:1rem 0;overflow-x:auto}
365
- .hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
366
- .hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
367
- .hm-bar{height:14px;border-radius:3px;min-width:4px}
368
- .hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}
369
- .arch-svg svg{border-radius:8px}
370
- .cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}
371
- .ir:hover .cp-btn{opacity:0.6}
372
- @media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}
373
- </style>
92
+ <title>VibeCode QA \u2014 ${e(proj)}</title>
93
+ <style>${CSS}</style>
374
94
  </head>
375
95
  <body>
376
96
 
@@ -381,25 +101,14 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
381
101
 
382
102
  <aside class="side">
383
103
  <div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
384
- ${catScores
385
- .map((cs) => {
386
- const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
387
- return `<div class="side-section"><a class="side-cat" onclick="go('${cs.id}')">${cs.label} <span style="color:${clr}">${cs.avg}</span></a>${cs.checks
388
- .map((c) => {
389
- const sk = c.details.skipped;
390
- const meta = getCheckMeta(c.name);
391
- return `<a class="side-check" onclick="go('${cs.id}')" title="${e(meta.label)}"><span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span> ${e(meta.label)}</a>`;
392
- })
393
- .join("")}</div>`;
394
- })
395
- .join("")}
104
+ ${sidebar}
396
105
  </aside>
397
106
  <div class="content">
398
- ${overviewPage}
107
+ ${overview}
399
108
  ${catPages}
400
- ${issuesPage}
401
- ${filesPage}
402
- ${heatmapPage}
109
+ ${issues}
110
+ ${files}
111
+ ${heatmap}
403
112
  <div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} &mdash; <code>npx @vibecodeqa/cli</code></div>
404
113
  </div>
405
114
 
@@ -427,47 +136,3 @@ document.querySelector('.tn').classList.add('active');
427
136
  </script>
428
137
  </body></html>`;
429
138
  }
430
- // ── SVG builders ──
431
- function buildRing(score, color) {
432
- const r = 42;
433
- const c = 2 * Math.PI * r;
434
- const off = c - (score / 100) * c;
435
- return `<svg viewBox="0 0 100 100" style="width:100px;height:100px"><circle cx="50" cy="50" r="${r}" fill="none" stroke="#1e1e24" stroke-width="7"/><circle cx="50" cy="50" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-dasharray="${c}" stroke-dashoffset="${off}" stroke-linecap="round" transform="rotate(-90 50 50)"/></svg>`;
436
- }
437
- function buildRadar(items) {
438
- const n = items.length;
439
- if (n < 3)
440
- return "";
441
- const cx = 120, cy = 120, r = 90;
442
- const step = (2 * Math.PI) / n;
443
- let grid = "";
444
- for (const pct of [25, 50, 75, 100]) {
445
- const rr = (pct / 100) * r;
446
- const pts = items
447
- .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
448
- .join(" ");
449
- grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
450
- }
451
- let axes = "";
452
- for (let i = 0; i < n; i++) {
453
- const a = i * step - Math.PI / 2;
454
- axes += `<line x1="${cx}" y1="${cy}" x2="${cx + r * Math.cos(a)}" y2="${cy + r * Math.sin(a)}" stroke="#1e1e24" stroke-width="0.7"/>`;
455
- const lx = cx + (r + 16) * Math.cos(a);
456
- const ly = cy + (r + 16) * Math.sin(a);
457
- axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
458
- }
459
- const dataPts = items
460
- .map((c, i) => {
461
- const a = i * step - Math.PI / 2;
462
- const rr = (c.score / 100) * r;
463
- return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
464
- })
465
- .join(" ");
466
- let dots = "";
467
- for (let i = 0; i < n; i++) {
468
- const a = i * step - Math.PI / 2;
469
- const rr = (items[i].score / 100) * r;
470
- dots += `<circle cx="${cx + rr * Math.cos(a)}" cy="${cy + rr * Math.sin(a)}" r="3.5" fill="#818cf8"/>`;
471
- }
472
- return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
473
- }
@@ -0,0 +1,26 @@
1
+ /** Page renderers for the HTML report. */
2
+ import type { CheckResult, VibeReport } from "../types.js";
3
+ export interface CatScore {
4
+ id: string;
5
+ label: string;
6
+ checks: CheckResult[];
7
+ avg: number;
8
+ }
9
+ export interface FileEntry {
10
+ file: string;
11
+ total: number;
12
+ errors: number;
13
+ warnings: number;
14
+ checks: string[];
15
+ }
16
+ type FL = (path: string, line?: number) => string;
17
+ export declare function overviewPage(report: VibeReport, active: CheckResult[], totalIssues: number, catScores: CatScore[]): string;
18
+ export declare function categoryPages(catScores: CatScore[], fl: FL): string;
19
+ export declare function issuesPage(allChecks: CheckResult[], totalIssues: number, fl: FL): string;
20
+ export declare function filesPage(topFiles: FileEntry[], fl: FL): string;
21
+ export declare function heatmapPage(fileIssues: Map<string, {
22
+ errors: number;
23
+ warnings: number;
24
+ checks: Set<string>;
25
+ }>, fl: FL): string;
26
+ export {};
@@ -0,0 +1,166 @@
1
+ /** Page renderers for the HTML report. */
2
+ import { getCheckMeta } from "../check-meta.js";
3
+ import { generateArchSVG } from "../runners/architecture.js";
4
+ import { e, gc, pc } from "./components.js";
5
+ import { buildRadar, buildRing } from "./svg.js";
6
+ export function overviewPage(report, active, totalIssues, catScores) {
7
+ const ringPct = report.score;
8
+ const barChart = active
9
+ .sort((a, b) => a.score - b.score)
10
+ .map((c) => {
11
+ return `<div class="brow"><span class="bl">${e(c.name)}</span><div class="bb"><div class="bf" style="width:${c.score}%;background:${gc(c.grade)}"></div></div><span class="bv" style="color:${gc(c.grade)}">${c.grade} ${c.score}</span></div>`;
12
+ })
13
+ .join("");
14
+ const catCards = catScores
15
+ .map((cs) => {
16
+ const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
17
+ const mini = cs.checks
18
+ .map((c) => {
19
+ const sk = c.details.skipped;
20
+ return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "\u2014" : c.grade}</span>`;
21
+ })
22
+ .join("");
23
+ return `<div class="cc" onclick="go('${cs.id}')"><div class="cc-s" style="color:${clr}">${cs.avg}</div><div class="cc-l">${cs.label}</div><div class="cc-m">${mini}</div></div>`;
24
+ })
25
+ .join("");
26
+ const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
27
+ return `<div id="p-overview" class="page active">
28
+ <div class="dash">
29
+ <div class="hero">
30
+ ${buildRing(ringPct, gc(report.grade))}
31
+ <div class="hc"><span class="hg" style="color:${gc(report.grade)}">${report.grade}</span><span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span><span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span></div>
32
+ </div>
33
+ <div class="radar">${radarSvg}</div>
34
+ </div>
35
+ <div class="cats">${catCards}</div>
36
+ <h3>All Checks</h3>
37
+ <div class="bars">${barChart}</div>
38
+ <div class="stack">${Object.entries(report.meta.stack)
39
+ .filter(([, v]) => v !== "none" && v !== "unknown")
40
+ .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
41
+ .join("")}</div>
42
+ </div>`;
43
+ }
44
+ export function categoryPages(catScores, fl) {
45
+ let catPagesHtml = "";
46
+ for (const cs of catScores) {
47
+ const subNav = cs.checks
48
+ .map((c, i) => {
49
+ const sk = c.details.skipped;
50
+ return `<a class="sn${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span></a>`;
51
+ })
52
+ .join("");
53
+ const subPages = cs.checks
54
+ .map((c, i) => {
55
+ const meta = getCheckMeta(c.name);
56
+ const sk = c.details.skipped;
57
+ const detailsFiltered = Object.entries(c.details)
58
+ .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
59
+ .map(([k, v]) => {
60
+ const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
61
+ return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
62
+ })
63
+ .join("");
64
+ // Group issues by file
65
+ const byFile = new Map();
66
+ const noFile = [];
67
+ for (const iss of c.issues) {
68
+ const f = iss.file?.split(":")[0];
69
+ if (f) {
70
+ const arr = byFile.get(f) || [];
71
+ arr.push(iss);
72
+ byFile.set(f, arr);
73
+ }
74
+ else {
75
+ noFile.push(iss);
76
+ }
77
+ }
78
+ let issuesHtml = "";
79
+ for (const [file, issues] of byFile) {
80
+ issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
81
+ for (const iss of issues) {
82
+ const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
83
+ issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span>${iss.line ? `<span class="il">${iss.line}</span>` : ""}<span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}<button class="cp-btn" data-prompt="${e(prompt)}" title="Copy fix prompt">\ud83d\udccb</button></div>`;
84
+ }
85
+ issuesHtml += `</div>`;
86
+ }
87
+ if (noFile.length > 0) {
88
+ issuesHtml += `<div class="fg"><div class="fn">General</div>`;
89
+ for (const iss of noFile) {
90
+ issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span><span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}</div>`;
91
+ }
92
+ issuesHtml += `</div>`;
93
+ }
94
+ return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
95
+ <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} \u00b7 weight ${meta.weight}% \u00b7 ${c.duration}ms \u00b7 ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
96
+ ${meta.description ? `<div class="info-panel"><div class="ip-row"><span class="ip-label">What</span><span>${e(meta.description)}</span></div><div class="ip-row"><span class="ip-label">Risk</span><span>${e(meta.risk)}</span></div><div class="ip-row"><span class="ip-label">Fix</span><span>${e(meta.recommendation)}</span></div></div>` : ""}
97
+ ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
98
+ ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
99
+ ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
100
+ ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
101
+ </div>`;
102
+ })
103
+ .join("");
104
+ const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
105
+ catPagesHtml += `<div id="p-${cs.id}" class="page">
106
+ <div class="cat-head"><span style="color:${clr};font-size:1.8rem;font-weight:900">${cs.avg}</span><span style="color:${clr}">/100</span><span style="color:var(--muted);margin-left:0.5rem">${cs.label}</span></div>
107
+ <div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
108
+ <div class="sub-nav">${subNav}</div>
109
+ ${subPages}
110
+ </div>`;
111
+ }
112
+ return catPagesHtml;
113
+ }
114
+ export function issuesPage(allChecks, totalIssues, fl) {
115
+ const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
116
+ const issueRows = allIssues
117
+ .slice(0, 200)
118
+ .map((i) => {
119
+ const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
120
+ return `<tr class="${i.severity}"><td class="is2">${i.severity[0].toUpperCase()}</td><td class="ic2">${e(i.check)}</td><td class="il2">${loc}</td><td>${e(i.message)}</td><td class="iru2">${e(i.rule || "")}</td></tr>`;
121
+ })
122
+ .join("");
123
+ return `<div id="p-issues" class="page">
124
+ <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
125
+ <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors \u00b7 ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
126
+ <table class="it"><thead><tr><th></th><th>Check</th><th>Location</th><th>Message</th><th>Rule</th></tr></thead><tbody>${issueRows}</tbody></table>
127
+ ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
128
+ </div>`;
129
+ }
130
+ export function filesPage(topFiles, fl) {
131
+ const fileRows = topFiles
132
+ .map((f) => {
133
+ const pct = Math.min(100, f.total * 5);
134
+ return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
135
+ })
136
+ .join("");
137
+ return `<div id="p-files" class="page">
138
+ <h2>File Heatmap</h2>
139
+ <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
140
+ ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
141
+ </div>`;
142
+ }
143
+ export function heatmapPage(fileIssues, fl) {
144
+ const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
145
+ let heatmapHtml = "";
146
+ if (heatmapFiles.length > 0) {
147
+ const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
148
+ heatmapHtml = heatmapFiles
149
+ .map(([file, d]) => {
150
+ const total = d.errors + d.warnings;
151
+ const intensity = maxIssues > 0 ? total / maxIssues : 0;
152
+ const r = Math.round(239 * intensity); // red channel
153
+ const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
154
+ const color = `rgb(${r},${g},30)`;
155
+ const barW = Math.max(4, Math.round(intensity * 200));
156
+ const checks = [...d.checks].join(", ");
157
+ return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
158
+ })
159
+ .join("");
160
+ }
161
+ return `<div id="p-heatmap" class="page">
162
+ <h2>Code Heatmap</h2>
163
+ <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Visual density of issues per file. Red = errors, orange = warnings. Bar width = relative issue count.</p>
164
+ ${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
165
+ </div>`;
166
+ }
@@ -0,0 +1,2 @@
1
+ /** All CSS for the HTML report, extracted for maintainability. */
2
+ export declare const CSS = "\n:root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}\n*{margin:0;padding:0;box-sizing:border-box}\nbody{font-family:\"Inter\",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}\ncode{font-family:\"SF Mono\",Menlo,monospace;font-size:0.85em}\n\n/* Top nav */\n.top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}\n.logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}\n.logo span{color:var(--accent)}\n.tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}\n.tn:hover{color:var(--text)}\n.tn.active{color:var(--text);border-bottom-color:var(--accent)}\n\n/* Sidebar */\n.side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}\n.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}\n.side-section:last-child{border-bottom:none}\n.side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}\n.side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}\n.side-cat:hover{background:#14141a}\n.side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}\n.side-check:hover{color:var(--text);background:#14141a}\n.side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}\n\n/* Content */\n.content{max-width:900px;margin-left:200px;padding:2rem}\n.page{display:none;animation:fadeIn 0.15s}\n.page.active{display:block}\n@keyframes fadeIn{from{opacity:0}to{opacity:1}}\n\n/* Overview */\n.dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}\n.hero{display:flex;align-items:center;gap:1rem}\n.hero svg{width:100px;height:100px}\n.hc{display:flex;flex-direction:column}\n.hg{font-size:2.5rem;font-weight:900;line-height:1}\n.hs{font-size:1rem;font-weight:600}\n.hd{font-size:0.68rem;color:var(--muted)}\n.radar{flex:1;display:flex;justify-content:center}\n.radar svg{max-width:240px;width:100%}\n.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}\n.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}\n.cc:hover{border-color:var(--accent)}\n.cc-s{font-size:1.8rem;font-weight:900}\n.cc-l{font-size:0.75rem;color:var(--muted)}\n.cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}\n.mc{font-size:0.65rem;font-weight:800}\nh3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}\n.bars{margin-bottom:1.5rem}\n.brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}\n.bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}\n.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.bf{height:100%;border-radius:2px}\n.bv{width:36px;font-weight:700;font-size:0.68rem}\n.stack{display:flex;gap:0.35rem;flex-wrap:wrap}\n.stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}\n\n/* Category pages */\n.cat-head{margin-bottom:0.3rem}\n.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}\n.bf2{height:100%;border-radius:2px}\n.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}\n.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}\n.sn:hover{color:var(--text)}\n.sn.active{color:var(--text);border-bottom-color:var(--accent)}\n.sp{display:none}.sp.active{display:block}\n\n/* Check detail */\n.ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}\n.ch-g{font-size:2rem;font-weight:900}\n.ch-s{display:block;font-size:0.7rem;color:var(--muted)}\n.pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}\n.info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}\n.ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}\n.ip-row:last-child{margin-bottom:0}\n.ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}\n.skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}\n.kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}\n.kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}\n.k{color:var(--muted);margin-right:0.3rem}\n.v{font-weight:600}\n\n/* Issue list grouped by file */\n.iss-list{margin-top:1rem}\n.fg{margin-bottom:0.8rem}\n.fn{font-size:0.72rem;font-weight:600;font-family:\"SF Mono\",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}\n.fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}\n.ir{font-size:0.65rem;font-family:\"SF Mono\",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}\n.is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}\n.ir.error .is{color:var(--fail);background:#ef444418}\n.ir.warning .is{color:var(--warn);background:#eab30818}\n.il{color:var(--accent);min-width:2rem;flex-shrink:0}\n.im{flex:1;word-break:break-word}\n.iru{color:#555;font-size:0.55rem}\n\n/* All issues table */\n.isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}\n.it{width:100%;border-collapse:collapse;font-size:0.68rem}\n.it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}\n.it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:\"SF Mono\",monospace;font-size:0.62rem}\n.it tr.error .is2{color:var(--fail)}\n.it tr.warning .is2{color:var(--warn)}\n.is2{font-weight:800;width:1rem}\n.ic2{color:var(--muted);width:70px}\n.il2{color:var(--muted)}\n.iru2{color:#555;font-size:0.58rem}\n\n/* File heatmap */\n.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}\n.ff{width:200px;font-family:\"SF Mono\",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.fbf{height:100%;border-radius:2px}\n.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}\n.fcs{font-size:0.6rem;color:#555}\n\n.footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}\n.footer a{color:var(--muted)}\n.flink{color:var(--accent);text-decoration:none;font-family:\"SF Mono\",monospace}.flink:hover{text-decoration:underline}\n.arch-svg{margin:1rem 0;overflow-x:auto}\n.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}\n.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:\"SF Mono\",monospace;font-size:0.65rem}\n.hm-bar{height:14px;border-radius:3px;min-width:4px}\n.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}\n.arch-svg svg{border-radius:8px}\n.cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}\n.ir:hover .cp-btn{opacity:0.6}\n@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}\n";
@@ -0,0 +1,130 @@
1
+ /** All CSS for the HTML report, extracted for maintainability. */
2
+ export const CSS = `
3
+ :root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}
4
+ *{margin:0;padding:0;box-sizing:border-box}
5
+ body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
6
+ code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
7
+
8
+ /* Top nav */
9
+ .top{position:sticky;top:0;z-index:20;background:#0c0c0fcc;backdrop-filter:blur(12px);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;align-items:center;gap:0}
10
+ .logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}
11
+ .logo span{color:var(--accent)}
12
+ .tn{padding:0.7rem 0.8rem;font-size:0.78rem;color:var(--muted);text-decoration:none;cursor:pointer;border-bottom:2px solid transparent;transition:all 0.15s}
13
+ .tn:hover{color:var(--text)}
14
+ .tn.active{color:var(--text);border-bottom-color:var(--accent)}
15
+
16
+ /* Sidebar */
17
+ .side{position:fixed;top:42px;left:0;bottom:0;width:200px;background:#0c0c0f;border-right:1px solid var(--border);overflow-y:auto;padding:0.8rem 0;font-size:0.7rem;z-index:10}
18
+ .side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
19
+ .side-section:last-child{border-bottom:none}
20
+ .side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
21
+ .side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
22
+ .side-cat:hover{background:#14141a}
23
+ .side-check{display:block;padding:0.2rem 0.8rem 0.2rem 1.2rem;color:var(--muted);cursor:pointer;text-decoration:none;font-size:0.65rem}
24
+ .side-check:hover{color:var(--text);background:#14141a}
25
+ .side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
26
+
27
+ /* Content */
28
+ .content{max-width:900px;margin-left:200px;padding:2rem}
29
+ .page{display:none;animation:fadeIn 0.15s}
30
+ .page.active{display:block}
31
+ @keyframes fadeIn{from{opacity:0}to{opacity:1}}
32
+
33
+ /* Overview */
34
+ .dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
35
+ .hero{display:flex;align-items:center;gap:1rem}
36
+ .hero svg{width:100px;height:100px}
37
+ .hc{display:flex;flex-direction:column}
38
+ .hg{font-size:2.5rem;font-weight:900;line-height:1}
39
+ .hs{font-size:1rem;font-weight:600}
40
+ .hd{font-size:0.68rem;color:var(--muted)}
41
+ .radar{flex:1;display:flex;justify-content:center}
42
+ .radar svg{max-width:240px;width:100%}
43
+ .cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
44
+ .cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
45
+ .cc:hover{border-color:var(--accent)}
46
+ .cc-s{font-size:1.8rem;font-weight:900}
47
+ .cc-l{font-size:0.75rem;color:var(--muted)}
48
+ .cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
49
+ .mc{font-size:0.65rem;font-weight:800}
50
+ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
51
+ .bars{margin-bottom:1.5rem}
52
+ .brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
53
+ .bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}
54
+ .bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
55
+ .bf{height:100%;border-radius:2px}
56
+ .bv{width:36px;font-weight:700;font-size:0.68rem}
57
+ .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
58
+ .stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}
59
+
60
+ /* Category pages */
61
+ .cat-head{margin-bottom:0.3rem}
62
+ .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
63
+ .bf2{height:100%;border-radius:2px}
64
+ .sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
65
+ .sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
66
+ .sn:hover{color:var(--text)}
67
+ .sn.active{color:var(--text);border-bottom-color:var(--accent)}
68
+ .sp{display:none}.sp.active{display:block}
69
+
70
+ /* Check detail */
71
+ .ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
72
+ .ch-g{font-size:2rem;font-weight:900}
73
+ .ch-s{display:block;font-size:0.7rem;color:var(--muted)}
74
+ .pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}
75
+ .info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}
76
+ .ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
77
+ .ip-row:last-child{margin-bottom:0}
78
+ .ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
79
+ .skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
80
+ .kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
81
+ .kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
82
+ .k{color:var(--muted);margin-right:0.3rem}
83
+ .v{font-weight:600}
84
+
85
+ /* Issue list grouped by file */
86
+ .iss-list{margin-top:1rem}
87
+ .fg{margin-bottom:0.8rem}
88
+ .fn{font-size:0.72rem;font-weight:600;font-family:"SF Mono",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}
89
+ .fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
90
+ .ir{font-size:0.65rem;font-family:"SF Mono",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}
91
+ .is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
92
+ .ir.error .is{color:var(--fail);background:#ef444418}
93
+ .ir.warning .is{color:var(--warn);background:#eab30818}
94
+ .il{color:var(--accent);min-width:2rem;flex-shrink:0}
95
+ .im{flex:1;word-break:break-word}
96
+ .iru{color:#555;font-size:0.55rem}
97
+
98
+ /* All issues table */
99
+ .isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
100
+ .it{width:100%;border-collapse:collapse;font-size:0.68rem}
101
+ .it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}
102
+ .it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
103
+ .it tr.error .is2{color:var(--fail)}
104
+ .it tr.warning .is2{color:var(--warn)}
105
+ .is2{font-weight:800;width:1rem}
106
+ .ic2{color:var(--muted);width:70px}
107
+ .il2{color:var(--muted)}
108
+ .iru2{color:#555;font-size:0.58rem}
109
+
110
+ /* File heatmap */
111
+ .fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
112
+ .ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
113
+ .fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
114
+ .fbf{height:100%;border-radius:2px}
115
+ .fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
116
+ .fcs{font-size:0.6rem;color:#555}
117
+
118
+ .footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
119
+ .footer a{color:var(--muted)}
120
+ .flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
121
+ .arch-svg{margin:1rem 0;overflow-x:auto}
122
+ .hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
123
+ .hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
124
+ .hm-bar{height:14px;border-radius:3px;min-width:4px}
125
+ .hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}
126
+ .arch-svg svg{border-radius:8px}
127
+ .cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}
128
+ .ir:hover .cp-btn{opacity:0.6}
129
+ @media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}
130
+ `;
@@ -0,0 +1,12 @@
1
+ /** SVG builders for the HTML report (score ring, radar chart). */
2
+ export declare function buildRing(score: number, color: string): string;
3
+ export declare function buildRadar(items: {
4
+ label: string;
5
+ score: number;
6
+ }[]): string;
7
+ /** Sparkline — mini line chart for trend display. */
8
+ export declare function buildSparkline(values: number[], opts?: {
9
+ width?: number;
10
+ height?: number;
11
+ color?: string;
12
+ }): string;
@@ -0,0 +1,67 @@
1
+ /** SVG builders for the HTML report (score ring, radar chart). */
2
+ export function buildRing(score, color) {
3
+ const r = 42;
4
+ const c = 2 * Math.PI * r;
5
+ const off = c - (score / 100) * c;
6
+ return `<svg viewBox="0 0 100 100" style="width:100px;height:100px"><circle cx="50" cy="50" r="${r}" fill="none" stroke="#1e1e24" stroke-width="7"/><circle cx="50" cy="50" r="${r}" fill="none" stroke="${color}" stroke-width="7" stroke-dasharray="${c}" stroke-dashoffset="${off}" stroke-linecap="round" transform="rotate(-90 50 50)"/></svg>`;
7
+ }
8
+ export function buildRadar(items) {
9
+ const n = items.length;
10
+ if (n < 3)
11
+ return "";
12
+ const cx = 120, cy = 120, r = 90;
13
+ const step = (2 * Math.PI) / n;
14
+ let grid = "";
15
+ for (const pct of [25, 50, 75, 100]) {
16
+ const rr = (pct / 100) * r;
17
+ const pts = items
18
+ .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
19
+ .join(" ");
20
+ grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
21
+ }
22
+ let axes = "";
23
+ for (let i = 0; i < n; i++) {
24
+ const a = i * step - Math.PI / 2;
25
+ axes += `<line x1="${cx}" y1="${cy}" x2="${cx + r * Math.cos(a)}" y2="${cy + r * Math.sin(a)}" stroke="#1e1e24" stroke-width="0.7"/>`;
26
+ const lx = cx + (r + 16) * Math.cos(a);
27
+ const ly = cy + (r + 16) * Math.sin(a);
28
+ axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
29
+ }
30
+ const dataPts = items
31
+ .map((c, i) => {
32
+ const a = i * step - Math.PI / 2;
33
+ const rr = (c.score / 100) * r;
34
+ return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
35
+ })
36
+ .join(" ");
37
+ let dots = "";
38
+ for (let i = 0; i < n; i++) {
39
+ const a = i * step - Math.PI / 2;
40
+ const rr = (items[i].score / 100) * r;
41
+ dots += `<circle cx="${cx + rr * Math.cos(a)}" cy="${cy + rr * Math.sin(a)}" r="3.5" fill="#818cf8"/>`;
42
+ }
43
+ return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
44
+ }
45
+ /** Sparkline — mini line chart for trend display. */
46
+ export function buildSparkline(values, opts) {
47
+ const width = opts?.width ?? 120;
48
+ const height = opts?.height ?? 30;
49
+ const color = opts?.color ?? "#818cf8";
50
+ if (values.length === 0)
51
+ return "";
52
+ if (values.length === 1) {
53
+ const y = (height / 2).toFixed(1);
54
+ return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><polyline points="${(width / 2).toFixed(1)},${y}" fill="none" stroke="${color}" stroke-width="1.5"/><circle cx="${(width / 2).toFixed(1)}" cy="${y}" r="1.5" fill="${color}"/></svg>`;
55
+ }
56
+ const max = Math.max(...values, 1);
57
+ const min = Math.min(...values, 0);
58
+ const range = max - min || 1;
59
+ const step = width / (values.length - 1);
60
+ const points = values.map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / range) * (height - 4) - 2).toFixed(1)}`).join(" ");
61
+ const dots = values.map((v, i) => {
62
+ const x = (i * step).toFixed(1);
63
+ const y = (height - ((v - min) / range) * (height - 4) - 2).toFixed(1);
64
+ return `<circle cx="${x}" cy="${y}" r="1.5" fill="${color}"/>`;
65
+ }).join("");
66
+ return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>${dots}</svg>`;
67
+ }
@@ -0,0 +1,3 @@
1
+ /** Error handling check — detects poor error handling patterns. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runErrorHandling(cwd: string, stack: StackInfo): CheckResult;
@@ -0,0 +1,48 @@
1
+ /** Error handling check — detects poor error handling patterns. */
2
+ import { gradeFromScore } from "../types.js";
3
+ import { getProductionFiles } from "../fs-utils.js";
4
+ export function runErrorHandling(cwd, stack) {
5
+ const start = Date.now();
6
+ const issues = [];
7
+ const files = getProductionFiles(cwd);
8
+ if (files.length === 0) {
9
+ return { name: "error-handling", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
10
+ }
11
+ let emptyCatch = 0;
12
+ let throwString = 0;
13
+ for (const f of files) {
14
+ const lines = f.content.split("\n");
15
+ for (let i = 0; i < lines.length; i++) {
16
+ const line = lines[i].trim();
17
+ if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line) || /catch\s*\{\s*\}/.test(line)) {
18
+ emptyCatch++;
19
+ issues.push({ severity: "error", message: "Empty catch block", file: f.path, line: i + 1, rule: "empty-catch" });
20
+ }
21
+ if (/\bthrow\s+["'`]/.test(line)) {
22
+ throwString++;
23
+ issues.push({ severity: "warning", message: "throw string literal — use throw new Error()", file: f.path, line: i + 1, rule: "throw-string" });
24
+ }
25
+ }
26
+ }
27
+ let hasErrorBoundary = false;
28
+ if (stack.framework === "react") {
29
+ for (const f of files) {
30
+ if (f.content.includes("componentDidCatch") || f.content.includes("ErrorBoundary")) {
31
+ hasErrorBoundary = true;
32
+ break;
33
+ }
34
+ }
35
+ if (!hasErrorBoundary && files.some((f) => f.ext === ".tsx")) {
36
+ issues.push({ severity: "warning", message: "React project with no Error Boundary", rule: "no-error-boundary" });
37
+ }
38
+ }
39
+ const score = Math.max(0, Math.min(100, 100 - emptyCatch * 5 - throwString * 2 - (stack.framework === "react" && !hasErrorBoundary ? 3 : 0)));
40
+ return {
41
+ name: "error-handling",
42
+ score,
43
+ grade: gradeFromScore(score),
44
+ details: { emptyCatch, throwString, hasErrorBoundary: stack.framework === "react" ? hasErrorBoundary : "n/a" },
45
+ issues,
46
+ duration: Date.now() - start,
47
+ };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Code health scanner for the AI coding era. 15 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {