@vibecodeqa/cli 0.14.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.",
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@ import { runContext } from "./runners/context.js";
11
11
  import { runDependencies } from "./runners/dependencies.js";
12
12
  import { runDocs } from "./runners/docs.js";
13
13
  import { runDuplication } from "./runners/duplication.js";
14
+ import { runErrorHandling } from "./runners/error-handling.js";
14
15
  import { runLint } from "./runners/lint.js";
15
16
  import { runSecrets } from "./runners/secrets.js";
16
17
  import { runSecurity } from "./runners/security.js";
@@ -65,6 +66,7 @@ async function main() {
65
66
  // Quality
66
67
  { name: "complexity", fn: () => runComplexity(cwd) },
67
68
  { name: "duplication", fn: () => runDuplication(cwd) },
69
+ { name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
68
70
  { name: "docs", fn: () => runDocs(cwd) },
69
71
  // Testing
70
72
  { name: "testing", fn: () => runTesting(cwd, stack, skipTests) },
@@ -129,7 +131,7 @@ async function main() {
129
131
  }
130
132
  }
131
133
  writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
132
- writeFileSync(join(outputDir, "report.html"), generateHTML(report));
134
+ writeFileSync(join(outputDir, "report.html"), generateHTML(report, historyDir));
133
135
  if (jsonOnly) {
134
136
  console.log(JSON.stringify(report));
135
137
  }
@@ -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
  }
@@ -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);
@@ -102,6 +104,40 @@ export function generateHTML(report) {
102
104
  })
103
105
  .join("");
104
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
+ }
105
141
  const overviewPage = `<div id="p-overview" class="page active">
106
142
  <div class="dash">
107
143
  <div class="hero">
@@ -111,6 +147,7 @@ export function generateHTML(report) {
111
147
  <div class="radar">${radarSvg}</div>
112
148
  </div>
113
149
  <div class="cats">${catCards}</div>
150
+ ${trendSection}
114
151
  <h3>All Checks</h3>
115
152
  <div class="bars">${barChart}</div>
116
153
  <div class="stack">${Object.entries(report.meta.stack)
@@ -300,6 +337,18 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
300
337
  .stack{display:flex;gap:0.35rem;flex-wrap:wrap}
301
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)}
302
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
+
303
352
  /* Category pages */
304
353
  .cat-head{margin-bottom:0.3rem}
305
354
  .bar2{height:4px;background:var(--card);border-radius:2px;margin-bottom:1rem;overflow:hidden}
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ /** Error handling check — detects poor error handling patterns. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runErrorHandling(cwd: string, stack: StackInfo): CheckResult;
@@ -0,0 +1,48 @@
1
+ /** Error handling check — detects poor error handling patterns. */
2
+ import { gradeFromScore } from "../types.js";
3
+ import { getProductionFiles } from "../fs-utils.js";
4
+ export function runErrorHandling(cwd, stack) {
5
+ const start = Date.now();
6
+ const issues = [];
7
+ const files = getProductionFiles(cwd);
8
+ if (files.length === 0) {
9
+ return { name: "error-handling", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
10
+ }
11
+ let emptyCatch = 0;
12
+ let throwString = 0;
13
+ for (const f of files) {
14
+ const lines = f.content.split("\n");
15
+ for (let i = 0; i < lines.length; i++) {
16
+ const line = lines[i].trim();
17
+ if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line) || /catch\s*\{\s*\}/.test(line)) {
18
+ emptyCatch++;
19
+ issues.push({ severity: "error", message: "Empty catch block", file: f.path, line: i + 1, rule: "empty-catch" });
20
+ }
21
+ if (/\bthrow\s+["'`]/.test(line)) {
22
+ throwString++;
23
+ issues.push({ severity: "warning", message: "throw string literal — use throw new Error()", file: f.path, line: i + 1, rule: "throw-string" });
24
+ }
25
+ }
26
+ }
27
+ let hasErrorBoundary = false;
28
+ if (stack.framework === "react") {
29
+ for (const f of files) {
30
+ if (f.content.includes("componentDidCatch") || f.content.includes("ErrorBoundary")) {
31
+ hasErrorBoundary = true;
32
+ break;
33
+ }
34
+ }
35
+ if (!hasErrorBoundary && files.some((f) => f.ext === ".tsx")) {
36
+ issues.push({ severity: "warning", message: "React project with no Error Boundary", rule: "no-error-boundary" });
37
+ }
38
+ }
39
+ const score = Math.max(0, Math.min(100, 100 - emptyCatch * 5 - throwString * 2 - (stack.framework === "react" && !hasErrorBoundary ? 3 : 0)));
40
+ return {
41
+ name: "error-handling",
42
+ score,
43
+ grade: gradeFromScore(score),
44
+ details: { emptyCatch, throwString, hasErrorBoundary: stack.framework === "react" ? hasErrorBoundary : "n/a" },
45
+ issues,
46
+ duration: Date.now() - start,
47
+ };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Code health scanner for the AI coding era. 15 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {