@vibecodeqa/cli 0.12.1 → 0.14.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.
@@ -153,7 +153,7 @@ export const CHECK_META = {
153
153
  },
154
154
  };
155
155
  export function getCheckMeta(name) {
156
- return CHECK_META[name] || {
156
+ return (CHECK_META[name] || {
157
157
  name,
158
158
  label: name,
159
159
  category: "Other",
@@ -162,5 +162,5 @@ export function getCheckMeta(name) {
162
162
  description: "",
163
163
  risk: "",
164
164
  recommendation: "",
165
- };
165
+ });
166
166
  }
package/dist/cli.js CHANGED
@@ -1,11 +1,11 @@
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";
@@ -17,8 +17,8 @@ import { runSecurity } from "./runners/security.js";
17
17
  import { runStandards } from "./runners/standards.js";
18
18
  import { runStructure } from "./runners/structure.js";
19
19
  import { runTesting } from "./runners/testing.js";
20
- import { runTypeCheck } from "./runners/types-check.js";
21
20
  import { runTypeSafety } from "./runners/type-safety.js";
21
+ import { runTypeCheck } from "./runners/types-check.js";
22
22
  import { computeScore } from "./score.js";
23
23
  import { computeTrend, formatTrend } from "./trend.js";
24
24
  import { gradeFromScore } from "./types.js";
@@ -43,14 +43,14 @@ async function main() {
43
43
  const start = Date.now();
44
44
  if (!jsonOnly) {
45
45
  console.log("");
46
- console.log(" \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v" + VERSION);
47
- console.log(" \x1b[2m" + cwd + "\x1b[0m");
46
+ console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
47
+ console.log(` \x1b[2m${cwd}\x1b[0m`);
48
48
  console.log("");
49
49
  }
50
50
  const stack = detectStack(cwd);
51
51
  if (!jsonOnly) {
52
52
  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(" + "));
53
+ console.log(` stack: ${parts.join(" + ")}`);
54
54
  console.log("");
55
55
  }
56
56
  const checks = [];
@@ -80,14 +80,14 @@ async function main() {
80
80
  ];
81
81
  for (const runner of runners) {
82
82
  if (!jsonOnly)
83
- process.stdout.write(" " + runner.name.padEnd(14));
83
+ process.stdout.write(` ${runner.name.padEnd(14)}`);
84
84
  const result = runner.fn();
85
85
  checks.push(result);
86
86
  if (!jsonOnly) {
87
87
  const skipped = result.details.skipped;
88
88
  const c = skipped ? "\x1b[2m" : color(result.grade);
89
89
  const label = skipped ? "skip" : result.grade;
90
- const scoreStr = skipped ? "—" : result.score + "/100";
90
+ const scoreStr = skipped ? "—" : `${result.score}/100`;
91
91
  const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
92
92
  console.log(`${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
93
93
  }
@@ -115,13 +115,17 @@ async function main() {
115
115
  const historyFile = join(historyDir, `${report.timestamp.replace(/[:.]/g, "-")}.json`);
116
116
  writeFileSync(historyFile, JSON.stringify(report, null, 2));
117
117
  // Keep only last 30 history entries
118
- const historyFiles = readdirSync(historyDir).filter((f) => f.endsWith(".json")).sort();
118
+ const historyFiles = readdirSync(historyDir)
119
+ .filter((f) => f.endsWith(".json"))
120
+ .sort();
119
121
  if (historyFiles.length > 30) {
120
122
  for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
121
123
  try {
122
124
  unlinkSync(join(historyDir, old));
123
125
  }
124
- catch { /* ignore */ }
126
+ catch {
127
+ /* ignore */
128
+ }
125
129
  }
126
130
  }
127
131
  writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
@@ -136,8 +140,8 @@ async function main() {
136
140
  if (trend)
137
141
  console.log(formatTrend(trend));
138
142
  console.log("");
139
- console.log(" \x1b[2mReport: " + join(outputDir, "report.html") + "\x1b[0m");
140
- console.log(" \x1b[2mJSON: " + join(outputDir, "report.json") + "\x1b[0m");
143
+ console.log(` \x1b[2mReport: ${join(outputDir, "report.html")}\x1b[0m`);
144
+ console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
141
145
  console.log("");
142
146
  }
143
147
  if (ciMode && score < 60) {
@@ -145,11 +149,13 @@ async function main() {
145
149
  }
146
150
  if (!jsonOnly && !ciMode && !watchMode) {
147
151
  try {
148
- const { execSync } = await import("node:child_process");
152
+ const { execFileSync } = await import("node:child_process");
149
153
  const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
150
- execSync(`${openCmd} "${join(outputDir, "report.html")}"`, { stdio: "ignore" });
154
+ execFileSync(openCmd, [join(outputDir, "report.html")], { stdio: "ignore" });
155
+ }
156
+ catch {
157
+ /* failed to open browser */
151
158
  }
152
- catch { /* failed to open browser */ }
153
159
  }
154
160
  // Watch mode — re-run on file changes
155
161
  if (watchMode) {
@@ -162,15 +168,20 @@ async function main() {
162
168
  console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
163
169
  console.log("");
164
170
  let debounce = null;
171
+ let running = false;
165
172
  for (const dir of srcDirs) {
166
173
  watch(dir, { recursive: true }, (_event, filename) => {
167
174
  if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
168
175
  return;
176
+ if (running)
177
+ return; // prevent concurrent re-runs (M5)
169
178
  if (debounce)
170
179
  clearTimeout(debounce);
171
- debounce = setTimeout(() => {
180
+ debounce = setTimeout(async () => {
181
+ running = true;
172
182
  console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
173
- main().catch(() => { });
183
+ await main().catch(() => { });
184
+ running = false;
174
185
  }, 500);
175
186
  });
176
187
  }
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
@@ -1,5 +1,5 @@
1
1
  /** Shared filesystem utilities — eliminates duplicate file-walking across runners. */
2
- import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
3
3
  import { basename, extname, join } from "node:path";
4
4
  const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".vibe-check", "coverage", "test-results", "__pycache__"]);
5
5
  const CODE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx"]);
@@ -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;
@@ -53,6 +55,9 @@ function walk(dir, cwd, out, exts) {
53
55
  if (SKIP_DIRS.has(entry))
54
56
  continue;
55
57
  const full = join(dir, entry);
58
+ // Skip symlinks to prevent traversal attacks (H3)
59
+ if (lstatSync(full).isSymbolicLink())
60
+ continue;
56
61
  if (statSync(full).isDirectory()) {
57
62
  walk(full, cwd, out, exts);
58
63
  }
@@ -60,8 +65,11 @@ function walk(dir, cwd, out, exts) {
60
65
  const ext = extname(entry);
61
66
  if (!exts.has(ext))
62
67
  continue;
68
+ // Skip files over 1MB to prevent memory issues (M1)
69
+ if (statSync(full).size > 1_000_000)
70
+ continue;
63
71
  const content = readFileSync(full, "utf-8");
64
- const relPath = full.replace(cwd + "/", "");
72
+ const relPath = full.replace(`${cwd}/`, "");
65
73
  const isTest = entry.includes(".test.") || entry.includes(".spec.") || relPath.includes("__tests__");
66
74
  out.push({
67
75
  path: relPath,
@@ -11,14 +11,14 @@
11
11
  import { getCheckMeta } from "../check-meta.js";
12
12
  import { generateArchSVG } from "../runners/architecture.js";
13
13
  function e(s) {
14
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
14
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15
15
  }
16
16
  /** Make a file path a clickable GitHub link if repoUrl is available. */
17
17
  function fileLink(path, line, repoUrl, branch) {
18
- const clean = path.split(":")[0]; // strip :line from composite paths
19
- if (!repoUrl)
18
+ const clean = path.split(":")[0];
19
+ if (!repoUrl || !/^https?:\/\//.test(repoUrl))
20
20
  return e(path);
21
- const href = `${repoUrl}/blob/${branch}/${clean}${line ? "#L" + line : ""}`;
21
+ const href = `${repoUrl}/blob/${branch}/${clean}${line ? `#L${line}` : ""}`;
22
22
  return `<a href="${e(href)}" target="_blank" rel="noopener" class="flink">${e(path)}</a>`;
23
23
  }
24
24
  function gc(grade) {
@@ -83,17 +83,24 @@ export function generateHTML(report) {
83
83
  const topNav = topNavItems.map((t) => `<a class="tn" data-page="${t.id}" onclick="go('${t.id}')">${t.label}</a>`).join("");
84
84
  // Overview page
85
85
  const ringPct = report.score;
86
- const barChart = active.sort((a, b) => a.score - b.score).map((c) => {
86
+ const barChart = active
87
+ .sort((a, b) => a.score - b.score)
88
+ .map((c) => {
87
89
  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) => {
90
+ })
91
+ .join("");
92
+ const catCards = catScores
93
+ .map((cs) => {
90
94
  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) => {
95
+ const mini = cs.checks
96
+ .map((c) => {
92
97
  const sk = c.details.skipped;
93
98
  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("");
99
+ })
100
+ .join("");
95
101
  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("");
102
+ })
103
+ .join("");
97
104
  const radarSvg = buildRadar(catScores.map((cs) => ({ label: cs.label, score: cs.avg })));
98
105
  const overviewPage = `<div id="p-overview" class="page active">
99
106
  <div class="dash">
@@ -106,22 +113,31 @@ export function generateHTML(report) {
106
113
  <div class="cats">${catCards}</div>
107
114
  <h3>All Checks</h3>
108
115
  <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>
116
+ <div class="stack">${Object.entries(report.meta.stack)
117
+ .filter(([, v]) => v !== "none" && v !== "unknown")
118
+ .map(([k, v]) => `<span>${k}: <b>${v}</b></span>`)
119
+ .join("")}</div>
110
120
  </div>`;
111
121
  // Category pages (with sub-nav tabs for each check)
112
122
  let catPages = "";
113
123
  for (const cs of catScores) {
114
- const subNav = cs.checks.map((c, i) => {
124
+ const subNav = cs.checks
125
+ .map((c, i) => {
115
126
  const sk = c.details.skipped;
116
127
  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) => {
128
+ })
129
+ .join("");
130
+ const subPages = cs.checks
131
+ .map((c, i) => {
119
132
  const meta = getCheckMeta(c.name);
120
133
  const sk = c.details.skipped;
121
- const detailsFiltered = Object.entries(c.details).filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph").map(([k, v]) => {
134
+ const detailsFiltered = Object.entries(c.details)
135
+ .filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
136
+ .map(([k, v]) => {
122
137
  const d = Array.isArray(v) ? v.join(", ") : typeof v === "object" ? JSON.stringify(v) : String(v);
123
138
  return `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(d)}</span></div>`;
124
- }).join("");
139
+ })
140
+ .join("");
125
141
  // Group issues by file
126
142
  const byFile = new Map();
127
143
  const noFile = [];
@@ -140,9 +156,8 @@ export function generateHTML(report) {
140
156
  for (const [file, issues] of byFile) {
141
157
  issuesHtml += `<div class="fg"><div class="fn">${fl(file)} <span class="fc">${issues.length}</span></div>`;
142
158
  for (const iss of issues) {
143
- const promptText = `Fix this issue in ${e(file)}${iss.line ? ":" + iss.line : ""}\n${iss.severity}: ${e(iss.message)}${iss.rule ? " (" + e(iss.rule) + ")" : ""}\nCheck: ${e(c.name)}`;
144
- const safePrompt = promptText.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
145
- 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" onclick="navigator.clipboard.writeText('${safePrompt}');this.textContent='✓';setTimeout(()=>this.textContent='📋',1000)" title="Copy fix prompt">📋</button></div>`;
159
+ const prompt = `Fix this issue in ${file}${iss.line ? `:${iss.line}` : ""}\n${iss.severity}: ${iss.message}${iss.rule ? ` (${iss.rule})` : ""}\nCheck: ${c.name}`;
160
+ 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>`;
146
161
  }
147
162
  issuesHtml += `</div>`;
148
163
  }
@@ -154,14 +169,15 @@ export function generateHTML(report) {
154
169
  issuesHtml += `</div>`;
155
170
  }
156
171
  return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
157
- <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>
172
+ <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>
158
173
  ${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>` : ""}
159
174
  ${sk ? `<p class="skip-r">${e(c.details.reason || "skipped")}</p>` : ""}
160
175
  ${c.name === "architecture" && !sk ? `<div class="arch-svg">${generateArchSVG(c.details)}</div>` : ""}
161
176
  ${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
162
177
  ${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
163
178
  </div>`;
164
- }).join("");
179
+ })
180
+ .join("");
165
181
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
166
182
  catPages += `<div id="p-${cs.id}" class="page">
167
183
  <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>
@@ -172,10 +188,13 @@ ${subPages}
172
188
  }
173
189
  // All Issues page
174
190
  const allIssues = allChecks.flatMap((c) => c.issues.map((i) => ({ check: c.name, ...i })));
175
- const issueRows = allIssues.slice(0, 200).map((i) => {
191
+ const issueRows = allIssues
192
+ .slice(0, 200)
193
+ .map((i) => {
176
194
  const loc = i.file ? fl(i.file.split(":")[0], i.line) : "";
177
195
  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>`;
178
- }).join("");
196
+ })
197
+ .join("");
179
198
  const issuesPage = `<div id="p-issues" class="page">
180
199
  <h2>All Issues <span style="color:var(--muted);font-weight:400">${totalIssues}</span></h2>
181
200
  <div class="isf">${allIssues.filter((i) => i.severity === "error").length} errors · ${allIssues.filter((i) => i.severity === "warning").length} warnings</div>
@@ -183,23 +202,24 @@ ${subPages}
183
202
  ${allIssues.length > 200 ? `<p style="color:var(--muted);text-align:center;margin-top:1rem">Showing 200 of ${allIssues.length}</p>` : ""}
184
203
  </div>`;
185
204
  // File heatmap page
186
- const fileRows = topFiles.map((f) => {
205
+ const fileRows = topFiles
206
+ .map((f) => {
187
207
  const pct = Math.min(100, f.total * 5);
188
208
  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>`;
189
- }).join("");
209
+ })
210
+ .join("");
190
211
  const filesPage = `<div id="p-files" class="page">
191
212
  <h2>File Heatmap</h2>
192
213
  <p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">Top ${topFiles.length} files by total issues across all checks</p>
193
214
  ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
194
215
  </div>`;
195
216
  // Codebase heatmap — each file = row of pixels, color = issue density
196
- const heatmapFiles = [...fileIssues.entries()]
197
- .sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings)
198
- .slice(0, 30);
217
+ const heatmapFiles = [...fileIssues.entries()].sort((a, b) => b[1].errors + b[1].warnings - a[1].errors - a[1].warnings).slice(0, 30);
199
218
  let heatmapHtml = "";
200
219
  if (heatmapFiles.length > 0) {
201
220
  const maxIssues = Math.max(...heatmapFiles.map(([, d]) => d.errors + d.warnings));
202
- heatmapHtml = heatmapFiles.map(([file, d]) => {
221
+ heatmapHtml = heatmapFiles
222
+ .map(([file, d]) => {
203
223
  const total = d.errors + d.warnings;
204
224
  const intensity = maxIssues > 0 ? total / maxIssues : 0;
205
225
  const r = Math.round(239 * intensity); // red channel
@@ -208,7 +228,8 @@ ${fileRows || '<p style="color:var(--muted)">No file-level issues found.</p>'}
208
228
  const barW = Math.max(4, Math.round(intensity * 200));
209
229
  const checks = [...d.checks].join(", ");
210
230
  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>`;
211
- }).join("");
231
+ })
232
+ .join("");
212
233
  }
213
234
  const heatmapPage = `<div id="p-heatmap" class="page">
214
235
  <h2>Code Heatmap</h2>
@@ -360,14 +381,18 @@ h3{font-size:0.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:
360
381
 
361
382
  <aside class="side">
362
383
  <div class="side-section">Score<div class="side-score" style="color:${gc(report.grade)}">${report.grade} ${report.score}</div></div>
363
- ${catScores.map((cs) => {
384
+ ${catScores
385
+ .map((cs) => {
364
386
  const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
365
- 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) => {
387
+ 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
388
+ .map((c) => {
366
389
  const sk = c.details.skipped;
367
390
  const meta = getCheckMeta(c.name);
368
391
  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>`;
369
- }).join("")}</div>`;
370
- }).join("")}
392
+ })
393
+ .join("")}</div>`;
394
+ })
395
+ .join("")}
371
396
  </aside>
372
397
  <div class="content">
373
398
  ${overviewPage}
@@ -390,6 +415,13 @@ function sub(el,cat){
390
415
  el.classList.add('active');
391
416
  document.querySelectorAll('#p-'+cat+' .sp').forEach(s=>{s.classList.toggle('active',s.dataset.sub===id)});
392
417
  }
418
+ // Copy-prompt buttons — read from data-attribute (no inline JS with user data)
419
+ document.addEventListener('click',function(ev){
420
+ var btn=ev.target.closest('.cp-btn');
421
+ if(!btn)return;
422
+ navigator.clipboard.writeText(btn.dataset.prompt||'');
423
+ btn.textContent='\\u2713';setTimeout(function(){btn.textContent='\\ud83d\\udccb'},1000);
424
+ });
393
425
  // Init: show overview
394
426
  document.querySelector('.tn').classList.add('active');
395
427
  </script>
@@ -411,7 +443,9 @@ function buildRadar(items) {
411
443
  let grid = "";
412
444
  for (const pct of [25, 50, 75, 100]) {
413
445
  const rr = (pct / 100) * r;
414
- const pts = items.map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`).join(" ");
446
+ const pts = items
447
+ .map((_, i) => `${cx + rr * Math.cos(i * step - Math.PI / 2)},${cy + rr * Math.sin(i * step - Math.PI / 2)}`)
448
+ .join(" ");
415
449
  grid += `<polygon points="${pts}" fill="none" stroke="#1e1e24" stroke-width="0.7"/>`;
416
450
  }
417
451
  let axes = "";
@@ -422,11 +456,13 @@ function buildRadar(items) {
422
456
  const ly = cy + (r + 16) * Math.sin(a);
423
457
  axes += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="middle" fill="#6b7280" font-size="9" font-weight="600">${items[i].label}</text>`;
424
458
  }
425
- const dataPts = items.map((c, i) => {
459
+ const dataPts = items
460
+ .map((c, i) => {
426
461
  const a = i * step - Math.PI / 2;
427
462
  const rr = (c.score / 100) * r;
428
463
  return `${cx + rr * Math.cos(a)},${cy + rr * Math.sin(a)}`;
429
- }).join(" ");
464
+ })
465
+ .join(" ");
430
466
  let dots = "";
431
467
  for (let i = 0; i < n; i++) {
432
468
  const a = i * step - Math.PI / 2;
@@ -10,14 +10,21 @@
10
10
  * 7. SVG architecture diagram
11
11
  */
12
12
  import { basename, dirname, extname } from "node:path";
13
- import { gradeFromScore } from "../types.js";
14
13
  import { getProductionFiles } from "../fs-utils.js";
14
+ import { gradeFromScore } from "../types.js";
15
15
  export function runArchitecture(cwd) {
16
16
  const start = Date.now();
17
17
  const issues = [];
18
18
  const files = getProductionFiles(cwd);
19
19
  if (files.length < 2) {
20
- return { name: "architecture", score: 100, grade: "A", details: { skipped: true, reason: "fewer than 2 source files" }, issues: [], duration: Date.now() - start };
20
+ return {
21
+ name: "architecture",
22
+ score: 100,
23
+ grade: "A",
24
+ details: { skipped: true, reason: "fewer than 2 source files" },
25
+ issues: [],
26
+ duration: Date.now() - start,
27
+ };
21
28
  }
22
29
  const graph = buildGraph(files);
23
30
  // ── Circular dependencies ──
@@ -34,7 +41,12 @@ export function runArchitecture(cwd) {
34
41
  for (const [path, node] of graph.nodes) {
35
42
  if (node.importedBy.length >= threshold) {
36
43
  godModules.push(path);
37
- issues.push({ severity: "warning", message: `God module: imported by ${node.importedBy.length}/${files.length} files — consider splitting`, file: path, rule: "god-module" });
44
+ issues.push({
45
+ severity: "warning",
46
+ message: `God module: imported by ${node.importedBy.length}/${files.length} files — consider splitting`,
47
+ file: path,
48
+ rule: "god-module",
49
+ });
38
50
  }
39
51
  }
40
52
  // ── Orphan files (not imported by anyone) ──
@@ -52,7 +64,12 @@ export function runArchitecture(cwd) {
52
64
  for (const [path, node] of graph.nodes) {
53
65
  if (node.imports.length > 10) {
54
66
  highFanOut++;
55
- issues.push({ severity: "warning", message: `High fan-out: imports ${node.imports.length} modules — hard to test in isolation`, file: path, rule: "high-fan-out" });
67
+ issues.push({
68
+ severity: "warning",
69
+ message: `High fan-out: imports ${node.imports.length} modules — hard to test in isolation`,
70
+ file: path,
71
+ rule: "high-fan-out",
72
+ });
56
73
  }
57
74
  }
58
75
  // ── High fan-in + fan-out (connector files) ──
@@ -60,7 +77,12 @@ export function runArchitecture(cwd) {
60
77
  for (const [path, node] of graph.nodes) {
61
78
  if (node.imports.length > 5 && node.importedBy.length > 5) {
62
79
  connectors++;
63
- issues.push({ severity: "warning", message: `Connector: ${node.imports.length} imports, ${node.importedBy.length} importers — high coupling`, file: path, rule: "connector-module" });
80
+ issues.push({
81
+ severity: "warning",
82
+ message: `Connector: ${node.imports.length} imports, ${node.importedBy.length} importers — high coupling`,
83
+ file: path,
84
+ rule: "connector-module",
85
+ });
64
86
  }
65
87
  }
66
88
  // ── Score ──
@@ -152,8 +174,8 @@ function resolveImport(fromPath, importPath, knownFiles) {
152
174
  }
153
175
  // Try index
154
176
  for (const ext of [".ts", ".tsx"]) {
155
- if (knownFiles.has(resolved + "/index" + ext))
156
- return resolved + "/index" + ext;
177
+ if (knownFiles.has(`${resolved}/index${ext}`))
178
+ return `${resolved}/index${ext}`;
157
179
  }
158
180
  return null;
159
181
  }
@@ -28,7 +28,7 @@ export function runComplexity(cwd) {
28
28
  const lines = content.split("\n");
29
29
  totalLines += lines.length;
30
30
  // Simple heuristic: find function boundaries and measure complexity
31
- const funcs = extractFunctions(content, file.replace(cwd + "/", ""));
31
+ const funcs = extractFunctions(content, file.replace(`${cwd}/`, ""));
32
32
  for (const f of funcs) {
33
33
  functions.push(f);
34
34
  if (f.lines > MAX_FUNCTION_LINES) {
@@ -79,9 +79,7 @@ function collectFiles(dir, out) {
79
79
  }
80
80
  else {
81
81
  const ext = extname(entry);
82
- if ((ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") &&
83
- !entry.includes(".test.") &&
84
- !entry.includes(".spec.")) {
82
+ if ((ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") && !entry.includes(".test.") && !entry.includes(".spec.")) {
85
83
  out.push(full);
86
84
  }
87
85
  }