@vibecodeqa/cli 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,12 +51,22 @@ export const CHECK_META = {
51
51
  risk: "Large files are hard to review and test. console.log in production leaks internal data. var causes hoisting bugs. == causes type coercion surprises. eval/innerHTML are security vulnerabilities. Inconsistent naming makes the codebase harder to navigate.",
52
52
  recommendation: "Split files over 300 lines. Replace console.log with a proper logger or remove it. Use const/let, ===, and safe DOM APIs. Enable TypeScript strict mode.",
53
53
  },
54
+ "error-handling": {
55
+ name: "error-handling",
56
+ label: "Error Handling",
57
+ category: "Quality",
58
+ priority: "high",
59
+ weight: 2,
60
+ description: "Detects poor error handling: empty catch blocks, throw with string literals, catch-and-rethrow without context, Promise.then() without .catch(), missing React Error Boundaries.",
61
+ risk: "Empty catch blocks silently swallow errors. throw 'string' loses stack traces. Missing Error Boundaries in React cause the entire app to crash on render errors.",
62
+ recommendation: "Handle or log every catch. Use throw new Error() for stack traces. Add Error Boundaries in React. Chain .catch() on promises.",
63
+ },
54
64
  complexity: {
55
65
  name: "complexity",
56
66
  label: "Complexity",
57
67
  category: "Quality",
58
68
  priority: "high",
59
- weight: 7,
69
+ weight: 5,
60
70
  description: "Measures cognitive complexity of each function: how many branches (if/else/switch/for/while/ternary/&&/||) and how many lines. Functions over 60 lines or with complexity over 15 are flagged.",
61
71
  risk: "Complex functions are the #1 source of bugs. Research shows defect density increases exponentially with cyclomatic complexity above 10 (McCabe, 1976). Complex code is also harder to review, test, and modify safely.",
62
72
  recommendation: "Extract complex functions into smaller ones. Use early returns to reduce nesting. Replace conditional chains with lookup tables or strategy patterns. Aim for functions under 30 lines with complexity under 10.",
@@ -153,7 +163,7 @@ export const CHECK_META = {
153
163
  },
154
164
  };
155
165
  export function getCheckMeta(name) {
156
- return CHECK_META[name] || {
166
+ return (CHECK_META[name] || {
157
167
  name,
158
168
  label: name,
159
169
  category: "Other",
@@ -162,5 +172,5 @@ export function getCheckMeta(name) {
162
172
  description: "",
163
173
  risk: "",
164
174
  recommendation: "",
165
- };
175
+ });
166
176
  }
package/dist/cli.js CHANGED
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  /** vibe-check — code health scanner for the AI coding era. */
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { detectRepoUrl, detectStack } from "./detect.js";
6
6
  import { generateHTML } from "./report/html.js";
7
- import { runComplexity } from "./runners/complexity.js";
8
7
  import { runArchitecture } from "./runners/architecture.js";
8
+ import { runComplexity } from "./runners/complexity.js";
9
9
  import { runConfusion } from "./runners/confusion.js";
10
10
  import { runContext } from "./runners/context.js";
11
11
  import { runDependencies } from "./runners/dependencies.js";
12
12
  import { runDocs } from "./runners/docs.js";
13
13
  import { runDuplication } from "./runners/duplication.js";
14
+ import { runErrorHandling } from "./runners/error-handling.js";
14
15
  import { runLint } from "./runners/lint.js";
15
16
  import { runSecrets } from "./runners/secrets.js";
16
17
  import { runSecurity } from "./runners/security.js";
17
18
  import { runStandards } from "./runners/standards.js";
18
19
  import { runStructure } from "./runners/structure.js";
19
20
  import { runTesting } from "./runners/testing.js";
20
- import { runTypeCheck } from "./runners/types-check.js";
21
21
  import { runTypeSafety } from "./runners/type-safety.js";
22
+ import { runTypeCheck } from "./runners/types-check.js";
22
23
  import { computeScore } from "./score.js";
23
24
  import { computeTrend, formatTrend } from "./trend.js";
24
25
  import { gradeFromScore } from "./types.js";
@@ -43,14 +44,14 @@ async function main() {
43
44
  const start = Date.now();
44
45
  if (!jsonOnly) {
45
46
  console.log("");
46
- console.log(" \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v" + VERSION);
47
- console.log(" \x1b[2m" + cwd + "\x1b[0m");
47
+ console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
48
+ console.log(` \x1b[2m${cwd}\x1b[0m`);
48
49
  console.log("");
49
50
  }
50
51
  const stack = detectStack(cwd);
51
52
  if (!jsonOnly) {
52
53
  const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
53
- console.log(" stack: " + parts.join(" + "));
54
+ console.log(` stack: ${parts.join(" + ")}`);
54
55
  console.log("");
55
56
  }
56
57
  const checks = [];
@@ -65,6 +66,7 @@ async function main() {
65
66
  // Quality
66
67
  { name: "complexity", fn: () => runComplexity(cwd) },
67
68
  { name: "duplication", fn: () => runDuplication(cwd) },
69
+ { name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
68
70
  { name: "docs", fn: () => runDocs(cwd) },
69
71
  // Testing
70
72
  { name: "testing", fn: () => runTesting(cwd, stack, skipTests) },
@@ -80,14 +82,14 @@ async function main() {
80
82
  ];
81
83
  for (const runner of runners) {
82
84
  if (!jsonOnly)
83
- process.stdout.write(" " + runner.name.padEnd(14));
85
+ process.stdout.write(` ${runner.name.padEnd(14)}`);
84
86
  const result = runner.fn();
85
87
  checks.push(result);
86
88
  if (!jsonOnly) {
87
89
  const skipped = result.details.skipped;
88
90
  const c = skipped ? "\x1b[2m" : color(result.grade);
89
91
  const label = skipped ? "skip" : result.grade;
90
- const scoreStr = skipped ? "—" : result.score + "/100";
92
+ const scoreStr = skipped ? "—" : `${result.score}/100`;
91
93
  const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
92
94
  console.log(`${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
93
95
  }
@@ -115,17 +117,21 @@ async function main() {
115
117
  const historyFile = join(historyDir, `${report.timestamp.replace(/[:.]/g, "-")}.json`);
116
118
  writeFileSync(historyFile, JSON.stringify(report, null, 2));
117
119
  // Keep only last 30 history entries
118
- const historyFiles = readdirSync(historyDir).filter((f) => f.endsWith(".json")).sort();
120
+ const historyFiles = readdirSync(historyDir)
121
+ .filter((f) => f.endsWith(".json"))
122
+ .sort();
119
123
  if (historyFiles.length > 30) {
120
124
  for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
121
125
  try {
122
126
  unlinkSync(join(historyDir, old));
123
127
  }
124
- catch { /* ignore */ }
128
+ catch {
129
+ /* ignore */
130
+ }
125
131
  }
126
132
  }
127
133
  writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
128
- writeFileSync(join(outputDir, "report.html"), generateHTML(report));
134
+ writeFileSync(join(outputDir, "report.html"), generateHTML(report, historyDir));
129
135
  if (jsonOnly) {
130
136
  console.log(JSON.stringify(report));
131
137
  }
@@ -136,8 +142,8 @@ async function main() {
136
142
  if (trend)
137
143
  console.log(formatTrend(trend));
138
144
  console.log("");
139
- console.log(" \x1b[2mReport: " + join(outputDir, "report.html") + "\x1b[0m");
140
- console.log(" \x1b[2mJSON: " + join(outputDir, "report.json") + "\x1b[0m");
145
+ console.log(` \x1b[2mReport: ${join(outputDir, "report.html")}\x1b[0m`);
146
+ console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
141
147
  console.log("");
142
148
  }
143
149
  if (ciMode && score < 60) {
@@ -149,7 +155,9 @@ async function main() {
149
155
  const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
150
156
  execFileSync(openCmd, [join(outputDir, "report.html")], { stdio: "ignore" });
151
157
  }
152
- catch { /* failed to open browser */ }
158
+ catch {
159
+ /* failed to open browser */
160
+ }
153
161
  }
154
162
  // Watch mode — re-run on file changes
155
163
  if (watchMode) {
package/dist/detect.js CHANGED
@@ -20,33 +20,11 @@ export function detectStack(cwd) {
20
20
  : allDeps.react || allDeps.vue
21
21
  ? "javascript"
22
22
  : "unknown";
23
- const framework = allDeps.react
24
- ? "react"
25
- : allDeps.vue
26
- ? "vue"
27
- : allDeps.svelte
28
- ? "svelte"
29
- : "none";
30
- const bundler = allDeps.vite
31
- ? "vite"
32
- : allDeps.webpack
33
- ? "webpack"
34
- : allDeps.esbuild
35
- ? "esbuild"
36
- : "none";
23
+ const framework = allDeps.react ? "react" : allDeps.vue ? "vue" : allDeps.svelte ? "svelte" : "none";
24
+ const bundler = allDeps.vite ? "vite" : allDeps.webpack ? "webpack" : allDeps.esbuild ? "esbuild" : "none";
37
25
  const testRunner = allDeps.vitest ? "vitest" : allDeps.jest ? "jest" : "none";
38
- const linter = allDeps["@biomejs/biome"]
39
- ? "biome"
40
- : allDeps.eslint
41
- ? "eslint"
42
- : "none";
43
- const packageManager = has("pnpm-lock.yaml")
44
- ? "pnpm"
45
- : has("bun.lockb")
46
- ? "bun"
47
- : has("yarn.lock")
48
- ? "yarn"
49
- : "npm";
26
+ const linter = allDeps["@biomejs/biome"] ? "biome" : allDeps.eslint ? "eslint" : "none";
27
+ const packageManager = has("pnpm-lock.yaml") ? "pnpm" : has("bun.lockb") ? "bun" : has("yarn.lock") ? "yarn" : "npm";
50
28
  return { language, framework, bundler, testRunner, linter, packageManager };
51
29
  }
52
30
  /** Detect GitHub/GitLab repo URL from git remote. */
@@ -55,7 +33,7 @@ export function detectRepoUrl(cwd) {
55
33
  const remote = execSync("git remote get-url origin", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
56
34
  const branch = execSync("git branch --show-current", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim() || "main";
57
35
  // Convert SSH to HTTPS
58
- let url = remote
36
+ const url = remote
59
37
  .replace(/^git@github\.com:/, "https://github.com/")
60
38
  .replace(/^git@gitlab\.com:/, "https://gitlab.com/")
61
39
  .replace(/\.git$/, "");
package/dist/fs-utils.js CHANGED
@@ -12,7 +12,9 @@ export function collectSourceFiles(cwd, opts) {
12
12
  try {
13
13
  walk(join(cwd, dir), cwd, files, opts?.extraExts ? ALL_EXTS : CODE_EXTS);
14
14
  }
15
- catch { /* dir doesn't exist */ }
15
+ catch {
16
+ /* dir doesn't exist */
17
+ }
16
18
  }
17
19
  if (opts?.includeTests)
18
20
  return files;
@@ -67,7 +69,7 @@ function walk(dir, cwd, out, exts) {
67
69
  if (statSync(full).size > 1_000_000)
68
70
  continue;
69
71
  const content = readFileSync(full, "utf-8");
70
- const relPath = full.replace(cwd + "/", "");
72
+ const relPath = full.replace(`${cwd}/`, "");
71
73
  const isTest = entry.includes(".test.") || entry.includes(".spec.") || relPath.includes("__tests__");
72
74
  out.push({
73
75
  path: relPath,
@@ -0,0 +1,14 @@
1
+ /** Read report history from .vibe-check/history/ and return sorted snapshots. */
2
+ export interface HistoryEntry {
3
+ timestamp: string;
4
+ score: number;
5
+ checkScores: Map<string, number>;
6
+ }
7
+ /** Load history entries from historyDir, sorted oldest-first. Returns last 30 max. */
8
+ export declare function loadHistory(historyDir: string): HistoryEntry[];
9
+ /** Compute a human-friendly delta badge like "up 3 from last week" or "down 5 from yesterday". */
10
+ export declare function scoreDeltaBadge(entries: HistoryEntry[]): {
11
+ arrow: string;
12
+ delta: number;
13
+ label: string;
14
+ } | null;
@@ -0,0 +1,51 @@
1
+ /** Read report history from .vibe-check/history/ and return sorted snapshots. */
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** Load history entries from historyDir, sorted oldest-first. Returns last 30 max. */
5
+ export function loadHistory(historyDir) {
6
+ if (!existsSync(historyDir))
7
+ return [];
8
+ const files = readdirSync(historyDir)
9
+ .filter((f) => f.endsWith(".json"))
10
+ .sort(); // filenames are timestamp-based, so lexicographic = chronological
11
+ const entries = [];
12
+ for (const file of files) {
13
+ try {
14
+ const raw = JSON.parse(readFileSync(join(historyDir, file), "utf-8"));
15
+ if (raw.score == null || !raw.checks)
16
+ continue;
17
+ const checkScores = new Map();
18
+ for (const c of raw.checks) {
19
+ checkScores.set(c.name, c.score);
20
+ }
21
+ entries.push({ timestamp: raw.timestamp, score: raw.score, checkScores });
22
+ }
23
+ catch {
24
+ // skip corrupt files
25
+ }
26
+ }
27
+ return entries;
28
+ }
29
+ /** Compute a human-friendly delta badge like "up 3 from last week" or "down 5 from yesterday". */
30
+ export function scoreDeltaBadge(entries) {
31
+ if (entries.length < 2)
32
+ return null;
33
+ const current = entries[entries.length - 1];
34
+ const prev = entries[entries.length - 2];
35
+ const delta = current.score - prev.score;
36
+ const arrow = delta > 0 ? "\u2191" : delta < 0 ? "\u2193" : "=";
37
+ const now = new Date(current.timestamp);
38
+ const then = new Date(prev.timestamp);
39
+ const diffMs = now.getTime() - then.getTime();
40
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
41
+ let timeLabel;
42
+ if (diffDays === 0)
43
+ timeLabel = "earlier today";
44
+ else if (diffDays === 1)
45
+ timeLabel = "yesterday";
46
+ else if (diffDays <= 7)
47
+ timeLabel = "last week";
48
+ else
49
+ timeLabel = `${diffDays}d ago`;
50
+ return { arrow, delta, label: `${arrow}${Math.abs(delta)} from ${timeLabel}` };
51
+ }
@@ -9,4 +9,4 @@
9
9
  * All in one self-contained HTML file using hash routing + show/hide.
10
10
  */
11
11
  import type { VibeReport } from "../types.js";
12
- export declare function generateHTML(report: VibeReport): string;
12
+ export declare function generateHTML(report: VibeReport, historyDir?: string): string;
@@ -9,7 +9,9 @@
9
9
  * All in one self-contained HTML file using hash routing + show/hide.
10
10
  */
11
11
  import { getCheckMeta } from "../check-meta.js";
12
+ import { loadHistory, scoreDeltaBadge } from "../history.js";
12
13
  import { generateArchSVG } from "../runners/architecture.js";
14
+ import { buildSparkline } from "./svg.js";
13
15
  function e(s) {
14
16
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15
17
  }
@@ -18,7 +20,7 @@ function fileLink(path, line, repoUrl, branch) {
18
20
  const clean = path.split(":")[0];
19
21
  if (!repoUrl || !/^https?:\/\//.test(repoUrl))
20
22
  return e(path);
21
- const href = `${repoUrl}/blob/${branch}/${clean}${line ? "#L" + line : ""}`;
23
+ const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
22
24
  return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
23
25
  }
24
26
  function gc(grade) {
@@ -29,13 +31,13 @@ function pc(p) {
29
31
  }
30
32
  const GROUPS = [
31
33
  { id: "foundations", label: "Foundations", checks: ["structure", "lint", "types", "type-safety", "standards"] },
32
- { id: "quality", label: "Quality", checks: ["complexity", "duplication", "docs"] },
34
+ { id: "quality", label: "Quality", checks: ["error-handling", "complexity", "duplication", "error-handling", "docs"] },
33
35
  { id: "testing", label: "Testing", checks: ["testing"] },
34
36
  { id: "arch", label: "Architecture", checks: ["architecture"] },
35
37
  { id: "security", label: "Security", checks: ["secrets", "security", "dependencies"] },
36
38
  { id: "llm", label: "LLM Readiness", checks: ["confusion", "context"] },
37
39
  ];
38
- export function generateHTML(report) {
40
+ export function generateHTML(report, historyDir) {
39
41
  const allChecks = report.checks;
40
42
  const checkMap = new Map(allChecks.map((c) => [c.name, c]));
41
43
  const active = allChecks.filter((c) => !c.details.skipped);
@@ -83,18 +85,59 @@ export function generateHTML(report) {
83
85
  const topNav = topNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
84
86
  // Overview page
85
87
  const ringPct = report.score;
86
- const barChart = active.sort((a, b) => a.score - b.score).map((c) => {
88
+ const barChart = active
89
+ .sort((a, b) => a.score - b.score)
90
+ .map((c) => {
87
91
  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>`;
88
- }).join("");
89
- const catCards = catScores.map((cs) => {
92
+ })
93
+ .join("");
94
+ const catCards = catScores
95
+ .map((cs) => {
90
96
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
91
- const mini = cs.checks.map((c) => {
97
+ const mini = cs.checks
98
+ .map((c) => {
92
99
  const sk = c.details.skipped;
93
100
  return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "—" : c.grade}</span>`;
94
- }).join("");
101
+ })
102
+ .join("");
95
103
  return `<div class="cc" onclick="go('${cs.id}')"><div class="cc-s" style="color:${clr}">${cs.avg}</div><div class="cc-l">${cs.label}</div><div class="cc-m">${mini}</div></div>`;
96
- }).join("");
104
+ })
105
+ .join("");
97
106
  const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
107
+ // ── Trend sparklines from history ──
108
+ let trendSection = "";
109
+ if (historyDir) {
110
+ const history = loadHistory(historyDir);
111
+ if (history.length >= 2) {
112
+ const scores = history.map((h) => h.score);
113
+ const badge = scoreDeltaBadge(history);
114
+ const badgeHtml = badge
115
+ ? `<span class="trend-badge" style="color:${badge.delta > 0 ? "var(--pass)" : badge.delta < 0 ? "var(--fail)" : "var(--muted)"}">${e(badge.label)}</span>`
116
+ : "";
117
+ // Composite score sparkline
118
+ const mainSparkline = buildSparkline(scores, { width: 120, height: 30, color: "#818cf8" });
119
+ // Per-check mini sparklines
120
+ const checkNames = [...new Set(history.flatMap((h) => [...h.checkScores.keys()]))];
121
+ const checkSparklines = checkNames
122
+ .map((name) => {
123
+ const vals = history.map((h) => h.checkScores.get(name) ?? 0);
124
+ const current = vals[vals.length - 1];
125
+ const prev = vals.length >= 2 ? vals[vals.length - 2] : current;
126
+ const delta = current - prev;
127
+ const dColor = delta > 0 ? "var(--pass)" : "var(--fail)";
128
+ const dSign = delta > 0 ? "+" : "";
129
+ const deltaStr = delta !== 0 ? `<span style="color:${dColor};font-size:0.58rem">${dSign}${delta}</span>` : "";
130
+ const svg = buildSparkline(vals, { width: 60, height: 20, color: "#6b7280", dotRadius: 1.5 });
131
+ return `<div class="ts-check"><span class="ts-name">${e(name)}</span>${svg}${deltaStr}</div>`;
132
+ })
133
+ .join("");
134
+ trendSection = `<div class="trend-section">
135
+ <h3>Trend <span style="font-size:0.65rem;font-weight:400;color:var(--muted)">${history.length} runs</span></h3>
136
+ <div class="ts-main"><div class="ts-spark">${mainSparkline}</div><div class="ts-info"><span class="ts-score">${report.score}</span>${badgeHtml}</div></div>
137
+ <div class="ts-checks">${checkSparklines}</div>
138
+ </div>`;
139
+ }
140
+ }
98
141
  const overviewPage = `<div id="p-overview" class="page active">
99
142
  <div class="dash">
100
143
  <div class="hero">
@@ -104,24 +147,34 @@ export function generateHTML(report) {
104
147
  <div class="radar">${radarSvg}</div>
105
148
  </div>
106
149
  <div class="cats">${catCards}</div>
150
+ ${trendSection}
107
151
  <h3>All Checks</h3>
108
152
  <div class="bars">${barChart}</div>
109
- <div class="stack">${Object.entries(report.meta.stack).filter(([, v]) => v !== "none" && v !== "unknown").map(([k, v]) => `<span>${k}: <b>${v}</b></span>`).join("")}</div>
153
+ <div class="stack">${Object.entries(report.meta.stack)
154
+ .filter(([, v]) => v !== "none" && v !== "unknown")
155
+ .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
156
+ .join("")}</div>
110
157
  </div>`;
111
158
  // Category pages (with sub-nav tabs for each check)
112
159
  let catPages = "";
113
160
  for (const cs of catScores) {
114
- const subNav = cs.checks.map((c, i) => {
161
+ const subNav = cs.checks
162
+ .map((c, i) => {
115
163
  const sk = c.details.skipped;
116
164
  return `<a class="sn${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span></a>`;
117
- }).join("");
118
- const subPages = cs.checks.map((c, i) => {
165
+ })
166
+ .join("");
167
+ const subPages = cs.checks
168
+ .map((c, i) => {
119
169
  const meta = getCheckMeta(c.name);
120
170
  const sk = c.details.skipped;
121
- const detailsFiltered = Object.entries(c.details).filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph").map(([k, v]) => {
171
+ const detailsFiltered = Object.entries(c.details)
172
+ .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
173
+ .map(([k, v]) => {
122
174
  const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
123
175
  return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
124
- }).join("");
176
+ })
177
+ .join("");
125
178
  // Group issues by file
126
179
  const byFile = new Map();
127
180
  const noFile = [];
@@ -140,7 +193,7 @@ export function generateHTML(report) {
140
193
  for (const [file, issues] of byFile) {
141
194
  issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
142
195
  for (const iss of issues) {
143
- const prompt = `Fix this issue in ${file}${iss.line ? ":" + iss.line : ""}\n${iss.severity}: ${iss.message}${iss.rule ? " (" + iss.rule + ")" : ""}\nCheck: ${c.name}`;
196
+ const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
144
197
  issuesHtml += `<div class="ir ${iss.severity}"><span class="is">${iss.severity[0].toUpperCase()}</span>${iss.line ? `<span class="il">${iss.line}</span>` : ""}<span class="im">${e(iss.message)}</span>${iss.rule ? `<span class="iru">${e(iss.rule)}</span>` : ""}<button class="cp-btn" data-prompt="${e(prompt)}" title="Copy fix prompt">📋</button></div>`;
145
198
  }
146
199
  issuesHtml += `</div>`;
@@ -153,14 +206,15 @@ export function generateHTML(report) {
153
206
  issuesHtml += `</div>`;
154
207
  }
155
208
  return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
156
- <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : c.score + "/100"} · weight ${meta.weight}% · ${c.duration}ms · ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
209
+ <div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} · weight ${meta.weight}% · ${c.duration}ms · ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
157
210
  ${meta.description ? `<div class="info-panel"><div class="ip-row"><span class="ip-label">What</span><span>${e(meta.description)}</span></div><div class="ip-row"><span class="ip-label">Risk</span><span>${e(meta.risk)}</span></div><div class="ip-row"><span class="ip-label">Fix</span><span>${e(meta.recommendation)}</span></div></div>` : ""}
158
211
  ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
159
212
  ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
160
213
  ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
161
214
  ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
162
215
  </div>`;
163
- }).join("");
216
+ })
217
+ .join("");
164
218
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
165
219
  catPages += `<div id="p-${cs.id}" class="page">
166
220
  <div class="cat-head"><span style="color:${clr};font-size:1.8rem;font-weight:900">${cs.avg}</span><span style="color:${clr}">/100</span><span style="color:var(--muted);margin-left:0.5rem">${cs.label}</span></div>
@@ -171,10 +225,13 @@ ${subPages}
171
225
  }
172
226
  // All Issues page
173
227
  const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
174
- const issueRows = allIssues.slice(0, 200).map((i) => {
228
+ const issueRows = allIssues
229
+ .slice(0, 200)
230
+ .map((i) => {
175
231
  const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
176
232
  return `<tr class="${i.severity}"><td class="is2">${i.severity[0].toUpperCase()}</td><td class="ic2">${e(i.check)}</td><td class="il2">${loc}</td><td>${e(i.message)}</td><td class="iru2">${e(i.rule || "")}</td></tr>`;
177
- }).join("");
233
+ })
234
+ .join("");
178
235
  const issuesPage = `<div id="p-issues" class="page">
179
236
  <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
180
237
  <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors · ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
@@ -182,23 +239,24 @@ ${subPages}
182
239
  ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
183
240
  </div>`;
184
241
  // File heatmap page
185
- const fileRows = topFiles.map((f) => {
242
+ const fileRows = topFiles
243
+ .map((f) => {
186
244
  const pct = Math.min(100, f.total * 5);
187
245
  return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span><span class="fcs">${f.checks.join(", ")}</span></div>`;
188
- }).join("");
246
+ })
247
+ .join("");
189
248
  const filesPage = `<div id="p-files" class="page">
190
249
  <h2>File Heatmap</h2>
191
250
  <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
192
251
  ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
193
252
  </div>`;
194
253
  // Codebase heatmap — each file = row of pixels, color = issue density
195
- const heatmapFiles = [...fileIssues.entries()]
196
- .sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings)
197
- .slice(0, 30);
254
+ const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
198
255
  let heatmapHtml = "";
199
256
  if (heatmapFiles.length > 0) {
200
257
  const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
201
- heatmapHtml = heatmapFiles.map(([file, d]) => {
258
+ heatmapHtml = heatmapFiles
259
+ .map(([file, d]) => {
202
260
  const total = d.errors + d.warnings;
203
261
  const intensity = maxIssues > 0 ? total / maxIssues : 0;
204
262
  const r = Math.round(239 * intensity); // red channel
@@ -207,7 +265,8 @@ ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
207
265
  const barW = Math.max(4, Math.round(intensity * 200));
208
266
  const checks = [...d.checks].join(", ");
209
267
  return `<div class="hm-row"><span class="hm-name">${fl(file)}</span><div class="hm-bar" style="width:${barW}px;background:${color}" title="${total} issues (${checks})"></div><span class="hm-count">${d.errors}E ${d.warnings}W</span></div>`;
210
- }).join("");
268
+ })
269
+ .join("");
211
270
  }
212
271
  const heatmapPage = `<div id="p-heatmap" class="page">
213
272
  <h2>Code Heatmap</h2>
@@ -278,6 +337,18 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
278
337
  .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
279
338
  .stack span{background:var(--card);border:1px solid var(--border);padding:0.1rem 0.45rem;border-radius:9999px;font-size:0.62rem;color:var(--muted)}
280
339
 
340
+ /* Trend sparklines */
341
+ .trend-section{background:var(--card);border:1px solid var(--border);border-radius:0.6rem;padding:1rem;margin-bottom:1.5rem}
342
+ .trend-section h3{margin-bottom:0.6rem}
343
+ .ts-main{display:flex;align-items:center;gap:1rem;margin-bottom:0.8rem}
344
+ .ts-spark{flex-shrink:0}
345
+ .ts-info{display:flex;align-items:baseline;gap:0.5rem}
346
+ .ts-score{font-size:1.4rem;font-weight:900;color:var(--text)}
347
+ .trend-badge{font-size:0.72rem;font-weight:600}
348
+ .ts-checks{display:flex;flex-wrap:wrap;gap:0.5rem 1rem}
349
+ .ts-check{display:flex;align-items:center;gap:0.3rem;font-size:0.65rem}
350
+ .ts-name{color:var(--muted);width:70px;text-align:right;flex-shrink:0}
351
+
281
352
  /* Category pages */
282
353
  .cat-head{margin-bottom:0.3rem}
283
354
  .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
@@ -359,14 +430,18 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
359
430
 
360
431
  <aside class="side">
361
432
  <div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
362
- ${catScores.map((cs) => {
433
+ ${catScores
434
+ .map((cs) => {
363
435
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
364
- 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.map((c) => {
436
+ return `<div class="side-section"><a class="side-cat" onclick="go('${cs.id}')">${cs.label} <span style="color:${clr}">${cs.avg}</span></a>${cs.checks
437
+ .map((c) => {
365
438
  const sk = c.details.skipped;
366
439
  const meta = getCheckMeta(c.name);
367
440
  return `<a class="side-check" onclick="go('${cs.id}')" title="${e(meta.label)}"><span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "—" : c.grade}</span> ${e(meta.label)}</a>`;
368
- }).join("")}</div>`;
369
- }).join("")}
441
+ })
442
+ .join("")}</div>`;
443
+ })
444
+ .join("")}
370
445
  </aside>
371
446
  <div class="content">
372
447
  ${overviewPage}
@@ -417,7 +492,9 @@ function buildRadar(items) {
417
492
  let grid = "";
418
493
  for (const pct of [25, 50, 75, 100]) {
419
494
  const rr = (pct / 100) * r;
420
- const pts = items.map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`).join(" ");
495
+ const pts = items
496
+ .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
497
+ .join(" ");
421
498
  grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
422
499
  }
423
500
  let axes = "";
@@ -428,11 +505,13 @@ function buildRadar(items) {
428
505
  const ly = cy + (r + 16) * Math.sin(a);
429
506
  axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
430
507
  }
431
- const dataPts = items.map((c, i) => {
508
+ const dataPts = items
509
+ .map((c, i) => {
432
510
  const a = i * step - Math.PI / 2;
433
511
  const rr = (c.score / 100) * r;
434
512
  return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
435
- }).join(" ");
513
+ })
514
+ .join(" ");
436
515
  let dots = "";
437
516
  for (let i = 0; i < n; i++) {
438
517
  const a = i * step - Math.PI / 2;
@@ -0,0 +1,9 @@
1
+ /** SVG sparkline builder — simple polyline with dots. */
2
+ export interface SparklineOptions {
3
+ width?: number;
4
+ height?: number;
5
+ color?: string;
6
+ dotRadius?: number;
7
+ }
8
+ /** Build an inline SVG sparkline from an array of values (0-100). */
9
+ export declare function buildSparkline(values: number[], opts?: SparklineOptions): string;
@@ -0,0 +1,23 @@
1
+ /** SVG sparkline builder — simple polyline with dots. */
2
+ /** Build an inline SVG sparkline from an array of values (0-100). */
3
+ export function buildSparkline(values, opts = {}) {
4
+ if (values.length === 0)
5
+ return "";
6
+ const w = opts.width ?? 120;
7
+ const h = opts.height ?? 30;
8
+ const color = opts.color ?? "#818cf8";
9
+ const dotR = opts.dotRadius ?? 2;
10
+ const padX = dotR + 1;
11
+ const padY = dotR + 1;
12
+ const plotW = w - padX * 2;
13
+ const plotH = h - padY * 2;
14
+ // Map values to SVG coordinates
15
+ const points = values.map((v, i) => {
16
+ const x = values.length === 1 ? w / 2 : padX + (i / (values.length - 1)) * plotW;
17
+ const y = padY + plotH - (Math.min(100, Math.max(0, v)) / 100) * plotH;
18
+ return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
19
+ });
20
+ const polyline = points.map((p) => `${p.x},${p.y}`).join(" ");
21
+ const dots = points.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="${dotR}" fill="${color}"/>`).join("");
22
+ return `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" style="display:block"><polyline points="${polyline}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>${dots}</svg>`;
23
+ }