@vibecodeqa/cli 0.9.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/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/check-meta.d.ts +15 -0
- package/dist/check-meta.js +166 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +140 -0
- package/dist/detect.d.ts +8 -0
- package/dist/detect.js +67 -0
- package/dist/fs-utils.d.ts +23 -0
- package/dist/fs-utils.js +77 -0
- package/dist/report/html.d.ts +12 -0
- package/dist/report/html.js +400 -0
- package/dist/runners/architecture.d.ts +28 -0
- package/dist/runners/architecture.js +272 -0
- package/dist/runners/complexity.d.ts +3 -0
- package/dist/runners/complexity.js +152 -0
- package/dist/runners/confusion.d.ts +16 -0
- package/dist/runners/confusion.js +198 -0
- package/dist/runners/context.d.ts +15 -0
- package/dist/runners/context.js +200 -0
- package/dist/runners/coverage.d.ts +3 -0
- package/dist/runners/coverage.js +65 -0
- package/dist/runners/dependencies.d.ts +3 -0
- package/dist/runners/dependencies.js +106 -0
- package/dist/runners/docs.d.ts +3 -0
- package/dist/runners/docs.js +97 -0
- package/dist/runners/duplication.d.ts +3 -0
- package/dist/runners/duplication.js +100 -0
- package/dist/runners/exec.d.ts +6 -0
- package/dist/runners/exec.js +25 -0
- package/dist/runners/lint.d.ts +3 -0
- package/dist/runners/lint.js +78 -0
- package/dist/runners/secrets.d.ts +3 -0
- package/dist/runners/secrets.js +108 -0
- package/dist/runners/security.d.ts +3 -0
- package/dist/runners/security.js +121 -0
- package/dist/runners/standards.d.ts +3 -0
- package/dist/runners/standards.js +153 -0
- package/dist/runners/structure.d.ts +3 -0
- package/dist/runners/structure.js +110 -0
- package/dist/runners/testing.d.ts +12 -0
- package/dist/runners/testing.js +401 -0
- package/dist/runners/tests.d.ts +3 -0
- package/dist/runners/tests.js +54 -0
- package/dist/runners/type-safety.d.ts +3 -0
- package/dist/runners/type-safety.js +74 -0
- package/dist/runners/types-check.d.ts +3 -0
- package/dist/runners/types-check.js +44 -0
- package/dist/score.d.ts +6 -0
- package/dist/score.js +19 -0
- package/dist/trend.d.ts +19 -0
- package/dist/trend.js +63 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.js +12 -0
- package/package.json +53 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Lint check — auto-detects biome or eslint. */
|
|
2
|
+
import { gradeFromScore } from "../types.js";
|
|
3
|
+
import { run } from "./exec.js";
|
|
4
|
+
export function runLint(cwd, stack) {
|
|
5
|
+
const start = Date.now();
|
|
6
|
+
const issues = [];
|
|
7
|
+
if (stack.linter === "biome") {
|
|
8
|
+
const { stdout } = run("npx biome check src/ --reporter=json 2>/dev/null || true", cwd);
|
|
9
|
+
try {
|
|
10
|
+
const data = JSON.parse(stdout);
|
|
11
|
+
const diagnostics = data.diagnostics || [];
|
|
12
|
+
for (const d of diagnostics) {
|
|
13
|
+
issues.push({
|
|
14
|
+
severity: d.severity === "error"
|
|
15
|
+
? "error"
|
|
16
|
+
: d.severity === "warning"
|
|
17
|
+
? "warning"
|
|
18
|
+
: "info",
|
|
19
|
+
message: d.description || d.message || "lint issue",
|
|
20
|
+
file: d.location?.path,
|
|
21
|
+
line: d.location?.span?.start?.line,
|
|
22
|
+
rule: d.category,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// biome may not output valid JSON on some errors — count from summary
|
|
28
|
+
const errors = stdout.match(/Found (\d+) error/)?.[1] || "0";
|
|
29
|
+
const warnings = stdout.match(/Found (\d+) warning/)?.[1] || "0";
|
|
30
|
+
for (let i = 0; i < parseInt(errors); i++)
|
|
31
|
+
issues.push({ severity: "error", message: "lint error" });
|
|
32
|
+
for (let i = 0; i < parseInt(warnings); i++)
|
|
33
|
+
issues.push({ severity: "warning", message: "lint warning" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (stack.linter === "eslint") {
|
|
37
|
+
const { stdout } = run("npx eslint src/ --format json 2>/dev/null || true", cwd);
|
|
38
|
+
try {
|
|
39
|
+
const files = JSON.parse(stdout);
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
for (const msg of file.messages || []) {
|
|
42
|
+
issues.push({
|
|
43
|
+
severity: msg.severity === 2 ? "error" : "warning",
|
|
44
|
+
message: msg.message,
|
|
45
|
+
file: file.filePath,
|
|
46
|
+
line: msg.line,
|
|
47
|
+
rule: msg.ruleId,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* eslint output parse failed */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
return {
|
|
58
|
+
name: "lint",
|
|
59
|
+
score: 0,
|
|
60
|
+
grade: "F",
|
|
61
|
+
details: { skipped: true, reason: "no linter detected" },
|
|
62
|
+
issues: [],
|
|
63
|
+
duration: Date.now() - start,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
67
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
68
|
+
// Score: start at 100, -10 per error, -2 per warning, floor at 0
|
|
69
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 10 - warnings * 2));
|
|
70
|
+
return {
|
|
71
|
+
name: "lint",
|
|
72
|
+
score,
|
|
73
|
+
grade: gradeFromScore(score),
|
|
74
|
+
details: { errors, warnings, linter: stack.linter },
|
|
75
|
+
issues,
|
|
76
|
+
duration: Date.now() - start,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Secret detection — scans for hardcoded keys/tokens in source files. */
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const SECRET_PATTERNS = [
|
|
6
|
+
{ name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
7
|
+
{
|
|
8
|
+
name: "AWS Secret Key",
|
|
9
|
+
pattern: /(?:aws_secret|AWS_SECRET)[^=]*=\s*['"][A-Za-z0-9/+=]{40}['"]/,
|
|
10
|
+
},
|
|
11
|
+
{ name: "GitHub Token (classic)", pattern: /ghp_[A-Za-z0-9]{36}/ },
|
|
12
|
+
{
|
|
13
|
+
name: "GitHub Token (fine-grained)",
|
|
14
|
+
pattern: /github_pat_[A-Za-z0-9_]{22,}/,
|
|
15
|
+
},
|
|
16
|
+
{ name: "GitHub OAuth", pattern: /gho_[A-Za-z0-9]{36}/ },
|
|
17
|
+
{ name: "Slack Token", pattern: /xox[bpors]-[0-9a-zA-Z-]{10,}/ },
|
|
18
|
+
{ name: "Stripe Secret Key", pattern: /sk_live_[0-9a-zA-Z]{24,}/ },
|
|
19
|
+
{ name: "Stripe Publishable Key", pattern: /pk_live_[0-9a-zA-Z]{24,}/ },
|
|
20
|
+
{
|
|
21
|
+
name: "OpenAI API Key",
|
|
22
|
+
pattern: /sk-[A-Za-z0-9]{20,}T3BlbkFJ[A-Za-z0-9]{20,}/,
|
|
23
|
+
},
|
|
24
|
+
{ name: "Anthropic API Key", pattern: /sk-ant-api\d{2}-[A-Za-z0-9-]{80,}/ },
|
|
25
|
+
{ name: "Google API Key", pattern: /AIza[0-9A-Za-z_-]{35}/ },
|
|
26
|
+
{
|
|
27
|
+
name: "Private Key",
|
|
28
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "Generic Secret Assignment",
|
|
32
|
+
pattern: /(?:password|secret|api_key|apikey|token|auth)\s*[:=]\s*['"][A-Za-z0-9+/=]{20,}['"]/,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
export function runSecrets(cwd) {
|
|
36
|
+
const start = Date.now();
|
|
37
|
+
const issues = [];
|
|
38
|
+
const files = [];
|
|
39
|
+
collectFiles(cwd, files);
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
const content = readFileSync(file, "utf-8");
|
|
42
|
+
const relPath = file.replace(cwd + "/", "");
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i];
|
|
46
|
+
// Skip comments
|
|
47
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
|
|
48
|
+
continue;
|
|
49
|
+
// Skip test files and mock data
|
|
50
|
+
if (relPath.includes(".test.") || relPath.includes("__mock"))
|
|
51
|
+
continue;
|
|
52
|
+
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
53
|
+
if (pattern.test(line)) {
|
|
54
|
+
issues.push({
|
|
55
|
+
severity: "error",
|
|
56
|
+
message: `Possible ${name}`,
|
|
57
|
+
file: relPath,
|
|
58
|
+
line: i + 1,
|
|
59
|
+
rule: "secret-detected",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const score = issues.length === 0 ? 100 : Math.max(0, 100 - issues.length * 25);
|
|
66
|
+
return {
|
|
67
|
+
name: "secrets",
|
|
68
|
+
score,
|
|
69
|
+
grade: gradeFromScore(score),
|
|
70
|
+
details: { secretsFound: issues.length },
|
|
71
|
+
issues,
|
|
72
|
+
duration: Date.now() - start,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function collectFiles(dir, out) {
|
|
76
|
+
for (const entry of readdirSync(dir)) {
|
|
77
|
+
if ([
|
|
78
|
+
"node_modules",
|
|
79
|
+
"dist",
|
|
80
|
+
".git",
|
|
81
|
+
".vibe-check",
|
|
82
|
+
"coverage",
|
|
83
|
+
"test-results",
|
|
84
|
+
].includes(entry))
|
|
85
|
+
continue;
|
|
86
|
+
const full = join(dir, entry);
|
|
87
|
+
const stat = statSync(full);
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
collectFiles(full, out);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const ext = extname(entry);
|
|
93
|
+
if ([
|
|
94
|
+
".ts",
|
|
95
|
+
".tsx",
|
|
96
|
+
".js",
|
|
97
|
+
".jsx",
|
|
98
|
+
".json",
|
|
99
|
+
".env",
|
|
100
|
+
".yaml",
|
|
101
|
+
".yml",
|
|
102
|
+
".toml",
|
|
103
|
+
].includes(ext)) {
|
|
104
|
+
out.push(full);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Security analysis — beyond secrets, checks for vulnerable code patterns. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const PATTERNS = [
|
|
6
|
+
// XSS
|
|
7
|
+
{ name: "innerHTML", pattern: /\.innerHTML\s*=/, severity: "warning", message: "XSS: innerHTML assignment — use textContent or DOM APIs", cwe: "CWE-79" },
|
|
8
|
+
{ name: "dangerouslySetInnerHTML", pattern: /dangerouslySetInnerHTML/, severity: "error", message: "XSS: dangerouslySetInnerHTML bypasses React protection", cwe: "CWE-79" },
|
|
9
|
+
{ name: "document.write", pattern: /document\.write\s*\(/, severity: "error", message: "XSS: document.write is dangerous", cwe: "CWE-79" },
|
|
10
|
+
{ name: "outerHTML", pattern: /\.outerHTML\s*=/, severity: "warning", message: "XSS: outerHTML assignment", cwe: "CWE-79" },
|
|
11
|
+
{ name: "insertAdjacentHTML", pattern: /\.insertAdjacentHTML\s*\(/, severity: "warning", message: "XSS: insertAdjacentHTML with user data", cwe: "CWE-79" },
|
|
12
|
+
// Injection
|
|
13
|
+
{ name: "eval", pattern: /\beval\s*\(/, severity: "error", message: "Injection: eval() executes arbitrary code", cwe: "CWE-94" },
|
|
14
|
+
{ name: "new Function", pattern: /new\s+Function\s*\(/, severity: "error", message: "Injection: new Function() is equivalent to eval()", cwe: "CWE-94" },
|
|
15
|
+
{ name: "child_process.exec", pattern: /\bexec(?:Sync)?\s*\((?!.*\{[^}]*encoding)/, severity: "warning", message: "Command injection risk: prefer execFile with argument array", cwe: "CWE-78" },
|
|
16
|
+
{ name: "template literal in SQL", pattern: /(?:query|prepare|execute)\s*\(\s*`[^`]*\$\{/, severity: "error", message: "SQL injection: use parameterized queries instead of template literals", cwe: "CWE-89" },
|
|
17
|
+
// Crypto
|
|
18
|
+
{ name: "Math.random for security", pattern: /Math\.random\s*\(\).*(?:token|secret|key|password|nonce|salt)/i, severity: "error", message: "Weak randomness: use crypto.randomUUID() or crypto.getRandomValues()", cwe: "CWE-330" },
|
|
19
|
+
{ name: "MD5/SHA1", pattern: /\b(?:md5|sha1|SHA1|MD5)\b/, severity: "warning", message: "Weak hash: MD5/SHA1 are broken — use SHA-256+", cwe: "CWE-328" },
|
|
20
|
+
// Prototype pollution
|
|
21
|
+
{ name: "Object.assign from user input", pattern: /Object\.assign\s*\(\s*\{\s*\}\s*,\s*(?:req|request|body|params|query)/, severity: "warning", message: "Prototype pollution risk: validate/sanitize before Object.assign", cwe: "CWE-1321" },
|
|
22
|
+
{ name: "spread from user input", pattern: /\{\s*\.\.\.(?:req|request|body|params|query)\./, severity: "warning", message: "Prototype pollution: spreading unvalidated user input", cwe: "CWE-1321" },
|
|
23
|
+
// Path traversal
|
|
24
|
+
{ name: "path traversal", pattern: /(?:readFile|writeFile|access|stat)(?:Sync)?\s*\([^)]*(?:req|request|body|params|query)/, severity: "warning", message: "Path traversal: validate file paths from user input", cwe: "CWE-22" },
|
|
25
|
+
// SSRF
|
|
26
|
+
{ name: "fetch with user URL", pattern: /fetch\s*\(\s*(?:req|request|body|params|query)\.(?:url|href|target)/, severity: "warning", message: "SSRF: validate URLs before fetching user-supplied targets", cwe: "CWE-918" },
|
|
27
|
+
// Sensitive data
|
|
28
|
+
{ name: "password in URL", pattern: /(?:password|secret|token|key)=[^&\s'"]+/i, severity: "warning", message: "Sensitive data in URL query string", cwe: "CWE-598" },
|
|
29
|
+
// Missing security headers (in response construction)
|
|
30
|
+
{ name: "no-cache header missing", pattern: /new Response\([^)]*\{[^}]*["']Set-Cookie["']/, severity: "warning", message: "Set-Cookie without Cache-Control: no-store", cwe: "CWE-525" },
|
|
31
|
+
];
|
|
32
|
+
export function runSecurity(cwd) {
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
const issues = [];
|
|
35
|
+
const files = [];
|
|
36
|
+
const dirs = ["src", "web/src"];
|
|
37
|
+
for (const dir of dirs) {
|
|
38
|
+
try {
|
|
39
|
+
collectFiles(join(cwd, dir), files);
|
|
40
|
+
}
|
|
41
|
+
catch { /* dir doesn't exist */ }
|
|
42
|
+
}
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
return { name: "security", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
|
|
45
|
+
}
|
|
46
|
+
const cwePrefixes = new Set();
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const content = readFileSync(file, "utf-8");
|
|
49
|
+
const relPath = file.replace(cwd + "/", "");
|
|
50
|
+
const lines = content.split("\n");
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*"))
|
|
55
|
+
continue;
|
|
56
|
+
for (const p of PATTERNS) {
|
|
57
|
+
if (p.pattern.test(line)) {
|
|
58
|
+
issues.push({
|
|
59
|
+
severity: p.severity,
|
|
60
|
+
message: p.message,
|
|
61
|
+
file: relPath,
|
|
62
|
+
line: i + 1,
|
|
63
|
+
rule: p.cwe || p.name,
|
|
64
|
+
});
|
|
65
|
+
if (p.cwe)
|
|
66
|
+
cwePrefixes.add(p.cwe);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Check for security-critical HTML files
|
|
72
|
+
const htmlFiles = ["index.html", "web/index.html", "public/index.html"];
|
|
73
|
+
for (const h of htmlFiles) {
|
|
74
|
+
const full = join(cwd, h);
|
|
75
|
+
if (!existsSync(full))
|
|
76
|
+
continue;
|
|
77
|
+
const html = readFileSync(full, "utf-8");
|
|
78
|
+
// Missing CSP
|
|
79
|
+
if (!html.includes("Content-Security-Policy") && !html.includes("content-security-policy")) {
|
|
80
|
+
issues.push({ severity: "info", message: "No Content-Security-Policy meta tag in HTML", file: h, rule: "CWE-1021" });
|
|
81
|
+
}
|
|
82
|
+
// External scripts without integrity
|
|
83
|
+
const scripts = html.match(/<script[^>]*src=["'][^"']*["'][^>]*>/g) || [];
|
|
84
|
+
for (const s of scripts) {
|
|
85
|
+
if (s.includes("integrity="))
|
|
86
|
+
continue;
|
|
87
|
+
if (s.includes("localhost") || s.includes("/src/"))
|
|
88
|
+
continue; // local dev scripts
|
|
89
|
+
if (!s.includes("integrity")) {
|
|
90
|
+
issues.push({ severity: "info", message: "External script without subresource integrity (SRI)", file: h, rule: "CWE-829" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
95
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
96
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 15 - warnings * 5));
|
|
97
|
+
return {
|
|
98
|
+
name: "security",
|
|
99
|
+
score,
|
|
100
|
+
grade: gradeFromScore(score),
|
|
101
|
+
details: { filesScanned: files.length, patterns: issues.length, cweCategories: cwePrefixes.size, errors, warnings },
|
|
102
|
+
issues,
|
|
103
|
+
duration: Date.now() - start,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function collectFiles(dir, out) {
|
|
107
|
+
for (const entry of readdirSync(dir)) {
|
|
108
|
+
if (["node_modules", "dist", ".git", ".vibe-check", "coverage", "test-results"].includes(entry))
|
|
109
|
+
continue;
|
|
110
|
+
const full = join(dir, entry);
|
|
111
|
+
if (statSync(full).isDirectory()) {
|
|
112
|
+
collectFiles(full, out);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const ext = extname(entry);
|
|
116
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
117
|
+
out.push(full);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** Code standards check — naming conventions, anti-patterns, config hygiene. */
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const CODE_SMELLS = [
|
|
6
|
+
{ name: "console.log", pattern: /\bconsole\.(log|debug|info)\s*\(/, severity: "warning", message: "console.log in production code", exclude: /\/\/ ?ok|eslint-disable|biome-ignore/ },
|
|
7
|
+
{ name: "var keyword", pattern: /\bvar\s+\w/, severity: "error", message: "Use const/let instead of var" },
|
|
8
|
+
{ name: "loose equality", pattern: /[^!=]==[^=]/, severity: "warning", message: "Use === instead of ==", exclude: /['"]use strict['"]/ },
|
|
9
|
+
{ name: "eval()", pattern: /\beval\s*\(/, severity: "error", message: "eval() is a security risk — never use it" },
|
|
10
|
+
{ name: "new Function()", pattern: /new\s+Function\s*\(/, severity: "error", message: "new Function() is equivalent to eval()" },
|
|
11
|
+
{ name: "innerHTML assignment", pattern: /\.innerHTML\s*=/, severity: "warning", message: "innerHTML is an XSS vector — use textContent or DOM APIs" },
|
|
12
|
+
{ name: "dangerouslySetInnerHTML", pattern: /dangerouslySetInnerHTML/, severity: "error", message: "dangerouslySetInnerHTML bypasses React's XSS protection" },
|
|
13
|
+
{ name: "document.write", pattern: /document\.write\s*\(/, severity: "error", message: "document.write blocks rendering" },
|
|
14
|
+
{ name: "http:// URL", pattern: /['"]http:\/\/(?!localhost|127\.0\.0\.1)/, severity: "warning", message: "Non-HTTPS URL — use https://" },
|
|
15
|
+
{ name: "TODO/FIXME", pattern: /\b(TODO|FIXME|HACK|XXX)\b/, severity: "warning", message: "Unresolved TODO/FIXME comment" },
|
|
16
|
+
{ name: "magic number", pattern: /(?:timeout|delay|interval|limit|max|min)\s*[:=]\s*\d{4,}(?!\d)/, severity: "warning", message: "Large magic number — consider a named constant" },
|
|
17
|
+
];
|
|
18
|
+
export function runStandards(cwd, stack) {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
const issues = [];
|
|
21
|
+
// Collect source files
|
|
22
|
+
const files = [];
|
|
23
|
+
const dirs = ["src", "web/src"];
|
|
24
|
+
for (const dir of dirs) {
|
|
25
|
+
try {
|
|
26
|
+
collectFiles(join(cwd, dir), cwd, files);
|
|
27
|
+
}
|
|
28
|
+
catch { /* dir doesn't exist */ }
|
|
29
|
+
}
|
|
30
|
+
// ── File naming conventions ──
|
|
31
|
+
let namingViolations = 0;
|
|
32
|
+
for (const f of files) {
|
|
33
|
+
const name = basename(f.path);
|
|
34
|
+
const ext = extname(name);
|
|
35
|
+
const base = name.replace(ext, "");
|
|
36
|
+
// React components should be PascalCase
|
|
37
|
+
if ((ext === ".tsx" || ext === ".jsx") && /^[A-Z]/.test(base)) {
|
|
38
|
+
// PascalCase component file — correct
|
|
39
|
+
}
|
|
40
|
+
else if ((ext === ".tsx" || ext === ".jsx") && /^[a-z]/.test(base) && base !== "main" && base !== "index") {
|
|
41
|
+
// lowercase tsx file that's not main/index — check if it exports a component
|
|
42
|
+
if (/export (default )?(function|const) [A-Z]/.test(f.content)) {
|
|
43
|
+
namingViolations++;
|
|
44
|
+
issues.push({ severity: "warning", message: `Component file should be PascalCase: ${name}`, file: f.path, rule: "file-naming" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Non-component TS files should be kebab-case or camelCase
|
|
48
|
+
if (ext === ".ts" && /[A-Z]/.test(base) && base !== "App" && !base.includes(".")) {
|
|
49
|
+
// PascalCase .ts file (not a component) — unusual
|
|
50
|
+
// Only flag if it's not a class file
|
|
51
|
+
if (!/export (default )?class /.test(f.content)) {
|
|
52
|
+
issues.push({ severity: "warning", message: `TS file uses PascalCase but doesn't export a class: ${name}`, file: f.path, rule: "file-naming" });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// ── Large files ──
|
|
57
|
+
let largeFiles = 0;
|
|
58
|
+
for (const f of files) {
|
|
59
|
+
const lines = f.content.split("\n").length;
|
|
60
|
+
if (lines > 300) {
|
|
61
|
+
largeFiles++;
|
|
62
|
+
issues.push({ severity: "warning", message: `${lines} lines — consider splitting (max 300)`, file: f.path, rule: "large-file" });
|
|
63
|
+
}
|
|
64
|
+
else if (lines > 200) {
|
|
65
|
+
issues.push({ severity: "warning", message: `${lines} lines — getting large`, file: f.path, rule: "large-file" });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── Code smell patterns ──
|
|
69
|
+
let smellCount = 0;
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
const lines = f.content.split("\n");
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("*"))
|
|
75
|
+
continue;
|
|
76
|
+
for (const check of CODE_SMELLS) {
|
|
77
|
+
if (check.pattern.test(line)) {
|
|
78
|
+
if (check.exclude && check.exclude.test(line))
|
|
79
|
+
continue;
|
|
80
|
+
smellCount++;
|
|
81
|
+
issues.push({ severity: check.severity, message: check.message, file: f.path, line: i + 1, rule: check.name });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ── Config hygiene ──
|
|
87
|
+
// tsconfig strict mode
|
|
88
|
+
if (stack.language === "typescript") {
|
|
89
|
+
const tsconfigPaths = ["tsconfig.json", "tsconfig.app.json"];
|
|
90
|
+
let strictFound = false;
|
|
91
|
+
for (const p of tsconfigPaths) {
|
|
92
|
+
try {
|
|
93
|
+
const tsconfig = JSON.parse(readFileSync(join(cwd, p), "utf-8"));
|
|
94
|
+
if (tsconfig.compilerOptions?.strict === true)
|
|
95
|
+
strictFound = true;
|
|
96
|
+
}
|
|
97
|
+
catch { /* no tsconfig */ }
|
|
98
|
+
}
|
|
99
|
+
if (!strictFound) {
|
|
100
|
+
issues.push({ severity: "warning", message: "TypeScript strict mode not enabled — add \"strict\": true to tsconfig", rule: "ts-strict" });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Tailwind: check for inline styles when TW is available
|
|
104
|
+
if (stack.framework === "react" && readDeps(cwd).tailwindcss) {
|
|
105
|
+
let inlineStyles = 0;
|
|
106
|
+
for (const f of files) {
|
|
107
|
+
if (!f.path.endsWith(".tsx"))
|
|
108
|
+
continue;
|
|
109
|
+
const matches = f.content.match(/style=\{\{/g);
|
|
110
|
+
if (matches)
|
|
111
|
+
inlineStyles += matches.length;
|
|
112
|
+
}
|
|
113
|
+
if (inlineStyles > 10) {
|
|
114
|
+
issues.push({ severity: "warning", message: `${inlineStyles} inline style objects in TSX — prefer Tailwind classes`, rule: "prefer-tailwind" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
118
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
119
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 8 - warnings * 3 - largeFiles * 5));
|
|
120
|
+
return {
|
|
121
|
+
name: "standards",
|
|
122
|
+
score,
|
|
123
|
+
grade: gradeFromScore(score),
|
|
124
|
+
details: { filesScanned: files.length, codeSmells: smellCount, largeFiles, namingViolations },
|
|
125
|
+
issues,
|
|
126
|
+
duration: Date.now() - start,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function collectFiles(dir, cwd, out) {
|
|
130
|
+
for (const entry of readdirSync(dir)) {
|
|
131
|
+
if (entry === "node_modules" || entry === "dist" || entry === ".git")
|
|
132
|
+
continue;
|
|
133
|
+
const full = join(dir, entry);
|
|
134
|
+
if (statSync(full).isDirectory()) {
|
|
135
|
+
collectFiles(full, cwd, out);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const ext = extname(entry);
|
|
139
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
|
|
140
|
+
out.push({ path: full.replace(cwd + "/", ""), content: readFileSync(full, "utf-8") });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function readDeps(cwd) {
|
|
146
|
+
try {
|
|
147
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
|
148
|
+
return { ...pkg.dependencies, ...pkg.devDependencies };
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Project structure check — does the repo have standard files and conventions? */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join, extname } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
const EXPECTED_FILES = [
|
|
6
|
+
{ name: "package.json", path: "package.json", required: true, description: "Package manifest" },
|
|
7
|
+
{ name: "tsconfig.json", path: "tsconfig.json", required: false, description: "TypeScript configuration" },
|
|
8
|
+
{ name: "LICENSE", path: "LICENSE", required: true, description: "Open source license" },
|
|
9
|
+
{ name: ".gitignore", path: ".gitignore", required: true, description: "Git ignore rules" },
|
|
10
|
+
{ name: "README.md", path: "README.md", required: false, description: "Project documentation" },
|
|
11
|
+
];
|
|
12
|
+
export function runStructure(cwd, stack) {
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
const issues = [];
|
|
15
|
+
const found = [];
|
|
16
|
+
const missing = [];
|
|
17
|
+
// Check standard files
|
|
18
|
+
for (const fc of EXPECTED_FILES) {
|
|
19
|
+
// tsconfig is required only for TS projects
|
|
20
|
+
const required = fc.name === "tsconfig.json" ? stack.language === "typescript" : fc.required;
|
|
21
|
+
if (existsSync(join(cwd, fc.path))) {
|
|
22
|
+
found.push(fc.name);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
missing.push(fc.name);
|
|
26
|
+
issues.push({
|
|
27
|
+
severity: required ? "error" : "warning",
|
|
28
|
+
message: `Missing ${fc.name} — ${fc.description}`,
|
|
29
|
+
rule: "missing-file",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Check for lockfile
|
|
34
|
+
const hasLock = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"].some((f) => existsSync(join(cwd, f)));
|
|
35
|
+
if (hasLock) {
|
|
36
|
+
found.push("lockfile");
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
issues.push({ severity: "warning", message: "No lockfile found — builds may not be reproducible", rule: "missing-lockfile" });
|
|
40
|
+
}
|
|
41
|
+
// Check for src directory
|
|
42
|
+
const hasSrc = existsSync(join(cwd, "src")) || existsSync(join(cwd, "web/src"));
|
|
43
|
+
if (!hasSrc) {
|
|
44
|
+
issues.push({ severity: "error", message: "No src/ directory found", rule: "no-src" });
|
|
45
|
+
}
|
|
46
|
+
// Count source vs test files
|
|
47
|
+
const srcFiles = [];
|
|
48
|
+
const testFiles = [];
|
|
49
|
+
collectAll(cwd, srcFiles, testFiles);
|
|
50
|
+
const srcCount = srcFiles.length;
|
|
51
|
+
const testCount = testFiles.length;
|
|
52
|
+
const testRatio = srcCount > 0 ? testCount / srcCount : 0;
|
|
53
|
+
if (testCount === 0 && srcCount > 0) {
|
|
54
|
+
issues.push({ severity: "error", message: `No test files found (${srcCount} source files with zero tests)`, rule: "no-tests" });
|
|
55
|
+
}
|
|
56
|
+
else if (testRatio < 0.3 && srcCount > 3) {
|
|
57
|
+
issues.push({ severity: "warning", message: `Low test-to-source ratio: ${testCount} tests for ${srcCount} source files (${Math.round(testRatio * 100)}%)`, rule: "low-test-ratio" });
|
|
58
|
+
}
|
|
59
|
+
// Check package.json has essential scripts
|
|
60
|
+
try {
|
|
61
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
|
62
|
+
const scripts = pkg.scripts || {};
|
|
63
|
+
if (!scripts.test)
|
|
64
|
+
issues.push({ severity: "warning", message: "No 'test' script in package.json", rule: "no-test-script" });
|
|
65
|
+
if (!scripts.build && !scripts.dev)
|
|
66
|
+
issues.push({ severity: "info", message: "No 'build' or 'dev' script in package.json", rule: "no-build-script" });
|
|
67
|
+
}
|
|
68
|
+
catch { /* no package.json or parse error */ }
|
|
69
|
+
const errors = issues.filter((i) => i.severity === "error").length;
|
|
70
|
+
const warnings = issues.filter((i) => i.severity === "warning").length;
|
|
71
|
+
const score = Math.max(0, Math.min(100, 100 - errors * 15 - warnings * 5));
|
|
72
|
+
return {
|
|
73
|
+
name: "structure",
|
|
74
|
+
score,
|
|
75
|
+
grade: gradeFromScore(score),
|
|
76
|
+
details: { found, missing, srcFiles: srcCount, testFiles: testCount, testRatio: Math.round(testRatio * 100) + "%" },
|
|
77
|
+
issues,
|
|
78
|
+
duration: Date.now() - start,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function collectAll(cwd, src, test) {
|
|
82
|
+
const dirs = ["src", "web/src"];
|
|
83
|
+
for (const dir of dirs) {
|
|
84
|
+
try {
|
|
85
|
+
walk(join(cwd, dir), src, test);
|
|
86
|
+
}
|
|
87
|
+
catch { /* dir doesn't exist */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function walk(dir, src, test) {
|
|
91
|
+
for (const entry of readdirSync(dir)) {
|
|
92
|
+
if (entry === "node_modules" || entry === "dist")
|
|
93
|
+
continue;
|
|
94
|
+
const full = join(dir, entry);
|
|
95
|
+
if (statSync(full).isDirectory()) {
|
|
96
|
+
walk(full, src, test);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const ext = extname(entry);
|
|
100
|
+
if ([".ts", ".tsx", ".js", ".jsx"].includes(ext)) {
|
|
101
|
+
if (entry.includes(".test.") || entry.includes(".spec.")) {
|
|
102
|
+
test.push(full);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
src.push(full);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Comprehensive testing assessment — pyramid layers, quality, coverage.
|
|
2
|
+
*
|
|
3
|
+
* Dimensions assessed:
|
|
4
|
+
* 1. Pyramid presence — which layers exist? (unit, integration, e2e, component)
|
|
5
|
+
* 2. Test execution — pass/fail from the runner
|
|
6
|
+
* 3. Coverage — statement, branch, function, line
|
|
7
|
+
* 4. File pairing — does each source file have a test file?
|
|
8
|
+
* 5. Test quality — naming, assertions, mocking patterns, snapshot smell
|
|
9
|
+
* 6. Pyramid balance — right ratio of fast-to-slow tests
|
|
10
|
+
*/
|
|
11
|
+
import type { CheckResult, StackInfo } from "../types.js";
|
|
12
|
+
export declare function runTesting(cwd: string, stack: StackInfo, skipExec: boolean): CheckResult;
|