@vibecodeqa/cli 0.16.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 +14 -1
- package/dist/report/html.d.ts +8 -6
- package/dist/report/html.js +33 -26
- package/dist/report/pages.d.ts +2 -3
- package/dist/report/pages.js +96 -52
- package/dist/report/styles.d.ts +1 -1
- package/dist/report/styles.js +37 -11
- package/dist/report/svg.d.ts +17 -0
- package/dist/report/svg.js +99 -0
- 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/check-meta.js
CHANGED
|
@@ -56,7 +56,7 @@ export const CHECK_META = {
|
|
|
56
56
|
label: "Error Handling",
|
|
57
57
|
category: "Quality",
|
|
58
58
|
priority: "high",
|
|
59
|
-
weight:
|
|
59
|
+
weight: 3,
|
|
60
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
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
62
|
recommendation: "Handle or log every catch. Use throw new Error() for stack traces. Add Error Boundaries in React. Chain .catch() on promises.",
|
|
@@ -96,7 +96,7 @@ export const CHECK_META = {
|
|
|
96
96
|
label: "Testing",
|
|
97
97
|
category: "Testing",
|
|
98
98
|
priority: "critical",
|
|
99
|
-
weight:
|
|
99
|
+
weight: 17,
|
|
100
100
|
description: "Deep assessment of test quality across 6 dimensions: pyramid presence (unit/integration/component/E2E layers), test execution (pass/fail), coverage (statement/branch/line/function), file pairing (test file per source file), test quality (assertion density, mock ratio, snapshot ratio), and E2E tool detection (Playwright/Cypress).",
|
|
101
101
|
risk: "Code without tests is code you can't safely change. Missing test layers mean entire categories of bugs go undetected: unit tests catch logic bugs, integration tests catch API contract breaks, E2E tests catch user-visible regressions. Low coverage means large portions of code are never exercised.",
|
|
102
102
|
recommendation: "Follow the testing pyramid: many unit tests, some integration tests, fewer E2E tests. Aim for >80% branch coverage. Every source file should have a corresponding test file. Use Playwright for E2E if you have a web frontend.",
|
|
@@ -136,7 +136,7 @@ export const CHECK_META = {
|
|
|
136
136
|
label: "Architecture",
|
|
137
137
|
category: "Architecture",
|
|
138
138
|
priority: "high",
|
|
139
|
-
weight:
|
|
139
|
+
weight: 6,
|
|
140
140
|
description: "Analyzes the import graph to detect structural problems: circular dependencies, god modules (imported by >50% of files), orphan modules (dead code), high fan-out (importing too many modules), and connector modules (high coupling). Generates an SVG architecture diagram.",
|
|
141
141
|
risk: "Circular dependencies create build order issues and make refactoring impossible without breaking changes. God modules become bottlenecks — any change ripples through the entire codebase. High coupling means you can't change one module without testing everything it touches.",
|
|
142
142
|
recommendation: "Break circular deps by extracting shared types to a separate file. Split god modules by concern. Reduce fan-out by co-locating related code. Use dependency injection for loose coupling.",
|
|
@@ -146,7 +146,7 @@ export const CHECK_META = {
|
|
|
146
146
|
label: "Confusion Index",
|
|
147
147
|
category: "LLM Readiness",
|
|
148
148
|
priority: "high",
|
|
149
|
-
weight:
|
|
149
|
+
weight: 7,
|
|
150
150
|
description: "Measures naming ambiguity that causes LLMs to misunderstand or edit the wrong code. Checks: file name confusability (Levenshtein distance + synonym detection), generic function/variable names, export name collisions across files, and ambiguous abbreviations.",
|
|
151
151
|
risk: "GPT-4o drops 28.6 percentage points on code summarization when names are ambiguous (arXiv:2510.03178). LLMs editing similar-named files is the #1 reported failure mode in AI-assisted development. Generic names like process(), handle(), data cause models to misinterpret intent.",
|
|
152
152
|
recommendation: "Use descriptive, unique names. Avoid synonym files (utils.ts + helpers.ts — pick one). Avoid generic exports. Disambiguate abbreviations (use 'authentication' not 'auth' if both auth meanings exist in the codebase).",
|
|
@@ -156,11 +156,31 @@ export const CHECK_META = {
|
|
|
156
156
|
label: "Context Locality",
|
|
157
157
|
category: "LLM Readiness",
|
|
158
158
|
priority: "high",
|
|
159
|
-
weight:
|
|
159
|
+
weight: 6,
|
|
160
160
|
description: "Measures how self-contained code is for LLM consumption. Checks: token density per file, import count, circular dependencies, and context sinks (files that import many modules but export little). Based on the finding that LLMs lose 30%+ accuracy for information in the middle of long contexts.",
|
|
161
161
|
risk: "Files over ~4000 tokens exceed the 'sweet spot' for LLM attention (Liu et al. 2023 'Lost in the Middle'). Circular dependencies create infinite loops in LLM code navigation. Heavy import chains force LLMs to load many files, burning context window budget (Chroma 'Context Rot' 2025).",
|
|
162
162
|
recommendation: "Keep files under 400 lines / 4000 tokens. Limit imports to <15 per file. Break circular dependencies. Co-locate related code to reduce cross-file jumps.",
|
|
163
163
|
},
|
|
164
|
+
react: {
|
|
165
|
+
name: "react",
|
|
166
|
+
label: "React Patterns",
|
|
167
|
+
category: "Quality",
|
|
168
|
+
priority: "high",
|
|
169
|
+
weight: 3,
|
|
170
|
+
description: "Checks React-specific patterns: conditional hook calls (violates Rules of Hooks), missing key props in .map(), index as key, prop spreading on DOM elements, and excessive inline handlers.",
|
|
171
|
+
risk: "Conditional hooks cause React to crash at runtime. Missing keys cause incorrect reconciliation — items can swap, duplicate, or lose state. Index keys break when lists are reordered or filtered.",
|
|
172
|
+
recommendation: "Never call hooks inside conditions, loops, or nested functions. Always provide a unique, stable key in .map(). Avoid spreading unknown props onto DOM elements. Extract inline handlers for readability.",
|
|
173
|
+
},
|
|
174
|
+
accessibility: {
|
|
175
|
+
name: "accessibility",
|
|
176
|
+
label: "Accessibility",
|
|
177
|
+
category: "Quality",
|
|
178
|
+
priority: "high",
|
|
179
|
+
weight: 4,
|
|
180
|
+
description: "Checks common accessibility violations: images without alt text, click handlers on non-interactive elements without keyboard support, form controls without labels, autoFocus usage, positive tabIndex, and missing html lang attribute.",
|
|
181
|
+
risk: "1 in 4 adults has a disability (CDC). Missing alt text makes images invisible to screen readers. Click-only divs exclude keyboard users. Unlabeled inputs are unusable with assistive technology. Missing lang attribute breaks screen reader pronunciation.",
|
|
182
|
+
recommendation: "Add alt text to all images (use alt=\"\" for decorative). Use <button> for clickable elements, not <div onClick>. Label all form controls with <label>, aria-label, or aria-labelledby. Set lang on <html>.",
|
|
183
|
+
},
|
|
164
184
|
};
|
|
165
185
|
export function getCheckMeta(name) {
|
|
166
186
|
return (CHECK_META[name] || {
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,8 @@ import { runDocs } from "./runners/docs.js";
|
|
|
13
13
|
import { runDuplication } from "./runners/duplication.js";
|
|
14
14
|
import { runErrorHandling } from "./runners/error-handling.js";
|
|
15
15
|
import { runLint } from "./runners/lint.js";
|
|
16
|
+
import { runReact } from "./runners/react.js";
|
|
17
|
+
import { runAccessibility } from "./runners/accessibility.js";
|
|
16
18
|
import { runSecrets } from "./runners/secrets.js";
|
|
17
19
|
import { runSecurity } from "./runners/security.js";
|
|
18
20
|
import { runStandards } from "./runners/standards.js";
|
|
@@ -33,6 +35,7 @@ const jsonOnly = flags.has("--json");
|
|
|
33
35
|
const ciMode = flags.has("--ci");
|
|
34
36
|
const skipTests = flags.has("--skip-tests");
|
|
35
37
|
const watchMode = flags.has("--watch");
|
|
38
|
+
const badgeMode = flags.has("--badge");
|
|
36
39
|
function color(grade) {
|
|
37
40
|
if (grade === "A")
|
|
38
41
|
return "\x1b[32m";
|
|
@@ -67,6 +70,8 @@ async function main() {
|
|
|
67
70
|
{ name: "complexity", fn: () => runComplexity(cwd) },
|
|
68
71
|
{ name: "duplication", fn: () => runDuplication(cwd) },
|
|
69
72
|
{ name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
|
|
73
|
+
{ name: "react", fn: () => runReact(cwd, stack) },
|
|
74
|
+
{ name: "accessibility", fn: () => runAccessibility(cwd) },
|
|
70
75
|
{ name: "docs", fn: () => runDocs(cwd) },
|
|
71
76
|
// Testing
|
|
72
77
|
{ name: "testing", fn: () => runTesting(cwd, stack, skipTests) },
|
|
@@ -131,7 +136,13 @@ async function main() {
|
|
|
131
136
|
}
|
|
132
137
|
}
|
|
133
138
|
writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
|
|
134
|
-
writeFileSync(join(outputDir, "report.html"), generateHTML(report));
|
|
139
|
+
writeFileSync(join(outputDir, "report.html"), generateHTML(report, historyDir));
|
|
140
|
+
// Badge SVG
|
|
141
|
+
if (badgeMode) {
|
|
142
|
+
const { buildBadge } = await import("./report/svg.js");
|
|
143
|
+
const badgeSvg = buildBadge(score, grade);
|
|
144
|
+
writeFileSync(join(outputDir, "badge.svg"), badgeSvg);
|
|
145
|
+
}
|
|
135
146
|
if (jsonOnly) {
|
|
136
147
|
console.log(JSON.stringify(report));
|
|
137
148
|
}
|
|
@@ -144,6 +155,8 @@ async function main() {
|
|
|
144
155
|
console.log("");
|
|
145
156
|
console.log(` \x1b[2mReport: ${join(outputDir, "report.html")}\x1b[0m`);
|
|
146
157
|
console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
|
|
158
|
+
if (badgeMode)
|
|
159
|
+
console.log(` \x1b[2mBadge: ${join(outputDir, "badge.svg")}\x1b[0m`);
|
|
147
160
|
console.log("");
|
|
148
161
|
}
|
|
149
162
|
if (ciMode && score < 60) {
|
package/dist/report/html.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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 type { VibeReport } from "../types.js";
|
|
12
|
-
export declare function generateHTML(report: VibeReport): string;
|
|
14
|
+
export declare function generateHTML(report: VibeReport, historyDir?: string): string;
|
package/dist/report/html.js
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
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
14
|
import { e, fileLink, gc } from "./components.js";
|
|
13
|
-
import { categoryPages, filesPage,
|
|
15
|
+
import { categoryPages, filesPage, issuesPage, overviewPage } from "./pages.js";
|
|
14
16
|
import { CSS } from "./styles.js";
|
|
15
17
|
const GROUPS = [
|
|
16
18
|
{ id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
|
|
17
|
-
{ id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "docs"] },
|
|
19
|
+
{ id: "quality", label: "Quality", checks: ["complexity", "duplication", "error-handling", "react", "accessibility", "docs"] },
|
|
18
20
|
{ id: "testing", label: "Testing", checks: ["testing"] },
|
|
19
21
|
{ id: "arch", label: "Architecture", checks: ["architecture"] },
|
|
20
22
|
{ id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
|
|
21
|
-
{ id: "llm", label: "
|
|
23
|
+
{ id: "llm", label: "AI Readiness", checks: ["confusion", "context"] },
|
|
22
24
|
];
|
|
23
|
-
export function generateHTML(report) {
|
|
25
|
+
export function generateHTML(report, historyDir) {
|
|
24
26
|
const allChecks = report.checks;
|
|
25
27
|
const checkMap = new Map(allChecks.map((c) => [c.name, c]));
|
|
26
28
|
const active = allChecks.filter((c) => !c.details.skipped);
|
|
@@ -29,13 +31,13 @@ export function generateHTML(report) {
|
|
|
29
31
|
const fl = (path, line) => fileLink(path, line, ru, br);
|
|
30
32
|
const totalIssues = allChecks.reduce((s, c) => s + c.issues.length, 0);
|
|
31
33
|
const proj = report.meta.cwd.split("/").pop() || "project";
|
|
32
|
-
// ──
|
|
34
|
+
// ── Aggregate file issues across all checks ──
|
|
33
35
|
const fileIssues = new Map();
|
|
34
36
|
for (const c of allChecks) {
|
|
35
37
|
for (const iss of c.issues) {
|
|
36
38
|
if (!iss.file)
|
|
37
39
|
continue;
|
|
38
|
-
const f = iss.file.split(":")[0];
|
|
40
|
+
const f = iss.file.split(":")[0];
|
|
39
41
|
const entry = fileIssues.get(f) || { errors: 0, warnings: 0, checks: new Set() };
|
|
40
42
|
if (iss.severity === "error")
|
|
41
43
|
entry.errors++;
|
|
@@ -48,7 +50,7 @@ export function generateHTML(report) {
|
|
|
48
50
|
const topFiles = [...fileIssues.entries()]
|
|
49
51
|
.map(([file, d]) => ({ file, total: d.errors + d.warnings, errors: d.errors, warnings: d.warnings, checks: [...d.checks] }))
|
|
50
52
|
.sort((a, b) => b.total - a.total)
|
|
51
|
-
.slice(0,
|
|
53
|
+
.slice(0, 30);
|
|
52
54
|
// ── Category averages ──
|
|
53
55
|
const catScores = GROUPS.map((g) => {
|
|
54
56
|
const checks = g.checks.map((n) => checkMap.get(n)).filter(Boolean);
|
|
@@ -56,17 +58,21 @@ export function generateHTML(report) {
|
|
|
56
58
|
const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
|
|
57
59
|
return { ...g, avg, checks };
|
|
58
60
|
});
|
|
59
|
-
// ──
|
|
60
|
-
const
|
|
61
|
+
// ── Primary nav (dimensions) ──
|
|
62
|
+
const dimNavItems = [
|
|
61
63
|
{ id: "overview", label: "Overview" },
|
|
62
64
|
...GROUPS.map((g) => ({ id: g.id, label: g.label })),
|
|
63
|
-
{ id: "issues", label: `Issues (${totalIssues})` },
|
|
64
|
-
{ id: "files", label: "File Map" },
|
|
65
|
-
{ id: "heatmap", label: "Heatmap" },
|
|
66
65
|
];
|
|
67
|
-
const
|
|
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>`)
|
|
73
|
+
.join("");
|
|
68
74
|
// ── Sidebar ──
|
|
69
|
-
const
|
|
75
|
+
const sidebarDims = catScores
|
|
70
76
|
.map((cs) => {
|
|
71
77
|
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
72
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
|
|
@@ -78,12 +84,12 @@ export function generateHTML(report) {
|
|
|
78
84
|
.join("")}</div>`;
|
|
79
85
|
})
|
|
80
86
|
.join("");
|
|
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>`;
|
|
81
88
|
// ── Assemble pages ──
|
|
82
|
-
const overview = overviewPage(report, active, totalIssues, catScores);
|
|
89
|
+
const overview = overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir);
|
|
83
90
|
const catPages = categoryPages(catScores, fl);
|
|
84
91
|
const issues = issuesPage(allChecks, totalIssues, fl);
|
|
85
|
-
const files = filesPage(topFiles, fl);
|
|
86
|
-
const heatmap = heatmapPage(fileIssues, fl);
|
|
92
|
+
const files = filesPage(topFiles, fileIssues, fl);
|
|
87
93
|
return `<!DOCTYPE html>
|
|
88
94
|
<html lang="en">
|
|
89
95
|
<head>
|
|
@@ -96,19 +102,20 @@ export function generateHTML(report) {
|
|
|
96
102
|
|
|
97
103
|
<nav class="top">
|
|
98
104
|
<div class="logo"><span>VibeCode</span> QA</div>
|
|
99
|
-
|
|
105
|
+
<div class="nav-dims">${dimNav}</div>
|
|
106
|
+
<div class="nav-views">${viewNav}</div>
|
|
100
107
|
</nav>
|
|
101
108
|
|
|
102
109
|
<aside class="side">
|
|
103
110
|
<div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
|
|
104
|
-
${
|
|
111
|
+
${sidebarDims}
|
|
112
|
+
${sidebarViews}
|
|
105
113
|
</aside>
|
|
106
114
|
<div class="content">
|
|
107
115
|
${overview}
|
|
108
116
|
${catPages}
|
|
109
117
|
${issues}
|
|
110
118
|
${files}
|
|
111
|
-
${heatmap}
|
|
112
119
|
<div class="footer">Generated by <a href="https://vibecodeqa.online">VibeCode QA</a> v${report.version} — <code>npx @vibecodeqa/cli</code></div>
|
|
113
120
|
</div>
|
|
114
121
|
|
|
@@ -124,7 +131,7 @@ function sub(el,cat){
|
|
|
124
131
|
el.classList.add('active');
|
|
125
132
|
document.querySelectorAll('#p-'+cat+' .sp').forEach(s=>{s.classList.toggle('active',s.dataset.sub===id)});
|
|
126
133
|
}
|
|
127
|
-
// Copy-prompt buttons
|
|
134
|
+
// Copy-prompt buttons
|
|
128
135
|
document.addEventListener('click',function(ev){
|
|
129
136
|
var btn=ev.target.closest('.cp-btn');
|
|
130
137
|
if(!btn)return;
|
package/dist/report/pages.d.ts
CHANGED
|
@@ -14,11 +14,10 @@ export interface FileEntry {
|
|
|
14
14
|
checks: string[];
|
|
15
15
|
}
|
|
16
16
|
type FL = (path: string, line?: number) => string;
|
|
17
|
-
export declare function overviewPage(report: VibeReport, active: CheckResult[], totalIssues: number, catScores: CatScore[]): string;
|
|
17
|
+
export declare function overviewPage(report: VibeReport, active: CheckResult[], totalIssues: number, catScores: CatScore[], allChecks: CheckResult[], topFiles: FileEntry[], fl: FL, historyDir?: string): string;
|
|
18
18
|
export declare function categoryPages(catScores: CatScore[], fl: FL): string;
|
|
19
19
|
export declare function issuesPage(allChecks: CheckResult[], totalIssues: number, fl: FL): string;
|
|
20
|
-
export declare function filesPage(topFiles: FileEntry[],
|
|
21
|
-
export declare function heatmapPage(fileIssues: Map<string, {
|
|
20
|
+
export declare function filesPage(topFiles: FileEntry[], fileIssues: Map<string, {
|
|
22
21
|
errors: number;
|
|
23
22
|
warnings: number;
|
|
24
23
|
checks: Set<string>;
|
package/dist/report/pages.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/** Page renderers for the HTML report. */
|
|
2
2
|
import { getCheckMeta } from "../check-meta.js";
|
|
3
|
+
import { loadHistory } from "../history.js";
|
|
3
4
|
import { generateArchSVG } from "../runners/architecture.js";
|
|
4
5
|
import { e, gc, pc } from "./components.js";
|
|
5
|
-
import { buildRadar, buildRing } from "./svg.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
import { buildPyramid, buildRadar, buildRing, buildTimeline } from "./svg.js";
|
|
7
|
+
// ── Overview ──────────────────────────────────────────────────────────
|
|
8
|
+
export function overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir) {
|
|
9
|
+
// Hero: score ring + grade
|
|
10
|
+
const hero = `<div class="hero">
|
|
11
|
+
${buildRing(report.score, gc(report.grade))}
|
|
12
|
+
<div class="hc">
|
|
13
|
+
<span class="hg" style="color:${gc(report.grade)}">${report.grade}</span>
|
|
14
|
+
<span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span>
|
|
15
|
+
<span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span>
|
|
16
|
+
</div>
|
|
17
|
+
</div>`;
|
|
18
|
+
// Radar chart
|
|
19
|
+
const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
|
|
20
|
+
// Category cards
|
|
14
21
|
const catCards = catScores
|
|
15
22
|
.map((cs) => {
|
|
16
23
|
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
@@ -23,24 +30,67 @@ export function overviewPage(report, active, totalIssues, catScores) {
|
|
|
23
30
|
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
31
|
})
|
|
25
32
|
.join("");
|
|
26
|
-
|
|
33
|
+
// Score timeline (from history)
|
|
34
|
+
let timelineSection = "";
|
|
35
|
+
if (historyDir) {
|
|
36
|
+
const history = loadHistory(historyDir);
|
|
37
|
+
if (history.length >= 2) {
|
|
38
|
+
const timelineSvg = buildTimeline(history.map((h) => ({ score: h.score, timestamp: h.timestamp })));
|
|
39
|
+
timelineSection = `<div class="ov-section"><h3>Score Timeline</h3><div class="timeline">${timelineSvg}</div></div>`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// All checks bar chart
|
|
43
|
+
const barChart = active
|
|
44
|
+
.sort((a, b) => a.score - b.score)
|
|
45
|
+
.map((c) => {
|
|
46
|
+
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>`;
|
|
47
|
+
})
|
|
48
|
+
.join("");
|
|
49
|
+
// Top issues preview (10 most severe)
|
|
50
|
+
const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
|
|
51
|
+
const sortedIssues = allIssues
|
|
52
|
+
.sort((a, b) => (a.severity === "error" ? 0 : a.severity === "warning" ? 1 : 2) - (b.severity === "error" ? 0 : b.severity === "warning" ? 1 : 2))
|
|
53
|
+
.slice(0, 10);
|
|
54
|
+
let topIssuesHtml = "";
|
|
55
|
+
if (sortedIssues.length > 0) {
|
|
56
|
+
const rows = sortedIssues
|
|
57
|
+
.map((i) => {
|
|
58
|
+
const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
|
|
59
|
+
return `<div class="ov-issue ${i.severity}"><span class="is">${i.severity[0].toUpperCase()}</span><span class="ov-check">${e(i.check)}</span>${loc ? `<span class="ov-loc">${loc}</span>` : ""}<span class="ov-msg">${e(i.message)}</span></div>`;
|
|
60
|
+
})
|
|
61
|
+
.join("");
|
|
62
|
+
const viewAll = allIssues.length > 10 ? `<a class="ov-link" onclick="go('issues')">View all ${allIssues.length} issues \u2192</a>` : "";
|
|
63
|
+
topIssuesHtml = `<div class="ov-section"><h3>Top Issues</h3>${rows}${viewAll}</div>`;
|
|
64
|
+
}
|
|
65
|
+
// File hotspots preview (top 5)
|
|
66
|
+
let fileHotspotsHtml = "";
|
|
67
|
+
if (topFiles.length > 0) {
|
|
68
|
+
const fileRows = topFiles.slice(0, 5).map((f) => {
|
|
69
|
+
const pct = Math.min(100, f.total * 5);
|
|
70
|
+
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></div>`;
|
|
71
|
+
}).join("");
|
|
72
|
+
const viewAll = topFiles.length > 5 ? `<a class="ov-link" onclick="go('files')">View all ${topFiles.length} files \u2192</a>` : "";
|
|
73
|
+
fileHotspotsHtml = `<div class="ov-section"><h3>File Hotspots</h3>${fileRows}${viewAll}</div>`;
|
|
74
|
+
}
|
|
75
|
+
// Stack badges
|
|
76
|
+
const stackHtml = Object.entries(report.meta.stack)
|
|
77
|
+
.filter(([, v]) => v !== "none" && v !== "unknown")
|
|
78
|
+
.map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
|
|
79
|
+
.join("");
|
|
27
80
|
return `<div id="p-overview" class="page active">
|
|
28
81
|
<div class="dash">
|
|
29
|
-
|
|
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>
|
|
82
|
+
${hero}
|
|
33
83
|
<div class="radar">${radarSvg}</div>
|
|
34
84
|
</div>
|
|
35
85
|
<div class="cats">${catCards}</div>
|
|
36
|
-
|
|
37
|
-
<div class="bars">${barChart}</div>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
.join("")}</div>
|
|
86
|
+
${timelineSection}
|
|
87
|
+
<div class="ov-section"><h3>All Checks</h3><div class="bars">${barChart}</div></div>
|
|
88
|
+
${topIssuesHtml}
|
|
89
|
+
${fileHotspotsHtml}
|
|
90
|
+
<div class="stack">${stackHtml}</div>
|
|
42
91
|
</div>`;
|
|
43
92
|
}
|
|
93
|
+
// ── Category dimension pages ──────────────────────────────────────────
|
|
44
94
|
export function categoryPages(catScores, fl) {
|
|
45
95
|
let catPagesHtml = "";
|
|
46
96
|
for (const cs of catScores) {
|
|
@@ -96,6 +146,7 @@ export function categoryPages(catScores, fl) {
|
|
|
96
146
|
${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
147
|
${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
|
|
98
148
|
${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
|
|
149
|
+
${c.name === "testing" && !sk && c.details.pyramid ? `<div class="arch-svg">${buildPyramid(c.details.pyramid)}</div>` : ""}
|
|
99
150
|
${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
|
|
100
151
|
${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
|
|
101
152
|
</div>`;
|
|
@@ -111,9 +162,14 @@ ${subPages}
|
|
|
111
162
|
}
|
|
112
163
|
return catPagesHtml;
|
|
113
164
|
}
|
|
165
|
+
// ── Issues view (cross-cutting) ──────────────────────────────────────
|
|
114
166
|
export function issuesPage(allChecks, totalIssues, fl) {
|
|
115
167
|
const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
|
|
168
|
+
const errorCount = allIssues.filter((i) => i.severity === "error").length;
|
|
169
|
+
const warnCount = allIssues.filter((i) => i.severity === "warning").length;
|
|
170
|
+
const infoCount = allIssues.filter((i) => i.severity === "info").length;
|
|
116
171
|
const issueRows = allIssues
|
|
172
|
+
.sort((a, b) => (a.severity === "error" ? 0 : a.severity === "warning" ? 1 : 2) - (b.severity === "error" ? 0 : b.severity === "warning" ? 1 : 2))
|
|
117
173
|
.slice(0, 200)
|
|
118
174
|
.map((i) => {
|
|
119
175
|
const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
|
|
@@ -122,45 +178,33 @@ export function issuesPage(allChecks, totalIssues, fl) {
|
|
|
122
178
|
.join("");
|
|
123
179
|
return `<div id="p-issues" class="page">
|
|
124
180
|
<h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
|
|
125
|
-
<div class="isf">${
|
|
181
|
+
<div class="isf"><span style="color:var(--fail)">${errorCount} errors</span> \u00b7 <span style="color:var(--warn)">${warnCount} warnings</span>${infoCount > 0 ? ` \u00b7 <span style="color:var(--info)">${infoCount} info</span>` : ""}</div>
|
|
126
182
|
<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
183
|
${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
|
|
128
184
|
</div>`;
|
|
129
185
|
}
|
|
130
|
-
|
|
131
|
-
|
|
186
|
+
// ── Files view (merged file map + heatmap) ───────────────────────────
|
|
187
|
+
export function filesPage(topFiles, fileIssues, fl) {
|
|
188
|
+
if (topFiles.length === 0) {
|
|
189
|
+
return `<div id="p-files" class="page"><h2>File Health</h2><p style="color:var(--muted)">No file-level issues found.</p></div>`;
|
|
190
|
+
}
|
|
191
|
+
// Heatmap (visual density bars)
|
|
192
|
+
const maxIssues = Math.max(...topFiles.map((f) => f.total));
|
|
193
|
+
const heatmapRows = topFiles
|
|
194
|
+
.slice(0, 30)
|
|
132
195
|
.map((f) => {
|
|
133
|
-
const
|
|
134
|
-
|
|
196
|
+
const intensity = maxIssues > 0 ? f.total / maxIssues : 0;
|
|
197
|
+
const r = Math.round(239 * intensity);
|
|
198
|
+
const g = Math.round(68 * (1 - intensity) + 197 * (f.errors === 0 ? 0.3 : 0));
|
|
199
|
+
const color = `rgb(${r},${g},30)`;
|
|
200
|
+
const barW = Math.max(4, Math.round(intensity * 200));
|
|
201
|
+
return `<div class="hm-row"><span class="hm-name">${fl(f.file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${f.total} issues (${f.checks.join(", ")})"></div><span class="hm-count">${f.errors}E ${f.warnings}W</span><span class="hm-checks">${f.checks.join(", ")}</span></div>`;
|
|
135
202
|
})
|
|
136
203
|
.join("");
|
|
137
204
|
return `<div id="p-files" class="page">
|
|
138
|
-
<h2>File
|
|
139
|
-
<p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem"
|
|
140
|
-
|
|
141
|
-
|
|
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>'}
|
|
205
|
+
<h2>File Health</h2>
|
|
206
|
+
<p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">${fileIssues.size} files with issues across ${topFiles.reduce((s, f) => { for (const c of f.checks)
|
|
207
|
+
s.add(c); return s; }, new Set()).size} checks. Bar color: red = errors, orange = warnings only. Width = relative issue density.</p>
|
|
208
|
+
${heatmapRows}
|
|
165
209
|
</div>`;
|
|
166
210
|
}
|
package/dist/report/styles.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
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:
|
|
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 \u2014 split into dimensions (left) and views (right) */\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;flex-shrink:0}\n.logo span{color:var(--accent)}\n.nav-dims{display:flex;align-items:center;gap:0}\n.nav-views{margin-left:auto;display:flex;align-items:center;gap:0;border-left:1px solid var(--border);padding-left:0.3rem}\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.tn-view{font-size:0.72rem;opacity:0.8}\n.tn-view.active{opacity:1}\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.side-views{padding-top:0.5rem}\n.side-views-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}\n.side-views .side-check{padding-left:0.8rem}\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\n/* Overview sections */\n.ov-section{margin-bottom:1.5rem}\n.ov-issue{font-size:0.68rem;font-family:\"SF Mono\",monospace;padding:0.2rem 0;display:flex;gap:0.4rem;align-items:baseline;border-bottom:1px solid var(--border)}\n.ov-issue .is{flex-shrink:0}\n.ov-issue.error .is{color:var(--fail)}\n.ov-issue.warning .is{color:var(--warn)}\n.ov-check{color:var(--muted);width:70px;flex-shrink:0;font-size:0.62rem}\n.ov-loc{color:var(--accent);flex-shrink:0;font-size:0.62rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.ov-msg{flex:1;word-break:break-word}\n.ov-link{display:block;margin-top:0.5rem;font-size:0.72rem;color:var(--accent);cursor:pointer;text-decoration:none}\n.ov-link:hover{text-decoration:underline}\n\n/* Timeline */\n.timeline{margin:0.5rem 0;overflow-x:auto}\n.timeline svg{max-width:100%}\n\n/* Bar chart */\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:90px;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;margin-top:1rem}\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;flex-wrap:wrap}\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 health (merged file map + 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.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;min-width:50px}\n.hm-checks{font-size:0.58rem;color:#555;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\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.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}.nav-views{display:none}}\n";
|
package/dist/report/styles.js
CHANGED
|
@@ -5,13 +5,17 @@ export const CSS = `
|
|
|
5
5
|
body{font-family:"Inter",system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
|
|
6
6
|
code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
|
|
7
7
|
|
|
8
|
-
/* Top nav */
|
|
8
|
+
/* Top nav — split into dimensions (left) and views (right) */
|
|
9
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}
|
|
10
|
+
.logo{font-weight:800;font-size:1rem;margin-right:1.5rem;padding:0.7rem 0;flex-shrink:0}
|
|
11
11
|
.logo span{color:var(--accent)}
|
|
12
|
+
.nav-dims{display:flex;align-items:center;gap:0}
|
|
13
|
+
.nav-views{margin-left:auto;display:flex;align-items:center;gap:0;border-left:1px solid var(--border);padding-left:0.3rem}
|
|
12
14
|
.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
15
|
.tn:hover{color:var(--text)}
|
|
14
16
|
.tn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
17
|
+
.tn-view{font-size:0.72rem;opacity:0.8}
|
|
18
|
+
.tn-view.active{opacity:1}
|
|
15
19
|
|
|
16
20
|
/* Sidebar */
|
|
17
21
|
.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}
|
|
@@ -23,6 +27,9 @@ code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
|
|
|
23
27
|
.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
28
|
.side-check:hover{color:var(--text);background:#14141a}
|
|
25
29
|
.side-check span{display:inline-block;width:1rem;font-weight:800;text-align:center}
|
|
30
|
+
.side-views{padding-top:0.5rem}
|
|
31
|
+
.side-views-label{padding:0.2rem 0.8rem;font-size:0.6rem;text-transform:uppercase;letter-spacing:0.05em;color:#444;font-weight:600}
|
|
32
|
+
.side-views .side-check{padding-left:0.8rem}
|
|
26
33
|
|
|
27
34
|
/* Content */
|
|
28
35
|
.content{max-width:900px;margin-left:200px;padding:2rem}
|
|
@@ -48,20 +55,38 @@ code{font-family:"SF Mono",Menlo,monospace;font-size:0.85em}
|
|
|
48
55
|
.cc-m{margin-top:0.3rem;display:flex;gap:0.25rem}
|
|
49
56
|
.mc{font-size:0.65rem;font-weight:800}
|
|
50
57
|
h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:0.5rem}
|
|
58
|
+
|
|
59
|
+
/* Overview sections */
|
|
60
|
+
.ov-section{margin-bottom:1.5rem}
|
|
61
|
+
.ov-issue{font-size:0.68rem;font-family:"SF Mono",monospace;padding:0.2rem 0;display:flex;gap:0.4rem;align-items:baseline;border-bottom:1px solid var(--border)}
|
|
62
|
+
.ov-issue .is{flex-shrink:0}
|
|
63
|
+
.ov-issue.error .is{color:var(--fail)}
|
|
64
|
+
.ov-issue.warning .is{color:var(--warn)}
|
|
65
|
+
.ov-check{color:var(--muted);width:70px;flex-shrink:0;font-size:0.62rem}
|
|
66
|
+
.ov-loc{color:var(--accent);flex-shrink:0;font-size:0.62rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
67
|
+
.ov-msg{flex:1;word-break:break-word}
|
|
68
|
+
.ov-link{display:block;margin-top:0.5rem;font-size:0.72rem;color:var(--accent);cursor:pointer;text-decoration:none}
|
|
69
|
+
.ov-link:hover{text-decoration:underline}
|
|
70
|
+
|
|
71
|
+
/* Timeline */
|
|
72
|
+
.timeline{margin:0.5rem 0;overflow-x:auto}
|
|
73
|
+
.timeline svg{max-width:100%}
|
|
74
|
+
|
|
75
|
+
/* Bar chart */
|
|
51
76
|
.bars{margin-bottom:1.5rem}
|
|
52
77
|
.brow{display:flex;align-items:center;gap:0.4rem;margin-bottom:0.25rem;font-size:0.72rem}
|
|
53
|
-
.bl{width:
|
|
78
|
+
.bl{width:90px;text-align:right;color:var(--muted);flex-shrink:0}
|
|
54
79
|
.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
55
80
|
.bf{height:100%;border-radius:2px}
|
|
56
81
|
.bv{width:36px;font-weight:700;font-size:0.68rem}
|
|
57
|
-
.stack{display:flex;gap:0.35rem;flex-wrap:wrap}
|
|
82
|
+
.stack{display:flex;gap:0.35rem;flex-wrap:wrap;margin-top:1rem}
|
|
58
83
|
.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
84
|
|
|
60
85
|
/* Category pages */
|
|
61
86
|
.cat-head{margin-bottom:0.3rem}
|
|
62
87
|
.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
|
|
63
88
|
.bf2{height:100%;border-radius:2px}
|
|
64
|
-
.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}
|
|
89
|
+
.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem;flex-wrap:wrap}
|
|
65
90
|
.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}
|
|
66
91
|
.sn:hover{color:var(--text)}
|
|
67
92
|
.sn.active{color:var(--text);border-bottom-color:var(--accent)}
|
|
@@ -107,24 +132,25 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
|
|
|
107
132
|
.il2{color:var(--muted)}
|
|
108
133
|
.iru2{color:#555;font-size:0.58rem}
|
|
109
134
|
|
|
110
|
-
/* File heatmap */
|
|
135
|
+
/* File health (merged file map + heatmap) */
|
|
111
136
|
.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}
|
|
112
137
|
.ff{width:200px;font-family:"SF Mono",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
113
138
|
.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}
|
|
114
139
|
.fbf{height:100%;border-radius:2px}
|
|
115
140
|
.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}
|
|
116
141
|
.fcs{font-size:0.6rem;color:#555}
|
|
142
|
+
.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}
|
|
143
|
+
.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:"SF Mono",monospace;font-size:0.65rem}
|
|
144
|
+
.hm-bar{height:14px;border-radius:3px;min-width:4px}
|
|
145
|
+
.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0;min-width:50px}
|
|
146
|
+
.hm-checks{font-size:0.58rem;color:#555;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
117
147
|
|
|
118
148
|
.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
149
|
.footer a{color:var(--muted)}
|
|
120
150
|
.flink{color:var(--accent);text-decoration:none;font-family:"SF Mono",monospace}.flink:hover{text-decoration:underline}
|
|
121
151
|
.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
152
|
.arch-svg svg{border-radius:8px}
|
|
127
153
|
.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
154
|
.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}}
|
|
155
|
+
@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}.nav-views{display:none}}
|
|
130
156
|
`;
|
package/dist/report/svg.d.ts
CHANGED
|
@@ -4,6 +4,23 @@ export declare function buildRadar(items: {
|
|
|
4
4
|
label: string;
|
|
5
5
|
score: number;
|
|
6
6
|
}[]): string;
|
|
7
|
+
/** Score timeline — larger chart showing score history over last N runs. */
|
|
8
|
+
export declare function buildTimeline(entries: {
|
|
9
|
+
score: number;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
}[], opts?: {
|
|
12
|
+
width?: number;
|
|
13
|
+
height?: number;
|
|
14
|
+
}): string;
|
|
15
|
+
/** Testing pyramid — proportional triangle showing test layer distribution. */
|
|
16
|
+
export declare function buildPyramid(layers: {
|
|
17
|
+
unit: number;
|
|
18
|
+
integration: number;
|
|
19
|
+
component: number;
|
|
20
|
+
e2e: number;
|
|
21
|
+
}): string;
|
|
22
|
+
/** Badge SVG — shields.io-style badge for README embedding. */
|
|
23
|
+
export declare function buildBadge(score: number, grade: string): string;
|
|
7
24
|
/** Sparkline — mini line chart for trend display. */
|
|
8
25
|
export declare function buildSparkline(values: number[], opts?: {
|
|
9
26
|
width?: number;
|
package/dist/report/svg.js
CHANGED
|
@@ -42,6 +42,105 @@ export function buildRadar(items) {
|
|
|
42
42
|
}
|
|
43
43
|
return `<svg viewBox="0 0 240 240">${grid}${axes}<polygon points="${dataPts}" fill="#818cf825" stroke="#818cf8" stroke-width="1.5"/>${dots}</svg>`;
|
|
44
44
|
}
|
|
45
|
+
/** Score timeline — larger chart showing score history over last N runs. */
|
|
46
|
+
export function buildTimeline(entries, opts) {
|
|
47
|
+
const width = opts?.width ?? 600;
|
|
48
|
+
const height = opts?.height ?? 120;
|
|
49
|
+
const pad = { top: 20, right: 20, bottom: 25, left: 35 };
|
|
50
|
+
const w = width - pad.left - pad.right;
|
|
51
|
+
const h = height - pad.top - pad.bottom;
|
|
52
|
+
if (entries.length === 0)
|
|
53
|
+
return "";
|
|
54
|
+
// Y axis: 0-100 always
|
|
55
|
+
const yScale = (v) => pad.top + h - (v / 100) * h;
|
|
56
|
+
const xScale = (i) => pad.left + (entries.length === 1 ? w / 2 : (i / (entries.length - 1)) * w);
|
|
57
|
+
// Grid lines at 25, 50, 75
|
|
58
|
+
let grid = "";
|
|
59
|
+
for (const v of [25, 50, 75]) {
|
|
60
|
+
const y = yScale(v).toFixed(1);
|
|
61
|
+
grid += `<line x1="${pad.left}" y1="${y}" x2="${pad.left + w}" y2="${y}" stroke="#1e1e24" stroke-width="0.7"/>`;
|
|
62
|
+
grid += `<text x="${pad.left - 6}" y="${y}" text-anchor="end" dominant-baseline="middle" fill="#555" font-size="8">${v}</text>`;
|
|
63
|
+
}
|
|
64
|
+
// Score line + dots
|
|
65
|
+
const points = entries.map((e, i) => `${xScale(i).toFixed(1)},${yScale(e.score).toFixed(1)}`).join(" ");
|
|
66
|
+
// Grade colors per dot
|
|
67
|
+
const dots = entries
|
|
68
|
+
.map((e, i) => {
|
|
69
|
+
const color = e.score >= 90 ? "#22c55e" : e.score >= 75 ? "#84cc16" : e.score >= 60 ? "#eab308" : e.score >= 40 ? "#f97316" : "#ef4444";
|
|
70
|
+
return `<circle cx="${xScale(i).toFixed(1)}" cy="${yScale(e.score).toFixed(1)}" r="3" fill="${color}"><title>${e.timestamp.split("T")[0]} — ${e.score}</title></circle>`;
|
|
71
|
+
})
|
|
72
|
+
.join("");
|
|
73
|
+
// X-axis labels (first, middle, last)
|
|
74
|
+
let xLabels = "";
|
|
75
|
+
const labelIndices = entries.length <= 3 ? entries.map((_, i) => i) : [0, Math.floor(entries.length / 2), entries.length - 1];
|
|
76
|
+
for (const i of labelIndices) {
|
|
77
|
+
const label = entries[i].timestamp.split("T")[0].slice(5); // MM-DD
|
|
78
|
+
xLabels += `<text x="${xScale(i).toFixed(1)}" y="${height - 4}" text-anchor="middle" fill="#555" font-size="7">${label}</text>`;
|
|
79
|
+
}
|
|
80
|
+
// Gradient fill under the line
|
|
81
|
+
const areaPoints = `${xScale(0).toFixed(1)},${yScale(0).toFixed(1)} ${points} ${xScale(entries.length - 1).toFixed(1)},${yScale(0).toFixed(1)}`;
|
|
82
|
+
return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
83
|
+
<defs><linearGradient id="tlg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#818cf8" stop-opacity="0.3"/><stop offset="100%" stop-color="#818cf8" stop-opacity="0.02"/></linearGradient></defs>
|
|
84
|
+
${grid}
|
|
85
|
+
<polygon points="${areaPoints}" fill="url(#tlg)"/>
|
|
86
|
+
<polyline points="${points}" fill="none" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
87
|
+
${dots}${xLabels}
|
|
88
|
+
</svg>`;
|
|
89
|
+
}
|
|
90
|
+
/** Testing pyramid — proportional triangle showing test layer distribution. */
|
|
91
|
+
export function buildPyramid(layers) {
|
|
92
|
+
const total = layers.unit + layers.integration + layers.component + layers.e2e;
|
|
93
|
+
if (total === 0)
|
|
94
|
+
return "";
|
|
95
|
+
const w = 200, h = 160;
|
|
96
|
+
const cx = w / 2;
|
|
97
|
+
// Pyramid: e2e at top (smallest), unit at bottom (largest)
|
|
98
|
+
// Each layer gets proportional height
|
|
99
|
+
const items = [
|
|
100
|
+
{ label: "E2E", count: layers.e2e, color: "#ef4444" },
|
|
101
|
+
{ label: "Component", count: layers.component, color: "#f97316" },
|
|
102
|
+
{ label: "Integration", count: layers.integration, color: "#eab308" },
|
|
103
|
+
{ label: "Unit", count: layers.unit, color: "#22c55e" },
|
|
104
|
+
];
|
|
105
|
+
const layerH = (h - 20) / 4;
|
|
106
|
+
let svg = "";
|
|
107
|
+
for (let i = 0; i < 4; i++) {
|
|
108
|
+
const item = items[i];
|
|
109
|
+
const y = 10 + i * layerH;
|
|
110
|
+
// Trapezoid: wider at bottom
|
|
111
|
+
const topW = ((i + 0.5) / 4) * (w - 40);
|
|
112
|
+
const botW = ((i + 1.5) / 4) * (w - 40);
|
|
113
|
+
const opacity = item.count > 0 ? 1 : 0.2;
|
|
114
|
+
const x1t = cx - topW / 2, x2t = cx + topW / 2;
|
|
115
|
+
const x1b = cx - botW / 2, x2b = cx + botW / 2;
|
|
116
|
+
svg += `<polygon points="${x1t},${y} ${x2t},${y} ${x2b},${y + layerH} ${x1b},${y + layerH}" fill="${item.color}" opacity="${opacity * 0.25}" stroke="${item.color}" stroke-opacity="${opacity * 0.6}" stroke-width="1"/>`;
|
|
117
|
+
svg += `<text x="${cx}" y="${y + layerH / 2 + 3}" text-anchor="middle" fill="${item.count > 0 ? "#e5e5e5" : "#555"}" font-size="9" font-weight="600">${item.label} (${item.count})</text>`;
|
|
118
|
+
}
|
|
119
|
+
return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${svg}</svg>`;
|
|
120
|
+
}
|
|
121
|
+
/** Badge SVG — shields.io-style badge for README embedding. */
|
|
122
|
+
export function buildBadge(score, grade) {
|
|
123
|
+
const color = score >= 90 ? "#22c55e" : score >= 75 ? "#84cc16" : score >= 60 ? "#eab308" : score >= 40 ? "#f97316" : "#ef4444";
|
|
124
|
+
const label = "vcqa";
|
|
125
|
+
const value = `${grade} ${score}`;
|
|
126
|
+
const labelW = 36, valueW = 44, totalW = labelW + valueW;
|
|
127
|
+
const h = 20, r = 3;
|
|
128
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${h}">
|
|
129
|
+
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
|
130
|
+
<clipPath id="r"><rect width="${totalW}" height="${h}" rx="${r}" fill="#fff"/></clipPath>
|
|
131
|
+
<g clip-path="url(#r)">
|
|
132
|
+
<rect width="${labelW}" height="${h}" fill="#555"/>
|
|
133
|
+
<rect x="${labelW}" width="${valueW}" height="${h}" fill="${color}"/>
|
|
134
|
+
<rect width="${totalW}" height="${h}" fill="url(#s)"/>
|
|
135
|
+
</g>
|
|
136
|
+
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
|
137
|
+
<text x="${labelW / 2}" y="14" fill="#010101" fill-opacity=".3">${label}</text>
|
|
138
|
+
<text x="${labelW / 2}" y="13">${label}</text>
|
|
139
|
+
<text x="${labelW + valueW / 2}" y="14" fill="#010101" fill-opacity=".3">${value}</text>
|
|
140
|
+
<text x="${labelW + valueW / 2}" y="13">${value}</text>
|
|
141
|
+
</g>
|
|
142
|
+
</svg>`;
|
|
143
|
+
}
|
|
45
144
|
/** Sparkline — mini line chart for trend display. */
|
|
46
145
|
export function buildSparkline(values, opts) {
|
|
47
146
|
const width = opts?.width ?? 120;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Accessibility check — detects common a11y violations in JSX/TSX code. */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
6
|
+
export function runAccessibility(cwd) {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
9
|
+
if (files.length === 0) {
|
|
10
|
+
return { name: "accessibility", score: 100, grade: "A", details: { skipped: true, reason: "no JSX/TSX files" }, issues: [], duration: Date.now() - start };
|
|
11
|
+
}
|
|
12
|
+
const issues = [];
|
|
13
|
+
let missingAlt = 0;
|
|
14
|
+
let clickDiv = 0;
|
|
15
|
+
let missingLabel = 0;
|
|
16
|
+
let missingLang = 0;
|
|
17
|
+
let autofocus = 0;
|
|
18
|
+
let positiveTabindex = 0;
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const lines = f.content.split("\n");
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
25
|
+
continue;
|
|
26
|
+
// 1. <img> without alt
|
|
27
|
+
if (/<img\b/.test(trimmed) && !/alt=/.test(trimmed)) {
|
|
28
|
+
const block = lines.slice(i, Math.min(i + 5, lines.length)).join(" ");
|
|
29
|
+
if (/<img\b/.test(block) && !/alt=/.test(block)) {
|
|
30
|
+
missingAlt++;
|
|
31
|
+
issues.push({ severity: "error", message: "<img> missing alt attribute", file: f.path, line: i + 1, rule: "img-alt" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 2. Click handler on non-interactive element without role/keyboard
|
|
35
|
+
if (/onClick=/.test(trimmed) && /<(?:div|span|p|li|section|article|header|footer)\b/.test(trimmed)) {
|
|
36
|
+
const block = lines.slice(i, Math.min(i + 3, lines.length)).join(" ");
|
|
37
|
+
if (!(/role=/.test(block) && /(?:onKeyDown|onKeyUp|onKeyPress|tabIndex)/.test(block))) {
|
|
38
|
+
clickDiv++;
|
|
39
|
+
issues.push({ severity: "warning", message: "Click handler on non-interactive element without role + keyboard handler", file: f.path, line: i + 1, rule: "click-events" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 3. <input>/<select>/<textarea> without associated label
|
|
43
|
+
if (/<(?:input|select|textarea)\b/.test(trimmed) && !/type=["'](?:hidden|submit|button|reset)["']/.test(trimmed)) {
|
|
44
|
+
const block = lines.slice(Math.max(0, i - 3), Math.min(i + 3, lines.length)).join(" ");
|
|
45
|
+
if (!/aria-label=/.test(block) && !/aria-labelledby=/.test(block) && !/<label/.test(block) && !/id=/.test(trimmed)) {
|
|
46
|
+
missingLabel++;
|
|
47
|
+
issues.push({ severity: "warning", message: "Form control without label, aria-label, or aria-labelledby", file: f.path, line: i + 1, rule: "form-label" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 4. autoFocus
|
|
51
|
+
if (/\bautoFocus\b/.test(trimmed) || /\bautofocus\b/.test(trimmed)) {
|
|
52
|
+
autofocus++;
|
|
53
|
+
issues.push({ severity: "warning", message: "autoFocus can disorient screen reader users", file: f.path, line: i + 1, rule: "no-autofocus" });
|
|
54
|
+
}
|
|
55
|
+
// 5. Positive tabIndex
|
|
56
|
+
if (/tabIndex=\{[1-9]/.test(trimmed) || /tabindex=["'][1-9]/.test(trimmed)) {
|
|
57
|
+
positiveTabindex++;
|
|
58
|
+
issues.push({ severity: "warning", message: "Positive tabIndex disrupts natural tab order — use 0 or -1", file: f.path, line: i + 1, rule: "tabindex" });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 6. Check for html lang attribute in index.html
|
|
63
|
+
const htmlPaths = ["index.html", "web/index.html", "public/index.html"];
|
|
64
|
+
for (const h of htmlPaths) {
|
|
65
|
+
const full = join(cwd, h);
|
|
66
|
+
if (!existsSync(full))
|
|
67
|
+
continue;
|
|
68
|
+
const content = readFileSync(full, "utf-8");
|
|
69
|
+
if (/<html\b/.test(content) && !/<html[^>]*lang=/.test(content)) {
|
|
70
|
+
missingLang++;
|
|
71
|
+
issues.push({ severity: "warning", message: "<html> missing lang attribute", file: h, rule: "html-lang" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
75
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
76
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 10 - warnings * 4));
|
|
77
|
+
return {
|
|
78
|
+
name: "accessibility",
|
|
79
|
+
score,
|
|
80
|
+
grade: gradeFromScore(score),
|
|
81
|
+
details: { jsxFiles: files.length, missingAlt, clickDiv, missingLabel, missingLang, autofocus, positiveTabindex },
|
|
82
|
+
issues,
|
|
83
|
+
duration: Date.now() - start,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** React-specific checks — hooks rules, conditional hooks, missing keys, prop spreading. */
|
|
2
|
+
import { gradeFromScore } from "../types.js";
|
|
3
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
4
|
+
export function runReact(cwd, stack) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
if (stack.framework !== "react") {
|
|
7
|
+
return { name: "react", score: 100, grade: "A", details: { skipped: true, reason: "not a React project" }, issues: [], duration: Date.now() - start };
|
|
8
|
+
}
|
|
9
|
+
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
10
|
+
if (files.length === 0) {
|
|
11
|
+
return { name: "react", score: 100, grade: "A", details: { skipped: true, reason: "no JSX/TSX files" }, issues: [], duration: Date.now() - start };
|
|
12
|
+
}
|
|
13
|
+
const issues = [];
|
|
14
|
+
let conditionalHooks = 0;
|
|
15
|
+
let missingKeys = 0;
|
|
16
|
+
let propSpreading = 0;
|
|
17
|
+
let inlineHandlers = 0;
|
|
18
|
+
let indexKeys = 0;
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const lines = f.content.split("\n");
|
|
21
|
+
// Track if we're inside a conditional block
|
|
22
|
+
let condDepth = 0;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i];
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
// Skip comments
|
|
27
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
28
|
+
continue;
|
|
29
|
+
// Track conditional blocks
|
|
30
|
+
if (/\b(if|else|switch)\s*\(/.test(trimmed))
|
|
31
|
+
condDepth++;
|
|
32
|
+
if (condDepth > 0 && trimmed.includes("{"))
|
|
33
|
+
condDepth++;
|
|
34
|
+
if (condDepth > 0 && trimmed.includes("}"))
|
|
35
|
+
condDepth--;
|
|
36
|
+
// 1. Hooks called inside conditionals
|
|
37
|
+
if (condDepth > 0 && /\buse[A-Z]\w*\s*\(/.test(trimmed) && !/\/\//.test(trimmed.split("use")[0])) {
|
|
38
|
+
conditionalHooks++;
|
|
39
|
+
issues.push({ severity: "error", message: "Hook called inside conditional — violates Rules of Hooks", file: f.path, line: i + 1, rule: "conditional-hook" });
|
|
40
|
+
}
|
|
41
|
+
// 2. Missing key in .map() returning JSX
|
|
42
|
+
if (/\.map\s*\(/.test(trimmed)) {
|
|
43
|
+
// Look ahead for JSX return without key
|
|
44
|
+
const mapBlock = lines.slice(i, Math.min(i + 10, lines.length)).join("\n");
|
|
45
|
+
if (/<\w/.test(mapBlock) && !mapBlock.includes("key=") && !mapBlock.includes("key:")) {
|
|
46
|
+
missingKeys++;
|
|
47
|
+
issues.push({ severity: "error", message: "JSX in .map() without key prop", file: f.path, line: i + 1, rule: "missing-key" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 3. index as key
|
|
51
|
+
if (/key=\{(?:i|idx|index)\}/.test(trimmed) || /key=\{.*(?:, *(?:i|idx|index)\))/.test(trimmed)) {
|
|
52
|
+
indexKeys++;
|
|
53
|
+
issues.push({ severity: "warning", message: "Using index as key — can cause rendering bugs with reorderable lists", file: f.path, line: i + 1, rule: "index-key" });
|
|
54
|
+
}
|
|
55
|
+
// 4. Prop spreading ({...props} on DOM elements)
|
|
56
|
+
if (/\{\.\.\.(?!children)\w+\}/.test(trimmed) && /<[a-z]/.test(trimmed)) {
|
|
57
|
+
propSpreading++;
|
|
58
|
+
issues.push({ severity: "warning", message: "Spreading props onto DOM element — can pass unexpected attributes", file: f.path, line: i + 1, rule: "prop-spreading" });
|
|
59
|
+
}
|
|
60
|
+
// 5. Inline arrow functions in JSX event handlers (performance)
|
|
61
|
+
if (/on[A-Z]\w*=\{(?:\(\) =>|function)/.test(trimmed)) {
|
|
62
|
+
inlineHandlers++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Only warn about inline handlers if there are many
|
|
67
|
+
if (inlineHandlers > 15) {
|
|
68
|
+
issues.push({ severity: "warning", message: `${inlineHandlers} inline arrow functions in JSX handlers — extract to named functions for readability`, rule: "inline-handlers" });
|
|
69
|
+
}
|
|
70
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
71
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
72
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 8 - warnings * 3));
|
|
73
|
+
return {
|
|
74
|
+
name: "react",
|
|
75
|
+
score,
|
|
76
|
+
grade: gradeFromScore(score),
|
|
77
|
+
details: { jsxFiles: files.length, conditionalHooks, missingKeys, indexKeys, propSpreading, inlineHandlers },
|
|
78
|
+
issues,
|
|
79
|
+
duration: Date.now() - start,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecodeqa/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Code health scanner for the AI coding era.
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "Code health scanner for the AI coding era. 18 checks, zero config, full report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vcqa": "./dist/cli.js",
|