@vibecodeqa/cli 0.15.0 → 0.17.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 +25 -5
- package/dist/cli.js +13 -0
- package/dist/report/components.d.ts +10 -0
- package/dist/report/components.js +21 -0
- package/dist/report/html.d.ts +7 -5
- package/dist/report/html.js +45 -422
- package/dist/report/pages.d.ts +25 -0
- package/dist/report/pages.js +210 -0
- package/dist/report/styles.d.ts +2 -0
- package/dist/report/styles.js +156 -0
- package/dist/report/svg.d.ts +26 -6
- package/dist/report/svg.js +163 -20
- package/dist/runners/accessibility.d.ts +3 -0
- package/dist/runners/accessibility.js +85 -0
- package/dist/runners/react.d.ts +3 -0
- package/dist/runners/react.js +81 -0
- package/package.json +2 -2
package/dist/report/html.js
CHANGED
|
@@ -1,41 +1,26 @@
|
|
|
1
1
|
/** Generate a multi-page navigable HTML report.
|
|
2
2
|
*
|
|
3
3
|
* Architecture:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Primary nav: Overview | Foundations | Quality | Testing | Security | Architecture | AI Readiness
|
|
5
|
+
* Secondary nav: Issues (N) | Files (right-aligned, visually distinct)
|
|
6
|
+
* Sidebar: Score + dimension tree + view links
|
|
7
|
+
* Overview: Dashboard with score, radar, timeline, category cards, top issues, file hotspots
|
|
8
|
+
* Dimensions: Sub-tabs for each check within a category
|
|
9
|
+
* Views: Cross-cutting data slices (issues table, file health map)
|
|
8
10
|
*
|
|
9
|
-
* All in one self-contained HTML file using
|
|
11
|
+
* All in one self-contained HTML file using show/hide navigation.
|
|
10
12
|
*/
|
|
11
13
|
import { getCheckMeta } from "../check-meta.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
function e(s) {
|
|
16
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
17
|
-
}
|
|
18
|
-
/** Make a file path a clickable GitHub link if repoUrl is available. */
|
|
19
|
-
function fileLink(path, line, repoUrl, branch) {
|
|
20
|
-
const clean = path.split(":")[0];
|
|
21
|
-
if (!repoUrl || !/^https?:\/\//.test(repoUrl))
|
|
22
|
-
return e(path);
|
|
23
|
-
const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
|
|
24
|
-
return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
|
|
25
|
-
}
|
|
26
|
-
function gc(grade) {
|
|
27
|
-
return { A: "#22c55e", B: "#84cc16", C: "#eab308", D: "#f97316", F: "#ef4444" }[grade] || "#6b7280";
|
|
28
|
-
}
|
|
29
|
-
function pc(p) {
|
|
30
|
-
return { critical: "#ef4444", high: "#f97316", medium: "#eab308", low: "#6b7280" }[p];
|
|
31
|
-
}
|
|
14
|
+
import { e, fileLink, gc } from "./components.js";
|
|
15
|
+
import { categoryPages, filesPage, issuesPage, overviewPage } from "./pages.js";
|
|
16
|
+
import { CSS } from "./styles.js";
|
|
32
17
|
const GROUPS = [
|
|
33
18
|
{ id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
|
|
34
|
-
{ id: "quality", label: "Quality", checks: ["
|
|
19
|
+
{ id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "react", "accessibility", "docs"] },
|
|
35
20
|
{ id: "testing", label: "Testing", checks: ["testing"] },
|
|
36
21
|
{ id: "arch", label: "Architecture", checks: ["architecture"] },
|
|
37
22
|
{ id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
|
|
38
|
-
{ id: "llm", label: "
|
|
23
|
+
{ id: "llm", label: "AI Readiness", checks: ["confusion", "context"] },
|
|
39
24
|
];
|
|
40
25
|
export function generateHTML(report, historyDir) {
|
|
41
26
|
const allChecks = report.checks;
|
|
@@ -46,13 +31,13 @@ export function generateHTML(report, historyDir) {
|
|
|
46
31
|
const fl = (path, line) => fileLink(path, line, ru, br);
|
|
47
32
|
const totalIssues = allChecks.reduce((s, c) => s + c.issues.length, 0);
|
|
48
33
|
const proj = report.meta.cwd.split("/").pop() || "project";
|
|
49
|
-
// ──
|
|
34
|
+
// ── Aggregate file issues across all checks ──
|
|
50
35
|
const fileIssues = new Map();
|
|
51
36
|
for (const c of allChecks) {
|
|
52
37
|
for (const iss of c.issues) {
|
|
53
38
|
if (!iss.file)
|
|
54
39
|
continue;
|
|
55
|
-
const f = iss.file.split(":")[0];
|
|
40
|
+
const f = iss.file.split(":")[0];
|
|
56
41
|
const entry = fileIssues.get(f) || { errors: 0, warnings: 0, checks: new Set() };
|
|
57
42
|
if (iss.severity === "error")
|
|
58
43
|
entry.errors++;
|
|
@@ -65,7 +50,7 @@ export function generateHTML(report, historyDir) {
|
|
|
65
50
|
const topFiles = [...fileIssues.entries()]
|
|
66
51
|
.map(([file, d]) => ({ file, total: d.errors + d.warnings, errors: d.errors, warnings: d.warnings, checks: [...d.checks] }))
|
|
67
52
|
.sort((a, b) => b.total - a.total)
|
|
68
|
-
.slice(0,
|
|
53
|
+
.slice(0, 30);
|
|
69
54
|
// ── Category averages ──
|
|
70
55
|
const catScores = GROUPS.map((g) => {
|
|
71
56
|
const checks = g.checks.map((n) => checkMap.get(n)).filter(Boolean);
|
|
@@ -73,382 +58,64 @@ export function generateHTML(report, historyDir) {
|
|
|
73
58
|
const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
|
|
74
59
|
return { ...g, avg, checks };
|
|
75
60
|
});
|
|
76
|
-
// ──
|
|
77
|
-
|
|
78
|
-
const topNavItems = [
|
|
61
|
+
// ── Primary nav (dimensions) ──
|
|
62
|
+
const dimNavItems = [
|
|
79
63
|
{ id: "overview", label: "Overview" },
|
|
80
64
|
...GROUPS.map((g) => ({ id: g.id, label: g.label })),
|
|
81
|
-
{ id: "issues", label: `Issues (${totalIssues})` },
|
|
82
|
-
{ id: "files", label: "File Map" },
|
|
83
|
-
{ id: "heatmap", label: "Heatmap" },
|
|
84
65
|
];
|
|
85
|
-
const
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
})
|
|
66
|
+
const dimNav = dimNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
|
|
67
|
+
// ── Secondary nav (data views, right-aligned) ──
|
|
68
|
+
const viewNav = [
|
|
69
|
+
{ id: "issues", label: `Issues (${totalIssues})` },
|
|
70
|
+
{ id: "files", label: "Files" },
|
|
71
|
+
]
|
|
72
|
+
.map((t) => `<a class="tn tn-view" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`)
|
|
93
73
|
.join("");
|
|
94
|
-
|
|
74
|
+
// ── Sidebar ──
|
|
75
|
+
const sidebarDims = catScores
|
|
95
76
|
.map((cs) => {
|
|
96
77
|
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
97
|
-
|
|
78
|
+
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
|
|
98
79
|
.map((c) => {
|
|
99
80
|
const sk = c.details.skipped;
|
|
100
|
-
return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "—" : c.grade}</span>`;
|
|
101
|
-
})
|
|
102
|
-
.join("");
|
|
103
|
-
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>`;
|
|
104
|
-
})
|
|
105
|
-
.join("");
|
|
106
|
-
const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
|
|
107
|
-
// ── Trend sparklines from history ──
|
|
108
|
-
let trendSection = "";
|
|
109
|
-
if (historyDir) {
|
|
110
|
-
const history = loadHistory(historyDir);
|
|
111
|
-
if (history.length >= 2) {
|
|
112
|
-
const scores = history.map((h) => h.score);
|
|
113
|
-
const badge = scoreDeltaBadge(history);
|
|
114
|
-
const badgeHtml = badge
|
|
115
|
-
? `<span class="trend-badge" style="color:${badge.delta > 0 ? "var(--pass)" : badge.delta < 0 ? "var(--fail)" : "var(--muted)"}">${e(badge.label)}</span>`
|
|
116
|
-
: "";
|
|
117
|
-
// Composite score sparkline
|
|
118
|
-
const mainSparkline = buildSparkline(scores, { width: 120, height: 30, color: "#818cf8" });
|
|
119
|
-
// Per-check mini sparklines
|
|
120
|
-
const checkNames = [...new Set(history.flatMap((h) => [...h.checkScores.keys()]))];
|
|
121
|
-
const checkSparklines = checkNames
|
|
122
|
-
.map((name) => {
|
|
123
|
-
const vals = history.map((h) => h.checkScores.get(name) ?? 0);
|
|
124
|
-
const current = vals[vals.length - 1];
|
|
125
|
-
const prev = vals.length >= 2 ? vals[vals.length - 2] : current;
|
|
126
|
-
const delta = current - prev;
|
|
127
|
-
const dColor = delta > 0 ? "var(--pass)" : "var(--fail)";
|
|
128
|
-
const dSign = delta > 0 ? "+" : "";
|
|
129
|
-
const deltaStr = delta !== 0 ? `<span style="color:${dColor};font-size:0.58rem">${dSign}${delta}</span>` : "";
|
|
130
|
-
const svg = buildSparkline(vals, { width: 60, height: 20, color: "#6b7280", dotRadius: 1.5 });
|
|
131
|
-
return `<div class="ts-check"><span class="ts-name">${e(name)}</span>${svg}${deltaStr}</div>`;
|
|
132
|
-
})
|
|
133
|
-
.join("");
|
|
134
|
-
trendSection = `<div class="trend-section">
|
|
135
|
-
<h3>Trend <span style="font-size:0.65rem;font-weight:400;color:var(--muted)">${history.length} runs</span></h3>
|
|
136
|
-
<div class="ts-main"><div class="ts-spark">${mainSparkline}</div><div class="ts-info"><span class="ts-score">${report.score}</span>${badgeHtml}</div></div>
|
|
137
|
-
<div class="ts-checks">${checkSparklines}</div>
|
|
138
|
-
</div>`;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const overviewPage = `<div id="p-overview" class="page active">
|
|
142
|
-
<div class="dash">
|
|
143
|
-
<div class="hero">
|
|
144
|
-
${buildRing(ringPct, gc(report.grade))}
|
|
145
|
-
<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>
|
|
146
|
-
</div>
|
|
147
|
-
<div class="radar">${radarSvg}</div>
|
|
148
|
-
</div>
|
|
149
|
-
<div class="cats">${catCards}</div>
|
|
150
|
-
${trendSection}
|
|
151
|
-
<h3>All Checks</h3>
|
|
152
|
-
<div class="bars">${barChart}</div>
|
|
153
|
-
<div class="stack">${Object.entries(report.meta.stack)
|
|
154
|
-
.filter(([, v]) => v !== "none" && v !== "unknown")
|
|
155
|
-
.map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
|
|
156
|
-
.join("")}</div>
|
|
157
|
-
</div>`;
|
|
158
|
-
// Category pages (with sub-nav tabs for each check)
|
|
159
|
-
let catPages = "";
|
|
160
|
-
for (const cs of catScores) {
|
|
161
|
-
const subNav = cs.checks
|
|
162
|
-
.map((c, i) => {
|
|
163
|
-
const sk = c.details.skipped;
|
|
164
|
-
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>`;
|
|
165
|
-
})
|
|
166
|
-
.join("");
|
|
167
|
-
const subPages = cs.checks
|
|
168
|
-
.map((c, i) => {
|
|
169
81
|
const meta = getCheckMeta(c.name);
|
|
170
|
-
|
|
171
|
-
const detailsFiltered = Object.entries(c.details)
|
|
172
|
-
.filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
|
|
173
|
-
.map(([k, v]) => {
|
|
174
|
-
const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
175
|
-
return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
|
|
176
|
-
})
|
|
177
|
-
.join("");
|
|
178
|
-
// Group issues by file
|
|
179
|
-
const byFile = new Map();
|
|
180
|
-
const noFile = [];
|
|
181
|
-
for (const iss of c.issues) {
|
|
182
|
-
const f = iss.file?.split(":")[0];
|
|
183
|
-
if (f) {
|
|
184
|
-
const arr = byFile.get(f) || [];
|
|
185
|
-
arr.push(iss);
|
|
186
|
-
byFile.set(f, arr);
|
|
187
|
-
}
|
|
188
|
-
else {
|
|
189
|
-
noFile.push(iss);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
let issuesHtml = "";
|
|
193
|
-
for (const [file, issues] of byFile) {
|
|
194
|
-
issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
|
|
195
|
-
for (const iss of issues) {
|
|
196
|
-
const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
|
|
197
|
-
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>`;
|
|
198
|
-
}
|
|
199
|
-
issuesHtml += `</div>`;
|
|
200
|
-
}
|
|
201
|
-
if (noFile.length > 0) {
|
|
202
|
-
issuesHtml += `<div class="fg"><div class="fn">General</div>`;
|
|
203
|
-
for (const iss of noFile) {
|
|
204
|
-
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>`;
|
|
205
|
-
}
|
|
206
|
-
issuesHtml += `</div>`;
|
|
207
|
-
}
|
|
208
|
-
return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
|
|
209
|
-
<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>
|
|
210
|
-
${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>` : ""}
|
|
211
|
-
${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
|
|
212
|
-
${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
|
|
213
|
-
${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
|
|
214
|
-
${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
|
|
215
|
-
</div>`;
|
|
82
|
+
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>`;
|
|
216
83
|
})
|
|
217
|
-
.join("")
|
|
218
|
-
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
219
|
-
catPages += `<div id="p-${cs.id}" class="page">
|
|
220
|
-
<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>
|
|
221
|
-
<div class="bar2"><div class="bf2" style="width:${cs.avg}%;background:${clr}"></div></div>
|
|
222
|
-
<div class="sub-nav">${subNav}</div>
|
|
223
|
-
${subPages}
|
|
224
|
-
</div>`;
|
|
225
|
-
}
|
|
226
|
-
// All Issues page
|
|
227
|
-
const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
|
|
228
|
-
const issueRows = allIssues
|
|
229
|
-
.slice(0, 200)
|
|
230
|
-
.map((i) => {
|
|
231
|
-
const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
|
|
232
|
-
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>`;
|
|
233
|
-
})
|
|
234
|
-
.join("");
|
|
235
|
-
const issuesPage = `<div id="p-issues" class="page">
|
|
236
|
-
<h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
|
|
237
|
-
<div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors · ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
|
|
238
|
-
<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>
|
|
239
|
-
${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
|
|
240
|
-
</div>`;
|
|
241
|
-
// File heatmap page
|
|
242
|
-
const fileRows = topFiles
|
|
243
|
-
.map((f) => {
|
|
244
|
-
const pct = Math.min(100, f.total * 5);
|
|
245
|
-
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>`;
|
|
84
|
+
.join("")}</div>`;
|
|
246
85
|
})
|
|
247
86
|
.join("");
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
|
|
255
|
-
let heatmapHtml = "";
|
|
256
|
-
if (heatmapFiles.length > 0) {
|
|
257
|
-
const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
|
|
258
|
-
heatmapHtml = heatmapFiles
|
|
259
|
-
.map(([file, d]) => {
|
|
260
|
-
const total = d.errors + d.warnings;
|
|
261
|
-
const intensity = maxIssues > 0 ? total / maxIssues : 0;
|
|
262
|
-
const r = Math.round(239 * intensity); // red channel
|
|
263
|
-
const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
|
|
264
|
-
const color = `rgb(${r},${g},30)`;
|
|
265
|
-
const barW = Math.max(4, Math.round(intensity * 200));
|
|
266
|
-
const checks = [...d.checks].join(", ");
|
|
267
|
-
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>`;
|
|
268
|
-
})
|
|
269
|
-
.join("");
|
|
270
|
-
}
|
|
271
|
-
const heatmapPage = `<div id="p-heatmap" class="page">
|
|
272
|
-
<h2>Code Heatmap</h2>
|
|
273
|
-
<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>
|
|
274
|
-
${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
|
|
275
|
-
</div>`;
|
|
87
|
+
const sidebarViews = `<div class="side-section side-views"><div class="side-views-label">Views</div><a class="side-check" onclick="go('issues')">Issues <span style="color:var(--muted)">${totalIssues}</span></a><a class="side-check" onclick="go('files')">Files <span style="color:var(--muted)">${fileIssues.size}</span></a></div>`;
|
|
88
|
+
// ── Assemble pages ──
|
|
89
|
+
const overview = overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir);
|
|
90
|
+
const catPages = categoryPages(catScores, fl);
|
|
91
|
+
const issues = issuesPage(allChecks, totalIssues, fl);
|
|
92
|
+
const files = filesPage(topFiles, fileIssues, fl);
|
|
276
93
|
return `<!DOCTYPE html>
|
|
277
94
|
<html lang="en">
|
|
278
95
|
<head>
|
|
279
96
|
<meta charset="utf-8">
|
|
280
97
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
281
|
-
<title>VibeCode QA
|
|
282
|
-
<style>
|
|
283
|
-
:root{--bg:#09090b;--card:#111115;--border:#1e1e24;--text:#e5e5e5;--muted:#6b7280;--pass:#22c55e;--fail:#ef4444;--warn:#eab308;--info:#6366f1;--accent:#818cf8}
|
|
284
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
285
|
-
body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
|
|
286
|
-
code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
|
|
287
|
-
|
|
288
|
-
/* Top nav */
|
|
289
|
-
.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}
|
|
290
|
-
.logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0}
|
|
291
|
-
.logo span{color:var(--accent)}
|
|
292
|
-
.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}
|
|
293
|
-
.tn:hover{color:var(--text)}
|
|
294
|
-
.tn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
295
|
-
|
|
296
|
-
/* Sidebar */
|
|
297
|
-
.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}
|
|
298
|
-
.side-section{padding:0.3rem 0;border-bottom:1px solid var(--border)}
|
|
299
|
-
.side-section:last-child{border-bottom:none}
|
|
300
|
-
.side-score{font-size:1.4rem;font-weight:900;padding:0.3rem 0.8rem}
|
|
301
|
-
.side-cat{display:block;padding:0.3rem 0.8rem;color:var(--text);font-weight:700;cursor:pointer;text-decoration:none;font-size:0.72rem}
|
|
302
|
-
.side-cat:hover{background:#14141a}
|
|
303
|
-
.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}
|
|
304
|
-
.side-check:hover{color:var(--text);background:#14141a}
|
|
305
|
-
.side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
|
|
306
|
-
|
|
307
|
-
/* Content */
|
|
308
|
-
.content{max-width:900px;margin-left:200px;padding:2rem}
|
|
309
|
-
.page{display:none;animation:fadeIn 0.15s}
|
|
310
|
-
.page.active{display:block}
|
|
311
|
-
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
|
312
|
-
|
|
313
|
-
/* Overview */
|
|
314
|
-
.dash{display:flex;gap:2rem;margin-bottom:2rem;align-items:center;flex-wrap:wrap}
|
|
315
|
-
.hero{display:flex;align-items:center;gap:1rem}
|
|
316
|
-
.hero svg{width:100px;height:100px}
|
|
317
|
-
.hc{display:flex;flex-direction:column}
|
|
318
|
-
.hg{font-size:2.5rem;font-weight:900;line-height:1}
|
|
319
|
-
.hs{font-size:1rem;font-weight:600}
|
|
320
|
-
.hd{font-size:0.68rem;color:var(--muted)}
|
|
321
|
-
.radar{flex:1;display:flex;justify-content:center}
|
|
322
|
-
.radar svg{max-width:240px;width:100%}
|
|
323
|
-
.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:0.6rem;margin-bottom:2rem}
|
|
324
|
-
.cc{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:0.8rem;cursor:pointer;transition:border-color 0.15s}
|
|
325
|
-
.cc:hover{border-color:var(--accent)}
|
|
326
|
-
.cc-s{font-size:1.8rem;font-weight:900}
|
|
327
|
-
.cc-l{font-size:0.75rem;color:var(--muted)}
|
|
328
|
-
.cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
|
|
329
|
-
.mc{font-size:0.65rem;font-weight:800}
|
|
330
|
-
h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
|
|
331
|
-
.bars{margin-bottom:1.5rem}
|
|
332
|
-
.brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
|
|
333
|
-
.bl{width:80px;text-align:right;color:var(--muted);flex-shrink:0}
|
|
334
|
-
.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
335
|
-
.bf{height:100%;border-radius:2px}
|
|
336
|
-
.bv{width:36px;font-weight:700;font-size:0.68rem}
|
|
337
|
-
.stack{display:flex;gap:0.35rem;flex-wrap:wrap}
|
|
338
|
-
.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)}
|
|
339
|
-
|
|
340
|
-
/* Trend sparklines */
|
|
341
|
-
.trend-section{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:1rem;margin-bottom:1.5rem}
|
|
342
|
-
.trend-section h3{margin-bottom:0.6rem}
|
|
343
|
-
.ts-main{display:flex;align-items:center;gap:1rem;margin-bottom:0.8rem}
|
|
344
|
-
.ts-spark{flex-shrink:0}
|
|
345
|
-
.ts-info{display:flex;align-items:baseline;gap:0.5rem}
|
|
346
|
-
.ts-score{font-size:1.4rem;font-weight:900;color:var(--text)}
|
|
347
|
-
.trend-badge{font-size:0.72rem;font-weight:600}
|
|
348
|
-
.ts-checks{display:flex;flex-wrap:wrap;gap:0.5rem 1rem}
|
|
349
|
-
.ts-check{display:flex;align-items:center;gap:0.3rem;font-size:0.65rem}
|
|
350
|
-
.ts-name{color:var(--muted);width:70px;text-align:right;flex-shrink:0}
|
|
351
|
-
|
|
352
|
-
/* Category pages */
|
|
353
|
-
.cat-head{margin-bottom:0.3rem}
|
|
354
|
-
.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
|
|
355
|
-
.bf2{height:100%;border-radius:2px}
|
|
356
|
-
.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
|
|
357
|
-
.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
|
|
358
|
-
.sn:hover{color:var(--text)}
|
|
359
|
-
.sn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
360
|
-
.sp{display:none}.sp.active{display:block}
|
|
361
|
-
|
|
362
|
-
/* Check detail */
|
|
363
|
-
.ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}
|
|
364
|
-
.ch-g{font-size:2rem;font-weight:900}
|
|
365
|
-
.ch-s{display:block;font-size:0.7rem;color:var(--muted)}
|
|
366
|
-
.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}
|
|
367
|
-
.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}
|
|
368
|
-
.ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}
|
|
369
|
-
.ip-row:last-child{margin-bottom:0}
|
|
370
|
-
.ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}
|
|
371
|
-
.skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}
|
|
372
|
-
.kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}
|
|
373
|
-
.kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}
|
|
374
|
-
.k{color:var(--muted);margin-right:0.3rem}
|
|
375
|
-
.v{font-weight:600}
|
|
376
|
-
|
|
377
|
-
/* Issue list grouped by file */
|
|
378
|
-
.iss-list{margin-top:1rem}
|
|
379
|
-
.fg{margin-bottom:0.8rem}
|
|
380
|
-
.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}
|
|
381
|
-
.fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}
|
|
382
|
-
.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}
|
|
383
|
-
.is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}
|
|
384
|
-
.ir.error .is{color:var(--fail);background:#ef444418}
|
|
385
|
-
.ir.warning .is{color:var(--warn);background:#eab30818}
|
|
386
|
-
.il{color:var(--accent);min-width:2rem;flex-shrink:0}
|
|
387
|
-
.im{flex:1;word-break:break-word}
|
|
388
|
-
.iru{color:#555;font-size:0.55rem}
|
|
389
|
-
|
|
390
|
-
/* All issues table */
|
|
391
|
-
.isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}
|
|
392
|
-
.it{width:100%;border-collapse:collapse;font-size:0.68rem}
|
|
393
|
-
.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)}
|
|
394
|
-
.it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:"SF Mono",monospace;font-size:0.62rem}
|
|
395
|
-
.it tr.error .is2{color:var(--fail)}
|
|
396
|
-
.it tr.warning .is2{color:var(--warn)}
|
|
397
|
-
.is2{font-weight:800;width:1rem}
|
|
398
|
-
.ic2{color:var(--muted);width:70px}
|
|
399
|
-
.il2{color:var(--muted)}
|
|
400
|
-
.iru2{color:#555;font-size:0.58rem}
|
|
401
|
-
|
|
402
|
-
/* File heatmap */
|
|
403
|
-
.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
|
|
404
|
-
.ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
405
|
-
.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
406
|
-
.fbf{height:100%;border-radius:2px}
|
|
407
|
-
.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
|
|
408
|
-
.fcs{font-size:0.6rem;color:#555}
|
|
409
|
-
|
|
410
|
-
.footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}
|
|
411
|
-
.footer a{color:var(--muted)}
|
|
412
|
-
.flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
|
|
413
|
-
.arch-svg{margin:1rem 0;overflow-x:auto}
|
|
414
|
-
.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
|
|
415
|
-
.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
|
|
416
|
-
.hm-bar{height:14px;border-radius:3px;min-width:4px}
|
|
417
|
-
.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}
|
|
418
|
-
.arch-svg svg{border-radius:8px}
|
|
419
|
-
.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}
|
|
420
|
-
.ir:hover .cp-btn{opacity:0.6}
|
|
421
|
-
@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}
|
|
422
|
-
</style>
|
|
98
|
+
<title>VibeCode QA \u2014 ${e(proj)}</title>
|
|
99
|
+
<style>${CSS}</style>
|
|
423
100
|
</head>
|
|
424
101
|
<body>
|
|
425
102
|
|
|
426
103
|
<nav class="top">
|
|
427
104
|
<div class="logo"><span>VibeCode</span> QA</div>
|
|
428
|
-
|
|
105
|
+
<div class="nav-dims">${dimNav}</div>
|
|
106
|
+
<div class="nav-views">${viewNav}</div>
|
|
429
107
|
</nav>
|
|
430
108
|
|
|
431
109
|
<aside class="side">
|
|
432
110
|
<div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
|
|
433
|
-
${
|
|
434
|
-
|
|
435
|
-
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
436
|
-
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
|
|
437
|
-
.map((c) => {
|
|
438
|
-
const sk = c.details.skipped;
|
|
439
|
-
const meta = getCheckMeta(c.name);
|
|
440
|
-
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>`;
|
|
441
|
-
})
|
|
442
|
-
.join("")}</div>`;
|
|
443
|
-
})
|
|
444
|
-
.join("")}
|
|
111
|
+
${sidebarDims}
|
|
112
|
+
${sidebarViews}
|
|
445
113
|
</aside>
|
|
446
114
|
<div class="content">
|
|
447
|
-
${
|
|
115
|
+
${overview}
|
|
448
116
|
${catPages}
|
|
449
|
-
${
|
|
450
|
-
${
|
|
451
|
-
${heatmapPage}
|
|
117
|
+
${issues}
|
|
118
|
+
${files}
|
|
452
119
|
<div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} — <code>npx @vibecodeqa/cli</code></div>
|
|
453
120
|
</div>
|
|
454
121
|
|
|
@@ -464,7 +131,7 @@ function sub(el,cat){
|
|
|
464
131
|
el.classList.add('active');
|
|
465
132
|
document.querySelectorAll('#p-'+cat+' .sp').forEach(s=>{s.classList.toggle('active',s.dataset.sub===id)});
|
|
466
133
|
}
|
|
467
|
-
// Copy-prompt buttons
|
|
134
|
+
// Copy-prompt buttons
|
|
468
135
|
document.addEventListener('click',function(ev){
|
|
469
136
|
var btn=ev.target.closest('.cp-btn');
|
|
470
137
|
if(!btn)return;
|
|
@@ -476,47 +143,3 @@ document.querySelector('.tn').classList.add('active');
|
|
|
476
143
|
</script>
|
|
477
144
|
</body></html>`;
|
|
478
145
|
}
|
|
479
|
-
// ── SVG builders ──
|
|
480
|
-
function buildRing(score, color) {
|
|
481
|
-
const r = 42;
|
|
482
|
-
const c = 2 * Math.PI * r;
|
|
483
|
-
const off = c - (score / 100) * c;
|
|
484
|
-
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>`;
|
|
485
|
-
}
|
|
486
|
-
function buildRadar(items) {
|
|
487
|
-
const n = items.length;
|
|
488
|
-
if (n < 3)
|
|
489
|
-
return "";
|
|
490
|
-
const cx = 120, cy = 120, r = 90;
|
|
491
|
-
const step = (2 * Math.PI) / n;
|
|
492
|
-
let grid = "";
|
|
493
|
-
for (const pct of [25, 50, 75, 100]) {
|
|
494
|
-
const rr = (pct / 100) * r;
|
|
495
|
-
const pts = items
|
|
496
|
-
.map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
|
|
497
|
-
.join(" ");
|
|
498
|
-
grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
|
|
499
|
-
}
|
|
500
|
-
let axes = "";
|
|
501
|
-
for (let i = 0; i < n; i++) {
|
|
502
|
-
const a = i * step - Math.PI / 2;
|
|
503
|
-
axes += `<line x1="${cx}" y1="${cy}" x2="${cx + r * Math.cos(a)}" y2="${cy + r * Math.sin(a)}" stroke="#1e1e24" stroke-width="0.7"/>`;
|
|
504
|
-
const lx = cx + (r + 16) * Math.cos(a);
|
|
505
|
-
const ly = cy + (r + 16) * Math.sin(a);
|
|
506
|
-
axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
|
|
507
|
-
}
|
|
508
|
-
const dataPts = items
|
|
509
|
-
.map((c, i) => {
|
|
510
|
-
const a = i * step - Math.PI / 2;
|
|
511
|
-
const rr = (c.score / 100) * r;
|
|
512
|
-
return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
|
|
513
|
-
})
|
|
514
|
-
.join(" ");
|
|
515
|
-
let dots = "";
|
|
516
|
-
for (let i = 0; i < n; i++) {
|
|
517
|
-
const a = i * step - Math.PI / 2;
|
|
518
|
-
const rr = (items[i].score / 100) * r;
|
|
519
|
-
dots += `<circle cx="${cx + rr * Math.cos(a)}" cy="${cy + rr * Math.sin(a)}" r="3.5" fill="#818cf8"/>`;
|
|
520
|
-
}
|
|
521
|
-
return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
|
|
522
|
-
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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[], allChecks: CheckResult[], topFiles: FileEntry[], fl: FL, historyDir?: string): 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[], fileIssues: Map<string, {
|
|
21
|
+
errors: number;
|
|
22
|
+
warnings: number;
|
|
23
|
+
checks: Set<string>;
|
|
24
|
+
}>, fl: FL): string;
|
|
25
|
+
export {};
|