@vibecodeqa/cli 0.42.0 → 0.43.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.
@@ -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;
@@ -0,0 +1,203 @@
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 { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
+ import { join, relative } from "node:path";
9
+ import { gradeFromScore } from "../types.js";
10
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".vibe-check", "dist", "build", "coverage", ".next", ".nuxt"]);
11
+ export function runHtmlQuality(cwd) {
12
+ const start = Date.now();
13
+ const issues = [];
14
+ const htmlFiles = collectHtmlFiles(cwd);
15
+ if (htmlFiles.length === 0) {
16
+ return {
17
+ name: "html-quality",
18
+ score: 0,
19
+ grade: "F",
20
+ details: { skipped: true, reason: "no HTML files found" },
21
+ issues: [],
22
+ duration: Date.now() - start,
23
+ };
24
+ }
25
+ const allLinks = new Set();
26
+ const titles = new Map();
27
+ for (const file of htmlFiles) {
28
+ const relPath = relative(cwd, file);
29
+ let content;
30
+ try {
31
+ content = readFileSync(file, "utf-8");
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ // ── Meta tags ──
37
+ if (content.includes("<head")) {
38
+ if (!/<title[^>]*>/.test(content)) {
39
+ issues.push({ severity: "error", message: "Missing <title> tag", file: relPath, rule: "missing-title" });
40
+ }
41
+ else {
42
+ const titleMatch = content.match(/<title[^>]*>([^<]*)<\/title>/i);
43
+ if (titleMatch) {
44
+ const title = titleMatch[1].trim();
45
+ if (title.length < 10) {
46
+ issues.push({ severity: "warning", message: `Title too short: "${title}" — aim for 30-60 characters`, file: relPath, rule: "short-title" });
47
+ }
48
+ const existing = titles.get(title) || [];
49
+ existing.push(relPath);
50
+ titles.set(title, existing);
51
+ }
52
+ }
53
+ if (!/<meta\s[^>]*name=["']description["']/i.test(content)) {
54
+ issues.push({ severity: "warning", message: "Missing meta description", file: relPath, rule: "missing-description" });
55
+ }
56
+ if (!/<meta\s[^>]*name=["']viewport["']/i.test(content)) {
57
+ issues.push({ severity: "error", message: "Missing viewport meta — page won't be mobile-responsive", file: relPath, rule: "missing-viewport" });
58
+ }
59
+ if (!/<meta\s[^>]*charset/i.test(content)) {
60
+ issues.push({ severity: "warning", message: "Missing charset declaration", file: relPath, rule: "missing-charset" });
61
+ }
62
+ if (!/<meta\s[^>]*property=["']og:title["']/i.test(content)) {
63
+ issues.push({ severity: "info", message: "Missing Open Graph tags (og:title, og:description) — social sharing will look plain", file: relPath, rule: "missing-og" });
64
+ }
65
+ if (!/<link\s[^>]*rel=["']canonical["']/i.test(content)) {
66
+ issues.push({ severity: "info", message: "Missing canonical link — may cause duplicate content issues", file: relPath, rule: "missing-canonical" });
67
+ }
68
+ if (!/<link\s[^>]*rel=["']icon["']/i.test(content) && !/<link\s[^>]*rel=["']shortcut icon["']/i.test(content)) {
69
+ issues.push({ severity: "info", message: "Missing favicon", file: relPath, rule: "missing-favicon" });
70
+ }
71
+ }
72
+ // ── HTML lang ──
73
+ if (/<html[\s>]/.test(content) && !/<html\s[^>]*lang=/i.test(content)) {
74
+ issues.push({ severity: "warning", message: "Missing lang attribute on <html> — screen readers need this", file: relPath, rule: "missing-lang" });
75
+ }
76
+ // ── Images ──
77
+ const imgRegex = /<img\s[^>]*>/gi;
78
+ let imgMatch;
79
+ while ((imgMatch = imgRegex.exec(content)) !== null) {
80
+ const tag = imgMatch[0];
81
+ const line = content.slice(0, imgMatch.index).split("\n").length;
82
+ if (!/alt\s*=/i.test(tag)) {
83
+ issues.push({ severity: "error", message: "Image missing alt attribute", file: relPath, line, rule: "img-no-alt" });
84
+ }
85
+ if (!/(?:width|height)\s*=/i.test(tag)) {
86
+ issues.push({ severity: "warning", message: "Image missing width/height — causes layout shift", file: relPath, line, rule: "img-no-dimensions" });
87
+ }
88
+ if (!/loading\s*=/i.test(tag)) {
89
+ issues.push({ severity: "info", message: "Image missing loading=\"lazy\" — add for below-fold images", file: relPath, line, rule: "img-no-lazy" });
90
+ }
91
+ }
92
+ // ── Links ──
93
+ const linkRegex = /<a\s[^>]*href=["']([^"']+)["'][^>]*>/gi;
94
+ let linkMatch;
95
+ while ((linkMatch = linkRegex.exec(content)) !== null) {
96
+ const href = linkMatch[1];
97
+ const tag = linkMatch[0];
98
+ const line = content.slice(0, linkMatch.index).split("\n").length;
99
+ // HTTP links on what should be HTTPS
100
+ if (href.startsWith("http://") && !href.includes("localhost")) {
101
+ issues.push({ severity: "warning", message: `HTTP link: ${href} — use HTTPS`, file: relPath, line, rule: "http-link" });
102
+ }
103
+ // External links without rel=noopener
104
+ if (href.startsWith("http") && /target\s*=\s*["']_blank["']/i.test(tag) && !/rel\s*=\s*["'][^"']*noopener/i.test(tag)) {
105
+ issues.push({ severity: "warning", message: "External link with target=\"_blank\" missing rel=\"noopener\"", file: relPath, line, rule: "missing-noopener" });
106
+ }
107
+ // Collect internal links for broken link check
108
+ if (!href.startsWith("http") && !href.startsWith("mailto:") && !href.startsWith("#") && !href.startsWith("javascript:")) {
109
+ allLinks.add(join(cwd, href.split("#")[0].split("?")[0]));
110
+ }
111
+ }
112
+ // ── Heading hierarchy ──
113
+ const headings = [];
114
+ const headingRegex = /<h(\d)/gi;
115
+ let hMatch;
116
+ while ((hMatch = headingRegex.exec(content)) !== null) {
117
+ headings.push(parseInt(hMatch[1], 10));
118
+ }
119
+ for (let i = 1; i < headings.length; i++) {
120
+ if (headings[i] > headings[i - 1] + 1) {
121
+ issues.push({
122
+ severity: "warning",
123
+ message: `Heading hierarchy skip: h${headings[i - 1]} → h${headings[i]} (should be h${headings[i - 1] + 1})`,
124
+ file: relPath,
125
+ rule: "heading-skip",
126
+ });
127
+ break;
128
+ }
129
+ }
130
+ // ── Performance ──
131
+ // Render-blocking scripts (script in head without async/defer)
132
+ const headContent = content.match(/<head[^>]*>([\s\S]*?)<\/head>/i)?.[1] || "";
133
+ const scriptInHead = /<script\s[^>]*src=["'][^"']+["'][^>]*>/gi;
134
+ let scriptMatch;
135
+ while ((scriptMatch = scriptInHead.exec(headContent)) !== null) {
136
+ const tag = scriptMatch[0];
137
+ if (!/\b(?:async|defer|type=["']module["'])\b/i.test(tag)) {
138
+ issues.push({ severity: "warning", message: "Render-blocking script in <head> — add async or defer", file: relPath, rule: "render-blocking" });
139
+ }
140
+ }
141
+ // ── Security ──
142
+ // Mixed content (http:// resources on a page)
143
+ if (/<(?:img|script|link|iframe)\s[^>]*(?:src|href)=["']http:\/\/(?!localhost)/i.test(content)) {
144
+ issues.push({ severity: "warning", message: "Mixed content: HTTP resource on page — use HTTPS", file: relPath, rule: "mixed-content" });
145
+ }
146
+ }
147
+ // ── Cross-file checks ──
148
+ // Broken internal links
149
+ for (const link of allLinks) {
150
+ if (!existsSync(link)) {
151
+ const relLink = relative(cwd, link);
152
+ issues.push({ severity: "warning", message: `Broken internal link: ${relLink}`, rule: "broken-link" });
153
+ }
154
+ }
155
+ // Duplicate titles
156
+ for (const [title, files] of titles) {
157
+ if (files.length > 1) {
158
+ issues.push({ severity: "warning", message: `Duplicate title "${title}" in ${files.length} files — each page should have a unique title`, rule: "duplicate-title" });
159
+ }
160
+ }
161
+ // SEO files
162
+ if (!existsSync(join(cwd, "robots.txt"))) {
163
+ issues.push({ severity: "info", message: "Missing robots.txt", rule: "missing-robots" });
164
+ }
165
+ if (!existsSync(join(cwd, "sitemap.xml"))) {
166
+ issues.push({ severity: "info", message: "Missing sitemap.xml", rule: "missing-sitemap" });
167
+ }
168
+ // Score
169
+ const errorCount = issues.filter((i) => i.severity === "error").length;
170
+ const warnCount = issues.filter((i) => i.severity === "warning").length;
171
+ const score = Math.max(0, 100 - errorCount * 15 - warnCount * 5);
172
+ return {
173
+ name: "html-quality",
174
+ score,
175
+ grade: gradeFromScore(score),
176
+ details: { htmlFiles: htmlFiles.length },
177
+ issues,
178
+ duration: Date.now() - start,
179
+ };
180
+ }
181
+ function collectHtmlFiles(cwd, subdir = "") {
182
+ const files = [];
183
+ const dir = subdir ? join(cwd, subdir) : cwd;
184
+ try {
185
+ for (const entry of readdirSync(dir)) {
186
+ if (SKIP_DIRS.has(entry))
187
+ continue;
188
+ const full = join(dir, entry);
189
+ try {
190
+ const stat = statSync(full);
191
+ if (stat.isDirectory()) {
192
+ files.push(...collectHtmlFiles(cwd, subdir ? join(subdir, entry) : entry));
193
+ }
194
+ else if (entry.endsWith(".html") || entry.endsWith(".htm")) {
195
+ files.push(full);
196
+ }
197
+ }
198
+ catch { /* skip */ }
199
+ }
200
+ }
201
+ catch { /* skip */ }
202
+ return files;
203
+ }
@@ -201,6 +201,7 @@ export function runReact(cwd, stack) {
201
201
  inlineHandlers,
202
202
  effectNoDeps,
203
203
  domManipulation,
204
+ suggestion: !hasHooksPlugin ? "Install eslint-plugin-react-hooks for deeper React analysis: pnpm add -D eslint-plugin-react-hooks" : undefined,
204
205
  },
205
206
  issues,
206
207
  duration: Date.now() - start,
@@ -122,8 +122,9 @@ export async function runSecrets(cwd) {
122
122
  // Try gitleaks first (industry standard, 800+ patterns)
123
123
  const gitleaksResult = tryGitleaks(cwd, issues);
124
124
  const tool = gitleaksResult ? "gitleaks" : "secretlint";
125
- if (!gitleaksResult)
125
+ if (!gitleaksResult) {
126
126
  issues.push(...(await scanFallback(cwd)));
127
+ }
127
128
  // ── .env file audit ──
128
129
  const envFiles = [".env", ".env.local", ".env.production", ".env.development"];
129
130
  const gitignore = existsSync(join(cwd, ".gitignore")) ? readFileSync(join(cwd, ".gitignore"), "utf-8") : "";
@@ -185,7 +186,11 @@ export async function runSecrets(cwd) {
185
186
  name: "secrets",
186
187
  score,
187
188
  grade: gradeFromScore(score),
188
- details: { secretsFound: issues.length, tool },
189
+ details: {
190
+ secretsFound: issues.length,
191
+ tool,
192
+ suggestion: !gitleaksResult ? "Install gitleaks for deeper secret detection (800+ patterns): brew install gitleaks" : undefined,
193
+ },
189
194
  issues,
190
195
  duration: Date.now() - start,
191
196
  };
@@ -301,11 +301,17 @@ export function runSecurity(cwd) {
301
301
  if (trimmed.startsWith("//") || trimmed.startsWith("*"))
302
302
  continue;
303
303
  // Skip pattern/config definition lines and string-heavy metadata (prevents false positives on own code)
304
- if (/\bpattern\s*:|name:\s*["']|message:\s*["']|description:\s*["']|risk:\s*["']|recommendation:\s*["']/.test(trimmed))
304
+ if (/\bpattern\s*:|name:\s*["']|message:\s*["'`]|description:\s*["']|risk:\s*["']|recommendation:\s*["']|rule:\s*["']|severity:\s*["']/.test(trimmed))
305
305
  continue;
306
306
  // Skip lines that are primarily string content (check-meta descriptions, etc.)
307
307
  if (/^\s*["'`].*["'`][,;]?\s*$/.test(line))
308
308
  continue;
309
+ // Skip return statements returning string literals (e.g., fix suggestions mentioning patterns)
310
+ if (/\breturn\s+["'`]/.test(trimmed))
311
+ continue;
312
+ // Skip rule-matching conditionals (e.g., if (rule === "secret-detected") ...)
313
+ if (/\brule\s*===?\s*["']/.test(trimmed) || /\bcheck\s*===?\s*["']/.test(trimmed))
314
+ continue;
309
315
  for (const p of PATTERNS) {
310
316
  if (p.pattern.test(line)) {
311
317
  issues.push({