@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.
@@ -56,7 +56,7 @@ export const CHECK_META = {
56
56
  label: "Error Handling",
57
57
  category: "Quality",
58
58
  priority: "high",
59
- weight: 2,
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: 22,
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: 7,
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: 8,
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: 7,
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) {
@@ -1,12 +1,14 @@
1
1
  /** Generate a multi-page navigable HTML report.
2
2
  *
3
3
  * Architecture:
4
- * Top nav: Overview | Foundations | Quality | Testing | Security | Issues
5
- * Sub-nav: Tabs for each check within a category
6
- * Detail: Per-check stats + full issue list grouped by file
7
- * File map: Cross-check heatmap of issues per file
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 hash routing + show/hide.
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;
@@ -1,26 +1,28 @@
1
1
  /** Generate a multi-page navigable HTML report.
2
2
  *
3
3
  * Architecture:
4
- * Top nav: Overview | Foundations | Quality | Testing | Security | Issues
5
- * Sub-nav: Tabs for each check within a category
6
- * Detail: Per-check stats + full issue list grouped by file
7
- * File map: Cross-check heatmap of issues per file
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 hash routing + show/hide.
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, heatmapPage, issuesPage, overviewPage } from "./pages.js";
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: "LLM Readiness", checks: ["confusion", "context"] },
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
- // ── File heatmap: aggregate issues per file across all checks ──
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]; // strip :line from composite file fields
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, 20);
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
- // ── Top nav ──
60
- const topNavItems = [
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 topNav = topNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
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 sidebar = catScores
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
- ${topNav}
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
- ${sidebar}
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} &mdash; <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 — read from data-attribute (no inline JS with user data)
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;
@@ -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[], fl: FL): string;
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>;
@@ -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
- export function overviewPage(report, active, totalIssues, catScores) {
7
- const ringPct = report.score;
8
- const barChart = active
9
- .sort((a, b) => a.score - b.score)
10
- .map((c) => {
11
- return `<div class="brow"><span class="bl">${e(c.name)}</span><div class="bb"><div class="bf" style="width:${c.score}%;background:${gc(c.grade)}"></div></div><span class="bv" style="color:${gc(c.grade)}">${c.grade} ${c.score}</span></div>`;
12
- })
13
- .join("");
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
- const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
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
- <div class="hero">
30
- ${buildRing(ringPct, gc(report.grade))}
31
- <div class="hc"><span class="hg" style="color:${gc(report.grade)}">${report.grade}</span><span class="hs" style="color:${gc(report.grade)}">${report.score}/100</span><span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span></div>
32
- </div>
82
+ ${hero}
33
83
  <div class="radar">${radarSvg}</div>
34
84
  </div>
35
85
  <div class="cats">${catCards}</div>
36
- <h3>All Checks</h3>
37
- <div class="bars">${barChart}</div>
38
- <div class="stack">${Object.entries(report.meta.stack)
39
- .filter(([, v]) => v !== "none" && v !== "unknown")
40
- .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
41
- .join("")}</div>
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">${allIssues.filter((i) => i.severity === "error").length} errors \u00b7 ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
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
- export function filesPage(topFiles, fl) {
131
- const fileRows = topFiles
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 pct = Math.min(100, f.total * 5);
134
- return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
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 Heatmap</h2>
139
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
140
- ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
141
- </div>`;
142
- }
143
- export function heatmapPage(fileIssues, fl) {
144
- const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
145
- let heatmapHtml = "";
146
- if (heatmapFiles.length > 0) {
147
- const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
148
- heatmapHtml = heatmapFiles
149
- .map(([file, d]) => {
150
- const total = d.errors + d.warnings;
151
- const intensity = maxIssues > 0 ? total / maxIssues : 0;
152
- const r = Math.round(239 * intensity); // red channel
153
- const g = Math.round(68 * (1 - intensity) + 197 * (d.errors === 0 ? 0.3 : 0)); // green
154
- const color = `rgb(${r},${g},30)`;
155
- const barW = Math.max(4, Math.round(intensity * 200));
156
- const checks = [...d.checks].join(", ");
157
- return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
158
- })
159
- .join("");
160
- }
161
- return `<div id="p-heatmap" class="page">
162
- <h2>Code Heatmap</h2>
163
- <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Visual density of issues per file. Red = errors, orange = warnings. Bar width = relative issue count.</p>
164
- ${heatmapHtml || '<p style="color:var(--muted)">No issues to visualize.</p>'}
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
  }
@@ -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:80px;text-align:right;color:var(--muted);flex-shrink:0}\n.bb{flex:1;height:14px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.bf{height:100%;border-radius:2px}\n.bv{width:36px;font-weight:700;font-size:0.68rem}\n.stack{display:flex;gap:0.35rem;flex-wrap:wrap}\n.stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}\n\n/* Category pages */\n.cat-head{margin-bottom:0.3rem}\n.bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}\n.bf2{height:100%;border-radius:2px}\n.sub-nav{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1rem}\n.sn{padding:0.5rem 0.8rem;font-size:0.75rem;color:var(--muted);cursor:pointer;border-bottom:2px solid transparent}\n.sn:hover{color:var(--text)}\n.sn.active{color:var(--text);border-bottom-color:var(--accent)}\n.sp{display:none}.sp.active{display:block}\n\n/* Check detail */\n.ch-head{display:flex;align-items:center;gap:0.7rem;margin-bottom:0.8rem}\n.ch-g{font-size:2rem;font-weight:900}\n.ch-s{display:block;font-size:0.7rem;color:var(--muted)}\n.pri{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:0.04em;padding:0.15rem 0.5rem;border-radius:9999px;border:1px solid currentColor;flex-shrink:0}\n.info-panel{background:#0d0d12;border:1px solid var(--border);border-radius:0.5rem;padding:0.7rem 0.9rem;margin-bottom:1rem;font-size:0.72rem;line-height:1.6}\n.ip-row{margin-bottom:0.4rem;display:flex;gap:0.5rem}\n.ip-row:last-child{margin-bottom:0}\n.ip-label{color:var(--accent);font-weight:700;min-width:2.5rem;flex-shrink:0}\n.skip-r{color:var(--muted);font-style:italic;font-size:0.78rem}\n.kvs{display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1rem}\n.kv{background:var(--card);border:1px solid var(--border);border-radius:0.4rem;padding:0.3rem 0.6rem;font-size:0.7rem}\n.k{color:var(--muted);margin-right:0.3rem}\n.v{font-weight:600}\n\n/* Issue list grouped by file */\n.iss-list{margin-top:1rem}\n.fg{margin-bottom:0.8rem}\n.fn{font-size:0.72rem;font-weight:600;font-family:\"SF Mono\",monospace;padding:0.3rem 0;border-bottom:1px solid var(--border);margin-bottom:0.2rem;display:flex;align-items:center;gap:0.5rem}\n.fc{background:var(--border);border-radius:9999px;padding:0 0.4rem;font-size:0.6rem;color:var(--muted)}\n.ir{font-size:0.65rem;font-family:\"SF Mono\",monospace;padding:0.12rem 0 0.12rem 0.5rem;display:flex;gap:0.4rem;align-items:baseline}\n.is{font-weight:800;font-size:0.55rem;width:0.9rem;text-align:center;border-radius:2px;flex-shrink:0}\n.ir.error .is{color:var(--fail);background:#ef444418}\n.ir.warning .is{color:var(--warn);background:#eab30818}\n.il{color:var(--accent);min-width:2rem;flex-shrink:0}\n.im{flex:1;word-break:break-word}\n.iru{color:#555;font-size:0.55rem}\n\n/* All issues table */\n.isf{color:var(--muted);font-size:0.75rem;margin-bottom:0.8rem}\n.it{width:100%;border-collapse:collapse;font-size:0.68rem}\n.it th{text-align:left;padding:0.35rem 0.4rem;color:var(--muted);font-size:0.62rem;text-transform:uppercase;border-bottom:1px solid var(--border)}\n.it td{padding:0.25rem 0.4rem;border-bottom:1px solid var(--border);font-family:\"SF Mono\",monospace;font-size:0.62rem}\n.it tr.error .is2{color:var(--fail)}\n.it tr.warning .is2{color:var(--warn)}\n.is2{font-weight:800;width:1rem}\n.ic2{color:var(--muted);width:70px}\n.il2{color:var(--muted)}\n.iru2{color:#555;font-size:0.58rem}\n\n/* File heatmap */\n.fr{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;font-size:0.7rem}\n.ff{width:200px;font-family:\"SF Mono\",monospace;font-size:0.65rem;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.fb{flex:1;height:12px;background:var(--card);border-radius:3px;overflow:hidden;border:1px solid var(--border)}\n.fbf{height:100%;border-radius:2px}\n.fv{width:50px;font-size:0.65rem;color:var(--muted);flex-shrink:0}\n.fcs{font-size:0.6rem;color:#555}\n\n.footer{text-align:center;color:var(--muted);font-size:0.58rem;margin-top:2rem;padding:0.8rem 0;border-top:1px solid var(--border)}\n.footer a{color:var(--muted)}\n.flink{color:var(--accent);text-decoration:none;font-family:\"SF Mono\",monospace}.flink:hover{text-decoration:underline}\n.arch-svg{margin:1rem 0;overflow-x:auto}\n.hm-row{display:flex;align-items:center;gap:0.5rem;margin-bottom:0.2rem;font-size:0.7rem}\n.hm-name{width:200px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:\"SF Mono\",monospace;font-size:0.65rem}\n.hm-bar{height:14px;border-radius:3px;min-width:4px}\n.hm-count{color:var(--muted);font-size:0.65rem;flex-shrink:0}\n.arch-svg svg{border-radius:8px}\n.cp-btn{background:none;border:none;cursor:pointer;font-size:0.6rem;opacity:0.3;padding:0 0.2rem;flex-shrink:0}.cp-btn:hover{opacity:1}\n.ir:hover .cp-btn{opacity:0.6}\n@media(max-width:768px){.side{display:none}.content{margin-left:0;padding:1rem}.cats{grid-template-columns:1fr 1fr}.dash{flex-direction:column}}\n";
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";
@@ -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:80px;text-align:right;color:var(--muted);flex-shrink:0}
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
  `;
@@ -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;
@@ -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,3 @@
1
+ /** Accessibility check — detects common a11y violations in JSX/TSX code. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runAccessibility(cwd: string): CheckResult;
@@ -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,3 @@
1
+ /** React-specific checks — hooks rules, conditional hooks, missing keys, prop spreading. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runReact(cwd: string, stack: StackInfo): CheckResult;
@@ -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.16.0",
4
- "description": "Code health scanner for the AI coding era. 15 checks, zero config, full report.",
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",