@vibecodeqa/cli 0.42.0 → 0.44.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/README.md +130 -165
- package/dist/check-meta.js +59 -6
- package/dist/cli.js +299 -762
- package/dist/commands/explain.d.ts +2 -0
- package/dist/commands/explain.js +33 -0
- package/dist/commands/fix.d.ts +6 -0
- package/dist/commands/fix.js +157 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/shared.d.ts +4 -0
- package/dist/commands/shared.js +80 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +12 -1
- package/dist/delta.d.ts +45 -0
- package/dist/delta.js +158 -0
- package/dist/detect.js +2 -2
- package/dist/pr-comment.d.ts +1 -1
- package/dist/pr-comment.js +23 -4
- package/dist/report/html.d.ts +1 -1
- package/dist/report/html.js +7 -2
- package/dist/report/pages.d.ts +2 -0
- package/dist/report/pages.js +167 -0
- package/dist/report/styles.d.ts +1 -1
- package/dist/report/styles.js +37 -0
- package/dist/runners/accessibility.js +4 -1
- package/dist/runners/best-practices.js +1 -1
- package/dist/runners/confusion.js +28 -17
- package/dist/runners/design-consistency.d.ts +12 -0
- package/dist/runners/design-consistency.js +125 -0
- package/dist/runners/error-handling.js +18 -2
- package/dist/runners/file-cohesion.d.ts +17 -0
- package/dist/runners/file-cohesion.js +177 -0
- package/dist/runners/frontend-health.d.ts +14 -0
- package/dist/runners/frontend-health.js +206 -0
- package/dist/runners/html-quality.d.ts +8 -0
- package/dist/runners/html-quality.js +203 -0
- package/dist/runners/lint.js +6 -1
- package/dist/runners/react.js +1 -0
- package/dist/runners/secrets.js +7 -2
- package/dist/runners/security.js +7 -1
- package/dist/runners/standards.d.ts +2 -2
- package/dist/runners/standards.js +45 -12
- package/dist/runners/structure.js +1 -1
- package/dist/runners/styling.d.ts +15 -0
- package/dist/runners/styling.js +280 -0
- package/dist/runners/testing.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Design Consistency — LLM-powered audit of visual consistency across components.
|
|
2
|
+
*
|
|
3
|
+
* Pro feature. Requires VCQA_PRO_KEY env var.
|
|
4
|
+
*
|
|
5
|
+
* Detects:
|
|
6
|
+
* - Components that define the same visual pattern differently (accidental design system)
|
|
7
|
+
* - Spacing/color/typography inconsistencies across files
|
|
8
|
+
* - Missing component extraction opportunities
|
|
9
|
+
* - Tailwind class patterns that should be @apply'd or componentized
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
15
|
+
import { gradeFromScore } from "../types.js";
|
|
16
|
+
export async function runDesignConsistency(cwd) {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
const proKey = process.env.VCQA_PRO_KEY || "";
|
|
19
|
+
if (!proKey) {
|
|
20
|
+
return {
|
|
21
|
+
name: "design-consistency",
|
|
22
|
+
score: 0,
|
|
23
|
+
grade: "F",
|
|
24
|
+
details: {
|
|
25
|
+
premium: true,
|
|
26
|
+
comingSoon: true,
|
|
27
|
+
reason: "Set VCQA_PRO_KEY to enable design consistency analysis",
|
|
28
|
+
description: "LLM-powered audit of visual consistency — finds components with duplicate styling, inconsistent spacing scales, and missing component extraction opportunities.",
|
|
29
|
+
},
|
|
30
|
+
issues: [],
|
|
31
|
+
duration: Date.now() - start,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const files = getProductionFiles(cwd);
|
|
35
|
+
const componentFiles = files.filter((f) => !f.isTest && /\.(tsx|jsx|vue|svelte)$/.test(f.path));
|
|
36
|
+
if (componentFiles.length < 2) {
|
|
37
|
+
return {
|
|
38
|
+
name: "design-consistency",
|
|
39
|
+
score: 100,
|
|
40
|
+
grade: "A",
|
|
41
|
+
details: { componentsAnalyzed: componentFiles.length },
|
|
42
|
+
issues: [],
|
|
43
|
+
duration: Date.now() - start,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Build hash of all component content for cache key
|
|
47
|
+
const h = createHash("sha256");
|
|
48
|
+
for (const f of componentFiles.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
49
|
+
h.update(f.path);
|
|
50
|
+
h.update(f.content.slice(0, 2000));
|
|
51
|
+
}
|
|
52
|
+
const contentHash = h.digest("hex").slice(0, 16);
|
|
53
|
+
// Check cache
|
|
54
|
+
const cache = loadCache(cwd);
|
|
55
|
+
if (cache && cache.hash === contentHash) {
|
|
56
|
+
return {
|
|
57
|
+
name: "design-consistency",
|
|
58
|
+
score: cache.findings.length === 0 ? 100 : Math.max(20, 100 - cache.findings.length * 12),
|
|
59
|
+
grade: gradeFromScore(cache.findings.length === 0 ? 100 : Math.max(20, 100 - cache.findings.length * 12)),
|
|
60
|
+
details: { premium: true, componentsAnalyzed: componentFiles.length, cached: true },
|
|
61
|
+
issues: cache.findings,
|
|
62
|
+
duration: Date.now() - start,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Extract styling snippets from top components (by size, most likely to have styling)
|
|
66
|
+
const candidates = componentFiles
|
|
67
|
+
.sort((a, b) => b.content.length - a.content.length)
|
|
68
|
+
.slice(0, 10);
|
|
69
|
+
const snippets = candidates.map((f) => ({
|
|
70
|
+
path: f.path,
|
|
71
|
+
content: f.content.slice(0, 2000),
|
|
72
|
+
}));
|
|
73
|
+
const issues = await analyzeDesign(snippets, proKey);
|
|
74
|
+
// Cache results
|
|
75
|
+
saveCache(cwd, { version: 1, hash: contentHash, findings: issues });
|
|
76
|
+
const score = issues.length === 0 ? 100 : Math.max(20, 100 - issues.length * 12);
|
|
77
|
+
return {
|
|
78
|
+
name: "design-consistency",
|
|
79
|
+
score,
|
|
80
|
+
grade: gradeFromScore(score),
|
|
81
|
+
details: { premium: true, componentsAnalyzed: componentFiles.length },
|
|
82
|
+
issues,
|
|
83
|
+
duration: Date.now() - start,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function analyzeDesign(files, proKey) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch("https://api.vibecodeqa.online/api/pro/design-consistency", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: {
|
|
91
|
+
"Content-Type": "application/json",
|
|
92
|
+
Authorization: `Bearer ${proKey}`,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({ files: files.map((f) => ({ path: f.path, content: f.content })) }),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
return [];
|
|
98
|
+
const data = (await res.json());
|
|
99
|
+
return data.findings || [];
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function loadCache(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
const cachePath = join(cwd, ".vibe-check", "design-consistency-cache.json");
|
|
108
|
+
if (existsSync(cachePath)) {
|
|
109
|
+
const data = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
110
|
+
if (data.version === 1)
|
|
111
|
+
return data;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* corrupt cache */ }
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function saveCache(cwd, cache) {
|
|
118
|
+
try {
|
|
119
|
+
const dir = join(cwd, ".vibe-check");
|
|
120
|
+
if (!existsSync(dir))
|
|
121
|
+
mkdirSync(dir, { recursive: true });
|
|
122
|
+
writeFileSync(join(dir, "design-consistency-cache.json"), JSON.stringify(cache));
|
|
123
|
+
}
|
|
124
|
+
catch { /* write failed */ }
|
|
125
|
+
}
|
|
@@ -25,12 +25,22 @@ export function runErrorHandling(cwd, stack) {
|
|
|
25
25
|
const line = lines[i].trim();
|
|
26
26
|
if (line.startsWith("//") || line.startsWith("*"))
|
|
27
27
|
continue;
|
|
28
|
-
// Skip
|
|
28
|
+
// Skip suppressed lines (// ok, // intentional, eslint-disable, biome-ignore)
|
|
29
|
+
if (/\/\/\s*(?:ok|intentional|eslint-disable|biome-ignore)/.test(line))
|
|
30
|
+
continue;
|
|
31
|
+
// Skip string-only lines, metadata definitions, and return statements with string literals
|
|
29
32
|
if (/^\s*["'`].*["'`][,;]?\s*$/.test(lines[i]))
|
|
30
33
|
continue;
|
|
31
34
|
if (/\b(?:message|description|risk|recommendation|name)\s*:\s*["']/.test(line))
|
|
32
35
|
continue;
|
|
36
|
+
if (/\breturn\s+["'`]/.test(line))
|
|
37
|
+
continue;
|
|
38
|
+
if (/\brule\s*===?\s*["']/.test(line))
|
|
39
|
+
continue;
|
|
33
40
|
if (/catch\s*\([^)]*\)\s*\{\s*\}/.test(line) || /catch\s*\{\s*\}/.test(line)) {
|
|
41
|
+
// Skip intentional one-liner try-catch for optional browser APIs
|
|
42
|
+
if (/try\s*\{.*(?:localStorage|sessionStorage|document\.).*\}\s*catch/.test(line))
|
|
43
|
+
continue;
|
|
34
44
|
emptyCatch++;
|
|
35
45
|
issues.push({ severity: "error", message: "Empty catch block", file: f.path, line: i + 1, rule: "empty-catch" });
|
|
36
46
|
}
|
|
@@ -83,8 +93,14 @@ export function runErrorHandling(cwd, stack) {
|
|
|
83
93
|
const line = lines[i].trim();
|
|
84
94
|
if (line.startsWith("//") || line.startsWith("*"))
|
|
85
95
|
continue;
|
|
96
|
+
if (/\/\/\s*(?:ok|intentional|eslint-disable|biome-ignore)/.test(line))
|
|
97
|
+
continue;
|
|
98
|
+
if (/^\s*["'`].*["'`][,;]?\s*$/.test(lines[i]))
|
|
99
|
+
continue;
|
|
86
100
|
if (/\b(?:message|description|risk|recommendation|name)\s*:\s*["']/.test(line))
|
|
87
101
|
continue;
|
|
102
|
+
if (/\breturn\s+["'`]/.test(line))
|
|
103
|
+
continue;
|
|
88
104
|
// JSON.parse of external input without try-catch
|
|
89
105
|
if (/\bJSON\.parse\s*\(/.test(line)) {
|
|
90
106
|
// Only flag if parsing user/network input (not internal known-safe files)
|
|
@@ -141,7 +157,7 @@ export function runErrorHandling(cwd, stack) {
|
|
|
141
157
|
}
|
|
142
158
|
}
|
|
143
159
|
// process.exit() in non-CLI files (library code shouldn't exit)
|
|
144
|
-
if (/\bprocess\.exit\s*\(/.test(line) && !f.path.includes("cli") && !f.path.includes("bin/")) {
|
|
160
|
+
if (/\bprocess\.exit\s*\(/.test(line) && !f.path.includes("cli") && !f.path.includes("bin/") && !f.path.includes("commands/") && !f.path.includes("monitor")) {
|
|
145
161
|
processExit++;
|
|
146
162
|
issues.push({
|
|
147
163
|
severity: "warning",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** File Cohesion — detects files with multiple responsibilities.
|
|
2
|
+
*
|
|
3
|
+
* Pro feature. Requires VCQA_PRO_KEY env var.
|
|
4
|
+
*
|
|
5
|
+
* Two-phase analysis:
|
|
6
|
+
* 1. Local heuristics (always run with Pro key):
|
|
7
|
+
* - Files with exports spanning multiple domains (auth + email + db)
|
|
8
|
+
* - Mixed concern signals: HTTP handlers + business logic + data access
|
|
9
|
+
* - High export count relative to file purpose
|
|
10
|
+
*
|
|
11
|
+
* 2. LLM-powered analysis (via api.vibecodeqa.online):
|
|
12
|
+
* - Labels each file's responsibility clusters
|
|
13
|
+
* - Suggests concrete split points
|
|
14
|
+
* - "This file handles auth, session, AND email — split into 3 modules"
|
|
15
|
+
*/
|
|
16
|
+
import type { CheckResult } from "../types.js";
|
|
17
|
+
export declare function runFileCohesion(cwd: string): Promise<CheckResult>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/** File Cohesion — detects files with multiple responsibilities.
|
|
2
|
+
*
|
|
3
|
+
* Pro feature. Requires VCQA_PRO_KEY env var.
|
|
4
|
+
*
|
|
5
|
+
* Two-phase analysis:
|
|
6
|
+
* 1. Local heuristics (always run with Pro key):
|
|
7
|
+
* - Files with exports spanning multiple domains (auth + email + db)
|
|
8
|
+
* - Mixed concern signals: HTTP handlers + business logic + data access
|
|
9
|
+
* - High export count relative to file purpose
|
|
10
|
+
*
|
|
11
|
+
* 2. LLM-powered analysis (via api.vibecodeqa.online):
|
|
12
|
+
* - Labels each file's responsibility clusters
|
|
13
|
+
* - Suggests concrete split points
|
|
14
|
+
* - "This file handles auth, session, AND email — split into 3 modules"
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
20
|
+
import { gradeFromScore } from "../types.js";
|
|
21
|
+
const CONCERN_PATTERNS = [
|
|
22
|
+
{ name: "HTTP/routing", patterns: [/\b(app|router|server)\.(get|post|put|delete|patch|use)\b/, /\bRequest\b.*\bResponse\b/, /\breq\s*,\s*res\b/, /\bfastify\b|\bhono\b|\bexpress\b/] },
|
|
23
|
+
{ name: "database", patterns: [/\b(prisma|sequelize|typeorm|knex|drizzle)\b/, /\bSELECT\b.*\bFROM\b/i, /\.query\s*\(/, /\bcreateClient\b.*\bsupabase\b/i] },
|
|
24
|
+
{ name: "auth", patterns: [/\b(jwt|token|session|cookie|passport|oauth|login|signup|signIn|signUp)\b/i, /\bverify(Token|Session|Auth)\b/, /\bbcrypt|argon2|scrypt\b/] },
|
|
25
|
+
{ name: "email", patterns: [/\bsendMail|sendEmail|transporter|nodemailer|resend|postmark\b/i, /\bsmtp\b/i] },
|
|
26
|
+
{ name: "file I/O", patterns: [/\breadFileSync|writeFileSync|createReadStream|createWriteStream\b/, /\bfs\.(read|write|mkdir|unlink|stat)\b/] },
|
|
27
|
+
{ name: "validation", patterns: [/\bz\.(string|number|object|array)\b/, /\bJoi\.(string|number|object)\b/, /\byup\.(string|number|object)\b/, /\bvalidate\w*Schema\b/] },
|
|
28
|
+
{ name: "UI rendering", patterns: [/\bJSX\b|\breturn\s*\(?\s*</, /\buseState|useEffect|useRef|useMemo\b/, /\brender\(\)/, /\bcomponent\b/i] },
|
|
29
|
+
{ name: "state management", patterns: [/\bcreateStore|useStore|createSlice|createReducer\b/, /\bdispatch\(|getState\(\)/, /\buseSelector|useDispatch\b/] },
|
|
30
|
+
{ name: "testing", patterns: [/\bdescribe\s*\(|it\s*\(|test\s*\(|expect\s*\(/, /\bbeforeEach|afterEach|beforeAll\b/, /\bjest\.|vitest\./] },
|
|
31
|
+
{ name: "CLI", patterns: [/\bprocess\.argv\b/, /\bcommander|yargs|meow|cac\b/, /\bparse(Args|Options)\b/] },
|
|
32
|
+
];
|
|
33
|
+
export async function runFileCohesion(cwd) {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
const proKey = process.env.VCQA_PRO_KEY || "";
|
|
36
|
+
if (!proKey) {
|
|
37
|
+
return {
|
|
38
|
+
name: "file-cohesion",
|
|
39
|
+
score: 0,
|
|
40
|
+
grade: "F",
|
|
41
|
+
details: {
|
|
42
|
+
premium: true,
|
|
43
|
+
comingSoon: true,
|
|
44
|
+
reason: "Set VCQA_PRO_KEY to enable file cohesion analysis",
|
|
45
|
+
description: "Detects files with multiple responsibilities — the #1 code smell in AI-generated code. Labels each file's concern clusters and suggests concrete split points.",
|
|
46
|
+
},
|
|
47
|
+
issues: [],
|
|
48
|
+
duration: Date.now() - start,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const files = getProductionFiles(cwd);
|
|
52
|
+
const issues = [];
|
|
53
|
+
const cache = loadCache(cwd);
|
|
54
|
+
let cacheHits = 0;
|
|
55
|
+
// Phase 1: local heuristics — detect multi-concern files
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const f of files) {
|
|
58
|
+
if (f.isTest)
|
|
59
|
+
continue;
|
|
60
|
+
const lines = f.content.split("\n").length;
|
|
61
|
+
if (lines < 100)
|
|
62
|
+
continue; // small files rarely have cohesion problems
|
|
63
|
+
const detectedConcerns = [];
|
|
64
|
+
for (const concern of CONCERN_PATTERNS) {
|
|
65
|
+
if (concern.patterns.some((p) => p.test(f.content))) {
|
|
66
|
+
detectedConcerns.push(concern.name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (detectedConcerns.length >= 2) {
|
|
70
|
+
candidates.push({ path: f.path, content: f.content, concerns: detectedConcerns, lines });
|
|
71
|
+
// Local issue for 2+ concerns
|
|
72
|
+
issues.push({
|
|
73
|
+
severity: detectedConcerns.length >= 3 ? "error" : "warning",
|
|
74
|
+
message: `${detectedConcerns.length} concerns detected: ${detectedConcerns.join(", ")} — consider splitting`,
|
|
75
|
+
file: f.path,
|
|
76
|
+
rule: "multi-concern",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Phase 2: LLM analysis for top candidates (by line count, most likely to benefit from splitting)
|
|
81
|
+
const topCandidates = candidates
|
|
82
|
+
.sort((a, b) => b.lines - a.lines)
|
|
83
|
+
.slice(0, 5);
|
|
84
|
+
for (const candidate of topCandidates) {
|
|
85
|
+
const hash = createHash("sha256").update(candidate.content).digest("hex").slice(0, 16);
|
|
86
|
+
// Check cache
|
|
87
|
+
const cached = cache.files[candidate.path];
|
|
88
|
+
if (cached && cached.hash === hash) {
|
|
89
|
+
cacheHits++;
|
|
90
|
+
if (cached.suggestion) {
|
|
91
|
+
issues.push({
|
|
92
|
+
severity: "warning",
|
|
93
|
+
message: cached.suggestion,
|
|
94
|
+
file: candidate.path,
|
|
95
|
+
rule: "split-suggestion",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Call LLM for concrete split suggestions
|
|
101
|
+
const analysis = await analyzeCohesion(candidate.path, candidate.content, proKey);
|
|
102
|
+
if (analysis) {
|
|
103
|
+
cache.files[candidate.path] = { hash, concerns: analysis.concerns, suggestion: analysis.suggestion };
|
|
104
|
+
if (analysis.suggestion) {
|
|
105
|
+
issues.push({
|
|
106
|
+
severity: "warning",
|
|
107
|
+
message: analysis.suggestion,
|
|
108
|
+
file: candidate.path,
|
|
109
|
+
rule: "split-suggestion",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
saveCache(cwd, cache);
|
|
115
|
+
const totalFiles = files.filter((f) => !f.isTest).length;
|
|
116
|
+
const multiConcernFiles = candidates.length;
|
|
117
|
+
const ratio = totalFiles > 0 ? multiConcernFiles / totalFiles : 0;
|
|
118
|
+
const score = Math.round(Math.max(0, 100 - ratio * 300));
|
|
119
|
+
return {
|
|
120
|
+
name: "file-cohesion",
|
|
121
|
+
score,
|
|
122
|
+
grade: gradeFromScore(score),
|
|
123
|
+
details: {
|
|
124
|
+
premium: true,
|
|
125
|
+
totalFiles,
|
|
126
|
+
multiConcernFiles,
|
|
127
|
+
cacheHits,
|
|
128
|
+
candidates: candidates.map((c) => ({ path: c.path, concerns: c.concerns, lines: c.lines })),
|
|
129
|
+
},
|
|
130
|
+
issues,
|
|
131
|
+
duration: Date.now() - start,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function analyzeCohesion(filePath, content, proKey) {
|
|
135
|
+
const truncated = content.slice(0, 4000);
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch("https://api.vibecodeqa.online/api/pro/file-cohesion", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
Authorization: `Bearer ${proKey}`,
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify({ file: filePath, content: truncated }),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
return null;
|
|
147
|
+
const data = (await res.json());
|
|
148
|
+
return {
|
|
149
|
+
concerns: data.concerns || [],
|
|
150
|
+
suggestion: data.suggestion || "",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function loadCache(cwd) {
|
|
158
|
+
try {
|
|
159
|
+
const cachePath = join(cwd, ".vibe-check", "file-cohesion-cache.json");
|
|
160
|
+
if (existsSync(cachePath)) {
|
|
161
|
+
const data = JSON.parse(readFileSync(cachePath, "utf-8"));
|
|
162
|
+
if (data.version === 1)
|
|
163
|
+
return data;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch { /* corrupt cache */ }
|
|
167
|
+
return { version: 1, files: {} };
|
|
168
|
+
}
|
|
169
|
+
function saveCache(cwd, cache) {
|
|
170
|
+
try {
|
|
171
|
+
const dir = join(cwd, ".vibe-check");
|
|
172
|
+
if (!existsSync(dir))
|
|
173
|
+
mkdirSync(dir, { recursive: true });
|
|
174
|
+
writeFileSync(join(dir, "file-cohesion-cache.json"), JSON.stringify(cache));
|
|
175
|
+
}
|
|
176
|
+
catch { /* write failed */ }
|
|
177
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Frontend health — detects HTML/framework antipatterns in component code.
|
|
2
|
+
*
|
|
3
|
+
* Checks:
|
|
4
|
+
* 1. Conflicting UI frameworks (MUI + Tailwind, Chakra + Tailwind, etc.)
|
|
5
|
+
* 2. Large/unoptimized images (no width/height, no next/image, large src)
|
|
6
|
+
* 3. Inconsistent icon systems (mixing lucide + heroicons + fontawesome)
|
|
7
|
+
* 4. Bundle-heavy imports (entire libraries instead of specific components)
|
|
8
|
+
* 5. Missing loading states (async data without suspense/skeleton)
|
|
9
|
+
* 6. Hardcoded strings (potential i18n issues)
|
|
10
|
+
* 7. Missing meta tags / head management
|
|
11
|
+
* 8. DOM nesting violations (div in p, button in a)
|
|
12
|
+
*/
|
|
13
|
+
import type { CheckResult } from "../types.js";
|
|
14
|
+
export declare function runFrontendHealth(cwd: string): CheckResult;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Frontend health — detects HTML/framework antipatterns in component code.
|
|
2
|
+
*
|
|
3
|
+
* Checks:
|
|
4
|
+
* 1. Conflicting UI frameworks (MUI + Tailwind, Chakra + Tailwind, etc.)
|
|
5
|
+
* 2. Large/unoptimized images (no width/height, no next/image, large src)
|
|
6
|
+
* 3. Inconsistent icon systems (mixing lucide + heroicons + fontawesome)
|
|
7
|
+
* 4. Bundle-heavy imports (entire libraries instead of specific components)
|
|
8
|
+
* 5. Missing loading states (async data without suspense/skeleton)
|
|
9
|
+
* 6. Hardcoded strings (potential i18n issues)
|
|
10
|
+
* 7. Missing meta tags / head management
|
|
11
|
+
* 8. DOM nesting violations (div in p, button in a)
|
|
12
|
+
*/
|
|
13
|
+
import { getProductionFiles, readDeps } from "../fs-utils.js";
|
|
14
|
+
import { gradeFromScore } from "../types.js";
|
|
15
|
+
// UI framework conflicts
|
|
16
|
+
const UI_FRAMEWORKS = [
|
|
17
|
+
{ name: "MUI", deps: ["@mui/material", "@mui/system", "@material-ui/core"] },
|
|
18
|
+
{ name: "Tailwind", deps: ["tailwindcss"] },
|
|
19
|
+
{ name: "Chakra", deps: ["@chakra-ui/react"] },
|
|
20
|
+
{ name: "Ant Design", deps: ["antd"] },
|
|
21
|
+
{ name: "Bootstrap", deps: ["react-bootstrap", "bootstrap"] },
|
|
22
|
+
{ name: "Mantine", deps: ["@mantine/core"] },
|
|
23
|
+
{ name: "Radix", deps: ["@radix-ui/react-dialog", "@radix-ui/react-dropdown-menu", "@radix-ui/themes"] },
|
|
24
|
+
{ name: "shadcn/ui", deps: ["cmdk", "vaul"] }, // shadcn uses these + Tailwind
|
|
25
|
+
{ name: "DaisyUI", deps: ["daisyui"] },
|
|
26
|
+
];
|
|
27
|
+
// Icon library conflicts
|
|
28
|
+
const ICON_LIBS = [
|
|
29
|
+
{ name: "lucide", patterns: [/from\s+["']lucide-react["']/, /from\s+["']lucide-vue-next["']/] },
|
|
30
|
+
{ name: "heroicons", patterns: [/from\s+["']@heroicons\/react/, /from\s+["']@heroicons\/vue/] },
|
|
31
|
+
{ name: "fontawesome", patterns: [/from\s+["']@fortawesome/, /fa-\w+/] },
|
|
32
|
+
{ name: "phosphor", patterns: [/from\s+["']@phosphor-icons/] },
|
|
33
|
+
{ name: "tabler", patterns: [/from\s+["']@tabler\/icons/] },
|
|
34
|
+
{ name: "react-icons", patterns: [/from\s+["']react-icons\//] },
|
|
35
|
+
{ name: "material-icons", patterns: [/from\s+["']@mui\/icons-material/] },
|
|
36
|
+
{ name: "ionicons", patterns: [/from\s+["']ionicons/, /ion-icon/] },
|
|
37
|
+
];
|
|
38
|
+
// Heavy full-library imports
|
|
39
|
+
const HEAVY_IMPORTS = [
|
|
40
|
+
{ pattern: /import\s+\*\s+as\s+\w+\s+from\s+["']lodash["']/, message: "Import entire lodash — use lodash/specific or lodash-es" },
|
|
41
|
+
{ pattern: /from\s+["']@mui\/icons-material["'](?!\/)/, message: "Import all MUI icons — import specific: @mui/icons-material/Add" },
|
|
42
|
+
{ pattern: /from\s+["']react-icons["'](?!\/)/, message: "Import all react-icons — import specific: react-icons/fi" },
|
|
43
|
+
{ pattern: /from\s+["']antd["']\s*;\s*$/, message: "Import all of antd — destructure: import { Button } from 'antd'" },
|
|
44
|
+
{ pattern: /import\s+(?:moment|dayjs)\s+from/, message: "Date library imported fully — consider tree-shakeable alternative" },
|
|
45
|
+
];
|
|
46
|
+
// DOM nesting violations
|
|
47
|
+
const NESTING_VIOLATIONS = [
|
|
48
|
+
{ parent: "<p", child: "<div", message: "div inside p — invalid HTML nesting" },
|
|
49
|
+
{ parent: "<p", child: "<p", message: "p inside p — invalid HTML nesting" },
|
|
50
|
+
{ parent: "<a", child: "<a", message: "a inside a — nested links are invalid" },
|
|
51
|
+
{ parent: "<button", child: "<button", message: "button inside button — invalid nesting" },
|
|
52
|
+
{ parent: "<a", child: "<button", message: "button inside a — use one or the other" },
|
|
53
|
+
];
|
|
54
|
+
export function runFrontendHealth(cwd) {
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
const issues = [];
|
|
57
|
+
const files = getProductionFiles(cwd);
|
|
58
|
+
const deps = readDeps(cwd);
|
|
59
|
+
const componentFiles = files.filter((f) => !f.isTest && /\.(tsx|jsx|vue|svelte)$/.test(f.path));
|
|
60
|
+
if (componentFiles.length === 0) {
|
|
61
|
+
return {
|
|
62
|
+
name: "frontend-health",
|
|
63
|
+
score: 0,
|
|
64
|
+
grade: "F",
|
|
65
|
+
details: { skipped: true, reason: "no component files found" },
|
|
66
|
+
issues: [],
|
|
67
|
+
duration: Date.now() - start,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// 1. Conflicting UI frameworks
|
|
71
|
+
const activeFrameworks = [];
|
|
72
|
+
for (const fw of UI_FRAMEWORKS) {
|
|
73
|
+
if (fw.deps.some((d) => d in deps)) {
|
|
74
|
+
activeFrameworks.push(fw.name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Tailwind + Radix/shadcn is intentional (shadcn uses Tailwind)
|
|
78
|
+
const conflicts = activeFrameworks.filter((f) => f !== "Radix" && f !== "shadcn/ui" && f !== "DaisyUI");
|
|
79
|
+
if (conflicts.length > 1 && !(conflicts.length === 2 && conflicts.includes("Tailwind") && conflicts.includes("shadcn/ui"))) {
|
|
80
|
+
issues.push({
|
|
81
|
+
severity: "error",
|
|
82
|
+
message: `Conflicting UI frameworks: ${conflicts.join(" + ")} — pick one`,
|
|
83
|
+
rule: "framework-conflict",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// 2-8. Scan component files
|
|
87
|
+
const iconLibsUsed = new Set();
|
|
88
|
+
let unoptimizedImages = 0;
|
|
89
|
+
let missingLoadingStates = 0;
|
|
90
|
+
for (const f of componentFiles) {
|
|
91
|
+
const lines = f.content.split("\n");
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const line = lines[i];
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
// Icon libraries used
|
|
96
|
+
for (const lib of ICON_LIBS) {
|
|
97
|
+
if (lib.patterns.some((p) => p.test(line))) {
|
|
98
|
+
iconLibsUsed.add(lib.name);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Heavy imports
|
|
102
|
+
for (const heavy of HEAVY_IMPORTS) {
|
|
103
|
+
if (heavy.pattern.test(line)) {
|
|
104
|
+
issues.push({
|
|
105
|
+
severity: "warning",
|
|
106
|
+
message: heavy.message,
|
|
107
|
+
file: f.path,
|
|
108
|
+
line: i + 1,
|
|
109
|
+
rule: "heavy-import",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Unoptimized images
|
|
114
|
+
if (/<img\s/.test(trimmed) && !trimmed.includes("next/image") && !trimmed.includes("Image")) {
|
|
115
|
+
if (!trimmed.includes("width") || !trimmed.includes("height")) {
|
|
116
|
+
unoptimizedImages++;
|
|
117
|
+
if (unoptimizedImages <= 3) {
|
|
118
|
+
issues.push({
|
|
119
|
+
severity: "warning",
|
|
120
|
+
message: "img without width/height — causes layout shift (use next/image or set dimensions)",
|
|
121
|
+
file: f.path,
|
|
122
|
+
line: i + 1,
|
|
123
|
+
rule: "unoptimized-image",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Large image sources (base64 data URLs in JSX)
|
|
129
|
+
if (/src\s*=\s*["']data:image\/[^"']{10000}/.test(line)) {
|
|
130
|
+
issues.push({
|
|
131
|
+
severity: "error",
|
|
132
|
+
message: "Large base64 image inline — move to a file and import",
|
|
133
|
+
file: f.path,
|
|
134
|
+
line: i + 1,
|
|
135
|
+
rule: "inline-image",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// DOM nesting violations (heuristic — not a full parser)
|
|
139
|
+
for (const v of NESTING_VIOLATIONS) {
|
|
140
|
+
if (trimmed.includes(v.child) && i > 0) {
|
|
141
|
+
// Check previous 5 lines for unclosed parent
|
|
142
|
+
const context = lines.slice(Math.max(0, i - 5), i).join(" ");
|
|
143
|
+
const parentOpens = (context.match(new RegExp(v.parent.replace("<", "<"), "g")) || []).length;
|
|
144
|
+
const parentCloses = (context.match(new RegExp(v.parent.replace("<", "</"), "g")) || []).length;
|
|
145
|
+
if (parentOpens > parentCloses) {
|
|
146
|
+
issues.push({
|
|
147
|
+
severity: "warning",
|
|
148
|
+
message: v.message,
|
|
149
|
+
file: f.path,
|
|
150
|
+
line: i + 1,
|
|
151
|
+
rule: "dom-nesting",
|
|
152
|
+
});
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Missing loading states (has fetch/useQuery but no loading/skeleton/suspense)
|
|
159
|
+
if (/\b(fetch|useQuery|useSWR|useEffect.*fetch)\b/.test(f.content)) {
|
|
160
|
+
if (!/\b(loading|isLoading|Skeleton|Spinner|Suspense|fallback)\b/.test(f.content)) {
|
|
161
|
+
missingLoadingStates++;
|
|
162
|
+
if (missingLoadingStates <= 3) {
|
|
163
|
+
issues.push({
|
|
164
|
+
severity: "info",
|
|
165
|
+
message: "Async data fetch without visible loading state",
|
|
166
|
+
file: f.path,
|
|
167
|
+
rule: "no-loading-state",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Icon system conflicts
|
|
174
|
+
if (iconLibsUsed.size > 1) {
|
|
175
|
+
issues.push({
|
|
176
|
+
severity: "warning",
|
|
177
|
+
message: `Mixed icon libraries: ${[...iconLibsUsed].join(", ")} — pick one for consistency`,
|
|
178
|
+
rule: "mixed-icons",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Summary issues
|
|
182
|
+
if (unoptimizedImages > 3) {
|
|
183
|
+
issues.push({
|
|
184
|
+
severity: "warning",
|
|
185
|
+
message: `${unoptimizedImages} images without dimensions — all cause layout shift`,
|
|
186
|
+
rule: "unoptimized-image",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Score
|
|
190
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
191
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
192
|
+
const score = Math.max(0, 100 - errorCount * 25 - warnCount * 10);
|
|
193
|
+
return {
|
|
194
|
+
name: "frontend-health",
|
|
195
|
+
score,
|
|
196
|
+
grade: gradeFromScore(score),
|
|
197
|
+
details: {
|
|
198
|
+
componentFiles: componentFiles.length,
|
|
199
|
+
activeFrameworks,
|
|
200
|
+
iconLibsUsed: [...iconLibsUsed],
|
|
201
|
+
unoptimizedImages,
|
|
202
|
+
},
|
|
203
|
+
issues,
|
|
204
|
+
duration: Date.now() - start,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** HTML quality — checks static HTML sites for meta tags, images, links, a11y, performance, SEO, security.
|
|
2
|
+
*
|
|
3
|
+
* Activates when the project has .html files. Works alongside framework checks —
|
|
4
|
+
* catches issues in static sites, landing pages, and docs that framework-specific
|
|
5
|
+
* checks miss entirely.
|
|
6
|
+
*/
|
|
7
|
+
import type { CheckResult } from "../types.js";
|
|
8
|
+
export declare function runHtmlQuality(cwd: string): CheckResult;
|