@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.
@@ -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";
@@ -45,6 +45,16 @@ const CODE_SMELLS = [
45
45
  export function runStandards(cwd, stack) {
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 ──
@@ -78,16 +88,25 @@ export function runStandards(cwd, stack) {
78
88
  }
79
89
  }
80
90
  }
81
- // ── Large files ──
91
+ // ── Large files (exponential penalty) ──
92
+ // Penalty grows with the square of excess lines. A 300-line file is a nudge.
93
+ // A 600-line file is a wall. A 1000-line file tanks the check.
82
94
  let largeFiles = 0;
95
+ let fileSizePenalty = 0;
96
+ const SOFT_LIMIT = 300;
83
97
  for (const f of files) {
84
98
  const lines = f.content.split("\n").length;
85
- if (lines > 300) {
99
+ if (lines > SOFT_LIMIT * 2) {
86
100
  largeFiles++;
87
- issues.push({ severity: "warning", message: `${lines} lines — consider splitting (max 300)`, file: f.path, rule: "large-file" });
101
+ const excess = lines - SOFT_LIMIT;
102
+ fileSizePenalty += (excess / 100) ** 2;
103
+ issues.push({ severity: "error", message: `${lines} lines — split this file (exponential penalty above ${SOFT_LIMIT})`, file: f.path, rule: "large-file" });
88
104
  }
89
- else if (lines > 200) {
90
- issues.push({ severity: "warning", message: `${lines} lines — getting large`, file: f.path, rule: "large-file" });
105
+ else if (lines > SOFT_LIMIT) {
106
+ largeFiles++;
107
+ const excess = lines - SOFT_LIMIT;
108
+ fileSizePenalty += (excess / 100) ** 2;
109
+ issues.push({ severity: "warning", message: `${lines} lines — consider splitting (penalty grows exponentially above ${SOFT_LIMIT})`, file: f.path, rule: "large-file" });
91
110
  }
92
111
  }
93
112
  // ── Code smell patterns ──
@@ -105,8 +124,8 @@ export function runStandards(cwd, stack) {
105
124
  if (/^\s*["'`].*["'`][,;]?\s*$/.test(line))
106
125
  continue;
107
126
  for (const check of CODE_SMELLS) {
108
- // Skip console.log in CLI entry points (intentional output)
109
- if (check.name === "console.log" && (f.path.includes("cli.") || f.path.includes("bin/")))
127
+ // Skip console.log in CLI projects (intentional terminal output)
128
+ if (check.name === "console.log" && isCLI)
110
129
  continue;
111
130
  if (check.pattern.test(line)) {
112
131
  if (check.exclude?.test(line))
@@ -192,7 +211,8 @@ export function runStandards(cwd, stack) {
192
211
  const totalFiles = files.length || 1;
193
212
  const errorPenalty = Math.min(40, (errors / totalFiles) * 150);
194
213
  const warningPenalty = Math.min(30, (warnings / totalFiles) * 80);
195
- const largePenalty = Math.min(20, (largeFiles / totalFiles) * 100);
214
+ // fileSizePenalty is already exponential normalize by file count, cap at 95
215
+ const largePenalty = Math.min(95, (fileSizePenalty / totalFiles) * 5);
196
216
  const score = Math.max(0, Math.min(100, Math.round(100 - errorPenalty - warningPenalty - largePenalty)));
197
217
  return {
198
218
  name: "standards",
@@ -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;
@@ -0,0 +1,280 @@
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 { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { getProductionFiles, readDeps } from "../fs-utils.js";
17
+ import { gradeFromScore } from "../types.js";
18
+ import { runJSON } from "./exec.js";
19
+ function tryStylelint(cwd) {
20
+ const deps = readDeps(cwd);
21
+ if (!deps.stylelint)
22
+ return null;
23
+ // Check for config
24
+ const hasConfig = existsSync(join(cwd, ".stylelintrc.json")) ||
25
+ existsSync(join(cwd, ".stylelintrc.js")) ||
26
+ existsSync(join(cwd, ".stylelintrc.yml")) ||
27
+ existsSync(join(cwd, "stylelint.config.js")) ||
28
+ existsSync(join(cwd, "stylelint.config.mjs")) ||
29
+ existsSync(join(cwd, "stylelint.config.cjs"));
30
+ if (!hasConfig) {
31
+ // No config — try with standard config if available
32
+ const hasStandard = deps["stylelint-config-standard"] || deps["stylelint-config-recommended"];
33
+ if (!hasStandard)
34
+ return null;
35
+ }
36
+ const results = runJSON("npx stylelint --formatter json \"src/**/*.{css,scss}\" 2>/dev/null || true", cwd, 30_000);
37
+ if (!results || !Array.isArray(results))
38
+ return null;
39
+ const issues = [];
40
+ for (const file of results) {
41
+ const relPath = file.source.replace(cwd + "/", "").replace(cwd + "\\", "");
42
+ for (const w of file.warnings) {
43
+ issues.push({
44
+ severity: w.severity === "error" ? "error" : "warning",
45
+ message: `${w.text} (${w.rule})`,
46
+ file: relPath,
47
+ line: w.line,
48
+ rule: w.rule,
49
+ });
50
+ }
51
+ }
52
+ return issues;
53
+ }
54
+ // ── Cross-file analysis (our unique value — no linter covers this) ──
55
+ const TAILWIND_CLASS = /className\s*=\s*["'`][^"'`]*(?:flex|grid|p-|m-|text-|bg-|rounded|border|shadow|w-|h-)/;
56
+ const CSS_MODULE = /styles\.\w+|\.module\.css|\.module\.scss/;
57
+ const STYLED_COMPONENT = /styled\.\w+|styled\(|css`/;
58
+ const INLINE_STYLE = /style\s*=\s*\{\s*\{|style\s*=\s*\{[^}]/;
59
+ const EMOTION_CSS = /@emotion|css\s*\(/;
60
+ const HARDCODED_COLOR_JSX = /(?:color|backgroundColor|borderColor|fill|stroke)\s*:\s*['"]#[0-9a-fA-F]{3,8}/;
61
+ const SPACING_PROP = /(?:margin|padding|gap|top|bottom|left|right|width|height|inset)\s*:\s*/;
62
+ const MAGIC_PX = /(\d+)px/g;
63
+ export function runStyling(cwd) {
64
+ const start = Date.now();
65
+ const issues = [];
66
+ const files = getProductionFiles(cwd);
67
+ const deps = readDeps(cwd);
68
+ const componentFiles = files.filter((f) => !f.isTest && /\.(tsx|jsx|vue|svelte)$/.test(f.path));
69
+ if (componentFiles.length === 0) {
70
+ return {
71
+ name: "styling",
72
+ score: 0,
73
+ grade: "F",
74
+ details: { skipped: true, reason: "no component files found" },
75
+ issues: [],
76
+ duration: Date.now() - start,
77
+ };
78
+ }
79
+ // ── Phase 1: Delegate to Stylelint ──
80
+ let tool = "built-in";
81
+ const stylelintIssues = tryStylelint(cwd);
82
+ if (stylelintIssues) {
83
+ tool = "stylelint";
84
+ issues.push(...stylelintIssues);
85
+ }
86
+ // ── Phase 2: Cross-file analysis (always runs — Stylelint can't do this) ──
87
+ const approaches = new Map();
88
+ const hasTailwind = existsSync(join(cwd, "tailwind.config.js")) ||
89
+ existsSync(join(cwd, "tailwind.config.ts")) ||
90
+ existsSync(join(cwd, "tailwind.config.mjs")) ||
91
+ !!deps.tailwindcss;
92
+ let inlineStyleCount = 0;
93
+ let classNameCount = 0;
94
+ let hardcodedColorCount = 0;
95
+ let importantCount = 0;
96
+ const spacingValues = new Map();
97
+ const tailwindStrings = new Map();
98
+ // Scan CSS files for !important (only if Stylelint didn't already flag them)
99
+ if (!stylelintIssues) {
100
+ scanCssFiles(cwd, "src", (content) => {
101
+ importantCount += (content.match(/!important/g) || []).length;
102
+ });
103
+ }
104
+ for (const f of componentFiles) {
105
+ const lines = f.content.split("\n");
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i];
108
+ const trimmed = line.trim();
109
+ if (trimmed.startsWith("//") || trimmed.startsWith("*"))
110
+ continue;
111
+ // Detect styling approaches
112
+ if (TAILWIND_CLASS.test(line))
113
+ approaches.set("tailwind", (approaches.get("tailwind") || 0) + 1);
114
+ if (CSS_MODULE.test(line))
115
+ approaches.set("css-modules", (approaches.get("css-modules") || 0) + 1);
116
+ if (STYLED_COMPONENT.test(line))
117
+ approaches.set("styled-components", (approaches.get("styled-components") || 0) + 1);
118
+ if (EMOTION_CSS.test(line))
119
+ approaches.set("emotion", (approaches.get("emotion") || 0) + 1);
120
+ if (INLINE_STYLE.test(line)) {
121
+ approaches.set("inline", (approaches.get("inline") || 0) + 1);
122
+ inlineStyleCount++;
123
+ }
124
+ if (/className/.test(line))
125
+ classNameCount++;
126
+ // Hardcoded colors in JSX (Stylelint only catches CSS files)
127
+ if (HARDCODED_COLOR_JSX.test(line)) {
128
+ if (f.path.includes("tailwind.config") || f.path.includes("theme") || f.path.includes("tokens"))
129
+ continue;
130
+ hardcodedColorCount++;
131
+ if (hardcodedColorCount <= 5) {
132
+ const match = line.match(/#[0-9a-fA-F]{3,8}/);
133
+ issues.push({
134
+ severity: "warning",
135
+ message: `Hardcoded color ${match?.[0] || ""} in JSX — use a CSS variable or design token`,
136
+ file: f.path,
137
+ line: i + 1,
138
+ rule: "hardcoded-color",
139
+ });
140
+ }
141
+ }
142
+ // Spacing values (cross-file consistency)
143
+ if (SPACING_PROP.test(line)) {
144
+ let match;
145
+ MAGIC_PX.lastIndex = 0;
146
+ while ((match = MAGIC_PX.exec(line)) !== null) {
147
+ const px = parseInt(match[1], 10);
148
+ if (px > 2)
149
+ spacingValues.set(px, (spacingValues.get(px) || 0) + 1);
150
+ }
151
+ }
152
+ // Duplicate Tailwind class strings
153
+ if (hasTailwind) {
154
+ const classMatch = line.match(/className\s*=\s*["'`]([^"'`]{20,})["'`]/);
155
+ if (classMatch) {
156
+ const classes = classMatch[1].trim();
157
+ const existing = tailwindStrings.get(classes) || [];
158
+ existing.push(f.path);
159
+ tailwindStrings.set(classes, existing);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ // ── Aggregate findings ──
165
+ // Mixed approaches
166
+ const activeApproaches = [...approaches.entries()].filter(([, count]) => count >= 3);
167
+ if (activeApproaches.length > 1) {
168
+ issues.push({
169
+ severity: "warning",
170
+ message: `Mixed styling approaches: ${activeApproaches.map(([n, c]) => `${n} (${c})`).join(", ")} — pick one`,
171
+ rule: "mixed-styling",
172
+ });
173
+ }
174
+ // Inline style ratio
175
+ if (componentFiles.length > 3 && inlineStyleCount > 0) {
176
+ const ratio = inlineStyleCount / (inlineStyleCount + classNameCount);
177
+ if (ratio > 0.3) {
178
+ issues.push({
179
+ severity: "warning",
180
+ message: `${Math.round(ratio * 100)}% inline styles — extract to CSS classes or Tailwind`,
181
+ rule: "inline-style-ratio",
182
+ });
183
+ }
184
+ }
185
+ // Hardcoded colors summary
186
+ if (hardcodedColorCount > 5) {
187
+ issues.push({ severity: "warning", message: `${hardcodedColorCount} hardcoded colors in JSX — define a color palette`, rule: "hardcoded-color" });
188
+ }
189
+ // !important (only if we counted, not Stylelint)
190
+ if (!stylelintIssues && importantCount > 3) {
191
+ issues.push({ severity: "warning", message: `${importantCount} uses of !important — indicates specificity wars`, rule: "important-abuse" });
192
+ }
193
+ // Inconsistent spacing
194
+ const values = [...spacingValues.keys()].sort((a, b) => a - b);
195
+ const notOnScale = values.filter((v) => v % 4 !== 0 && v !== 1 && v !== 2);
196
+ if (notOnScale.length > 3) {
197
+ issues.push({ severity: "warning", message: `Inconsistent spacing: ${notOnScale.slice(0, 6).join(", ")}px — use a 4px/8px scale`, rule: "inconsistent-spacing" });
198
+ }
199
+ // Duplicate Tailwind strings
200
+ if (hasTailwind) {
201
+ let dupeCount = 0;
202
+ for (const [classes, usedIn] of tailwindStrings) {
203
+ if (usedIn.length >= 3) {
204
+ dupeCount++;
205
+ if (dupeCount <= 3) {
206
+ issues.push({
207
+ severity: "info",
208
+ message: `Tailwind classes duplicated in ${usedIn.length} files — extract component: "${classes.slice(0, 60)}${classes.length > 60 ? "..." : ""}"`,
209
+ rule: "duplicate-tailwind",
210
+ });
211
+ }
212
+ }
213
+ }
214
+ if (dupeCount > 3) {
215
+ issues.push({ severity: "warning", message: `${dupeCount} duplicated Tailwind class strings — extract shared components`, rule: "duplicate-tailwind" });
216
+ }
217
+ }
218
+ // Tailwind theme check
219
+ if (hasTailwind && componentFiles.length > 5) {
220
+ let hasThemeExtend = false;
221
+ for (const cfg of ["tailwind.config.js", "tailwind.config.ts", "tailwind.config.mjs"]) {
222
+ if (existsSync(join(cwd, cfg))) {
223
+ try {
224
+ if (readFileSync(join(cwd, cfg), "utf-8").includes("extend"))
225
+ hasThemeExtend = true;
226
+ }
227
+ catch { /* ignore */ }
228
+ break;
229
+ }
230
+ }
231
+ if (!hasThemeExtend) {
232
+ issues.push({ severity: "info", message: "No theme extension in tailwind.config — consider defining custom colors/spacing", rule: "tailwind-no-theme" });
233
+ }
234
+ }
235
+ const errorCount = issues.filter((i) => i.severity === "error").length;
236
+ const warnCount = issues.filter((i) => i.severity === "warning").length;
237
+ const score = Math.max(0, 100 - errorCount * 20 - warnCount * 12);
238
+ return {
239
+ name: "styling",
240
+ score,
241
+ grade: gradeFromScore(score),
242
+ details: {
243
+ tool,
244
+ totalComponentFiles: componentFiles.length,
245
+ approaches: Object.fromEntries(approaches),
246
+ hasTailwind,
247
+ inlineStyleCount,
248
+ hardcodedColorCount,
249
+ importantCount,
250
+ spacingValues: spacingValues.size,
251
+ stylelintIssues: stylelintIssues?.length ?? 0,
252
+ suggestion: !stylelintIssues ? "Install Stylelint for deeper CSS analysis (170+ rules): pnpm add -D stylelint stylelint-config-standard" : undefined,
253
+ },
254
+ issues,
255
+ duration: Date.now() - start,
256
+ };
257
+ }
258
+ /** Recursively scan for CSS/SCSS files. */
259
+ function scanCssFiles(cwd, subdir, fn) {
260
+ const { readdirSync, statSync } = require("node:fs");
261
+ const dir = join(cwd, subdir);
262
+ if (!existsSync(dir))
263
+ return;
264
+ try {
265
+ for (const entry of readdirSync(dir)) {
266
+ if (entry === "node_modules" || entry === ".git" || entry === ".vibe-check")
267
+ continue;
268
+ const full = join(dir, entry);
269
+ try {
270
+ const stat = statSync(full);
271
+ if (stat.isDirectory())
272
+ scanCssFiles(cwd, join(subdir, entry), fn);
273
+ else if (/\.(css|scss)$/.test(entry))
274
+ fn(readFileSync(full, "utf-8"));
275
+ }
276
+ catch { /* skip */ }
277
+ }
278
+ }
279
+ catch { /* skip */ }
280
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Code health scanner for the AI coding era. 25 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {