@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.
- package/dist/check-meta.js +13 -3
- package/dist/cli.js +22 -14
- package/dist/detect.js +5 -27
- package/dist/fs-utils.js +4 -2
- package/dist/history.d.ts +14 -0
- package/dist/history.js +51 -0
- package/dist/report/html.d.ts +1 -1
- package/dist/report/html.js +113 -34
- package/dist/report/svg.d.ts +9 -0
- package/dist/report/svg.js +23 -0
- package/dist/runners/architecture.js +29 -7
- package/dist/runners/complexity.js +2 -4
- package/dist/runners/confusion.js +107 -21
- package/dist/runners/context.js +31 -7
- package/dist/runners/dependencies.js +4 -12
- package/dist/runners/docs.js +19 -5
- package/dist/runners/duplication.js +13 -4
- package/dist/runners/error-handling.d.ts +3 -0
- package/dist/runners/error-handling.js +48 -0
- package/dist/runners/lint.js +3 -7
- package/dist/runners/secrets.js +3 -20
- package/dist/runners/security.js +121 -19
- package/dist/runners/standards.js +57 -13
- package/dist/runners/structure.js +14 -6
- package/dist/runners/testing.js +97 -28
- package/dist/runners/type-safety.js +17 -4
- package/dist/runners/types-check.js +2 -3
- package/package.json +1 -1
package/dist/check-meta.js
CHANGED
|
@@ -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:
|
|
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,
|
|
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(
|
|
47
|
-
console.log(
|
|
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(
|
|
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(
|
|
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
|
|
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)
|
|
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 {
|
|
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(
|
|
140
|
-
console.log(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|
package/dist/history.js
ADDED
|
@@ -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
|
+
}
|
package/dist/report/html.d.ts
CHANGED
|
@@ -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;
|
package/dist/report/html.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 ?
|
|
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
|
|
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
|
-
})
|
|
89
|
-
|
|
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
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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)
|
|
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
|
|
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
|
-
})
|
|
118
|
-
|
|
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)
|
|
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
|
-
})
|
|
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 ?
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
-
})
|
|
369
|
-
|
|
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
|
|
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
|
|
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
|
-
})
|
|
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
|
+
}
|