@vibecodeqa/cli 0.21.0 → 0.23.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 +126 -106
- 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 +187 -40
- 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/standards.js +4 -1
- package/dist/runners/type-safety.js +3 -1
- package/package.json +1 -1
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
|
+
}
|
|
@@ -13,32 +13,23 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { readDeps } from "../fs-utils.js";
|
|
15
15
|
import { gradeFromScore } from "../types.js";
|
|
16
|
-
|
|
17
|
-
const start = Date.now();
|
|
16
|
+
function checkCICD(cwd, has, read) {
|
|
18
17
|
const issues = [];
|
|
19
18
|
let practices = 0;
|
|
20
19
|
let followed = 0;
|
|
21
|
-
const has = (f) => existsSync(join(cwd, f));
|
|
22
|
-
const read = (f) => { try {
|
|
23
|
-
return readFileSync(join(cwd, f), "utf-8");
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return "";
|
|
27
|
-
} };
|
|
28
|
-
// ── 1. CI/CD Best Practices ──
|
|
29
20
|
// Check for GitHub Actions workflows
|
|
30
21
|
const hasWorkflows = has(".github/workflows");
|
|
31
22
|
practices++;
|
|
32
23
|
if (hasWorkflows) {
|
|
33
24
|
followed++;
|
|
34
|
-
const workflows = readdirSync(join(cwd, ".github/workflows")).filter(f => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
25
|
+
const workflows = readdirSync(join(cwd, ".github/workflows")).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
|
|
35
26
|
for (const wf of workflows) {
|
|
36
27
|
const content = read(`.github/workflows/${wf}`);
|
|
37
28
|
// Check: actions pinned to SHA (not @v4, @main)
|
|
38
29
|
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")));
|
|
30
|
+
const unpinned = actionUses.filter((u) => !u.includes("@") || (!u.match(/@[a-f0-9]{40}/) && !u.includes("@sha")));
|
|
40
31
|
// Only flag third-party actions (not actions/*)
|
|
41
|
-
const unpinnedThirdParty = unpinned.filter(u => !u.includes("actions/") && !u.includes("pnpm/"));
|
|
32
|
+
const unpinnedThirdParty = unpinned.filter((u) => !u.includes("actions/") && !u.includes("pnpm/"));
|
|
42
33
|
if (unpinnedThirdParty.length > 0) {
|
|
43
34
|
issues.push({
|
|
44
35
|
severity: "info",
|
|
@@ -97,7 +88,12 @@ export function runBestPractices(cwd) {
|
|
|
97
88
|
rule: "no-ci",
|
|
98
89
|
});
|
|
99
90
|
}
|
|
100
|
-
|
|
91
|
+
return { practices, followed, issues };
|
|
92
|
+
}
|
|
93
|
+
function checkSupplyChain(has, read) {
|
|
94
|
+
const issues = [];
|
|
95
|
+
let practices = 0;
|
|
96
|
+
let followed = 0;
|
|
101
97
|
// Lockfile committed
|
|
102
98
|
practices++;
|
|
103
99
|
const hasLockfile = has("pnpm-lock.yaml") || has("package-lock.json") || has("yarn.lock") || has("bun.lockb") || has("pubspec.lock");
|
|
@@ -114,7 +110,11 @@ export function runBestPractices(cwd) {
|
|
|
114
110
|
followed++;
|
|
115
111
|
}
|
|
116
112
|
else {
|
|
117
|
-
issues.push({
|
|
113
|
+
issues.push({
|
|
114
|
+
severity: "info",
|
|
115
|
+
message: "No engine constraints (engines in package.json or .nvmrc) — Node version not pinned",
|
|
116
|
+
rule: "pin-node-version",
|
|
117
|
+
});
|
|
118
118
|
}
|
|
119
119
|
// npm provenance / package.json has repository field
|
|
120
120
|
if (pkg) {
|
|
@@ -123,10 +123,19 @@ export function runBestPractices(cwd) {
|
|
|
123
123
|
followed++;
|
|
124
124
|
}
|
|
125
125
|
else {
|
|
126
|
-
issues.push({
|
|
126
|
+
issues.push({
|
|
127
|
+
severity: "info",
|
|
128
|
+
message: "package.json missing repository field — provenance attestation won't link to source",
|
|
129
|
+
rule: "repository-field",
|
|
130
|
+
});
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
|
-
|
|
133
|
+
return { practices, followed, issues };
|
|
134
|
+
}
|
|
135
|
+
function checkRepoHygiene(has) {
|
|
136
|
+
const issues = [];
|
|
137
|
+
let practices = 0;
|
|
138
|
+
let followed = 0;
|
|
130
139
|
// SECURITY.md or security policy
|
|
131
140
|
practices++;
|
|
132
141
|
if (has("SECURITY.md") || has(".github/SECURITY.md")) {
|
|
@@ -149,14 +158,27 @@ export function runBestPractices(cwd) {
|
|
|
149
158
|
followed++;
|
|
150
159
|
}
|
|
151
160
|
else {
|
|
152
|
-
issues.push({
|
|
161
|
+
issues.push({
|
|
162
|
+
severity: "info",
|
|
163
|
+
message: "No CONTRIBUTING.md — onboarding is harder for new contributors",
|
|
164
|
+
rule: "contributing-guide",
|
|
165
|
+
});
|
|
153
166
|
}
|
|
154
|
-
|
|
167
|
+
return { practices, followed, issues };
|
|
168
|
+
}
|
|
169
|
+
function checkDevExperience(cwd, has) {
|
|
170
|
+
const issues = [];
|
|
171
|
+
let practices = 0;
|
|
172
|
+
let followed = 0;
|
|
155
173
|
// .env.example
|
|
156
174
|
practices++;
|
|
157
175
|
const hasEnvFiles = has(".env") || has(".env.local") || has(".env.development");
|
|
158
176
|
if (hasEnvFiles && !has(".env.example")) {
|
|
159
|
-
issues.push({
|
|
177
|
+
issues.push({
|
|
178
|
+
severity: "info",
|
|
179
|
+
message: "Has .env files but no .env.example — new developers won't know what vars are needed",
|
|
180
|
+
rule: "env-example",
|
|
181
|
+
});
|
|
160
182
|
}
|
|
161
183
|
else {
|
|
162
184
|
followed++;
|
|
@@ -168,7 +190,11 @@ export function runBestPractices(cwd) {
|
|
|
168
190
|
followed++;
|
|
169
191
|
}
|
|
170
192
|
else {
|
|
171
|
-
issues.push({
|
|
193
|
+
issues.push({
|
|
194
|
+
severity: "info",
|
|
195
|
+
message: "No pre-commit hooks (husky/lefthook) — lint/format not enforced before commit",
|
|
196
|
+
rule: "pre-commit-hooks",
|
|
197
|
+
});
|
|
172
198
|
}
|
|
173
199
|
// Renovate/Dependabot for automated dependency updates
|
|
174
200
|
practices++;
|
|
@@ -176,16 +202,34 @@ export function runBestPractices(cwd) {
|
|
|
176
202
|
followed++;
|
|
177
203
|
}
|
|
178
204
|
else {
|
|
179
|
-
issues.push({
|
|
205
|
+
issues.push({
|
|
206
|
+
severity: "info",
|
|
207
|
+
message: "No Dependabot/Renovate — dependency updates are manual and often forgotten",
|
|
208
|
+
rule: "automated-deps",
|
|
209
|
+
});
|
|
180
210
|
}
|
|
181
|
-
|
|
211
|
+
return { practices, followed, issues };
|
|
212
|
+
}
|
|
213
|
+
function checkCodeQualityTooling(has, read) {
|
|
214
|
+
const issues = [];
|
|
215
|
+
let practices = 0;
|
|
216
|
+
let followed = 0;
|
|
182
217
|
// Linter configured
|
|
183
218
|
practices++;
|
|
184
|
-
if (has("biome.json") ||
|
|
219
|
+
if (has("biome.json") ||
|
|
220
|
+
has(".eslintrc.json") ||
|
|
221
|
+
has(".eslintrc.js") ||
|
|
222
|
+
has("eslint.config.js") ||
|
|
223
|
+
has("eslint.config.ts") ||
|
|
224
|
+
has("analysis_options.yaml")) {
|
|
185
225
|
followed++;
|
|
186
226
|
}
|
|
187
227
|
else {
|
|
188
|
-
issues.push({
|
|
228
|
+
issues.push({
|
|
229
|
+
severity: "warning",
|
|
230
|
+
message: "No linter config (ESLint/Biome/dart analyze) — code style not enforced",
|
|
231
|
+
rule: "linter-config",
|
|
232
|
+
});
|
|
189
233
|
}
|
|
190
234
|
// Formatter configured
|
|
191
235
|
practices++;
|
|
@@ -193,7 +237,11 @@ export function runBestPractices(cwd) {
|
|
|
193
237
|
followed++;
|
|
194
238
|
}
|
|
195
239
|
else {
|
|
196
|
-
issues.push({
|
|
240
|
+
issues.push({
|
|
241
|
+
severity: "info",
|
|
242
|
+
message: "No formatter config (Prettier/Biome/.editorconfig) — inconsistent code formatting",
|
|
243
|
+
rule: "formatter-config",
|
|
244
|
+
});
|
|
197
245
|
}
|
|
198
246
|
// TypeScript strict mode
|
|
199
247
|
practices++;
|
|
@@ -202,9 +250,19 @@ export function runBestPractices(cwd) {
|
|
|
202
250
|
followed++;
|
|
203
251
|
}
|
|
204
252
|
else {
|
|
205
|
-
issues.push({
|
|
253
|
+
issues.push({
|
|
254
|
+
severity: "info",
|
|
255
|
+
message: "TypeScript strict mode not enabled — allows implicit any and null errors",
|
|
256
|
+
rule: "ts-strict-mode",
|
|
257
|
+
});
|
|
206
258
|
}
|
|
207
|
-
|
|
259
|
+
return { practices, followed, issues };
|
|
260
|
+
}
|
|
261
|
+
function checkTesting(has, read) {
|
|
262
|
+
const issues = [];
|
|
263
|
+
let practices = 0;
|
|
264
|
+
let followed = 0;
|
|
265
|
+
const pkg = read("package.json");
|
|
208
266
|
// Test script exists
|
|
209
267
|
practices++;
|
|
210
268
|
if (pkg.includes('"test"') || has("pubspec.yaml")) {
|
|
@@ -219,9 +277,18 @@ export function runBestPractices(cwd) {
|
|
|
219
277
|
followed++;
|
|
220
278
|
}
|
|
221
279
|
else {
|
|
222
|
-
issues.push({
|
|
280
|
+
issues.push({
|
|
281
|
+
severity: "info",
|
|
282
|
+
message: "No test coverage configuration — coverage thresholds not enforced",
|
|
283
|
+
rule: "coverage-config",
|
|
284
|
+
});
|
|
223
285
|
}
|
|
224
|
-
|
|
286
|
+
return { practices, followed, issues };
|
|
287
|
+
}
|
|
288
|
+
function checkDocker(has, read) {
|
|
289
|
+
const issues = [];
|
|
290
|
+
let practices = 0;
|
|
291
|
+
let followed = 0;
|
|
225
292
|
// Dockerfile best practices (if Docker is used)
|
|
226
293
|
if (has("Dockerfile") || has("docker-compose.yml") || has("docker-compose.yaml")) {
|
|
227
294
|
practices++;
|
|
@@ -230,7 +297,11 @@ export function runBestPractices(cwd) {
|
|
|
230
297
|
followed++;
|
|
231
298
|
}
|
|
232
299
|
else if (dockerfile.includes(":latest")) {
|
|
233
|
-
issues.push({
|
|
300
|
+
issues.push({
|
|
301
|
+
severity: "warning",
|
|
302
|
+
message: "Dockerfile uses :latest tag — pin to a specific version for reproducible builds",
|
|
303
|
+
rule: "docker-pin-version",
|
|
304
|
+
});
|
|
234
305
|
}
|
|
235
306
|
else {
|
|
236
307
|
followed++;
|
|
@@ -242,7 +313,11 @@ export function runBestPractices(cwd) {
|
|
|
242
313
|
followed++;
|
|
243
314
|
}
|
|
244
315
|
else if (dockerfile.length > 100) {
|
|
245
|
-
issues.push({
|
|
316
|
+
issues.push({
|
|
317
|
+
severity: "info",
|
|
318
|
+
message: "Dockerfile is single-stage — consider multi-stage to reduce image size",
|
|
319
|
+
rule: "docker-multi-stage",
|
|
320
|
+
});
|
|
246
321
|
}
|
|
247
322
|
else {
|
|
248
323
|
followed++;
|
|
@@ -253,10 +328,20 @@ export function runBestPractices(cwd) {
|
|
|
253
328
|
followed++;
|
|
254
329
|
}
|
|
255
330
|
else {
|
|
256
|
-
issues.push({
|
|
331
|
+
issues.push({
|
|
332
|
+
severity: "info",
|
|
333
|
+
message: "No .dockerignore — node_modules and build artifacts will bloat Docker image",
|
|
334
|
+
rule: "dockerignore",
|
|
335
|
+
});
|
|
257
336
|
}
|
|
258
337
|
}
|
|
259
|
-
|
|
338
|
+
return { practices, followed, issues };
|
|
339
|
+
}
|
|
340
|
+
function checkGitPractices(cwd, has, read) {
|
|
341
|
+
const issues = [];
|
|
342
|
+
let practices = 0;
|
|
343
|
+
let followed = 0;
|
|
344
|
+
const deps = readDeps(cwd);
|
|
260
345
|
// .gitignore is comprehensive
|
|
261
346
|
practices++;
|
|
262
347
|
const gitignore = read(".gitignore");
|
|
@@ -264,7 +349,11 @@ export function runBestPractices(cwd) {
|
|
|
264
349
|
followed++;
|
|
265
350
|
}
|
|
266
351
|
else if (gitignore) {
|
|
267
|
-
issues.push({
|
|
352
|
+
issues.push({
|
|
353
|
+
severity: "info",
|
|
354
|
+
message: ".gitignore exists but may be incomplete — ensure build artifacts are excluded",
|
|
355
|
+
rule: "gitignore-complete",
|
|
356
|
+
});
|
|
268
357
|
}
|
|
269
358
|
else {
|
|
270
359
|
followed++; // no gitignore = handled by structure check
|
|
@@ -275,9 +364,19 @@ export function runBestPractices(cwd) {
|
|
|
275
364
|
followed++;
|
|
276
365
|
}
|
|
277
366
|
else {
|
|
278
|
-
issues.push({
|
|
367
|
+
issues.push({
|
|
368
|
+
severity: "info",
|
|
369
|
+
message: "No commit convention enforcement (commitlint/changesets) — changelog generation is manual",
|
|
370
|
+
rule: "conventional-commits",
|
|
371
|
+
});
|
|
279
372
|
}
|
|
280
|
-
|
|
373
|
+
return { practices, followed, issues };
|
|
374
|
+
}
|
|
375
|
+
function checkMonitoring(cwd) {
|
|
376
|
+
const issues = [];
|
|
377
|
+
let practices = 0;
|
|
378
|
+
let followed = 0;
|
|
379
|
+
const deps = readDeps(cwd);
|
|
281
380
|
// Error tracking (Sentry, Bugsnag, etc.) — only for apps/servers, not CLI tools
|
|
282
381
|
const isApp = deps.react || deps.vue || deps.svelte || deps.express || deps.fastify || deps.hono || deps.next || deps.nuxt;
|
|
283
382
|
if (isApp) {
|
|
@@ -286,10 +385,21 @@ export function runBestPractices(cwd) {
|
|
|
286
385
|
followed++;
|
|
287
386
|
}
|
|
288
387
|
else {
|
|
289
|
-
issues.push({
|
|
388
|
+
issues.push({
|
|
389
|
+
severity: "info",
|
|
390
|
+
message: "No error tracking (Sentry/Bugsnag) — production errors may go unnoticed",
|
|
391
|
+
rule: "error-tracking",
|
|
392
|
+
});
|
|
290
393
|
}
|
|
291
394
|
}
|
|
292
|
-
|
|
395
|
+
return { practices, followed, issues };
|
|
396
|
+
}
|
|
397
|
+
function checkAPIConfig(cwd, read) {
|
|
398
|
+
const issues = [];
|
|
399
|
+
let practices = 0;
|
|
400
|
+
let followed = 0;
|
|
401
|
+
const deps = readDeps(cwd);
|
|
402
|
+
const pkg = read("package.json");
|
|
293
403
|
// Environment validation (zod, joi, envalid)
|
|
294
404
|
practices++;
|
|
295
405
|
if (deps.zod || deps.joi || deps.envalid || deps["@t3-oss/env-core"] || deps["@t3-oss/env-nextjs"]) {
|
|
@@ -298,12 +408,49 @@ export function runBestPractices(cwd) {
|
|
|
298
408
|
else {
|
|
299
409
|
const hasEnvUsage = pkg.includes("process.env") || read("src/index.ts").includes("process.env") || read("src/main.ts").includes("process.env");
|
|
300
410
|
if (hasEnvUsage) {
|
|
301
|
-
issues.push({
|
|
411
|
+
issues.push({
|
|
412
|
+
severity: "info",
|
|
413
|
+
message: "Uses env vars but no validation library (zod/envalid) — missing vars crash at runtime",
|
|
414
|
+
rule: "env-validation",
|
|
415
|
+
});
|
|
302
416
|
}
|
|
303
417
|
else {
|
|
304
418
|
followed++;
|
|
305
419
|
}
|
|
306
420
|
}
|
|
421
|
+
return { practices, followed, issues };
|
|
422
|
+
}
|
|
423
|
+
export function runBestPractices(cwd) {
|
|
424
|
+
const start = Date.now();
|
|
425
|
+
const has = (f) => existsSync(join(cwd, f));
|
|
426
|
+
const read = (f) => {
|
|
427
|
+
try {
|
|
428
|
+
return readFileSync(join(cwd, f), "utf-8");
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
return "";
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const categories = [
|
|
435
|
+
checkCICD(cwd, has, read),
|
|
436
|
+
checkSupplyChain(has, read),
|
|
437
|
+
checkRepoHygiene(has),
|
|
438
|
+
checkDevExperience(cwd, has),
|
|
439
|
+
checkCodeQualityTooling(has, read),
|
|
440
|
+
checkTesting(has, read),
|
|
441
|
+
checkDocker(has, read),
|
|
442
|
+
checkGitPractices(cwd, has, read),
|
|
443
|
+
checkMonitoring(cwd),
|
|
444
|
+
checkAPIConfig(cwd, read),
|
|
445
|
+
];
|
|
446
|
+
let practices = 0;
|
|
447
|
+
let followed = 0;
|
|
448
|
+
const issues = [];
|
|
449
|
+
for (const cat of categories) {
|
|
450
|
+
practices += cat.practices;
|
|
451
|
+
followed += cat.followed;
|
|
452
|
+
issues.push(...cat.issues);
|
|
453
|
+
}
|
|
307
454
|
// ── Score ──
|
|
308
455
|
const pct = practices > 0 ? Math.round((followed / practices) * 100) : 100;
|
|
309
456
|
const score = pct;
|
|
@@ -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));
|