@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,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
|
+
}
|
package/dist/runners/lint.js
CHANGED
|
@@ -13,7 +13,9 @@ export function runLint(cwd, stack, workspace) {
|
|
|
13
13
|
// Determine the target path for linting
|
|
14
14
|
// Monorepos with root config: lint "." (biome/eslint will find all files)
|
|
15
15
|
// Single-package: lint "src/"
|
|
16
|
-
const lintTarget = workspace?.isMonorepo ? "."
|
|
16
|
+
const lintTarget = workspace?.isMonorepo ? "."
|
|
17
|
+
: existsSync(join(cwd, "src")) ? "src/"
|
|
18
|
+
: ".";
|
|
17
19
|
if (stack.linter === "biome") {
|
|
18
20
|
const { stdout } = run(`npx biome check ${lintTarget} --reporter=json 2>/dev/null || true`, cwd);
|
|
19
21
|
try {
|
|
@@ -23,6 +25,9 @@ export function runLint(cwd, stack, workspace) {
|
|
|
23
25
|
// biome path can be string or {file: "..."} depending on version
|
|
24
26
|
const rawPath = d.location?.path;
|
|
25
27
|
const file = typeof rawPath === "string" ? rawPath : rawPath?.file || undefined;
|
|
28
|
+
// Skip generated output files
|
|
29
|
+
if (file && (file.includes(".vibe-check/") || file.includes("node_modules/")))
|
|
30
|
+
continue;
|
|
26
31
|
issues.push({
|
|
27
32
|
severity: d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
|
|
28
33
|
message: d.description || d.message || "lint issue",
|
package/dist/runners/react.js
CHANGED
|
@@ -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,
|
package/dist/runners/secrets.js
CHANGED
|
@@ -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: {
|
|
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
|
};
|
package/dist/runners/security.js
CHANGED
|
@@ -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({
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** Code standards check — naming conventions, anti-patterns, config hygiene. */
|
|
2
|
-
import type { CheckResult, StackInfo } from "../types.js";
|
|
3
|
-
export declare function runStandards(cwd: string, stack: StackInfo): CheckResult;
|
|
2
|
+
import type { CheckResult, StackInfo, WorkspaceInfo } from "../types.js";
|
|
3
|
+
export declare function runStandards(cwd: string, stack: StackInfo, workspace?: WorkspaceInfo): CheckResult;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Code standards check — naming conventions, anti-patterns, config hygiene. */
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { basename, extname, join } from "node:path";
|
|
4
4
|
import { getProductionFiles, readDeps } from "../fs-utils.js";
|
|
5
5
|
import { gradeFromScore } from "../types.js";
|
|
@@ -42,22 +42,39 @@ const CODE_SMELLS = [
|
|
|
42
42
|
message: "Large magic number — consider a named constant",
|
|
43
43
|
},
|
|
44
44
|
];
|
|
45
|
-
export function runStandards(cwd, stack) {
|
|
45
|
+
export function runStandards(cwd, stack, workspace) {
|
|
46
46
|
const start = Date.now();
|
|
47
47
|
const issues = [];
|
|
48
|
+
// Detect CLI projects — console.log is intentional in CLI tools
|
|
49
|
+
let isCLI = false;
|
|
50
|
+
try {
|
|
51
|
+
const pkgPath = join(cwd, "package.json");
|
|
52
|
+
if (existsSync(pkgPath)) {
|
|
53
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
54
|
+
isCLI = !!pkg.bin;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* ignore */ }
|
|
48
58
|
// Collect source files
|
|
49
59
|
const files = getProductionFiles(cwd);
|
|
50
60
|
// ── File naming conventions ──
|
|
51
61
|
let namingViolations = 0;
|
|
62
|
+
// Detect dominant convention for tsx/jsx files before flagging
|
|
63
|
+
const tsxFiles = files.filter((f) => {
|
|
64
|
+
const ext = extname(basename(f.path));
|
|
65
|
+
return ext === ".tsx" || ext === ".jsx";
|
|
66
|
+
});
|
|
67
|
+
const pascalCount = tsxFiles.filter((f) => /^[A-Z]/.test(basename(f.path).replace(extname(basename(f.path)), ""))).length;
|
|
68
|
+
const usesKebabConvention = tsxFiles.length >= 3 && pascalCount < tsxFiles.length / 2;
|
|
52
69
|
for (const f of files) {
|
|
53
70
|
const name = basename(f.path);
|
|
54
71
|
const ext = extname(name);
|
|
55
72
|
const base = name.replace(ext, "");
|
|
56
|
-
// React components should be PascalCase
|
|
73
|
+
// React components should be PascalCase — but only flag if PascalCase is the project convention
|
|
57
74
|
if ((ext === ".tsx" || ext === ".jsx") && /^[A-Z]/.test(base)) {
|
|
58
75
|
// PascalCase component file — correct
|
|
59
76
|
}
|
|
60
|
-
else if ((ext === ".tsx" || ext === ".jsx") && /^[a-z]/.test(base) && base !== "main" && base !== "index") {
|
|
77
|
+
else if (!usesKebabConvention && (ext === ".tsx" || ext === ".jsx") && /^[a-z]/.test(base) && base !== "main" && base !== "index") {
|
|
61
78
|
// lowercase tsx file that's not main/index — check if it exports a component
|
|
62
79
|
if (/export (default )?(function|const) [A-Z]/.test(f.content)) {
|
|
63
80
|
namingViolations++;
|
|
@@ -78,16 +95,25 @@ export function runStandards(cwd, stack) {
|
|
|
78
95
|
}
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
|
-
// ── Large files ──
|
|
98
|
+
// ── Large files (exponential penalty) ──
|
|
99
|
+
// Penalty grows with the square of excess lines. A 300-line file is a nudge.
|
|
100
|
+
// A 600-line file is a wall. A 1000-line file tanks the check.
|
|
82
101
|
let largeFiles = 0;
|
|
102
|
+
let fileSizePenalty = 0;
|
|
103
|
+
const SOFT_LIMIT = 300;
|
|
83
104
|
for (const f of files) {
|
|
84
105
|
const lines = f.content.split("\n").length;
|
|
85
|
-
if (lines >
|
|
106
|
+
if (lines > SOFT_LIMIT * 2) {
|
|
86
107
|
largeFiles++;
|
|
87
|
-
|
|
108
|
+
const excess = lines - SOFT_LIMIT;
|
|
109
|
+
fileSizePenalty += (excess / 100) ** 2;
|
|
110
|
+
issues.push({ severity: "error", message: `${lines} lines — split this file (exponential penalty above ${SOFT_LIMIT})`, file: f.path, rule: "large-file" });
|
|
88
111
|
}
|
|
89
|
-
else if (lines >
|
|
90
|
-
|
|
112
|
+
else if (lines > SOFT_LIMIT) {
|
|
113
|
+
largeFiles++;
|
|
114
|
+
const excess = lines - SOFT_LIMIT;
|
|
115
|
+
fileSizePenalty += (excess / 100) ** 2;
|
|
116
|
+
issues.push({ severity: "warning", message: `${lines} lines — consider splitting (penalty grows exponentially above ${SOFT_LIMIT})`, file: f.path, rule: "large-file" });
|
|
91
117
|
}
|
|
92
118
|
}
|
|
93
119
|
// ── Code smell patterns ──
|
|
@@ -105,8 +131,8 @@ export function runStandards(cwd, stack) {
|
|
|
105
131
|
if (/^\s*["'`].*["'`][,;]?\s*$/.test(line))
|
|
106
132
|
continue;
|
|
107
133
|
for (const check of CODE_SMELLS) {
|
|
108
|
-
// Skip console.log in CLI
|
|
109
|
-
if (check.name === "console.log" &&
|
|
134
|
+
// Skip console.log in CLI projects (intentional terminal output)
|
|
135
|
+
if (check.name === "console.log" && isCLI)
|
|
110
136
|
continue;
|
|
111
137
|
if (check.pattern.test(line)) {
|
|
112
138
|
if (check.exclude?.test(line))
|
|
@@ -121,6 +147,12 @@ export function runStandards(cwd, stack) {
|
|
|
121
147
|
// tsconfig maturity
|
|
122
148
|
if (stack.language === "typescript") {
|
|
123
149
|
const tsconfigPaths = ["tsconfig.json", "tsconfig.app.json", "tsconfig.base.json"];
|
|
150
|
+
// In monorepos, also check each workspace package's tsconfig
|
|
151
|
+
if (workspace?.isMonorepo) {
|
|
152
|
+
for (const pkg of workspace.packages) {
|
|
153
|
+
tsconfigPaths.push(join(pkg.path, "tsconfig.json"));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
124
156
|
let strictFound = false;
|
|
125
157
|
let compilerOpts = {};
|
|
126
158
|
for (const p of tsconfigPaths) {
|
|
@@ -192,7 +224,8 @@ export function runStandards(cwd, stack) {
|
|
|
192
224
|
const totalFiles = files.length || 1;
|
|
193
225
|
const errorPenalty = Math.min(40, (errors / totalFiles) * 150);
|
|
194
226
|
const warningPenalty = Math.min(30, (warnings / totalFiles) * 80);
|
|
195
|
-
|
|
227
|
+
// fileSizePenalty is already exponential — normalize by file count, cap at 80
|
|
228
|
+
const largePenalty = Math.min(80, (fileSizePenalty / totalFiles) * 3);
|
|
196
229
|
const score = Math.max(0, Math.min(100, Math.round(100 - errorPenalty - warningPenalty - largePenalty)));
|
|
197
230
|
return {
|
|
198
231
|
name: "standards",
|
|
@@ -61,7 +61,7 @@ export function runStructure(cwd, stack, workspace) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
// Check for lockfile — in monorepos, lockfiles may be in packages
|
|
64
|
-
const lockfiles = isDart ? ["pubspec.lock"] : ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"];
|
|
64
|
+
const lockfiles = isDart ? ["pubspec.lock"] : ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
|
|
65
65
|
let hasLock = lockfiles.some((f) => existsSync(join(cwd, f)));
|
|
66
66
|
if (!hasLock && workspace?.isMonorepo) {
|
|
67
67
|
hasLock = workspace.packages.some((p) => lockfiles.some((f) => existsSync(join(cwd, p.path, f))));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Styling consistency — delegates to Stylelint when available, adds cross-file analysis.
|
|
2
|
+
*
|
|
3
|
+
* Tool delegation (same pattern as lint → biome/eslint, secrets → gitleaks):
|
|
4
|
+
* - Stylelint installed → run it for CSS/SCSS linting (170+ rules)
|
|
5
|
+
* - Always: cross-file analysis that no CSS linter covers:
|
|
6
|
+
* 1. Mixed styling approaches (Tailwind + CSS modules + styled-components + inline)
|
|
7
|
+
* 2. Hardcoded colors in JSX (not CSS — Stylelint handles CSS)
|
|
8
|
+
* 3. Magic numbers in spacing (cross-file consistency)
|
|
9
|
+
* 4. Inline style ratio
|
|
10
|
+
* 5. !important abuse
|
|
11
|
+
* 6. Duplicate Tailwind class strings across components
|
|
12
|
+
* 7. Inconsistent spacing values across components
|
|
13
|
+
*/
|
|
14
|
+
import type { CheckResult } from "../types.js";
|
|
15
|
+
export declare function runStyling(cwd: string): CheckResult;
|