@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.
- package/dist/check-meta.js +11 -1
- package/dist/cli.js +2 -0
- package/dist/history.d.ts +14 -0
- package/dist/history.js +51 -0
- package/dist/report/components.d.ts +10 -0
- package/dist/report/components.js +21 -0
- package/dist/report/html.js +23 -358
- package/dist/report/pages.d.ts +26 -0
- package/dist/report/pages.js +166 -0
- package/dist/report/styles.d.ts +2 -0
- package/dist/report/styles.js +130 -0
- package/dist/report/svg.d.ts +12 -0
- package/dist/report/svg.js +67 -0
- package/dist/runners/error-handling.d.ts +3 -0
- package/dist/runners/error-handling.js +48 -0
- package/package.json +1 -1
package/dist/check-meta.js
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/history.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
+
}
|
package/dist/report/html.js
CHANGED
|
@@ -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 {
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
85
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
107
|
+
${overview}
|
|
399
108
|
${catPages}
|
|
400
|
-
${
|
|
401
|
-
${
|
|
402
|
-
${
|
|
109
|
+
${issues}
|
|
110
|
+
${files}
|
|
111
|
+
${heatmap}
|
|
403
112
|
<div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} — <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,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
|
+
}
|