@vibecodeqa/cli 0.41.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.
- package/README.md +130 -165
- package/dist/check-meta.js +99 -6
- package/dist/cli.js +268 -761
- 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 +131 -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.js +18 -0
- package/dist/runners/accessibility.js +4 -1
- package/dist/runners/container-health.d.ts +3 -0
- package/dist/runners/container-health.js +141 -0
- package/dist/runners/design-consistency.d.ts +12 -0
- package/dist/runners/design-consistency.js +125 -0
- package/dist/runners/env-validation.d.ts +3 -0
- package/dist/runners/env-validation.js +122 -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/git-hygiene.d.ts +3 -0
- package/dist/runners/git-hygiene.js +125 -0
- package/dist/runners/html-quality.d.ts +8 -0
- package/dist/runners/html-quality.js +203 -0
- package/dist/runners/memory-safety.d.ts +3 -0
- package/dist/runners/memory-safety.js +114 -0
- 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.js +29 -9
- package/dist/runners/styling.d.ts +15 -0
- package/dist/runners/styling.js +280 -0
- package/package.json +1 -1
|
@@ -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,125 @@
|
|
|
1
|
+
/** Git hygiene — checks commit quality, large files, and repo health. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
5
|
+
import { gradeFromScore } from "../types.js";
|
|
6
|
+
import { run } from "./exec.js";
|
|
7
|
+
export function runGitHygiene(cwd) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const issues = [];
|
|
10
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
11
|
+
return {
|
|
12
|
+
name: "git-hygiene",
|
|
13
|
+
score: 0,
|
|
14
|
+
grade: "F",
|
|
15
|
+
details: { skipped: true, reason: "not a git repository" },
|
|
16
|
+
issues: [],
|
|
17
|
+
duration: Date.now() - start,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// 1. Check for merge conflict markers in source files
|
|
21
|
+
const files = getProductionFiles(cwd);
|
|
22
|
+
for (const f of files) {
|
|
23
|
+
const lines = f.content.split("\n");
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
if (/^<{7}\s|^={7}$|^>{7}\s/.test(lines[i])) {
|
|
26
|
+
issues.push({
|
|
27
|
+
severity: "error",
|
|
28
|
+
message: "Merge conflict marker found",
|
|
29
|
+
file: f.path,
|
|
30
|
+
line: i + 1,
|
|
31
|
+
rule: "merge-conflict",
|
|
32
|
+
});
|
|
33
|
+
break; // one per file is enough
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 2. Check recent commit message quality (last 20 commits)
|
|
38
|
+
const { stdout: logOutput, ok: logOk } = run("git log --oneline -20 --format='%s' 2>/dev/null", cwd, 10_000);
|
|
39
|
+
if (logOk && logOutput.trim()) {
|
|
40
|
+
const messages = logOutput.trim().split("\n").filter(Boolean);
|
|
41
|
+
let poorMessages = 0;
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
const trimmed = msg.trim().replace(/^'|'$/g, "");
|
|
44
|
+
// Flag very short or generic commit messages
|
|
45
|
+
if (trimmed.length < 5 || /^(fix|update|change|wip|test|stuff|asdf|temp|\.+)$/i.test(trimmed)) {
|
|
46
|
+
poorMessages++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (messages.length > 0) {
|
|
50
|
+
const poorRatio = poorMessages / messages.length;
|
|
51
|
+
if (poorRatio > 0.5) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: `${poorMessages}/${messages.length} recent commits have low-quality messages`,
|
|
55
|
+
rule: "poor-commit-messages",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 3. Check for large files tracked in git
|
|
61
|
+
const { stdout: lsOutput, ok: lsOk } = run("git ls-files -z 2>/dev/null | xargs -0 -I{} sh -c 'wc -c < \"{}\" | tr -d \" \" | xargs -I@ echo @\\t{}' 2>/dev/null | sort -rn | head -5", cwd, 15_000);
|
|
62
|
+
if (lsOk && lsOutput.trim()) {
|
|
63
|
+
for (const line of lsOutput.trim().split("\n")) {
|
|
64
|
+
const parts = line.split("\t");
|
|
65
|
+
if (parts.length < 2)
|
|
66
|
+
continue;
|
|
67
|
+
const size = parseInt(parts[0], 10);
|
|
68
|
+
const file = parts[1];
|
|
69
|
+
if (size > 5_000_000) { // 5MB
|
|
70
|
+
issues.push({
|
|
71
|
+
severity: "warning",
|
|
72
|
+
message: `Large file tracked in git: ${file} (${(size / 1_000_000).toFixed(1)}MB) — consider Git LFS`,
|
|
73
|
+
file,
|
|
74
|
+
rule: "large-file",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 4. Check for committed binary files
|
|
80
|
+
const binaryExts = new Set([".zip", ".tar", ".gz", ".jar", ".war", ".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".sqlite", ".db"]);
|
|
81
|
+
const { stdout: allFiles } = run("git ls-files 2>/dev/null", cwd, 10_000);
|
|
82
|
+
if (allFiles) {
|
|
83
|
+
for (const file of allFiles.trim().split("\n")) {
|
|
84
|
+
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
|
|
85
|
+
if (binaryExts.has(ext)) {
|
|
86
|
+
issues.push({
|
|
87
|
+
severity: "warning",
|
|
88
|
+
message: `Binary file tracked in git: ${file} — use .gitignore or Git LFS`,
|
|
89
|
+
file,
|
|
90
|
+
rule: "binary-in-git",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 5. Check .gitignore completeness
|
|
96
|
+
if (existsSync(join(cwd, ".gitignore"))) {
|
|
97
|
+
const gitignore = require("node:fs").readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
98
|
+
const missing = [];
|
|
99
|
+
if (!gitignore.includes("node_modules") && existsSync(join(cwd, "package.json")))
|
|
100
|
+
missing.push("node_modules");
|
|
101
|
+
if (!gitignore.includes(".env") && existsSync(join(cwd, ".env")))
|
|
102
|
+
missing.push(".env");
|
|
103
|
+
if (!gitignore.includes("dist") && !gitignore.includes("build"))
|
|
104
|
+
missing.push("dist/build");
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
issues.push({
|
|
107
|
+
severity: "warning",
|
|
108
|
+
message: `.gitignore missing common entries: ${missing.join(", ")}`,
|
|
109
|
+
file: ".gitignore",
|
|
110
|
+
rule: "gitignore-incomplete",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
115
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
116
|
+
const score = Math.max(0, 100 - errorCount * 30 - warnCount * 10);
|
|
117
|
+
return {
|
|
118
|
+
name: "git-hygiene",
|
|
119
|
+
score,
|
|
120
|
+
grade: gradeFromScore(score),
|
|
121
|
+
details: { commitCount: logOutput?.trim().split("\n").length || 0 },
|
|
122
|
+
issues,
|
|
123
|
+
duration: Date.now() - start,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Memory safety — detects resource leak patterns in TypeScript/JavaScript. */
|
|
2
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
3
|
+
import { gradeFromScore } from "../types.js";
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
name: "setInterval-no-clear",
|
|
7
|
+
pattern: /\bsetInterval\s*\(/g,
|
|
8
|
+
severity: "warning",
|
|
9
|
+
message: "setInterval without clearInterval — potential memory leak",
|
|
10
|
+
rule: "interval-leak",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "addEventListener-no-remove",
|
|
14
|
+
pattern: /\.addEventListener\s*\(/g,
|
|
15
|
+
severity: "warning",
|
|
16
|
+
message: "addEventListener without removeEventListener — may leak if component unmounts",
|
|
17
|
+
rule: "listener-leak",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "global-var-assignment",
|
|
21
|
+
pattern: /(?:^|\n)\s*(?:window|globalThis|global)\.\w+\s*=/g,
|
|
22
|
+
severity: "warning",
|
|
23
|
+
message: "Global variable assignment — pollutes global scope, hard to garbage collect",
|
|
24
|
+
rule: "global-pollution",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "new-without-cleanup",
|
|
28
|
+
pattern: /new\s+(?:MutationObserver|IntersectionObserver|ResizeObserver|PerformanceObserver)\s*\(/g,
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: "Observer created — ensure .disconnect() is called on cleanup",
|
|
31
|
+
rule: "observer-leak",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "websocket-no-close",
|
|
35
|
+
pattern: /new\s+WebSocket\s*\(/g,
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: "WebSocket opened — ensure .close() is called on cleanup",
|
|
38
|
+
rule: "websocket-leak",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "event-emitter-leak",
|
|
42
|
+
pattern: /\.on\s*\(\s*['"`]/g,
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message: "Event listener registered — ensure .off() or .removeListener() on cleanup",
|
|
45
|
+
rule: "emitter-leak",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
export function runMemorySafety(cwd) {
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
const issues = [];
|
|
51
|
+
const files = getProductionFiles(cwd);
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
if (f.isTest)
|
|
54
|
+
continue;
|
|
55
|
+
const lines = f.content.split("\n");
|
|
56
|
+
for (const pat of PATTERNS) {
|
|
57
|
+
// Check if the file has the pattern
|
|
58
|
+
const matches = f.content.match(pat.pattern);
|
|
59
|
+
if (!matches)
|
|
60
|
+
continue;
|
|
61
|
+
// For interval/listener leaks, check if cleanup exists in the same file
|
|
62
|
+
if (pat.rule === "interval-leak") {
|
|
63
|
+
if (f.content.includes("clearInterval"))
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (pat.rule === "listener-leak") {
|
|
67
|
+
if (f.content.includes("removeEventListener"))
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (pat.rule === "observer-leak") {
|
|
71
|
+
if (f.content.includes(".disconnect()"))
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (pat.rule === "websocket-leak") {
|
|
75
|
+
if (f.content.includes(".close()"))
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (pat.rule === "emitter-leak") {
|
|
79
|
+
// Skip if .off or .removeListener or .removeAllListeners in same file
|
|
80
|
+
if (f.content.includes(".off(") || f.content.includes(".removeListener(") || f.content.includes(".removeAllListeners("))
|
|
81
|
+
continue;
|
|
82
|
+
// Skip Node.js event emitter patterns (server.on, app.on, router.on)
|
|
83
|
+
if (/\b(?:server|app|router|express|fastify|hono)\b/.test(f.content))
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Find first occurrence line number
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
if (pat.pattern.test(lines[i])) {
|
|
89
|
+
pat.pattern.lastIndex = 0; // reset regex
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: pat.severity,
|
|
92
|
+
message: pat.message,
|
|
93
|
+
file: f.path,
|
|
94
|
+
line: i + 1,
|
|
95
|
+
rule: pat.rule,
|
|
96
|
+
});
|
|
97
|
+
break; // one per file per pattern
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const totalFiles = files.filter((f) => !f.isTest).length;
|
|
103
|
+
const affectedFiles = new Set(issues.map((i) => i.file)).size;
|
|
104
|
+
const ratio = totalFiles > 0 ? affectedFiles / totalFiles : 0;
|
|
105
|
+
const score = Math.round(Math.max(0, 100 - ratio * 200));
|
|
106
|
+
return {
|
|
107
|
+
name: "memory-safety",
|
|
108
|
+
score,
|
|
109
|
+
grade: gradeFromScore(score),
|
|
110
|
+
details: { totalFiles, affectedFiles, patterns: issues.length },
|
|
111
|
+
issues,
|
|
112
|
+
duration: Date.now() - start,
|
|
113
|
+
};
|
|
114
|
+
}
|