@vibecodeqa/cli 0.15.0 → 0.17.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/dist/check-meta.js +25 -5
- package/dist/cli.js +13 -0
- package/dist/report/components.d.ts +10 -0
- package/dist/report/components.js +21 -0
- package/dist/report/html.d.ts +7 -5
- package/dist/report/html.js +45 -422
- package/dist/report/pages.d.ts +25 -0
- package/dist/report/pages.js +210 -0
- package/dist/report/styles.d.ts +2 -0
- package/dist/report/styles.js +156 -0
- package/dist/report/svg.d.ts +26 -6
- package/dist/report/svg.js +163 -20
- package/dist/runners/accessibility.d.ts +3 -0
- package/dist/runners/accessibility.js +85 -0
- package/dist/runners/react.d.ts +3 -0
- package/dist/runners/react.js +81 -0
- package/package.json +2 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Accessibility check — detects common a11y violations in JSX/TSX code. */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
6
|
+
export function runAccessibility(cwd) {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
9
|
+
if (files.length === 0) {
|
|
10
|
+
return { name: "accessibility", score: 100, grade: "A", details: { skipped: true, reason: "no JSX/TSX files" }, issues: [], duration: Date.now() - start };
|
|
11
|
+
}
|
|
12
|
+
const issues = [];
|
|
13
|
+
let missingAlt = 0;
|
|
14
|
+
let clickDiv = 0;
|
|
15
|
+
let missingLabel = 0;
|
|
16
|
+
let missingLang = 0;
|
|
17
|
+
let autofocus = 0;
|
|
18
|
+
let positiveTabindex = 0;
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const lines = f.content.split("\n");
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i];
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
25
|
+
continue;
|
|
26
|
+
// 1. <img> without alt
|
|
27
|
+
if (/<img\b/.test(trimmed) && !/alt=/.test(trimmed)) {
|
|
28
|
+
const block = lines.slice(i, Math.min(i + 5, lines.length)).join(" ");
|
|
29
|
+
if (/<img\b/.test(block) && !/alt=/.test(block)) {
|
|
30
|
+
missingAlt++;
|
|
31
|
+
issues.push({ severity: "error", message: "<img> missing alt attribute", file: f.path, line: i + 1, rule: "img-alt" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 2. Click handler on non-interactive element without role/keyboard
|
|
35
|
+
if (/onClick=/.test(trimmed) && /<(?:div|span|p|li|section|article|header|footer)\b/.test(trimmed)) {
|
|
36
|
+
const block = lines.slice(i, Math.min(i + 3, lines.length)).join(" ");
|
|
37
|
+
if (!(/role=/.test(block) && /(?:onKeyDown|onKeyUp|onKeyPress|tabIndex)/.test(block))) {
|
|
38
|
+
clickDiv++;
|
|
39
|
+
issues.push({ severity: "warning", message: "Click handler on non-interactive element without role + keyboard handler", file: f.path, line: i + 1, rule: "click-events" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// 3. <input>/<select>/<textarea> without associated label
|
|
43
|
+
if (/<(?:input|select|textarea)\b/.test(trimmed) && !/type=["'](?:hidden|submit|button|reset)["']/.test(trimmed)) {
|
|
44
|
+
const block = lines.slice(Math.max(0, i - 3), Math.min(i + 3, lines.length)).join(" ");
|
|
45
|
+
if (!/aria-label=/.test(block) && !/aria-labelledby=/.test(block) && !/<label/.test(block) && !/id=/.test(trimmed)) {
|
|
46
|
+
missingLabel++;
|
|
47
|
+
issues.push({ severity: "warning", message: "Form control without label, aria-label, or aria-labelledby", file: f.path, line: i + 1, rule: "form-label" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 4. autoFocus
|
|
51
|
+
if (/\bautoFocus\b/.test(trimmed) || /\bautofocus\b/.test(trimmed)) {
|
|
52
|
+
autofocus++;
|
|
53
|
+
issues.push({ severity: "warning", message: "autoFocus can disorient screen reader users", file: f.path, line: i + 1, rule: "no-autofocus" });
|
|
54
|
+
}
|
|
55
|
+
// 5. Positive tabIndex
|
|
56
|
+
if (/tabIndex=\{[1-9]/.test(trimmed) || /tabindex=["'][1-9]/.test(trimmed)) {
|
|
57
|
+
positiveTabindex++;
|
|
58
|
+
issues.push({ severity: "warning", message: "Positive tabIndex disrupts natural tab order — use 0 or -1", file: f.path, line: i + 1, rule: "tabindex" });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 6. Check for html lang attribute in index.html
|
|
63
|
+
const htmlPaths = ["index.html", "web/index.html", "public/index.html"];
|
|
64
|
+
for (const h of htmlPaths) {
|
|
65
|
+
const full = join(cwd, h);
|
|
66
|
+
if (!existsSync(full))
|
|
67
|
+
continue;
|
|
68
|
+
const content = readFileSync(full, "utf-8");
|
|
69
|
+
if (/<html\b/.test(content) && !/<html[^>]*lang=/.test(content)) {
|
|
70
|
+
missingLang++;
|
|
71
|
+
issues.push({ severity: "warning", message: "<html> missing lang attribute", file: h, rule: "html-lang" });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
75
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
76
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 10 - warnings * 4));
|
|
77
|
+
return {
|
|
78
|
+
name: "accessibility",
|
|
79
|
+
score,
|
|
80
|
+
grade: gradeFromScore(score),
|
|
81
|
+
details: { jsxFiles: files.length, missingAlt, clickDiv, missingLabel, missingLang, autofocus, positiveTabindex },
|
|
82
|
+
issues,
|
|
83
|
+
duration: Date.now() - start,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** React-specific checks — hooks rules, conditional hooks, missing keys, prop spreading. */
|
|
2
|
+
import { gradeFromScore } from "../types.js";
|
|
3
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
4
|
+
export function runReact(cwd, stack) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
if (stack.framework !== "react") {
|
|
7
|
+
return { name: "react", score: 100, grade: "A", details: { skipped: true, reason: "not a React project" }, issues: [], duration: Date.now() - start };
|
|
8
|
+
}
|
|
9
|
+
const files = getProductionFiles(cwd).filter((f) => f.ext === ".tsx" || f.ext === ".jsx");
|
|
10
|
+
if (files.length === 0) {
|
|
11
|
+
return { name: "react", score: 100, grade: "A", details: { skipped: true, reason: "no JSX/TSX files" }, issues: [], duration: Date.now() - start };
|
|
12
|
+
}
|
|
13
|
+
const issues = [];
|
|
14
|
+
let conditionalHooks = 0;
|
|
15
|
+
let missingKeys = 0;
|
|
16
|
+
let propSpreading = 0;
|
|
17
|
+
let inlineHandlers = 0;
|
|
18
|
+
let indexKeys = 0;
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const lines = f.content.split("\n");
|
|
21
|
+
// Track if we're inside a conditional block
|
|
22
|
+
let condDepth = 0;
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i];
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
// Skip comments
|
|
27
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
28
|
+
continue;
|
|
29
|
+
// Track conditional blocks
|
|
30
|
+
if (/\b(if|else|switch)\s*\(/.test(trimmed))
|
|
31
|
+
condDepth++;
|
|
32
|
+
if (condDepth > 0 && trimmed.includes("{"))
|
|
33
|
+
condDepth++;
|
|
34
|
+
if (condDepth > 0 && trimmed.includes("}"))
|
|
35
|
+
condDepth--;
|
|
36
|
+
// 1. Hooks called inside conditionals
|
|
37
|
+
if (condDepth > 0 && /\buse[A-Z]\w*\s*\(/.test(trimmed) && !/\/\//.test(trimmed.split("use")[0])) {
|
|
38
|
+
conditionalHooks++;
|
|
39
|
+
issues.push({ severity: "error", message: "Hook called inside conditional — violates Rules of Hooks", file: f.path, line: i + 1, rule: "conditional-hook" });
|
|
40
|
+
}
|
|
41
|
+
// 2. Missing key in .map() returning JSX
|
|
42
|
+
if (/\.map\s*\(/.test(trimmed)) {
|
|
43
|
+
// Look ahead for JSX return without key
|
|
44
|
+
const mapBlock = lines.slice(i, Math.min(i + 10, lines.length)).join("\n");
|
|
45
|
+
if (/<\w/.test(mapBlock) && !mapBlock.includes("key=") && !mapBlock.includes("key:")) {
|
|
46
|
+
missingKeys++;
|
|
47
|
+
issues.push({ severity: "error", message: "JSX in .map() without key prop", file: f.path, line: i + 1, rule: "missing-key" });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// 3. index as key
|
|
51
|
+
if (/key=\{(?:i|idx|index)\}/.test(trimmed) || /key=\{.*(?:, *(?:i|idx|index)\))/.test(trimmed)) {
|
|
52
|
+
indexKeys++;
|
|
53
|
+
issues.push({ severity: "warning", message: "Using index as key — can cause rendering bugs with reorderable lists", file: f.path, line: i + 1, rule: "index-key" });
|
|
54
|
+
}
|
|
55
|
+
// 4. Prop spreading ({...props} on DOM elements)
|
|
56
|
+
if (/\{\.\.\.(?!children)\w+\}/.test(trimmed) && /<[a-z]/.test(trimmed)) {
|
|
57
|
+
propSpreading++;
|
|
58
|
+
issues.push({ severity: "warning", message: "Spreading props onto DOM element — can pass unexpected attributes", file: f.path, line: i + 1, rule: "prop-spreading" });
|
|
59
|
+
}
|
|
60
|
+
// 5. Inline arrow functions in JSX event handlers (performance)
|
|
61
|
+
if (/on[A-Z]\w*=\{(?:\(\) =>|function)/.test(trimmed)) {
|
|
62
|
+
inlineHandlers++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Only warn about inline handlers if there are many
|
|
67
|
+
if (inlineHandlers > 15) {
|
|
68
|
+
issues.push({ severity: "warning", message: `${inlineHandlers} inline arrow functions in JSX handlers — extract to named functions for readability`, rule: "inline-handlers" });
|
|
69
|
+
}
|
|
70
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
71
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
72
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 8 - warnings * 3));
|
|
73
|
+
return {
|
|
74
|
+
name: "react",
|
|
75
|
+
score,
|
|
76
|
+
grade: gradeFromScore(score),
|
|
77
|
+
details: { jsxFiles: files.length, conditionalHooks, missingKeys, indexKeys, propSpreading, inlineHandlers },
|
|
78
|
+
issues,
|
|
79
|
+
duration: Date.now() - start,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibecodeqa/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Code health scanner for the AI coding era.
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"description": "Code health scanner for the AI coding era. 18 checks, zero config, full report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"vcqa": "./dist/cli.js",
|