@vibecodeqa/cli 0.20.1 → 0.22.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 +1 -1
- package/dist/cli.js +5 -5
- package/dist/fs-utils.js +17 -2
- package/dist/report/components.d.ts +8 -0
- package/dist/report/components.js +4 -0
- package/dist/report/favicon.d.ts +2 -0
- package/dist/report/favicon.js +2 -0
- package/dist/report/html.js +45 -34
- package/dist/report/pages.js +24 -18
- package/dist/report/svg.js +4 -2
- package/dist/runners/accessibility.js +37 -6
- package/dist/runners/architecture.d.ts +2 -0
- package/dist/runners/architecture.js +155 -1
- package/dist/runners/best-practices.js +111 -33
- package/dist/runners/dependencies.js +3 -1
- package/dist/runners/duplication.js +8 -1
- package/dist/runners/error-handling.js +16 -3
- package/dist/runners/performance.js +3 -1
- package/dist/runners/react.js +43 -7
- package/dist/runners/type-safety.js +3 -1
- package/package.json +1 -1
package/dist/check-meta.js
CHANGED
|
@@ -179,7 +179,7 @@ export const CHECK_META = {
|
|
|
179
179
|
weight: 4,
|
|
180
180
|
description: "Checks common accessibility violations: images without alt text, click handlers on non-interactive elements without keyboard support, form controls without labels, autoFocus usage, positive tabIndex, and missing html lang attribute.",
|
|
181
181
|
risk: "1 in 4 adults has a disability (CDC). Missing alt text makes images invisible to screen readers. Click-only divs exclude keyboard users. Unlabeled inputs are unusable with assistive technology. Missing lang attribute breaks screen reader pronunciation.",
|
|
182
|
-
recommendation:
|
|
182
|
+
recommendation: 'Add alt text to all images (use alt="" for decorative). Use <button> for clickable elements, not <div onClick>. Label all form controls with <label>, aria-label, or aria-labelledby. Set lang on <html>.',
|
|
183
183
|
},
|
|
184
184
|
performance: {
|
|
185
185
|
name: "performance",
|
package/dist/cli.js
CHANGED
|
@@ -4,21 +4,21 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFile
|
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { detectRepoUrl, detectStack } from "./detect.js";
|
|
6
6
|
import { generatePages } from "./report/html.js";
|
|
7
|
+
import { runAccessibility } from "./runners/accessibility.js";
|
|
7
8
|
import { runArchitecture } from "./runners/architecture.js";
|
|
8
9
|
import { runBestPractices } from "./runners/best-practices.js";
|
|
10
|
+
import { runCodeCoherence } from "./runners/code-coherence.js";
|
|
9
11
|
import { runComplexity } from "./runners/complexity.js";
|
|
10
12
|
import { runConfusion } from "./runners/confusion.js";
|
|
11
13
|
import { runContext } from "./runners/context.js";
|
|
12
14
|
import { runDependencies } from "./runners/dependencies.js";
|
|
15
|
+
import { runDocCoherence } from "./runners/doc-coherence.js";
|
|
13
16
|
import { runDocs } from "./runners/docs.js";
|
|
14
17
|
import { runDuplication } from "./runners/duplication.js";
|
|
15
|
-
import { runPerformance } from "./runners/performance.js";
|
|
16
18
|
import { runErrorHandling } from "./runners/error-handling.js";
|
|
17
19
|
import { runLint } from "./runners/lint.js";
|
|
20
|
+
import { runPerformance } from "./runners/performance.js";
|
|
18
21
|
import { runReact } from "./runners/react.js";
|
|
19
|
-
import { runAccessibility } from "./runners/accessibility.js";
|
|
20
|
-
import { runDocCoherence } from "./runners/doc-coherence.js";
|
|
21
|
-
import { runCodeCoherence } from "./runners/code-coherence.js";
|
|
22
22
|
import { runSecrets } from "./runners/secrets.js";
|
|
23
23
|
import { runSecurity } from "./runners/security.js";
|
|
24
24
|
import { runStandards } from "./runners/standards.js";
|
|
@@ -180,7 +180,7 @@ async function main() {
|
|
|
180
180
|
body: JSON.stringify({ repo, report }),
|
|
181
181
|
});
|
|
182
182
|
if (res.ok) {
|
|
183
|
-
const data = await res.json();
|
|
183
|
+
const data = (await res.json());
|
|
184
184
|
if (!jsonOnly)
|
|
185
185
|
console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
|
|
186
186
|
}
|
package/dist/fs-utils.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
/** Shared filesystem utilities — eliminates duplicate file-walking across runners. */
|
|
2
2
|
import { lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { basename, extname, join } from "node:path";
|
|
4
|
-
const SKIP_DIRS = new Set([
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
"node_modules",
|
|
6
|
+
"dist",
|
|
7
|
+
".git",
|
|
8
|
+
".vibe-check",
|
|
9
|
+
"coverage",
|
|
10
|
+
"test-results",
|
|
11
|
+
"__pycache__",
|
|
12
|
+
".dart_tool",
|
|
13
|
+
"build",
|
|
14
|
+
".flutter-plugins",
|
|
15
|
+
]);
|
|
5
16
|
const CODE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".dart"]);
|
|
6
17
|
const ALL_EXTS = new Set([...CODE_EXTS, ".json", ".env", ".yaml", ".yml", ".toml"]);
|
|
7
18
|
/** Walk source directories and return all code files. */
|
|
@@ -76,7 +87,11 @@ function walk(dir, cwd, out, exts) {
|
|
|
76
87
|
continue;
|
|
77
88
|
const content = readFileSync(full, "utf-8");
|
|
78
89
|
const relPath = full.replace(`${cwd}/`, "");
|
|
79
|
-
const isTest = entry.includes(".test.") ||
|
|
90
|
+
const isTest = entry.includes(".test.") ||
|
|
91
|
+
entry.includes(".spec.") ||
|
|
92
|
+
entry.endsWith("_test.dart") ||
|
|
93
|
+
relPath.includes("__tests__") ||
|
|
94
|
+
relPath.includes("test/");
|
|
80
95
|
out.push({
|
|
81
96
|
path: relPath,
|
|
82
97
|
fullPath: full,
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
/** Reusable HTML components and helpers for the report. */
|
|
2
2
|
import type { Priority } from "../check-meta.js";
|
|
3
|
+
import type { CheckResult } from "../types.js";
|
|
4
|
+
/** Type-safe accessor for check detail flags. */
|
|
5
|
+
export declare function det(c: CheckResult): {
|
|
6
|
+
skipped?: boolean;
|
|
7
|
+
comingSoon?: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
[k: string]: unknown;
|
|
10
|
+
};
|
|
3
11
|
/** HTML-escape a string. */
|
|
4
12
|
export declare function e(s: string): string;
|
|
5
13
|
/** Make a file path a clickable GitHub link if repoUrl is available. */
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
/** Reusable HTML components and helpers for the report. */
|
|
2
|
+
/** Type-safe accessor for check detail flags. */
|
|
3
|
+
export function det(c) {
|
|
4
|
+
return c.details;
|
|
5
|
+
}
|
|
2
6
|
/** HTML-escape a string. */
|
|
3
7
|
export function e(s) {
|
|
4
8
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Simple SVG favicon — "VQ" monogram in accent purple. */
|
|
2
|
+
export declare const FAVICON_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 32 32\"><rect width=\"32\" height=\"32\" rx=\"6\" fill=\"#818cf8\"/><text x=\"16\" y=\"22\" text-anchor=\"middle\" font-family=\"system-ui,sans-serif\" font-size=\"16\" font-weight=\"900\" fill=\"#fff\">VQ</text></svg>";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/** Simple SVG favicon — "VQ" monogram in accent purple. */
|
|
2
|
+
export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#818cf8"/><text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="16" font-weight="900" fill="#fff">VQ</text></svg>`;
|
package/dist/report/html.js
CHANGED
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
* Mobile: Hamburger toggles both top nav dropdown and sidebar panel.
|
|
12
12
|
*/
|
|
13
13
|
import { getCheckMeta } from "../check-meta.js";
|
|
14
|
-
import { e, fileLink, gc } from "./components.js";
|
|
14
|
+
import { det, e, fileLink, gc } from "./components.js";
|
|
15
|
+
import { FAVICON_SVG } from "./favicon.js";
|
|
15
16
|
import { categoryPage, filesPage, issuesPage, overviewPage } from "./pages.js";
|
|
16
17
|
import { CSS } from "./styles.js";
|
|
17
18
|
export const GROUPS = [
|
|
18
19
|
{ id: "foundations", label: "Foundations", file: "foundations.html", checks: ["structure", "lint", "types", "type-safety", "standards"] },
|
|
19
|
-
{
|
|
20
|
+
{
|
|
21
|
+
id: "quality",
|
|
22
|
+
label: "Quality",
|
|
23
|
+
file: "quality.html",
|
|
24
|
+
checks: ["complexity", "duplication", "error-handling", "react", "accessibility", "docs", "best-practices"],
|
|
25
|
+
},
|
|
20
26
|
{ id: "testing", label: "Testing", file: "testing.html", checks: ["testing"] },
|
|
21
27
|
{ id: "arch", label: "Architecture", file: "architecture.html", checks: ["architecture", "performance"] },
|
|
22
28
|
{ id: "security", label: "Security", file: "security.html", checks: ["secrets", "security", "dependencies"] },
|
|
@@ -27,7 +33,7 @@ export function generatePages(report, historyDir) {
|
|
|
27
33
|
const pages = new Map();
|
|
28
34
|
const allChecks = report.checks;
|
|
29
35
|
const checkMap = new Map(allChecks.map((c) => [c.name, c]));
|
|
30
|
-
const active = allChecks.filter((c) => !c.
|
|
36
|
+
const active = allChecks.filter((c) => !det(c).skipped && !det(c).comingSoon);
|
|
31
37
|
const ru = report.meta.repoUrl;
|
|
32
38
|
const br = report.meta.branch;
|
|
33
39
|
const fl = (path, line) => fileLink(path, line, ru, br);
|
|
@@ -54,38 +60,44 @@ export function generatePages(report, historyDir) {
|
|
|
54
60
|
.slice(0, 30);
|
|
55
61
|
const catScores = GROUPS.map((g) => {
|
|
56
62
|
const checks = g.checks.map((n) => checkMap.get(n)).filter(Boolean);
|
|
57
|
-
const scored = checks.filter((c) => !c.
|
|
63
|
+
const scored = checks.filter((c) => !det(c).skipped);
|
|
58
64
|
const avg = scored.length > 0 ? Math.round(scored.reduce((s, c) => s + c.score, 0) / scored.length) : 0;
|
|
59
65
|
return { ...g, avg, checks };
|
|
60
66
|
});
|
|
61
67
|
const w = (id, sidebar, content) => wrap(proj, id, report, totalIssues, sidebar, content);
|
|
62
68
|
// ── Overview: sidebar shows score + category summary ──
|
|
63
|
-
const overviewSidebar = sidebarScore(report)
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
const overviewSidebar = sidebarScore(report) +
|
|
70
|
+
catScores
|
|
71
|
+
.map((cs) => {
|
|
72
|
+
const isPremium = cs.checks.every((c) => det(c).comingSoon);
|
|
66
73
|
const clr = isPremium ? "#6366f1" : gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
67
74
|
const label = isPremium
|
|
68
75
|
? `<span class="pro-badge" style="font-size:0.5rem;padding:0.08rem 0.35rem">PRO</span>`
|
|
69
76
|
: `<span style="color:${clr}">${cs.avg}</span>`;
|
|
70
77
|
return `<a class="side-cat" href="${cs.file}">${cs.label} ${label}</a>`;
|
|
71
|
-
})
|
|
72
|
-
|
|
78
|
+
})
|
|
79
|
+
.join("") +
|
|
80
|
+
sidebarViews(totalIssues, fileIssues.size);
|
|
73
81
|
pages.set("index.html", w("overview", overviewSidebar, overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir)));
|
|
74
82
|
// ── Category pages: sidebar shows the checks within this category ──
|
|
75
83
|
for (let i = 0; i < GROUPS.length; i++) {
|
|
76
84
|
const g = GROUPS[i];
|
|
77
85
|
const cs = catScores[i];
|
|
78
|
-
const catSidebar = sidebarScore(report)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
86
|
+
const catSidebar = sidebarScore(report) +
|
|
87
|
+
`<div class="side-section"><div class="side-cat-title">${cs.label}</div>` +
|
|
88
|
+
cs.checks
|
|
89
|
+
.map((c) => {
|
|
90
|
+
const sk = det(c).skipped;
|
|
91
|
+
const premium = det(c).comingSoon;
|
|
83
92
|
const meta = getCheckMeta(c.name);
|
|
84
|
-
const badge = premium
|
|
93
|
+
const badge = premium
|
|
94
|
+
? `<span style="color:#6366f1">PRO</span>`
|
|
95
|
+
: `<span style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade} ${sk ? "" : c.score}</span>`;
|
|
85
96
|
return `<a class="side-check" onclick="var t=document.querySelector('[data-sub=\\'${cs.id}-${c.name}\\']');if(t)sub(t,'${cs.id}')" title="${e(meta.label)}">${badge} ${e(meta.label)}</a>`;
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
+
|
|
97
|
+
})
|
|
98
|
+
.join("") +
|
|
99
|
+
`</div>` +
|
|
100
|
+
sidebarViews(totalIssues, fileIssues.size);
|
|
89
101
|
pages.set(g.file, w(g.id, catSidebar, categoryPage(cs, fl)));
|
|
90
102
|
}
|
|
91
103
|
// ── Issues: sidebar shows severity breakdown ──
|
|
@@ -93,21 +105,21 @@ export function generatePages(report, historyDir) {
|
|
|
93
105
|
const errCount = allIssuesList.filter((i) => i.severity === "error").length;
|
|
94
106
|
const warnCount = allIssuesList.filter((i) => i.severity === "warning").length;
|
|
95
107
|
const infoCount = allIssuesList.filter((i) => i.severity === "info").length;
|
|
96
|
-
const issuesSidebar = sidebarScore(report)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
const issuesSidebar = sidebarScore(report) +
|
|
109
|
+
`<div class="side-section"><div class="side-cat-title">Breakdown</div>` +
|
|
110
|
+
`<div class="side-stat"><span style="color:var(--fail)">${errCount}</span> errors</div>` +
|
|
111
|
+
`<div class="side-stat"><span style="color:var(--warn)">${warnCount}</span> warnings</div>` +
|
|
112
|
+
`<div class="side-stat"><span style="color:var(--info)">${infoCount}</span> info</div>` +
|
|
113
|
+
`</div>` +
|
|
114
|
+
sidebarViews(totalIssues, fileIssues.size);
|
|
103
115
|
pages.set("issues.html", w("issues", issuesSidebar, issuesPage(allChecks, totalIssues, fl)));
|
|
104
116
|
// ── Files: sidebar shows file stats ──
|
|
105
|
-
const filesSidebar = sidebarScore(report)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
const filesSidebar = sidebarScore(report) +
|
|
118
|
+
`<div class="side-section"><div class="side-cat-title">File Health</div>` +
|
|
119
|
+
`<div class="side-stat"><span style="color:var(--text)">${fileIssues.size}</span> files with issues</div>` +
|
|
120
|
+
`<div class="side-stat"><span style="color:var(--fail)">${topFiles.filter((f) => f.errors > 0).length}</span> with errors</div>` +
|
|
121
|
+
`</div>` +
|
|
122
|
+
sidebarViews(totalIssues, fileIssues.size);
|
|
111
123
|
pages.set("files.html", w("files", filesSidebar, filesPage(topFiles, fileIssues, fl)));
|
|
112
124
|
return pages;
|
|
113
125
|
}
|
|
@@ -129,14 +141,13 @@ function wrap(proj, currentId, report, totalIssues, sidebar, content) {
|
|
|
129
141
|
{ id: "issues", label: `Issues (${totalIssues})`, file: "issues.html" },
|
|
130
142
|
{ id: "files", label: "Files", file: "files.html" },
|
|
131
143
|
];
|
|
132
|
-
const nav = navItems
|
|
133
|
-
.map((t) => `<a class="tn${t.id === currentId ? " active" : ""}" href="${t.file}">${t.label}</a>`)
|
|
134
|
-
.join("");
|
|
144
|
+
const nav = navItems.map((t) => `<a class="tn${t.id === currentId ? " active" : ""}" href="${t.file}">${t.label}</a>`).join("");
|
|
135
145
|
return `<!DOCTYPE html>
|
|
136
146
|
<html lang="en">
|
|
137
147
|
<head>
|
|
138
148
|
<meta charset="utf-8">
|
|
139
149
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
150
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${encodeURIComponent(FAVICON_SVG)}">
|
|
140
151
|
<title>VibeCode QA \u2014 ${e(proj)}</title>
|
|
141
152
|
<style>${CSS}</style>
|
|
142
153
|
</head>
|
package/dist/report/pages.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Page renderers for the HTML report. */
|
|
2
2
|
import { getCheckMeta } from "../check-meta.js";
|
|
3
3
|
import { loadHistory } from "../history.js";
|
|
4
|
-
import { generateArchSVG, generateDSM, generatePackageDiagram } from "../runners/architecture.js";
|
|
5
|
-
import { e, gc, pc } from "./components.js";
|
|
4
|
+
import { generateArchSVG, generateDSM, generatePackageDiagram, generateSequenceDiagram } from "../runners/architecture.js";
|
|
5
|
+
import { det, e, gc, pc } from "./components.js";
|
|
6
6
|
import { buildPyramid, buildRadar, buildRing, buildTimeline } from "./svg.js";
|
|
7
7
|
// ── Overview ──────────────────────────────────────────────────────────
|
|
8
8
|
export function overviewPage(report, active, totalIssues, catScores, allChecks, topFiles, fl, historyDir) {
|
|
@@ -14,14 +14,14 @@ export function overviewPage(report, active, totalIssues, catScores, allChecks,
|
|
|
14
14
|
<span class="hd">${active.length} checks \u00b7 ${totalIssues} issues \u00b7 ${report.meta.duration}ms</span>
|
|
15
15
|
</div>
|
|
16
16
|
</div>`;
|
|
17
|
-
const scoredCats = catScores.filter((cs) => cs.checks.some((c) => !c.
|
|
17
|
+
const scoredCats = catScores.filter((cs) => cs.checks.some((c) => !det(c).skipped && !det(c).comingSoon));
|
|
18
18
|
const radarSvg = scoredCats.length >= 3 ? buildRadar(scoredCats.map((cs) => ({ label: cs.label, score: cs.avg }))) : "";
|
|
19
19
|
const catCards = catScores
|
|
20
20
|
.map((cs) => {
|
|
21
21
|
const clr = gc(cs.avg >= 90 ? "A" : cs.avg >= 75 ? "B" : cs.avg >= 60 ? "C" : cs.avg >= 40 ? "D" : "F");
|
|
22
22
|
const mini = cs.checks
|
|
23
23
|
.map((c) => {
|
|
24
|
-
const sk = c.
|
|
24
|
+
const sk = det(c).skipped;
|
|
25
25
|
return `<span class="mc" style="color:${sk ? "#555" : gc(c.grade)}" title="${e(c.name)}: ${sk ? "skip" : c.score}">${sk ? "\u2014" : c.grade}</span>`;
|
|
26
26
|
})
|
|
27
27
|
.join("");
|
|
@@ -60,10 +60,13 @@ export function overviewPage(report, active, totalIssues, catScores, allChecks,
|
|
|
60
60
|
}
|
|
61
61
|
let fileHotspotsHtml = "";
|
|
62
62
|
if (topFiles.length > 0) {
|
|
63
|
-
const fileRows = topFiles
|
|
63
|
+
const fileRows = topFiles
|
|
64
|
+
.slice(0, 5)
|
|
65
|
+
.map((f) => {
|
|
64
66
|
const pct = Math.min(100, f.total * 5);
|
|
65
67
|
return `<div class="fr"><span class="ff">${fl(f.file)}</span><div class="fb"><div class="fbf" style="width:${pct}%;background:${f.errors > 0 ? "var(--fail)" : "var(--warn)"}"></div></div><span class="fv">${f.errors}E ${f.warnings}W</span></div>`;
|
|
66
|
-
})
|
|
68
|
+
})
|
|
69
|
+
.join("");
|
|
67
70
|
const viewAll = topFiles.length > 5 ? `<a class="ov-link" href="files.html">View all ${topFiles.length} files \u2192</a>` : "";
|
|
68
71
|
fileHotspotsHtml = `<div class="ov-section"><h3>File Hotspots</h3>${fileRows}${viewAll}</div>`;
|
|
69
72
|
}
|
|
@@ -87,8 +90,8 @@ ${fileHotspotsHtml}
|
|
|
87
90
|
export function categoryPage(cs, fl) {
|
|
88
91
|
const subNav = cs.checks
|
|
89
92
|
.map((c, i) => {
|
|
90
|
-
const sk = c.
|
|
91
|
-
const premium = c.
|
|
93
|
+
const sk = det(c).skipped;
|
|
94
|
+
const premium = det(c).comingSoon;
|
|
92
95
|
const badge = premium ? "PRO" : sk ? "\u2014" : c.grade;
|
|
93
96
|
const clr = premium ? "#6366f1" : sk ? "#555" : gc(c.grade);
|
|
94
97
|
return `<a class="sn${i === 0 ? " active" : ""}${premium ? " sn-pro" : ""}" data-sub="${cs.id}-${c.name}" onclick="sub(this,'${cs.id}')">${e(c.name)} <span style="color:${clr}">${badge}</span></a>`;
|
|
@@ -97,8 +100,8 @@ export function categoryPage(cs, fl) {
|
|
|
97
100
|
const subPages = cs.checks
|
|
98
101
|
.map((c, i) => {
|
|
99
102
|
const meta = getCheckMeta(c.name);
|
|
100
|
-
const sk = c.
|
|
101
|
-
const premium = c.
|
|
103
|
+
const sk = det(c).skipped;
|
|
104
|
+
const premium = det(c).comingSoon;
|
|
102
105
|
const detailsFiltered = Object.entries(c.details)
|
|
103
106
|
.filter(([k]) => k !== "skipped" && k !== "reason" && k !== "graph")
|
|
104
107
|
.map(([k, v]) => {
|
|
@@ -136,9 +139,9 @@ export function categoryPage(cs, fl) {
|
|
|
136
139
|
issuesHtml += `</div>`;
|
|
137
140
|
}
|
|
138
141
|
if (premium) {
|
|
139
|
-
const
|
|
140
|
-
const desc =
|
|
141
|
-
const detailKvs = Object.entries(
|
|
142
|
+
const d = c.details;
|
|
143
|
+
const desc = d.description || meta.description;
|
|
144
|
+
const detailKvs = Object.entries(d)
|
|
142
145
|
.filter(([k]) => !["premium", "comingSoon", "reason", "description"].includes(k))
|
|
143
146
|
.map(([k, v]) => `<div class="kv"><span class="k">${e(k)}</span><span class="v">${e(Array.isArray(v) ? v.join(", ") : String(v))}</span></div>`)
|
|
144
147
|
.join("");
|
|
@@ -156,9 +159,9 @@ ${detailKvs ? `<div class="kvs" style="margin-top:0.8rem">${detailKvs}</div>` :
|
|
|
156
159
|
return `<div class="sp${i === 0 ? " active" : ""}" data-sub="${cs.id}-${c.name}">
|
|
157
160
|
<div class="ch-head"><span class="ch-g" style="color:${sk ? "#555" : gc(c.grade)}">${sk ? "\u2014" : c.grade}</span><div><b>${e(meta.label)}</b><span class="ch-s">${sk ? "skipped" : `${c.score}/100`} \u00b7 weight ${meta.weight}% \u00b7 ${c.duration}ms \u00b7 ${c.issues.length} issues</span></div><span class="pri" style="color:${pc(meta.priority)}">${meta.priority}</span></div>
|
|
158
161
|
${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
|
-
${sk ? `<p class="skip-r">${e(c.
|
|
160
|
-
${c.name === "architecture" && !sk ? `<h3 style="margin-top:1.5rem">Dependency Graph</h3><div class="arch-svg">${generateArchSVG(c.details)}</div><h3 style="margin-top:1.5rem">Package Diagram</h3><div class="arch-svg">${generatePackageDiagram(c.details)}</div><h3 style="margin-top:1.5rem">Dependency Matrix (DSM)</h3><div class="arch-svg">${generateDSM(c.details)}</div>` : ""}
|
|
161
|
-
${c.name === "testing" && !sk && c.
|
|
162
|
+
${sk ? `<p class="skip-r">${e(det(c).reason || "skipped")}</p>` : ""}
|
|
163
|
+
${c.name === "architecture" && !sk ? `${c.details.containerSvg ? `<h3 style="margin-top:1.5rem">Container Diagram</h3><div class="arch-svg">${c.details.containerSvg}</div>` : ""}<h3 style="margin-top:1.5rem">Dependency Graph</h3><div class="arch-svg">${generateArchSVG(c.details)}</div><h3 style="margin-top:1.5rem">Sequence Diagram</h3><div class="arch-svg">${generateSequenceDiagram(c.details)}</div><h3 style="margin-top:1.5rem">Package Diagram</h3><div class="arch-svg">${generatePackageDiagram(c.details)}</div><h3 style="margin-top:1.5rem">Dependency Matrix (DSM)</h3><div class="arch-svg">${generateDSM(c.details)}</div>` : ""}
|
|
164
|
+
${c.name === "testing" && !sk && det(c).pyramid ? `<div class="arch-svg">${buildPyramid(det(c).pyramid)}</div>` : ""}
|
|
162
165
|
${detailsFiltered ? `<div class="kvs">${detailsFiltered}</div>` : ""}
|
|
163
166
|
${issuesHtml ? `<div class="iss-list">${issuesHtml}</div>` : '<p style="color:var(--muted);font-size:0.8rem;margin-top:1rem">No issues found.</p>'}
|
|
164
167
|
</div>`;
|
|
@@ -210,7 +213,10 @@ export function filesPage(topFiles, fileIssues, fl) {
|
|
|
210
213
|
.join("");
|
|
211
214
|
return `
|
|
212
215
|
<h2>File Health</h2>
|
|
213
|
-
<p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">${fileIssues.size} files with issues across ${topFiles.reduce((s, f) => {
|
|
214
|
-
|
|
216
|
+
<p style="color:var(--muted);font-size:0.78rem;margin-bottom:1rem">${fileIssues.size} files with issues across ${topFiles.reduce((s, f) => {
|
|
217
|
+
for (const c of f.checks)
|
|
218
|
+
s.add(c);
|
|
219
|
+
return s;
|
|
220
|
+
}, new Set()).size} checks.</p>
|
|
215
221
|
${heatmapRows}`;
|
|
216
222
|
}
|
package/dist/report/svg.js
CHANGED
|
@@ -157,10 +157,12 @@ export function buildSparkline(values, opts) {
|
|
|
157
157
|
const range = max - min || 1;
|
|
158
158
|
const step = width / (values.length - 1);
|
|
159
159
|
const points = values.map((v, i) => `${(i * step).toFixed(1)},${(height - ((v - min) / range) * (height - 4) - 2).toFixed(1)}`).join(" ");
|
|
160
|
-
const dots = values
|
|
160
|
+
const dots = values
|
|
161
|
+
.map((v, i) => {
|
|
161
162
|
const x = (i * step).toFixed(1);
|
|
162
163
|
const y = (height - ((v - min) / range) * (height - 4) - 2).toFixed(1);
|
|
163
164
|
return `<circle cx="${x}" cy="${y}" r="1.5" fill="${color}"/>`;
|
|
164
|
-
})
|
|
165
|
+
})
|
|
166
|
+
.join("");
|
|
165
167
|
return `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><polyline points="${points}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round"/>${dots}</svg>`;
|
|
166
168
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/** Accessibility check — detects common a11y violations in JSX/TSX code. */
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { gradeFromScore } from "../types.js";
|
|
5
4
|
import { getProductionFiles } from "../fs-utils.js";
|
|
5
|
+
import { gradeFromScore } from "../types.js";
|
|
6
6
|
export function runAccessibility(cwd) {
|
|
7
7
|
const start = Date.now();
|
|
8
8
|
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
9
9
|
if (files.length === 0) {
|
|
10
|
-
return {
|
|
10
|
+
return {
|
|
11
|
+
name: "accessibility",
|
|
12
|
+
score: 100,
|
|
13
|
+
grade: "A",
|
|
14
|
+
details: { skipped: true, reason: "no JSX/TSX files" },
|
|
15
|
+
issues: [],
|
|
16
|
+
duration: Date.now() - start,
|
|
17
|
+
};
|
|
11
18
|
}
|
|
12
19
|
const issues = [];
|
|
13
20
|
let missingAlt = 0;
|
|
@@ -36,7 +43,13 @@ export function runAccessibility(cwd) {
|
|
|
36
43
|
const block = lines.slice(i, Math.min(i + 3, lines.length)).join(" ");
|
|
37
44
|
if (!(/role=/.test(block) && /(?:onKeyDown|onKeyUp|onKeyPress|tabIndex)/.test(block))) {
|
|
38
45
|
clickDiv++;
|
|
39
|
-
issues.push({
|
|
46
|
+
issues.push({
|
|
47
|
+
severity: "warning",
|
|
48
|
+
message: "Click handler on non-interactive element without role + keyboard handler",
|
|
49
|
+
file: f.path,
|
|
50
|
+
line: i + 1,
|
|
51
|
+
rule: "click-events",
|
|
52
|
+
});
|
|
40
53
|
}
|
|
41
54
|
}
|
|
42
55
|
// 3. <input>/<select>/<textarea> without associated label
|
|
@@ -44,18 +57,36 @@ export function runAccessibility(cwd) {
|
|
|
44
57
|
const block = lines.slice(Math.max(0, i - 3), Math.min(i + 3, lines.length)).join(" ");
|
|
45
58
|
if (!/aria-label=/.test(block) && !/aria-labelledby=/.test(block) && !/<label/.test(block) && !/id=/.test(trimmed)) {
|
|
46
59
|
missingLabel++;
|
|
47
|
-
issues.push({
|
|
60
|
+
issues.push({
|
|
61
|
+
severity: "warning",
|
|
62
|
+
message: "Form control without label, aria-label, or aria-labelledby",
|
|
63
|
+
file: f.path,
|
|
64
|
+
line: i + 1,
|
|
65
|
+
rule: "form-label",
|
|
66
|
+
});
|
|
48
67
|
}
|
|
49
68
|
}
|
|
50
69
|
// 4. autoFocus
|
|
51
70
|
if (/\bautoFocus\b/.test(trimmed) || /\bautofocus\b/.test(trimmed)) {
|
|
52
71
|
autofocus++;
|
|
53
|
-
issues.push({
|
|
72
|
+
issues.push({
|
|
73
|
+
severity: "warning",
|
|
74
|
+
message: "autoFocus can disorient screen reader users",
|
|
75
|
+
file: f.path,
|
|
76
|
+
line: i + 1,
|
|
77
|
+
rule: "no-autofocus",
|
|
78
|
+
});
|
|
54
79
|
}
|
|
55
80
|
// 5. Positive tabIndex
|
|
56
81
|
if (/tabIndex=\{[1-9]/.test(trimmed) || /tabindex=["'][1-9]/.test(trimmed)) {
|
|
57
82
|
positiveTabindex++;
|
|
58
|
-
issues.push({
|
|
83
|
+
issues.push({
|
|
84
|
+
severity: "warning",
|
|
85
|
+
message: "Positive tabIndex disrupts natural tab order — use 0 or -1",
|
|
86
|
+
file: f.path,
|
|
87
|
+
line: i + 1,
|
|
88
|
+
rule: "tabindex",
|
|
89
|
+
});
|
|
59
90
|
}
|
|
60
91
|
}
|
|
61
92
|
}
|
|
@@ -27,4 +27,6 @@ export declare function runArchitecture(cwd: string): CheckResult;
|
|
|
27
27
|
export declare function generateArchSVG(details: Record<string, unknown>): string;
|
|
28
28
|
export declare function generateDSM(details: Record<string, unknown>): string;
|
|
29
29
|
export declare function generatePackageDiagram(details: Record<string, unknown>): string;
|
|
30
|
+
export declare function generateSequenceDiagram(details: Record<string, unknown>): string;
|
|
31
|
+
export declare function generateContainerDiagram(cwd: string): string;
|
|
30
32
|
export {};
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 6. Layer violations (optional: detect cross-layer imports)
|
|
10
10
|
* 7. SVG architecture diagram
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
13
14
|
import { getProductionFiles } from "../fs-utils.js";
|
|
14
15
|
import { gradeFromScore } from "../types.js";
|
|
15
16
|
export function runArchitecture(cwd) {
|
|
@@ -105,6 +106,7 @@ export function runArchitecture(cwd) {
|
|
|
105
106
|
highFanOut,
|
|
106
107
|
connectors,
|
|
107
108
|
graph: graphData,
|
|
109
|
+
containerSvg: generateContainerDiagram(cwd),
|
|
108
110
|
},
|
|
109
111
|
issues,
|
|
110
112
|
duration: Date.now() - start,
|
|
@@ -504,3 +506,155 @@ export function generatePackageDiagram(details) {
|
|
|
504
506
|
const H = maxH + gap;
|
|
505
507
|
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${svg}</svg>`;
|
|
506
508
|
}
|
|
509
|
+
// ── Sequence Diagram ─────────────────────────────────────────────────
|
|
510
|
+
// Traces the longest import chains from entry points, showing how a request
|
|
511
|
+
// flows through the system. UML-style lifelines with arrows.
|
|
512
|
+
export function generateSequenceDiagram(details) {
|
|
513
|
+
const graph = details.graph;
|
|
514
|
+
if (!graph || Object.keys(graph).length < 3)
|
|
515
|
+
return "";
|
|
516
|
+
// Find entry points (files with 0 importers that aren't utility files)
|
|
517
|
+
const entries = Object.entries(graph);
|
|
518
|
+
const entryPoints = entries
|
|
519
|
+
.filter(([path, info]) => {
|
|
520
|
+
const name = basename(path, extname(path));
|
|
521
|
+
return info.importedBy.length === 0 && ["index", "main", "cli", "App", "app", "server"].includes(name);
|
|
522
|
+
})
|
|
523
|
+
.map(([p]) => p);
|
|
524
|
+
if (entryPoints.length === 0)
|
|
525
|
+
return "";
|
|
526
|
+
// BFS from first entry point to find the longest chain (max 8 deep)
|
|
527
|
+
const entry = entryPoints[0];
|
|
528
|
+
const chain = findLongestChain(entry, graph, 8);
|
|
529
|
+
if (chain.length < 3)
|
|
530
|
+
return "";
|
|
531
|
+
// Draw sequence diagram
|
|
532
|
+
const participants = chain.map((p) => basename(p, extname(p)));
|
|
533
|
+
const lifelineSpacing = 120;
|
|
534
|
+
const W = participants.length * lifelineSpacing + 40;
|
|
535
|
+
const messageH = 36;
|
|
536
|
+
const headerH = 50;
|
|
537
|
+
const H = headerH + (chain.length - 1) * messageH + 40;
|
|
538
|
+
let svg = "";
|
|
539
|
+
// Participant boxes (lifeline headers)
|
|
540
|
+
for (let i = 0; i < participants.length; i++) {
|
|
541
|
+
const x = 20 + i * lifelineSpacing + lifelineSpacing / 2;
|
|
542
|
+
const name = participants[i];
|
|
543
|
+
const boxW = Math.max(60, name.length * 7 + 16);
|
|
544
|
+
svg += `<rect x="${x - boxW / 2}" y="8" width="${boxW}" height="22" rx="4" fill="#ffffff08" stroke="#ffffff15"/>`;
|
|
545
|
+
svg += `<text x="${x}" y="23" text-anchor="middle" fill="#9ca3af" font-size="9" font-weight="600">${name}</text>`;
|
|
546
|
+
// Lifeline (dashed vertical)
|
|
547
|
+
svg += `<line x1="${x}" y1="30" x2="${x}" y2="${H - 10}" stroke="#ffffff10" stroke-width="1" stroke-dasharray="4,3"/>`;
|
|
548
|
+
}
|
|
549
|
+
// Arrows between lifelines (imports = calls)
|
|
550
|
+
for (let i = 0; i < chain.length - 1; i++) {
|
|
551
|
+
const fromX = 20 + i * lifelineSpacing + lifelineSpacing / 2;
|
|
552
|
+
const toX = 20 + (i + 1) * lifelineSpacing + lifelineSpacing / 2;
|
|
553
|
+
const y = headerH + i * messageH;
|
|
554
|
+
// Arrow
|
|
555
|
+
svg += `<line x1="${fromX}" y1="${y}" x2="${toX - 6}" y2="${y}" stroke="#6d78d0" stroke-width="1.5" marker-end="url(#seq-arrow)"/>`;
|
|
556
|
+
// Label (the import)
|
|
557
|
+
const label = `import`;
|
|
558
|
+
svg += `<text x="${(fromX + toX) / 2}" y="${y - 6}" text-anchor="middle" fill="#6b7280" font-size="7">${label}</text>`;
|
|
559
|
+
}
|
|
560
|
+
// Arrow marker
|
|
561
|
+
const defs = `<defs><marker id="seq-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="7" markerHeight="5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#6d78d0"/></marker></defs>`;
|
|
562
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${svg}</svg>`;
|
|
563
|
+
}
|
|
564
|
+
function findLongestChain(start, graph, maxDepth) {
|
|
565
|
+
let longest = [start];
|
|
566
|
+
const visited = new Set([start]);
|
|
567
|
+
function dfs(node, path) {
|
|
568
|
+
if (path.length > longest.length)
|
|
569
|
+
longest = [...path];
|
|
570
|
+
if (path.length >= maxDepth)
|
|
571
|
+
return;
|
|
572
|
+
const info = graph[node];
|
|
573
|
+
if (!info)
|
|
574
|
+
return;
|
|
575
|
+
for (const imp of info.imports) {
|
|
576
|
+
if (!visited.has(imp) && graph[imp]) {
|
|
577
|
+
visited.add(imp);
|
|
578
|
+
dfs(imp, [...path, imp]);
|
|
579
|
+
visited.delete(imp);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
dfs(start, [start]);
|
|
584
|
+
return longest;
|
|
585
|
+
}
|
|
586
|
+
// ── Container Diagram ────────────────────────────────────────────────
|
|
587
|
+
// Auto-detects high-level system containers from config files:
|
|
588
|
+
// frontend, backend/API, database, worker, static site, etc.
|
|
589
|
+
export function generateContainerDiagram(cwd) {
|
|
590
|
+
const has = (f) => existsSync(join(cwd, f));
|
|
591
|
+
const containers = [];
|
|
592
|
+
// Detect containers from config files
|
|
593
|
+
if (has("src/App.tsx") || has("src/App.vue") || has("src/App.svelte") || has("web/src/App.tsx")) {
|
|
594
|
+
const tech = has("src/App.tsx") ? "React" : has("src/App.vue") ? "Vue" : "Svelte";
|
|
595
|
+
containers.push({ name: "Frontend", type: "webapp", tech });
|
|
596
|
+
}
|
|
597
|
+
if (has("wrangler.toml") || has("wrangler.json")) {
|
|
598
|
+
containers.push({ name: "Worker", type: "worker", tech: "Cloudflare Workers" });
|
|
599
|
+
}
|
|
600
|
+
if (has("Dockerfile") || has("server.ts") || has("src/server.ts") || has("src/index.ts")) {
|
|
601
|
+
if (!containers.some((c) => c.name === "Frontend")) {
|
|
602
|
+
containers.push({ name: "API Server", type: "api", tech: "Node.js" });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (has("prisma/schema.prisma") || has("drizzle.config.ts")) {
|
|
606
|
+
const tech = has("prisma/schema.prisma") ? "Prisma" : "Drizzle";
|
|
607
|
+
containers.push({ name: "Database", type: "db", tech });
|
|
608
|
+
}
|
|
609
|
+
if (has("firebase.json") || has(".firebaserc")) {
|
|
610
|
+
containers.push({ name: "Firebase", type: "baas", tech: "Firebase" });
|
|
611
|
+
}
|
|
612
|
+
if (has("supabase/config.toml") || has(".supabase")) {
|
|
613
|
+
containers.push({ name: "Supabase", type: "baas", tech: "Supabase" });
|
|
614
|
+
}
|
|
615
|
+
if (has("pubspec.yaml")) {
|
|
616
|
+
containers.push({ name: "Mobile App", type: "mobile", tech: "Flutter" });
|
|
617
|
+
}
|
|
618
|
+
if (has("package.json") && !containers.length) {
|
|
619
|
+
containers.push({ name: "Application", type: "app", tech: "Node.js" });
|
|
620
|
+
}
|
|
621
|
+
if (containers.length < 2)
|
|
622
|
+
return ""; // Only interesting with 2+ containers
|
|
623
|
+
// Layout: horizontal boxes with connecting lines
|
|
624
|
+
const boxW = 140;
|
|
625
|
+
const boxH = 60;
|
|
626
|
+
const gap = 30;
|
|
627
|
+
const W = containers.length * (boxW + gap) + gap;
|
|
628
|
+
const H = 120;
|
|
629
|
+
const typeColors = {
|
|
630
|
+
webapp: "#6d78d0",
|
|
631
|
+
worker: "#d97706",
|
|
632
|
+
api: "#22c55e",
|
|
633
|
+
db: "#8b5cf6",
|
|
634
|
+
baas: "#ec4899",
|
|
635
|
+
mobile: "#06b6d4",
|
|
636
|
+
app: "#6d78d0",
|
|
637
|
+
};
|
|
638
|
+
let svg = "";
|
|
639
|
+
for (let i = 0; i < containers.length; i++) {
|
|
640
|
+
const c = containers[i];
|
|
641
|
+
const x = gap + i * (boxW + gap);
|
|
642
|
+
const y = (H - boxH) / 2;
|
|
643
|
+
const color = typeColors[c.type] || "#6d78d0";
|
|
644
|
+
// Box
|
|
645
|
+
svg += `<rect x="${x}" y="${y}" width="${boxW}" height="${boxH}" rx="8" fill="${color}15" stroke="${color}50"/>`;
|
|
646
|
+
// Name
|
|
647
|
+
svg += `<text x="${x + boxW / 2}" y="${y + 24}" text-anchor="middle" fill="#e5e5e5" font-size="10" font-weight="700">${c.name}</text>`;
|
|
648
|
+
// Tech
|
|
649
|
+
svg += `<text x="${x + boxW / 2}" y="${y + 40}" text-anchor="middle" fill="#6b7280" font-size="8">[${c.tech}]</text>`;
|
|
650
|
+
// Connection to next
|
|
651
|
+
if (i < containers.length - 1) {
|
|
652
|
+
const ax = x + boxW;
|
|
653
|
+
const bx = ax + gap;
|
|
654
|
+
const ay = H / 2;
|
|
655
|
+
svg += `<line x1="${ax}" y1="${ay}" x2="${bx}" y2="${ay}" stroke="#ffffff20" stroke-width="1.5" marker-end="url(#cont-arrow)"/>`;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
const defs = `<defs><marker id="cont-arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="7" markerHeight="5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="#ffffff40"/></marker></defs>`;
|
|
659
|
+
return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;max-width:${W}px">${defs}${svg}</svg>`;
|
|
660
|
+
}
|
|
@@ -19,26 +19,28 @@ export function runBestPractices(cwd) {
|
|
|
19
19
|
let practices = 0;
|
|
20
20
|
let followed = 0;
|
|
21
21
|
const has = (f) => existsSync(join(cwd, f));
|
|
22
|
-
const read = (f) => {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const read = (f) => {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(join(cwd, f), "utf-8");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
};
|
|
28
30
|
// ── 1. CI/CD Best Practices ──
|
|
29
31
|
// Check for GitHub Actions workflows
|
|
30
32
|
const hasWorkflows = has(".github/workflows");
|
|
31
33
|
practices++;
|
|
32
34
|
if (hasWorkflows) {
|
|
33
35
|
followed++;
|
|
34
|
-
const workflows = readdirSync(join(cwd, ".github/workflows")).filter(f => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
36
|
+
const workflows = readdirSync(join(cwd, ".github/workflows")).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
35
37
|
for (const wf of workflows) {
|
|
36
38
|
const content = read(`.github/workflows/${wf}`);
|
|
37
39
|
// Check: actions pinned to SHA (not @v4, @main)
|
|
38
40
|
const actionUses = content.match(/uses:\s*([^\n]+)/g) || [];
|
|
39
|
-
const unpinned = actionUses.filter(u => !u.includes("@") || (!u.match(/@[a-f0-9]{40}/) && !u.includes("@sha")));
|
|
41
|
+
const unpinned = actionUses.filter((u) => !u.includes("@") || (!u.match(/@[a-f0-9]{40}/) && !u.includes("@sha")));
|
|
40
42
|
// Only flag third-party actions (not actions/*)
|
|
41
|
-
const unpinnedThirdParty = unpinned.filter(u => !u.includes("actions/") && !u.includes("pnpm/"));
|
|
43
|
+
const unpinnedThirdParty = unpinned.filter((u) => !u.includes("actions/") && !u.includes("pnpm/"));
|
|
42
44
|
if (unpinnedThirdParty.length > 0) {
|
|
43
45
|
issues.push({
|
|
44
46
|
severity: "info",
|
|
@@ -114,7 +116,11 @@ export function runBestPractices(cwd) {
|
|
|
114
116
|
followed++;
|
|
115
117
|
}
|
|
116
118
|
else {
|
|
117
|
-
issues.push({
|
|
119
|
+
issues.push({
|
|
120
|
+
severity: "info",
|
|
121
|
+
message: "No engine constraints (engines in package.json or .nvmrc) — Node version not pinned",
|
|
122
|
+
rule: "pin-node-version",
|
|
123
|
+
});
|
|
118
124
|
}
|
|
119
125
|
// npm provenance / package.json has repository field
|
|
120
126
|
if (pkg) {
|
|
@@ -123,7 +129,11 @@ export function runBestPractices(cwd) {
|
|
|
123
129
|
followed++;
|
|
124
130
|
}
|
|
125
131
|
else {
|
|
126
|
-
issues.push({
|
|
132
|
+
issues.push({
|
|
133
|
+
severity: "info",
|
|
134
|
+
message: "package.json missing repository field — provenance attestation won't link to source",
|
|
135
|
+
rule: "repository-field",
|
|
136
|
+
});
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
139
|
// ── 3. Repo Hygiene ──
|
|
@@ -149,14 +159,22 @@ export function runBestPractices(cwd) {
|
|
|
149
159
|
followed++;
|
|
150
160
|
}
|
|
151
161
|
else {
|
|
152
|
-
issues.push({
|
|
162
|
+
issues.push({
|
|
163
|
+
severity: "info",
|
|
164
|
+
message: "No CONTRIBUTING.md — onboarding is harder for new contributors",
|
|
165
|
+
rule: "contributing-guide",
|
|
166
|
+
});
|
|
153
167
|
}
|
|
154
168
|
// ── 4. Developer Experience ──
|
|
155
169
|
// .env.example
|
|
156
170
|
practices++;
|
|
157
171
|
const hasEnvFiles = has(".env") || has(".env.local") || has(".env.development");
|
|
158
172
|
if (hasEnvFiles && !has(".env.example")) {
|
|
159
|
-
issues.push({
|
|
173
|
+
issues.push({
|
|
174
|
+
severity: "info",
|
|
175
|
+
message: "Has .env files but no .env.example — new developers won't know what vars are needed",
|
|
176
|
+
rule: "env-example",
|
|
177
|
+
});
|
|
160
178
|
}
|
|
161
179
|
else {
|
|
162
180
|
followed++;
|
|
@@ -168,7 +186,11 @@ export function runBestPractices(cwd) {
|
|
|
168
186
|
followed++;
|
|
169
187
|
}
|
|
170
188
|
else {
|
|
171
|
-
issues.push({
|
|
189
|
+
issues.push({
|
|
190
|
+
severity: "info",
|
|
191
|
+
message: "No pre-commit hooks (husky/lefthook) — lint/format not enforced before commit",
|
|
192
|
+
rule: "pre-commit-hooks",
|
|
193
|
+
});
|
|
172
194
|
}
|
|
173
195
|
// Renovate/Dependabot for automated dependency updates
|
|
174
196
|
practices++;
|
|
@@ -176,16 +198,29 @@ export function runBestPractices(cwd) {
|
|
|
176
198
|
followed++;
|
|
177
199
|
}
|
|
178
200
|
else {
|
|
179
|
-
issues.push({
|
|
201
|
+
issues.push({
|
|
202
|
+
severity: "info",
|
|
203
|
+
message: "No Dependabot/Renovate — dependency updates are manual and often forgotten",
|
|
204
|
+
rule: "automated-deps",
|
|
205
|
+
});
|
|
180
206
|
}
|
|
181
207
|
// ── 5. Code Quality Tooling ──
|
|
182
208
|
// Linter configured
|
|
183
209
|
practices++;
|
|
184
|
-
if (has("biome.json") ||
|
|
210
|
+
if (has("biome.json") ||
|
|
211
|
+
has(".eslintrc.json") ||
|
|
212
|
+
has(".eslintrc.js") ||
|
|
213
|
+
has("eslint.config.js") ||
|
|
214
|
+
has("eslint.config.ts") ||
|
|
215
|
+
has("analysis_options.yaml")) {
|
|
185
216
|
followed++;
|
|
186
217
|
}
|
|
187
218
|
else {
|
|
188
|
-
issues.push({
|
|
219
|
+
issues.push({
|
|
220
|
+
severity: "warning",
|
|
221
|
+
message: "No linter config (ESLint/Biome/dart analyze) — code style not enforced",
|
|
222
|
+
rule: "linter-config",
|
|
223
|
+
});
|
|
189
224
|
}
|
|
190
225
|
// Formatter configured
|
|
191
226
|
practices++;
|
|
@@ -193,7 +228,11 @@ export function runBestPractices(cwd) {
|
|
|
193
228
|
followed++;
|
|
194
229
|
}
|
|
195
230
|
else {
|
|
196
|
-
issues.push({
|
|
231
|
+
issues.push({
|
|
232
|
+
severity: "info",
|
|
233
|
+
message: "No formatter config (Prettier/Biome/.editorconfig) — inconsistent code formatting",
|
|
234
|
+
rule: "formatter-config",
|
|
235
|
+
});
|
|
197
236
|
}
|
|
198
237
|
// TypeScript strict mode
|
|
199
238
|
practices++;
|
|
@@ -202,7 +241,11 @@ export function runBestPractices(cwd) {
|
|
|
202
241
|
followed++;
|
|
203
242
|
}
|
|
204
243
|
else {
|
|
205
|
-
issues.push({
|
|
244
|
+
issues.push({
|
|
245
|
+
severity: "info",
|
|
246
|
+
message: "TypeScript strict mode not enabled — allows implicit any and null errors",
|
|
247
|
+
rule: "ts-strict-mode",
|
|
248
|
+
});
|
|
206
249
|
}
|
|
207
250
|
// ── 6. Testing Best Practices ──
|
|
208
251
|
// Test script exists
|
|
@@ -219,7 +262,11 @@ export function runBestPractices(cwd) {
|
|
|
219
262
|
followed++;
|
|
220
263
|
}
|
|
221
264
|
else {
|
|
222
|
-
issues.push({
|
|
265
|
+
issues.push({
|
|
266
|
+
severity: "info",
|
|
267
|
+
message: "No test coverage configuration — coverage thresholds not enforced",
|
|
268
|
+
rule: "coverage-config",
|
|
269
|
+
});
|
|
223
270
|
}
|
|
224
271
|
// ── 7. Docker / Deployment ──
|
|
225
272
|
// Dockerfile best practices (if Docker is used)
|
|
@@ -230,7 +277,11 @@ export function runBestPractices(cwd) {
|
|
|
230
277
|
followed++;
|
|
231
278
|
}
|
|
232
279
|
else if (dockerfile.includes(":latest")) {
|
|
233
|
-
issues.push({
|
|
280
|
+
issues.push({
|
|
281
|
+
severity: "warning",
|
|
282
|
+
message: "Dockerfile uses :latest tag — pin to a specific version for reproducible builds",
|
|
283
|
+
rule: "docker-pin-version",
|
|
284
|
+
});
|
|
234
285
|
}
|
|
235
286
|
else {
|
|
236
287
|
followed++;
|
|
@@ -242,7 +293,11 @@ export function runBestPractices(cwd) {
|
|
|
242
293
|
followed++;
|
|
243
294
|
}
|
|
244
295
|
else if (dockerfile.length > 100) {
|
|
245
|
-
issues.push({
|
|
296
|
+
issues.push({
|
|
297
|
+
severity: "info",
|
|
298
|
+
message: "Dockerfile is single-stage — consider multi-stage to reduce image size",
|
|
299
|
+
rule: "docker-multi-stage",
|
|
300
|
+
});
|
|
246
301
|
}
|
|
247
302
|
else {
|
|
248
303
|
followed++;
|
|
@@ -253,7 +308,11 @@ export function runBestPractices(cwd) {
|
|
|
253
308
|
followed++;
|
|
254
309
|
}
|
|
255
310
|
else {
|
|
256
|
-
issues.push({
|
|
311
|
+
issues.push({
|
|
312
|
+
severity: "info",
|
|
313
|
+
message: "No .dockerignore — node_modules and build artifacts will bloat Docker image",
|
|
314
|
+
rule: "dockerignore",
|
|
315
|
+
});
|
|
257
316
|
}
|
|
258
317
|
}
|
|
259
318
|
// ── 8. Git Practices ──
|
|
@@ -264,7 +323,11 @@ export function runBestPractices(cwd) {
|
|
|
264
323
|
followed++;
|
|
265
324
|
}
|
|
266
325
|
else if (gitignore) {
|
|
267
|
-
issues.push({
|
|
326
|
+
issues.push({
|
|
327
|
+
severity: "info",
|
|
328
|
+
message: ".gitignore exists but may be incomplete — ensure build artifacts are excluded",
|
|
329
|
+
rule: "gitignore-complete",
|
|
330
|
+
});
|
|
268
331
|
}
|
|
269
332
|
else {
|
|
270
333
|
followed++; // no gitignore = handled by structure check
|
|
@@ -275,16 +338,27 @@ export function runBestPractices(cwd) {
|
|
|
275
338
|
followed++;
|
|
276
339
|
}
|
|
277
340
|
else {
|
|
278
|
-
issues.push({
|
|
341
|
+
issues.push({
|
|
342
|
+
severity: "info",
|
|
343
|
+
message: "No commit convention enforcement (commitlint/changesets) — changelog generation is manual",
|
|
344
|
+
rule: "conventional-commits",
|
|
345
|
+
});
|
|
279
346
|
}
|
|
280
347
|
// ── 9. Monitoring & Observability ──
|
|
281
|
-
// Error tracking (Sentry, Bugsnag, etc.)
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
348
|
+
// Error tracking (Sentry, Bugsnag, etc.) — only for apps/servers, not CLI tools
|
|
349
|
+
const isApp = deps.react || deps.vue || deps.svelte || deps.express || deps.fastify || deps.hono || deps.next || deps.nuxt;
|
|
350
|
+
if (isApp) {
|
|
351
|
+
practices++;
|
|
352
|
+
if (deps["@sentry/node"] || deps["@sentry/react"] || deps["@sentry/browser"] || deps.bugsnag || deps["@bugsnag/js"]) {
|
|
353
|
+
followed++;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
issues.push({
|
|
357
|
+
severity: "info",
|
|
358
|
+
message: "No error tracking (Sentry/Bugsnag) — production errors may go unnoticed",
|
|
359
|
+
rule: "error-tracking",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
288
362
|
}
|
|
289
363
|
// ── 10. API & Configuration ──
|
|
290
364
|
// Environment validation (zod, joi, envalid)
|
|
@@ -295,7 +369,11 @@ export function runBestPractices(cwd) {
|
|
|
295
369
|
else {
|
|
296
370
|
const hasEnvUsage = pkg.includes("process.env") || read("src/index.ts").includes("process.env") || read("src/main.ts").includes("process.env");
|
|
297
371
|
if (hasEnvUsage) {
|
|
298
|
-
issues.push({
|
|
372
|
+
issues.push({
|
|
373
|
+
severity: "info",
|
|
374
|
+
message: "Uses env vars but no validation library (zod/envalid) — missing vars crash at runtime",
|
|
375
|
+
rule: "env-validation",
|
|
376
|
+
});
|
|
299
377
|
}
|
|
300
378
|
else {
|
|
301
379
|
followed++;
|
|
@@ -20,7 +20,9 @@ export function runDependencies(cwd, stack) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
catch {
|
|
23
|
+
catch {
|
|
24
|
+
/* parse failed */
|
|
25
|
+
}
|
|
24
26
|
if (majorOutdated > 0)
|
|
25
27
|
issues.push({ severity: "warning", message: `${majorOutdated} packages behind by a major version` });
|
|
26
28
|
const score = Math.max(0, Math.min(100, 100 - majorOutdated));
|
|
@@ -28,7 +28,14 @@ export function runDuplication(cwd) {
|
|
|
28
28
|
const block = lines
|
|
29
29
|
.slice(i, i + MIN_LINES)
|
|
30
30
|
.map((l) => l.trim())
|
|
31
|
-
.filter((l) => l.length > 0 &&
|
|
31
|
+
.filter((l) => l.length > 0 &&
|
|
32
|
+
!l.startsWith("//") &&
|
|
33
|
+
!l.startsWith("*") &&
|
|
34
|
+
!l.startsWith("import ") &&
|
|
35
|
+
!l.startsWith("export {") &&
|
|
36
|
+
l !== "{" &&
|
|
37
|
+
l !== "}" &&
|
|
38
|
+
l !== "");
|
|
32
39
|
if (block.length < MIN_LINES - 2)
|
|
33
40
|
continue; // too many empty/trivial lines
|
|
34
41
|
const key = block.join("\n");
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/** Error handling check — detects poor error handling patterns. */
|
|
2
|
-
import { gradeFromScore } from "../types.js";
|
|
3
2
|
import { getProductionFiles } from "../fs-utils.js";
|
|
3
|
+
import { gradeFromScore } from "../types.js";
|
|
4
4
|
export function runErrorHandling(cwd, stack) {
|
|
5
5
|
const start = Date.now();
|
|
6
6
|
const issues = [];
|
|
7
7
|
const files = getProductionFiles(cwd);
|
|
8
8
|
if (files.length === 0) {
|
|
9
|
-
return {
|
|
9
|
+
return {
|
|
10
|
+
name: "error-handling",
|
|
11
|
+
score: 100,
|
|
12
|
+
grade: "A",
|
|
13
|
+
details: { skipped: true, reason: "no source files" },
|
|
14
|
+
issues: [],
|
|
15
|
+
duration: Date.now() - start,
|
|
16
|
+
};
|
|
10
17
|
}
|
|
11
18
|
let emptyCatch = 0;
|
|
12
19
|
let throwString = 0;
|
|
@@ -20,7 +27,13 @@ export function runErrorHandling(cwd, stack) {
|
|
|
20
27
|
}
|
|
21
28
|
if (/\bthrow\s+["'`]/.test(line)) {
|
|
22
29
|
throwString++;
|
|
23
|
-
issues.push({
|
|
30
|
+
issues.push({
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: "throw string literal — use throw new Error()",
|
|
33
|
+
file: f.path,
|
|
34
|
+
line: i + 1,
|
|
35
|
+
rule: "throw-string",
|
|
36
|
+
});
|
|
24
37
|
}
|
|
25
38
|
}
|
|
26
39
|
}
|
package/dist/runners/react.js
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
/** React-specific checks — hooks rules, conditional hooks, missing keys, prop spreading. */
|
|
2
|
-
import { gradeFromScore } from "../types.js";
|
|
3
2
|
import { getProductionFiles } from "../fs-utils.js";
|
|
3
|
+
import { gradeFromScore } from "../types.js";
|
|
4
4
|
export function runReact(cwd, stack) {
|
|
5
5
|
const start = Date.now();
|
|
6
6
|
if (stack.framework !== "react") {
|
|
7
|
-
return {
|
|
7
|
+
return {
|
|
8
|
+
name: "react",
|
|
9
|
+
score: 100,
|
|
10
|
+
grade: "A",
|
|
11
|
+
details: { skipped: true, reason: "not a React project" },
|
|
12
|
+
issues: [],
|
|
13
|
+
duration: Date.now() - start,
|
|
14
|
+
};
|
|
8
15
|
}
|
|
9
16
|
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
10
17
|
if (files.length === 0) {
|
|
11
|
-
return {
|
|
18
|
+
return {
|
|
19
|
+
name: "react",
|
|
20
|
+
score: 100,
|
|
21
|
+
grade: "A",
|
|
22
|
+
details: { skipped: true, reason: "no JSX/TSX files" },
|
|
23
|
+
issues: [],
|
|
24
|
+
duration: Date.now() - start,
|
|
25
|
+
};
|
|
12
26
|
}
|
|
13
27
|
const issues = [];
|
|
14
28
|
let conditionalHooks = 0;
|
|
@@ -41,7 +55,13 @@ export function runReact(cwd, stack) {
|
|
|
41
55
|
// 1. Hooks called inside conditionals
|
|
42
56
|
if (condBraceDepth > 0 && /\buse[A-Z]\w*\s*\(/.test(trimmed) && !/\/\//.test(trimmed.split("use")[0])) {
|
|
43
57
|
conditionalHooks++;
|
|
44
|
-
issues.push({
|
|
58
|
+
issues.push({
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: "Hook called inside conditional — violates Rules of Hooks",
|
|
61
|
+
file: f.path,
|
|
62
|
+
line: i + 1,
|
|
63
|
+
rule: "conditional-hook",
|
|
64
|
+
});
|
|
45
65
|
}
|
|
46
66
|
// 2. Missing key in .map() returning JSX
|
|
47
67
|
if (/\.map\s*\(/.test(trimmed)) {
|
|
@@ -55,12 +75,24 @@ export function runReact(cwd, stack) {
|
|
|
55
75
|
// 3. index as key
|
|
56
76
|
if (/key=\{(?:i|idx|index)\}/.test(trimmed) || /key=\{.*(?:, *(?:i|idx|index)\))/.test(trimmed)) {
|
|
57
77
|
indexKeys++;
|
|
58
|
-
issues.push({
|
|
78
|
+
issues.push({
|
|
79
|
+
severity: "warning",
|
|
80
|
+
message: "Using index as key — can cause rendering bugs with reorderable lists",
|
|
81
|
+
file: f.path,
|
|
82
|
+
line: i + 1,
|
|
83
|
+
rule: "index-key",
|
|
84
|
+
});
|
|
59
85
|
}
|
|
60
86
|
// 4. Prop spreading ({...props} on DOM elements)
|
|
61
87
|
if (/\{\.\.\.(?!children)\w+\}/.test(trimmed) && /<[a-z]/.test(trimmed)) {
|
|
62
88
|
propSpreading++;
|
|
63
|
-
issues.push({
|
|
89
|
+
issues.push({
|
|
90
|
+
severity: "warning",
|
|
91
|
+
message: "Spreading props onto DOM element — can pass unexpected attributes",
|
|
92
|
+
file: f.path,
|
|
93
|
+
line: i + 1,
|
|
94
|
+
rule: "prop-spreading",
|
|
95
|
+
});
|
|
64
96
|
}
|
|
65
97
|
// 5. Inline arrow functions in JSX event handlers (performance)
|
|
66
98
|
if (/on[A-Z]\w*=\{(?:\(\) =>|function)/.test(trimmed)) {
|
|
@@ -70,7 +102,11 @@ export function runReact(cwd, stack) {
|
|
|
70
102
|
}
|
|
71
103
|
// Only warn about inline handlers if there are many
|
|
72
104
|
if (inlineHandlers > 15) {
|
|
73
|
-
issues.push({
|
|
105
|
+
issues.push({
|
|
106
|
+
severity: "warning",
|
|
107
|
+
message: `${inlineHandlers} inline arrow functions in JSX handlers — extract to named functions for readability`,
|
|
108
|
+
rule: "inline-handlers",
|
|
109
|
+
});
|
|
74
110
|
}
|
|
75
111
|
const errors = issues.filter((i) => i.severity === "error").length;
|
|
76
112
|
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
@@ -42,9 +42,11 @@ export function runTypeSafety(cwd, isDart = false) {
|
|
|
42
42
|
const trimmed = line.trim();
|
|
43
43
|
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
44
44
|
continue;
|
|
45
|
-
// Skip pattern definition lines (prevents false positives
|
|
45
|
+
// Skip pattern definition lines and string-heavy lines (prevents false positives)
|
|
46
46
|
if (/\bpattern\s*:|name:\s*["']|message:\s*["']|description:\s*["']|risk:\s*["']|recommendation:\s*["']/.test(trimmed))
|
|
47
47
|
continue;
|
|
48
|
+
if (/^\s*["'`].*["'`][,;]?\s*$/.test(line))
|
|
49
|
+
continue;
|
|
48
50
|
for (const p of PATTERNS) {
|
|
49
51
|
const matches = line.match(p.pattern);
|
|
50
52
|
if (matches) {
|