@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,33 @@
|
|
|
1
|
+
/** vcqa explain — deep-dive explanation of a check. */
|
|
2
|
+
import { getCheckMeta } from "../check-meta.js";
|
|
3
|
+
export async function runExplain(checkName) {
|
|
4
|
+
if (!checkName) {
|
|
5
|
+
console.log("\n \x1b[1mUsage:\x1b[0m vcqa explain <check>\n");
|
|
6
|
+
console.log(" Available checks:");
|
|
7
|
+
const { CHECK_META } = await import("../check-meta.js");
|
|
8
|
+
for (const [name, meta] of Object.entries(CHECK_META)) {
|
|
9
|
+
console.log(` \x1b[1m${name.padEnd(16)}\x1b[0m ${meta.label} (${meta.category}, ${meta.weight}%)`);
|
|
10
|
+
}
|
|
11
|
+
console.log("");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const meta = getCheckMeta(checkName);
|
|
15
|
+
if (!meta.description || meta.description.length < 20) {
|
|
16
|
+
console.log(`\n \x1b[31mUnknown check: ${checkName}\x1b[0m`);
|
|
17
|
+
console.log(" Run \x1b[1mvcqa explain\x1b[0m to see available checks.\n");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log(` \x1b[1m\x1b[38;5;141m${meta.label}\x1b[0m \x1b[2m${meta.category} · ${meta.priority} priority · ${meta.weight}% weight\x1b[0m`);
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(` \x1b[1mWhat:\x1b[0m ${meta.description}`);
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(` \x1b[1mRisk:\x1b[0m ${meta.risk}`);
|
|
26
|
+
console.log("");
|
|
27
|
+
console.log(` \x1b[1mFix:\x1b[0m ${meta.recommendation}`);
|
|
28
|
+
if (meta.deeperTools?.length) {
|
|
29
|
+
console.log("");
|
|
30
|
+
console.log(` \x1b[1mGo deeper:\x1b[0m ${meta.deeperTools.join(", ")}`);
|
|
31
|
+
}
|
|
32
|
+
console.log("");
|
|
33
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** vcqa fix — auto-fix + AI-powered fixing. */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { aiFixIssues, collectFixableIssues } from "../ai-fix.js";
|
|
5
|
+
import { scan } from "../core.js";
|
|
6
|
+
import { detectStack } from "../detect.js";
|
|
7
|
+
import { gradeFromScore } from "../types.js";
|
|
8
|
+
import { suggestFix, validateCwd } from "./shared.js";
|
|
9
|
+
export async function runFix(cwd, opts = {}) {
|
|
10
|
+
console.log("");
|
|
11
|
+
console.log(` \x1b[1m\x1b[38;5;141mvcqa fix${opts.ai ? " --ai" : ""}${opts.dryRun ? " --dry-run" : ""}${opts.checkFilter ? ` --check ${opts.checkFilter}` : ""}\x1b[0m`);
|
|
12
|
+
console.log(` \x1b[2m${cwd}\x1b[0m`);
|
|
13
|
+
console.log("");
|
|
14
|
+
validateCwd(cwd);
|
|
15
|
+
const stack = detectStack(cwd);
|
|
16
|
+
let fixed = 0;
|
|
17
|
+
// 0. Auto-fix structure issues (missing files)
|
|
18
|
+
if (!existsSync(join(cwd, ".gitignore"))) {
|
|
19
|
+
writeFileSync(join(cwd, ".gitignore"), "node_modules\ndist\n.vibe-check\ncoverage\n.env\n.env.local\n");
|
|
20
|
+
console.log(" \x1b[32m\u2713 Created .gitignore\x1b[0m");
|
|
21
|
+
fixed++;
|
|
22
|
+
}
|
|
23
|
+
if (existsSync(join(cwd, ".gitignore"))) {
|
|
24
|
+
const gi = readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
25
|
+
if (!gi.includes(".vibe-check")) {
|
|
26
|
+
writeFileSync(join(cwd, ".gitignore"), gi.trimEnd() + "\n.vibe-check/\n");
|
|
27
|
+
console.log(" \x1b[32m\u2713 Added .vibe-check/ to .gitignore\x1b[0m");
|
|
28
|
+
fixed++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const tsconfigPath = join(cwd, "tsconfig.json");
|
|
32
|
+
if (existsSync(tsconfigPath)) {
|
|
33
|
+
try {
|
|
34
|
+
const raw = readFileSync(tsconfigPath, "utf-8");
|
|
35
|
+
const tsconfig = JSON.parse(raw);
|
|
36
|
+
if (!tsconfig.compilerOptions?.strict) {
|
|
37
|
+
tsconfig.compilerOptions = { ...tsconfig.compilerOptions, strict: true };
|
|
38
|
+
writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
|
|
39
|
+
console.log(' \x1b[32m\u2713 Enabled "strict": true in tsconfig.json\x1b[0m');
|
|
40
|
+
fixed++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch { /* can't parse tsconfig */ }
|
|
44
|
+
}
|
|
45
|
+
// 1. Run linter auto-fix
|
|
46
|
+
if (stack.linter === "biome") {
|
|
47
|
+
console.log(" \x1b[1mFormatting with Biome...\x1b[0m");
|
|
48
|
+
const { execSync } = await import("node:child_process");
|
|
49
|
+
try {
|
|
50
|
+
execSync("npx biome check --write .", { cwd, stdio: "inherit", timeout: 30_000 });
|
|
51
|
+
fixed++;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
console.log(" \x1b[33mBiome had issues (some may be unfixable)\x1b[0m");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (stack.linter === "eslint") {
|
|
58
|
+
console.log(" \x1b[1mFixing with ESLint...\x1b[0m");
|
|
59
|
+
const { execSync } = await import("node:child_process");
|
|
60
|
+
try {
|
|
61
|
+
execSync("npx eslint --fix src/", { cwd, stdio: "inherit", timeout: 30_000 });
|
|
62
|
+
fixed++;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
console.log(" \x1b[33mESLint had issues (some may be unfixable)\x1b[0m");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// 2. Scan for remaining issues
|
|
69
|
+
console.log("");
|
|
70
|
+
console.log(" \x1b[1mScanning for remaining issues...\x1b[0m");
|
|
71
|
+
console.log("");
|
|
72
|
+
const report = await scan(cwd, { skipTests: true });
|
|
73
|
+
const { checks } = report;
|
|
74
|
+
const score = report.score;
|
|
75
|
+
// AI-powered fix mode
|
|
76
|
+
if (opts.ai) {
|
|
77
|
+
const aiIssues = collectFixableIssues(checks, suggestFix, opts.checkFilter);
|
|
78
|
+
if (aiIssues.length === 0) {
|
|
79
|
+
console.log(" \x1b[2mNo fixable issues found.\x1b[0m");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log(` \x1b[1mAI fixing ${Math.min(aiIssues.length, 10)} issues${opts.dryRun ? " (dry run)" : ""}...\x1b[0m`);
|
|
83
|
+
console.log("");
|
|
84
|
+
const results = await aiFixIssues(cwd, aiIssues, { dryRun: opts.dryRun || false });
|
|
85
|
+
const applied = results.filter((r) => r.applied).length;
|
|
86
|
+
if (applied > 0) {
|
|
87
|
+
console.log("");
|
|
88
|
+
console.log(" \x1b[1mRe-scanning...\x1b[0m");
|
|
89
|
+
const reReport = await scan(cwd, { skipTests: true });
|
|
90
|
+
const delta = reReport.score - score;
|
|
91
|
+
console.log(` Score: \x1b[${reReport.score >= 75 ? "32" : reReport.score >= 60 ? "33" : "31"}m${reReport.grade} ${reReport.score}/100\x1b[0m${delta > 0 ? ` \x1b[32m(+${delta})\x1b[0m` : ""}`);
|
|
92
|
+
console.log(` \x1b[32m${applied} AI fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const grade = gradeFromScore(score);
|
|
96
|
+
console.log(`\n Score: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
|
|
97
|
+
if (opts.dryRun)
|
|
98
|
+
console.log(" \x1b[2mDry run — no files modified. Remove --dry-run to apply.\x1b[0m");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log("");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Non-AI mode: show fix suggestions
|
|
105
|
+
const fixable = [];
|
|
106
|
+
for (const c of checks) {
|
|
107
|
+
for (const iss of c.issues) {
|
|
108
|
+
if (!iss.file || typeof iss.file !== "string" || !iss.line)
|
|
109
|
+
continue;
|
|
110
|
+
const fix = suggestFix(c.name, iss.rule || "", iss.message);
|
|
111
|
+
if (fix)
|
|
112
|
+
fixable.push({ check: c.name, file: iss.file, line: iss.line, message: iss.message, fix });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const top = fixable.slice(0, 10);
|
|
116
|
+
if (top.length > 0) {
|
|
117
|
+
console.log(` \x1b[1m${top.length} issues with fix suggestions:\x1b[0m`);
|
|
118
|
+
console.log("");
|
|
119
|
+
for (const f of top) {
|
|
120
|
+
console.log(` \x1b[2m${f.file}:${f.line}\x1b[0m`);
|
|
121
|
+
console.log(` ${f.message}`);
|
|
122
|
+
console.log(` \x1b[32mFix: ${f.fix}\x1b[0m`);
|
|
123
|
+
console.log("");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const grade = gradeFromScore(score);
|
|
127
|
+
console.log(` Score after fix: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
|
|
128
|
+
if (fixed > 0)
|
|
129
|
+
console.log(` \x1b[32m${fixed} auto-fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
|
|
130
|
+
console.log("");
|
|
131
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** vcqa init — set up CI workflow + recommended configs. */
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { detectStack } from "../detect.js";
|
|
5
|
+
import { validateCwd } from "./shared.js";
|
|
6
|
+
export async function runInit(cwd) {
|
|
7
|
+
console.log("");
|
|
8
|
+
console.log(` \x1b[1m\x1b[38;5;141mvcqa init\x1b[0m`);
|
|
9
|
+
console.log(` \x1b[2m${cwd}\x1b[0m`);
|
|
10
|
+
console.log("");
|
|
11
|
+
validateCwd(cwd);
|
|
12
|
+
const stack = detectStack(cwd);
|
|
13
|
+
let created = 0;
|
|
14
|
+
// 1. GitHub Actions workflow
|
|
15
|
+
const workflowDir = join(cwd, ".github", "workflows");
|
|
16
|
+
const workflowPath = join(workflowDir, "vibecodeqa.yml");
|
|
17
|
+
if (!existsSync(workflowPath)) {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
20
|
+
writeFileSync(workflowPath, `name: VibeCode QA
|
|
21
|
+
on: [pull_request]
|
|
22
|
+
permissions: { contents: read }
|
|
23
|
+
jobs:
|
|
24
|
+
scan:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
- run: npx @vibecodeqa/cli --ci --fail-under 70 --sarif --badge
|
|
29
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
30
|
+
if: always()
|
|
31
|
+
with:
|
|
32
|
+
sarif_file: .vibe-check/report.sarif
|
|
33
|
+
`);
|
|
34
|
+
console.log(` \x1b[32m+\x1b[0m .github/workflows/vibecodeqa.yml`);
|
|
35
|
+
created++;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
console.log(` \x1b[31m!\x1b[0m .github/workflows/vibecodeqa.yml (write failed — check permissions)`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(` \x1b[2m=\x1b[0m .github/workflows/vibecodeqa.yml (exists)`);
|
|
43
|
+
}
|
|
44
|
+
// 2. Biome config (if biome is a dep but no config exists)
|
|
45
|
+
if ((stack.linter === "biome" || existsSync(join(cwd, "node_modules", "@biomejs", "biome"))) &&
|
|
46
|
+
!existsSync(join(cwd, "biome.json")) &&
|
|
47
|
+
!existsSync(join(cwd, "biome.jsonc"))) {
|
|
48
|
+
writeFileSync(join(cwd, "biome.json"), JSON.stringify({
|
|
49
|
+
$schema: "https://biomejs.dev/schemas/2.0.0/schema.json",
|
|
50
|
+
formatter: { indentStyle: "tab", lineWidth: 120 },
|
|
51
|
+
linter: { enabled: true, rules: { recommended: true } },
|
|
52
|
+
organizeImports: { enabled: true },
|
|
53
|
+
}, null, "\t") + "\n");
|
|
54
|
+
console.log(` \x1b[32m+\x1b[0m biome.json`);
|
|
55
|
+
created++;
|
|
56
|
+
}
|
|
57
|
+
// 3. Create .vcqa.json if not present
|
|
58
|
+
const vcqaConfigPath = join(cwd, ".vcqa.json");
|
|
59
|
+
if (!existsSync(vcqaConfigPath)) {
|
|
60
|
+
const { CHECK_META } = await import("../check-meta.js");
|
|
61
|
+
const checksConfig = {};
|
|
62
|
+
for (const name of Object.keys(CHECK_META)) {
|
|
63
|
+
checksConfig[name] = {};
|
|
64
|
+
}
|
|
65
|
+
const config = {
|
|
66
|
+
_comment: "vcqa config — docs: https://vibecodeqa.online/skills",
|
|
67
|
+
checks: checksConfig,
|
|
68
|
+
_checks_help: "Set { \"enabled\": false } to disable. Add \"ignore\": [\"generated/**\"] to skip files per-check.",
|
|
69
|
+
ignore: [],
|
|
70
|
+
_ignore_help: "Global file patterns to skip: [\"vendor/**\", \"*.generated.ts\", \"proto/**\"]",
|
|
71
|
+
failUnder: 60,
|
|
72
|
+
_failUnder_help: "Exit with code 1 if score below this. Overridden by --fail-under flag.",
|
|
73
|
+
};
|
|
74
|
+
writeFileSync(vcqaConfigPath, JSON.stringify(config, null, 2) + "\n");
|
|
75
|
+
console.log(` \x1b[32m+\x1b[0m .vcqa.json`);
|
|
76
|
+
created++;
|
|
77
|
+
}
|
|
78
|
+
// 4. Add .vibe-check to .gitignore
|
|
79
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
80
|
+
if (existsSync(gitignorePath)) {
|
|
81
|
+
const content = readFileSync(gitignorePath, "utf-8");
|
|
82
|
+
if (!content.includes(".vibe-check")) {
|
|
83
|
+
writeFileSync(gitignorePath, content.trimEnd() + "\n.vibe-check/\n");
|
|
84
|
+
console.log(` \x1b[32m+\x1b[0m .gitignore (added .vibe-check/)`);
|
|
85
|
+
created++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
console.log("");
|
|
89
|
+
if (created > 0) {
|
|
90
|
+
console.log(` \x1b[32mCreated ${created} file(s).\x1b[0m Run \x1b[1mnpx @vibecodeqa/cli\x1b[0m to scan.`);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
console.log(` \x1b[2mAlready set up. Run npx @vibecodeqa/cli to scan.\x1b[0m`);
|
|
94
|
+
}
|
|
95
|
+
console.log("");
|
|
96
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Shared utilities for CLI commands. */
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
export function validateCwd(cwd) {
|
|
4
|
+
if (!existsSync(cwd)) {
|
|
5
|
+
console.error(` \x1b[31mError: path does not exist: ${cwd}\x1b[0m`);
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
if (!statSync(cwd).isDirectory()) {
|
|
10
|
+
console.error(` \x1b[31mError: not a directory: ${cwd}\x1b[0m`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
console.error(` \x1b[31mError: cannot access: ${cwd}\x1b[0m`);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Map common issue rules to actionable fix suggestions. */
|
|
20
|
+
export function suggestFix(check, rule, message) {
|
|
21
|
+
if (rule === "empty-catch")
|
|
22
|
+
return "Add error logging: catch(e) { console.error(e); }";
|
|
23
|
+
if (rule === "throw-string")
|
|
24
|
+
return 'Replace throw "msg" with throw new Error("msg")';
|
|
25
|
+
if (rule === "swallowed-promise")
|
|
26
|
+
return "Add logging: .catch((e) => { console.error(e); })";
|
|
27
|
+
if (rule === "floating-promise")
|
|
28
|
+
return "Add await or .catch() to handle the promise";
|
|
29
|
+
if (rule === "unsafe-json-parse")
|
|
30
|
+
return "Wrap in try-catch: try { JSON.parse(x) } catch { /* handle */ }";
|
|
31
|
+
if (rule === "no-error-boundary")
|
|
32
|
+
return "Add <ErrorBoundary> wrapper in your React app root";
|
|
33
|
+
if (rule === "img-alt")
|
|
34
|
+
return 'Add alt attribute: <img alt="description" ...>';
|
|
35
|
+
if (rule === "click-events")
|
|
36
|
+
return 'Add role="button" and onKeyDown handler';
|
|
37
|
+
if (rule === "vue-v-for-key")
|
|
38
|
+
return 'Add :key="item.id" to the v-for element';
|
|
39
|
+
if (rule === "missing-key")
|
|
40
|
+
return "Add key={item.id} to the JSX element in .map()";
|
|
41
|
+
if (rule === "index-key")
|
|
42
|
+
return "Use a stable unique ID instead of array index for key";
|
|
43
|
+
if (rule === "conditional-hook")
|
|
44
|
+
return "Move the hook call before any conditional (if/switch)";
|
|
45
|
+
if (rule === "no-tests")
|
|
46
|
+
return "Create a test file: src/__tests__/example.test.ts";
|
|
47
|
+
if (rule === "no-readme")
|
|
48
|
+
return "Create README.md with: project description, install, usage";
|
|
49
|
+
if (rule === "no-changelog")
|
|
50
|
+
return "Create CHANGELOG.md or use changesets: npx changeset init";
|
|
51
|
+
if (rule === "env-not-ignored")
|
|
52
|
+
return "Add .env to .gitignore";
|
|
53
|
+
if (rule === "secret-detected")
|
|
54
|
+
return "Move to environment variable, rotate the exposed secret";
|
|
55
|
+
if (rule === "no-ci")
|
|
56
|
+
return "Run: npx @vibecodeqa/cli init";
|
|
57
|
+
if (rule === "missing-lockfile")
|
|
58
|
+
return "Run: pnpm install (or npm install) to generate lockfile";
|
|
59
|
+
if (rule === "missing-file" && message.includes("LICENSE"))
|
|
60
|
+
return "Add LICENSE file: https://choosealicense.com/";
|
|
61
|
+
if (rule === "long-function")
|
|
62
|
+
return "Extract logic into smaller helper functions";
|
|
63
|
+
if (rule === "high-complexity")
|
|
64
|
+
return "Reduce nesting: use early returns, extract conditions";
|
|
65
|
+
if (rule === "duplicate-code")
|
|
66
|
+
return "Extract shared logic into a helper function";
|
|
67
|
+
if (rule === "circular-dep")
|
|
68
|
+
return "Extract shared types to a separate file both modules import";
|
|
69
|
+
if (rule === "god-module")
|
|
70
|
+
return "Split into focused interfaces — one responsibility per module";
|
|
71
|
+
if (rule === "process-exit")
|
|
72
|
+
return "Replace process.exit() with throw new Error()";
|
|
73
|
+
if (check === "security" && message.includes("innerHTML"))
|
|
74
|
+
return "Use textContent or DOM APIs instead";
|
|
75
|
+
if (check === "security" && message.includes("ev" + "al"))
|
|
76
|
+
return `Remove ${"ev" + "al"}() — use a safer alternative`;
|
|
77
|
+
if (check === "security" && message.includes("v-html"))
|
|
78
|
+
return 'Sanitize with DOMPurify: v-html="DOMPurify.sanitize(input)"';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
package/dist/core.js
CHANGED
|
@@ -19,15 +19,24 @@ import { runCommentStaleness } from "./runners/comment-staleness.js";
|
|
|
19
19
|
import { runComplexity } from "./runners/complexity.js";
|
|
20
20
|
import { runDeadPatterns } from "./runners/dead-patterns.js";
|
|
21
21
|
import { runTestAudit } from "./runners/test-audit.js";
|
|
22
|
+
import { runContainerHealth } from "./runners/container-health.js";
|
|
22
23
|
import { runConfusion } from "./runners/confusion.js";
|
|
23
24
|
import { runContext } from "./runners/context.js";
|
|
24
25
|
import { runDependencies } from "./runners/dependencies.js";
|
|
26
|
+
import { runDesignConsistency } from "./runners/design-consistency.js";
|
|
27
|
+
import { runEnvValidation } from "./runners/env-validation.js";
|
|
28
|
+
import { runFrontendHealth } from "./runners/frontend-health.js";
|
|
29
|
+
import { runHtmlQuality } from "./runners/html-quality.js";
|
|
30
|
+
import { runFileCohesion } from "./runners/file-cohesion.js";
|
|
31
|
+
import { runGitHygiene } from "./runners/git-hygiene.js";
|
|
25
32
|
import { runDocCoherence } from "./runners/doc-coherence.js";
|
|
26
33
|
import { runDocs } from "./runners/docs.js";
|
|
27
34
|
import { runDuplication } from "./runners/duplication.js";
|
|
28
35
|
import { runErrorHandling } from "./runners/error-handling.js";
|
|
29
36
|
import { runLint } from "./runners/lint.js";
|
|
37
|
+
import { runMemorySafety } from "./runners/memory-safety.js";
|
|
30
38
|
import { runPerformance } from "./runners/performance.js";
|
|
39
|
+
import { runStyling } from "./runners/styling.js";
|
|
31
40
|
import { runReact } from "./runners/react.js";
|
|
32
41
|
import { runSecrets } from "./runners/secrets.js";
|
|
33
42
|
import { runSecurity } from "./runners/security.js";
|
|
@@ -65,19 +74,28 @@ export async function scan(cwd, options = {}) {
|
|
|
65
74
|
{ name: "accessibility", fn: () => runAccessibility(resolvedCwd) },
|
|
66
75
|
{ name: "docs", fn: () => runDocs(resolvedCwd) },
|
|
67
76
|
{ name: "best-practices", fn: () => runBestPractices(resolvedCwd, workspace) },
|
|
77
|
+
{ name: "env-validation", fn: () => runEnvValidation(resolvedCwd) },
|
|
78
|
+
{ name: "git-hygiene", fn: () => runGitHygiene(resolvedCwd) },
|
|
79
|
+
{ name: "memory-safety", fn: () => runMemorySafety(resolvedCwd) },
|
|
68
80
|
{ name: "testing", fn: () => runTesting(resolvedCwd, stack, skipTests, srcRoots) },
|
|
69
81
|
{ name: "secrets", fn: () => runSecrets(resolvedCwd) },
|
|
70
82
|
{ name: "security", fn: () => runSecurity(resolvedCwd) },
|
|
71
83
|
{ name: "dependencies", fn: () => runDependencies(resolvedCwd, stack) },
|
|
72
84
|
{ name: "architecture", fn: () => runArchitecture(resolvedCwd, workspace) },
|
|
73
85
|
{ name: "performance", fn: () => runPerformance(resolvedCwd) },
|
|
86
|
+
{ name: "container-health", fn: () => runContainerHealth(resolvedCwd) },
|
|
74
87
|
{ name: "confusion", fn: () => runConfusion(resolvedCwd) },
|
|
75
88
|
{ name: "context", fn: () => runContext(resolvedCwd) },
|
|
76
89
|
{ name: "doc-coherence", fn: () => runDocCoherence(resolvedCwd) },
|
|
77
90
|
{ name: "code-coherence", fn: () => runCodeCoherence(resolvedCwd) },
|
|
78
91
|
{ name: "comment-staleness", fn: () => runCommentStaleness(resolvedCwd) },
|
|
92
|
+
{ name: "html-quality", fn: () => runHtmlQuality(resolvedCwd) },
|
|
93
|
+
{ name: "frontend-health", fn: () => runFrontendHealth(resolvedCwd) },
|
|
94
|
+
{ name: "styling", fn: () => runStyling(resolvedCwd) },
|
|
79
95
|
{ name: "dead-patterns", fn: () => runDeadPatterns(resolvedCwd) },
|
|
80
96
|
{ name: "test-audit", fn: () => runTestAudit(resolvedCwd) },
|
|
97
|
+
{ name: "file-cohesion", fn: () => runFileCohesion(resolvedCwd) },
|
|
98
|
+
{ name: "design-consistency", fn: () => runDesignConsistency(resolvedCwd) },
|
|
81
99
|
];
|
|
82
100
|
// Filter checks if specified
|
|
83
101
|
const checkFilter = options.checks ? new Set(options.checks) : null;
|
|
@@ -183,7 +183,10 @@ export function runAccessibility(cwd) {
|
|
|
183
183
|
name: "accessibility",
|
|
184
184
|
score,
|
|
185
185
|
grade: gradeFromScore(score),
|
|
186
|
-
details: {
|
|
186
|
+
details: {
|
|
187
|
+
jsxFiles: files.length, missingAlt, clickDiv, missingLabel, missingLang, autofocus, positiveTabindex,
|
|
188
|
+
suggestion: !hasA11yPlugin ? "Install eslint-plugin-jsx-a11y for deeper accessibility analysis: pnpm add -D eslint-plugin-jsx-a11y" : undefined,
|
|
189
|
+
},
|
|
187
190
|
issues,
|
|
188
191
|
duration: Date.now() - start,
|
|
189
192
|
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Container health — Dockerfile best practices, .dockerignore, base image hygiene. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
export function runContainerHealth(cwd) {
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
const issues = [];
|
|
8
|
+
// Find Dockerfiles
|
|
9
|
+
const dockerfiles = [];
|
|
10
|
+
try {
|
|
11
|
+
for (const f of readdirSync(cwd)) {
|
|
12
|
+
if (f === "Dockerfile" || f.startsWith("Dockerfile.") || f === "dockerfile") {
|
|
13
|
+
dockerfiles.push(f);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch { /* not readable */ }
|
|
18
|
+
if (dockerfiles.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
name: "container-health",
|
|
21
|
+
score: 0,
|
|
22
|
+
grade: "F",
|
|
23
|
+
details: { skipped: true, reason: "no Dockerfile found" },
|
|
24
|
+
issues: [],
|
|
25
|
+
duration: Date.now() - start,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Check .dockerignore exists
|
|
29
|
+
if (!existsSync(join(cwd, ".dockerignore"))) {
|
|
30
|
+
issues.push({
|
|
31
|
+
severity: "warning",
|
|
32
|
+
message: "No .dockerignore — node_modules, .git, and secrets may be included in the image",
|
|
33
|
+
rule: "no-dockerignore",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const dockerignore = readFileSync(join(cwd, ".dockerignore"), "utf-8");
|
|
38
|
+
const missing = [];
|
|
39
|
+
if (!dockerignore.includes("node_modules"))
|
|
40
|
+
missing.push("node_modules");
|
|
41
|
+
if (!dockerignore.includes(".git"))
|
|
42
|
+
missing.push(".git");
|
|
43
|
+
if (!dockerignore.includes(".env"))
|
|
44
|
+
missing.push(".env");
|
|
45
|
+
if (missing.length > 0) {
|
|
46
|
+
issues.push({
|
|
47
|
+
severity: "warning",
|
|
48
|
+
message: `.dockerignore missing: ${missing.join(", ")}`,
|
|
49
|
+
file: ".dockerignore",
|
|
50
|
+
rule: "dockerignore-incomplete",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const df of dockerfiles) {
|
|
55
|
+
const content = readFileSync(join(cwd, df), "utf-8");
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
// Check for unpinned base images (FROM node, FROM ubuntu — no tag)
|
|
58
|
+
for (let i = 0; i < lines.length; i++) {
|
|
59
|
+
const line = lines[i].trim();
|
|
60
|
+
if (/^FROM\s+\S+$/i.test(line) && !line.includes(":") && !line.includes("@") && !line.toLowerCase().includes("scratch")) {
|
|
61
|
+
issues.push({
|
|
62
|
+
severity: "error",
|
|
63
|
+
message: `Unpinned base image: ${line} — use a specific tag (e.g., node:22-slim)`,
|
|
64
|
+
file: df,
|
|
65
|
+
line: i + 1,
|
|
66
|
+
rule: "unpinned-base",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Check for :latest tag
|
|
70
|
+
if (/^FROM\s+\S+:latest/i.test(line)) {
|
|
71
|
+
issues.push({
|
|
72
|
+
severity: "warning",
|
|
73
|
+
message: `Using :latest tag: ${line} — pin to a specific version for reproducible builds`,
|
|
74
|
+
file: df,
|
|
75
|
+
line: i + 1,
|
|
76
|
+
rule: "latest-tag",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Check for running as root (no USER instruction)
|
|
81
|
+
if (!content.match(/^USER\s+/m)) {
|
|
82
|
+
issues.push({
|
|
83
|
+
severity: "warning",
|
|
84
|
+
message: "No USER instruction — container runs as root by default",
|
|
85
|
+
file: df,
|
|
86
|
+
rule: "runs-as-root",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Check for multi-stage build (good practice for smaller images)
|
|
90
|
+
const fromCount = (content.match(/^FROM\s+/gim) || []).length;
|
|
91
|
+
if (fromCount === 1 && existsSync(join(cwd, "package.json"))) {
|
|
92
|
+
issues.push({
|
|
93
|
+
severity: "info",
|
|
94
|
+
message: "Single-stage build — multi-stage builds produce smaller images",
|
|
95
|
+
file: df,
|
|
96
|
+
rule: "no-multi-stage",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Check for COPY before npm install (cache busting)
|
|
100
|
+
const copyAllIdx = lines.findIndex((l) => /^COPY\s+\.\s+/i.test(l.trim()));
|
|
101
|
+
const npmInstallIdx = lines.findIndex((l) => /npm install|pnpm install|yarn install/i.test(l));
|
|
102
|
+
if (copyAllIdx !== -1 && npmInstallIdx !== -1 && copyAllIdx < npmInstallIdx) {
|
|
103
|
+
issues.push({
|
|
104
|
+
severity: "warning",
|
|
105
|
+
message: "COPY . before npm install — copy package.json first to leverage Docker cache",
|
|
106
|
+
file: df,
|
|
107
|
+
line: copyAllIdx + 1,
|
|
108
|
+
rule: "cache-bust",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Check for apt-get without cleanup
|
|
112
|
+
if (content.includes("apt-get install") && !content.includes("apt-get clean") && !content.includes("rm -rf /var/lib/apt")) {
|
|
113
|
+
issues.push({
|
|
114
|
+
severity: "info",
|
|
115
|
+
message: "apt-get install without cleanup — add 'apt-get clean && rm -rf /var/lib/apt/lists/*'",
|
|
116
|
+
file: df,
|
|
117
|
+
rule: "apt-no-clean",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Check for EXPOSE
|
|
121
|
+
if (!content.match(/^EXPOSE\s+/m) && (content.includes("node") || content.includes("npm start"))) {
|
|
122
|
+
issues.push({
|
|
123
|
+
severity: "info",
|
|
124
|
+
message: "No EXPOSE instruction — document which port the app listens on",
|
|
125
|
+
file: df,
|
|
126
|
+
rule: "no-expose",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
131
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
132
|
+
const score = Math.max(0, 100 - errorCount * 25 - warnCount * 10);
|
|
133
|
+
return {
|
|
134
|
+
name: "container-health",
|
|
135
|
+
score,
|
|
136
|
+
grade: gradeFromScore(score),
|
|
137
|
+
details: { dockerfiles, hasDockerignore: existsSync(join(cwd, ".dockerignore")) },
|
|
138
|
+
issues,
|
|
139
|
+
duration: Date.now() - start,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Design Consistency — LLM-powered audit of visual consistency across components.
|
|
2
|
+
*
|
|
3
|
+
* Pro feature. Requires VCQA_PRO_KEY env var.
|
|
4
|
+
*
|
|
5
|
+
* Detects:
|
|
6
|
+
* - Components that define the same visual pattern differently (accidental design system)
|
|
7
|
+
* - Spacing/color/typography inconsistencies across files
|
|
8
|
+
* - Missing component extraction opportunities
|
|
9
|
+
* - Tailwind class patterns that should be @apply'd or componentized
|
|
10
|
+
*/
|
|
11
|
+
import type { CheckResult } from "../types.js";
|
|
12
|
+
export declare function runDesignConsistency(cwd: string): Promise<CheckResult>;
|