@vibecodeqa/cli 0.41.0 → 0.42.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 +43 -3
- package/dist/cli.js +10 -10
- package/dist/core.js +8 -0
- package/dist/runners/container-health.d.ts +3 -0
- package/dist/runners/container-health.js +141 -0
- package/dist/runners/env-validation.d.ts +3 -0
- package/dist/runners/env-validation.js +122 -0
- package/dist/runners/git-hygiene.d.ts +3 -0
- package/dist/runners/git-hygiene.js +125 -0
- package/dist/runners/memory-safety.d.ts +3 -0
- package/dist/runners/memory-safety.js +114 -0
- package/package.json +1 -1
package/dist/check-meta.js
CHANGED
|
@@ -79,7 +79,7 @@ export const CHECK_META = {
|
|
|
79
79
|
label: "Duplication",
|
|
80
80
|
category: "Quality",
|
|
81
81
|
priority: "medium",
|
|
82
|
-
weight:
|
|
82
|
+
weight: 3,
|
|
83
83
|
description: "Detects copy-pasted code blocks of 6+ lines across source files. Duplication is measured as a percentage of total source lines involved in duplicate blocks.",
|
|
84
84
|
risk: "Duplicated code means bugs must be fixed in multiple places. Miss one copy and the bug persists. DRY (Don't Repeat Yourself) violations increase maintenance cost linearly with each copy.",
|
|
85
85
|
recommendation: "Extract duplicated logic into shared functions or modules. If two files share the same pattern, create a helper. If the duplication is across repos, consider vendoring a shared module.",
|
|
@@ -100,7 +100,7 @@ export const CHECK_META = {
|
|
|
100
100
|
label: "Testing",
|
|
101
101
|
category: "Testing",
|
|
102
102
|
priority: "critical",
|
|
103
|
-
weight:
|
|
103
|
+
weight: 13,
|
|
104
104
|
description: "Deep assessment of test quality across 6 dimensions: pyramid presence (unit/integration/component/E2E layers), test execution (pass/fail), coverage (statement/branch/line/function), file pairing (test file per source file), test quality (assertion density, mock ratio, snapshot ratio), and E2E tool detection (Playwright/Cypress).",
|
|
105
105
|
risk: "Code without tests is code you can't safely change. Missing test layers mean entire categories of bugs go undetected: unit tests catch logic bugs, integration tests catch API contract breaks, E2E tests catch user-visible regressions. Low coverage means large portions of code are never exercised.",
|
|
106
106
|
recommendation: "Follow the testing pyramid: many unit tests, some integration tests, fewer E2E tests. Aim for >80% branch coverage. Every source file should have a corresponding test file. Use Playwright for E2E if you have a web frontend.",
|
|
@@ -153,7 +153,7 @@ export const CHECK_META = {
|
|
|
153
153
|
label: "Confusion Index",
|
|
154
154
|
category: "LLM Readiness",
|
|
155
155
|
priority: "high",
|
|
156
|
-
weight:
|
|
156
|
+
weight: 4,
|
|
157
157
|
description: "Measures naming ambiguity that causes LLMs to misunderstand or edit the wrong code. Checks: file name confusability (Levenshtein distance + synonym detection), generic function/variable names, export name collisions across files, and ambiguous abbreviations.",
|
|
158
158
|
risk: "GPT-4o drops 28.6 percentage points on code summarization when names are ambiguous (arXiv:2510.03178). LLMs editing similar-named files is the #1 reported failure mode in AI-assisted development. Generic names like process(), handle(), data cause models to misinterpret intent.",
|
|
159
159
|
recommendation: "Use descriptive, unique names. Avoid synonym files (utils.ts + helpers.ts — pick one). Avoid generic exports. Disambiguate abbreviations (use 'authentication' not 'auth' if both auth meanings exist in the codebase).",
|
|
@@ -266,6 +266,46 @@ export const CHECK_META = {
|
|
|
266
266
|
recommendation: "Enable test-audit with a VibeCode QA Pro subscription. The LLM analyzes each test to determine if its assertions actually verify the behavior described in its name.",
|
|
267
267
|
premium: true,
|
|
268
268
|
},
|
|
269
|
+
"env-validation": {
|
|
270
|
+
name: "env-validation",
|
|
271
|
+
label: "Environment Validation",
|
|
272
|
+
category: "Quality",
|
|
273
|
+
priority: "medium",
|
|
274
|
+
weight: 2,
|
|
275
|
+
description: "Checks .env file hygiene: .gitignore coverage, .env.example existence and drift, hardcoded secrets in env files, and empty required variables.",
|
|
276
|
+
risk: "A missing .env.example means new developers can't onboard without asking which env vars to set. Drift between .env and .env.example causes 'works on my machine' failures. Committed .env files leak secrets.",
|
|
277
|
+
recommendation: "Create .env.example with all required vars (values blanked). Ensure .env is in .gitignore. Keep .env.example in sync with .env.",
|
|
278
|
+
},
|
|
279
|
+
"git-hygiene": {
|
|
280
|
+
name: "git-hygiene",
|
|
281
|
+
label: "Git Hygiene",
|
|
282
|
+
category: "Quality",
|
|
283
|
+
priority: "medium",
|
|
284
|
+
weight: 2,
|
|
285
|
+
description: "Checks git repository health: merge conflict markers in source, commit message quality, large/binary files tracked, and .gitignore completeness.",
|
|
286
|
+
risk: "Merge conflict markers cause syntax errors. Large binary files bloat the repo forever (git history is append-only). Poor commit messages make git blame and bisect useless for debugging.",
|
|
287
|
+
recommendation: "Resolve all merge conflicts. Use Git LFS for files over 5MB. Write descriptive commit messages (what and why, not just 'fix').",
|
|
288
|
+
},
|
|
289
|
+
"memory-safety": {
|
|
290
|
+
name: "memory-safety",
|
|
291
|
+
label: "Memory Safety",
|
|
292
|
+
category: "Quality",
|
|
293
|
+
priority: "high",
|
|
294
|
+
weight: 2,
|
|
295
|
+
description: "Detects resource leak patterns: setInterval without clearInterval, addEventListener without removeEventListener, unclosed WebSockets/Observers, and global variable pollution.",
|
|
296
|
+
risk: "Resource leaks cause memory growth over time, eventually crashing the app or browser tab. Leaked event listeners fire on stale state, causing bugs. Global pollution creates hard-to-trace conflicts between modules.",
|
|
297
|
+
recommendation: "Always pair setInterval with clearInterval in cleanup. Remove event listeners in componentWillUnmount/useEffect return. Call .disconnect() on Observers. Avoid window.* assignments.",
|
|
298
|
+
},
|
|
299
|
+
"container-health": {
|
|
300
|
+
name: "container-health",
|
|
301
|
+
label: "Container Health",
|
|
302
|
+
category: "Quality",
|
|
303
|
+
priority: "medium",
|
|
304
|
+
weight: 0,
|
|
305
|
+
description: "Checks Dockerfile best practices: pinned base images, .dockerignore, multi-stage builds, layer caching, non-root user, and exposed ports.",
|
|
306
|
+
risk: "Unpinned base images break builds when upstream tags change. Missing .dockerignore includes node_modules and .git in the image (10x size). Running as root in containers is a security risk.",
|
|
307
|
+
recommendation: "Pin base images to specific tags. Add .dockerignore with node_modules/.git/.env. Use multi-stage builds. Add USER instruction.",
|
|
308
|
+
},
|
|
269
309
|
};
|
|
270
310
|
export function getCheckMeta(name) {
|
|
271
311
|
return (CHECK_META[name] || {
|
package/dist/cli.js
CHANGED
|
@@ -18,13 +18,17 @@ import { runComplexity } from "./runners/complexity.js";
|
|
|
18
18
|
import { runDeadPatterns } from "./runners/dead-patterns.js";
|
|
19
19
|
import { runTestAudit } from "./runners/test-audit.js";
|
|
20
20
|
import { runConfusion } from "./runners/confusion.js";
|
|
21
|
+
import { runContainerHealth } from "./runners/container-health.js";
|
|
21
22
|
import { runContext } from "./runners/context.js";
|
|
22
23
|
import { runDependencies } from "./runners/dependencies.js";
|
|
24
|
+
import { runEnvValidation } from "./runners/env-validation.js";
|
|
25
|
+
import { runGitHygiene } from "./runners/git-hygiene.js";
|
|
23
26
|
import { runDocCoherence } from "./runners/doc-coherence.js";
|
|
24
27
|
import { runDocs } from "./runners/docs.js";
|
|
25
28
|
import { runDuplication } from "./runners/duplication.js";
|
|
26
29
|
import { runErrorHandling } from "./runners/error-handling.js";
|
|
27
30
|
import { runLint } from "./runners/lint.js";
|
|
31
|
+
import { runMemorySafety } from "./runners/memory-safety.js";
|
|
28
32
|
import { runPerformance } from "./runners/performance.js";
|
|
29
33
|
import { runReact } from "./runners/react.js";
|
|
30
34
|
import { runSecrets } from "./runners/secrets.js";
|
|
@@ -117,6 +121,9 @@ async function runChecks(cwd, stack, workspace, skipTests, isDart, jsonOnly, con
|
|
|
117
121
|
{ name: "accessibility", fn: () => runAccessibility(cwd) },
|
|
118
122
|
{ name: "docs", fn: () => runDocs(cwd) },
|
|
119
123
|
{ name: "best-practices", fn: () => runBestPractices(cwd, workspace) },
|
|
124
|
+
{ name: "env-validation", fn: () => runEnvValidation(cwd) },
|
|
125
|
+
{ name: "git-hygiene", fn: () => runGitHygiene(cwd) },
|
|
126
|
+
{ name: "memory-safety", fn: () => runMemorySafety(cwd) },
|
|
120
127
|
// Testing
|
|
121
128
|
{ name: "testing", fn: () => runTesting(cwd, stack, skipTests, srcRoots) },
|
|
122
129
|
// Security
|
|
@@ -126,6 +133,7 @@ async function runChecks(cwd, stack, workspace, skipTests, isDart, jsonOnly, con
|
|
|
126
133
|
// Architecture
|
|
127
134
|
{ name: "architecture", fn: () => runArchitecture(cwd, workspace) },
|
|
128
135
|
{ name: "performance", fn: () => runPerformance(cwd) },
|
|
136
|
+
{ name: "container-health", fn: () => runContainerHealth(cwd) },
|
|
129
137
|
// LLM Readiness
|
|
130
138
|
{ name: "confusion", fn: () => runConfusion(cwd) },
|
|
131
139
|
{ name: "context", fn: () => runContext(cwd) },
|
|
@@ -477,17 +485,9 @@ jobs:
|
|
|
477
485
|
// 3. Create .vcqa.json if not present
|
|
478
486
|
const vcqaConfigPath = join(cwd, ".vcqa.json");
|
|
479
487
|
if (!existsSync(vcqaConfigPath)) {
|
|
480
|
-
const
|
|
481
|
-
"structure", "lint", "types", "type-safety", "standards",
|
|
482
|
-
"complexity", "duplication", "error-handling", "react", "accessibility",
|
|
483
|
-
"docs", "best-practices", "testing",
|
|
484
|
-
"secrets", "security", "dependencies",
|
|
485
|
-
"architecture", "performance",
|
|
486
|
-
"confusion", "context",
|
|
487
|
-
"doc-coherence", "code-coherence", "comment-staleness", "dead-patterns", "test-audit",
|
|
488
|
-
];
|
|
488
|
+
const { CHECK_META } = await import("./check-meta.js");
|
|
489
489
|
const checksConfig = {};
|
|
490
|
-
for (const name of
|
|
490
|
+
for (const name of Object.keys(CHECK_META)) {
|
|
491
491
|
checksConfig[name] = {};
|
|
492
492
|
}
|
|
493
493
|
const config = {
|
package/dist/core.js
CHANGED
|
@@ -19,14 +19,18 @@ 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 { runEnvValidation } from "./runners/env-validation.js";
|
|
27
|
+
import { runGitHygiene } from "./runners/git-hygiene.js";
|
|
25
28
|
import { runDocCoherence } from "./runners/doc-coherence.js";
|
|
26
29
|
import { runDocs } from "./runners/docs.js";
|
|
27
30
|
import { runDuplication } from "./runners/duplication.js";
|
|
28
31
|
import { runErrorHandling } from "./runners/error-handling.js";
|
|
29
32
|
import { runLint } from "./runners/lint.js";
|
|
33
|
+
import { runMemorySafety } from "./runners/memory-safety.js";
|
|
30
34
|
import { runPerformance } from "./runners/performance.js";
|
|
31
35
|
import { runReact } from "./runners/react.js";
|
|
32
36
|
import { runSecrets } from "./runners/secrets.js";
|
|
@@ -65,12 +69,16 @@ export async function scan(cwd, options = {}) {
|
|
|
65
69
|
{ name: "accessibility", fn: () => runAccessibility(resolvedCwd) },
|
|
66
70
|
{ name: "docs", fn: () => runDocs(resolvedCwd) },
|
|
67
71
|
{ name: "best-practices", fn: () => runBestPractices(resolvedCwd, workspace) },
|
|
72
|
+
{ name: "env-validation", fn: () => runEnvValidation(resolvedCwd) },
|
|
73
|
+
{ name: "git-hygiene", fn: () => runGitHygiene(resolvedCwd) },
|
|
74
|
+
{ name: "memory-safety", fn: () => runMemorySafety(resolvedCwd) },
|
|
68
75
|
{ name: "testing", fn: () => runTesting(resolvedCwd, stack, skipTests, srcRoots) },
|
|
69
76
|
{ name: "secrets", fn: () => runSecrets(resolvedCwd) },
|
|
70
77
|
{ name: "security", fn: () => runSecurity(resolvedCwd) },
|
|
71
78
|
{ name: "dependencies", fn: () => runDependencies(resolvedCwd, stack) },
|
|
72
79
|
{ name: "architecture", fn: () => runArchitecture(resolvedCwd, workspace) },
|
|
73
80
|
{ name: "performance", fn: () => runPerformance(resolvedCwd) },
|
|
81
|
+
{ name: "container-health", fn: () => runContainerHealth(resolvedCwd) },
|
|
74
82
|
{ name: "confusion", fn: () => runConfusion(resolvedCwd) },
|
|
75
83
|
{ name: "context", fn: () => runContext(resolvedCwd) },
|
|
76
84
|
{ name: "doc-coherence", fn: () => runDocCoherence(resolvedCwd) },
|
|
@@ -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,122 @@
|
|
|
1
|
+
/** Environment validation — checks .env hygiene, .env.example drift, and unsafe patterns. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { gradeFromScore } from "../types.js";
|
|
5
|
+
export function runEnvValidation(cwd) {
|
|
6
|
+
const start = Date.now();
|
|
7
|
+
const issues = [];
|
|
8
|
+
const envFiles = readdirSync(cwd).filter((f) => f.startsWith(".env"));
|
|
9
|
+
const hasEnv = envFiles.some((f) => f === ".env" || f === ".env.local");
|
|
10
|
+
const hasExample = envFiles.some((f) => f === ".env.example" || f === ".env.template");
|
|
11
|
+
// Check .gitignore includes .env
|
|
12
|
+
if (hasEnv) {
|
|
13
|
+
const gitignore = existsSync(join(cwd, ".gitignore")) ? readFileSync(join(cwd, ".gitignore"), "utf-8") : "";
|
|
14
|
+
if (!gitignore.includes(".env")) {
|
|
15
|
+
issues.push({ severity: "error", message: ".env not in .gitignore — secrets may be committed", file: ".gitignore", rule: "env-not-ignored" });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// Check .env.example exists when .env does
|
|
19
|
+
if (hasEnv && !hasExample) {
|
|
20
|
+
issues.push({ severity: "warning", message: "No .env.example — other developers won't know which vars are needed", rule: "no-env-example" });
|
|
21
|
+
}
|
|
22
|
+
// Check .env.example drift — vars in .env.example should match .env
|
|
23
|
+
if (hasEnv && hasExample) {
|
|
24
|
+
const exampleFile = envFiles.find((f) => f === ".env.example" || f === ".env.template");
|
|
25
|
+
const envVars = parseEnvKeys(readFileSync(join(cwd, ".env"), "utf-8"));
|
|
26
|
+
const exampleVars = parseEnvKeys(readFileSync(join(cwd, exampleFile), "utf-8"));
|
|
27
|
+
for (const key of exampleVars) {
|
|
28
|
+
if (!envVars.has(key)) {
|
|
29
|
+
issues.push({ severity: "info", message: `${exampleFile} has ${key} but .env doesn't — may be missing`, file: exampleFile, rule: "env-example-drift" });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const key of envVars) {
|
|
33
|
+
if (!exampleVars.has(key)) {
|
|
34
|
+
issues.push({ severity: "warning", message: `${key} in .env but not in ${exampleFile} — won't be documented for other developers`, file: exampleFile, rule: "env-example-drift" });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Scan .env files for unsafe patterns
|
|
39
|
+
for (const f of envFiles) {
|
|
40
|
+
if (f === ".env.example" || f === ".env.template")
|
|
41
|
+
continue;
|
|
42
|
+
const content = readFileSync(join(cwd, f), "utf-8");
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
for (let i = 0; i < lines.length; i++) {
|
|
45
|
+
const line = lines[i].trim();
|
|
46
|
+
if (!line || line.startsWith("#"))
|
|
47
|
+
continue;
|
|
48
|
+
// Check for values that look like they should be secret but have defaults
|
|
49
|
+
if (/^(DATABASE_URL|DB_PASSWORD|SECRET_KEY|JWT_SECRET|API_KEY|PRIVATE_KEY)=/i.test(line)) {
|
|
50
|
+
const value = line.split("=").slice(1).join("=").trim().replace(/^["']|["']$/g, "");
|
|
51
|
+
if (value && !value.startsWith("$") && !value.includes("${") && value.length < 20 && !/^(changeme|replace|todo|xxx|your[-_])/i.test(value)) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: `${line.split("=")[0]} appears to have a hardcoded value — use a placeholder in committed files`,
|
|
55
|
+
file: f,
|
|
56
|
+
line: i + 1,
|
|
57
|
+
rule: "env-hardcoded-secret",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Check for empty required-looking vars
|
|
62
|
+
if (/^[A-Z_]+=\s*$/.test(line)) {
|
|
63
|
+
const key = line.split("=")[0];
|
|
64
|
+
if (/KEY|SECRET|TOKEN|PASSWORD|URL/i.test(key)) {
|
|
65
|
+
issues.push({
|
|
66
|
+
severity: "info",
|
|
67
|
+
message: `${key} is empty — may cause runtime errors`,
|
|
68
|
+
file: f,
|
|
69
|
+
line: i + 1,
|
|
70
|
+
rule: "env-empty-var",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Check for env vars used in code but not in .env.example
|
|
77
|
+
if (hasExample) {
|
|
78
|
+
const exampleFile = envFiles.find((f) => f === ".env.example" || f === ".env.template");
|
|
79
|
+
const exampleVars = parseEnvKeys(readFileSync(join(cwd, exampleFile), "utf-8"));
|
|
80
|
+
// Quick scan of package.json for referenced env vars
|
|
81
|
+
if (existsSync(join(cwd, "package.json"))) {
|
|
82
|
+
try {
|
|
83
|
+
const pkg = readFileSync(join(cwd, "package.json"), "utf-8");
|
|
84
|
+
const envRefs = pkg.match(/process\.env\.([A-Z_]+)/g) || [];
|
|
85
|
+
for (const ref of new Set(envRefs)) {
|
|
86
|
+
const varName = ref.replace("process.env.", "");
|
|
87
|
+
if (!exampleVars.has(varName) && !["NODE_ENV", "CI", "HOME", "PATH", "PWD"].includes(varName)) {
|
|
88
|
+
issues.push({
|
|
89
|
+
severity: "info",
|
|
90
|
+
message: `${varName} used in code but not in ${exampleFile}`,
|
|
91
|
+
rule: "env-undocumented",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
100
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
101
|
+
const score = Math.max(0, 100 - errorCount * 25 - warnCount * 10);
|
|
102
|
+
return {
|
|
103
|
+
name: "env-validation",
|
|
104
|
+
score,
|
|
105
|
+
grade: gradeFromScore(score),
|
|
106
|
+
details: { envFiles, hasExample },
|
|
107
|
+
issues,
|
|
108
|
+
duration: Date.now() - start,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function parseEnvKeys(content) {
|
|
112
|
+
const keys = new Set();
|
|
113
|
+
for (const line of content.split("\n")) {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
116
|
+
continue;
|
|
117
|
+
const eq = trimmed.indexOf("=");
|
|
118
|
+
if (eq > 0)
|
|
119
|
+
keys.add(trimmed.slice(0, eq).trim());
|
|
120
|
+
}
|
|
121
|
+
return keys;
|
|
122
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Git hygiene — checks commit quality, large files, and repo health. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
5
|
+
import { gradeFromScore } from "../types.js";
|
|
6
|
+
import { run } from "./exec.js";
|
|
7
|
+
export function runGitHygiene(cwd) {
|
|
8
|
+
const start = Date.now();
|
|
9
|
+
const issues = [];
|
|
10
|
+
if (!existsSync(join(cwd, ".git"))) {
|
|
11
|
+
return {
|
|
12
|
+
name: "git-hygiene",
|
|
13
|
+
score: 0,
|
|
14
|
+
grade: "F",
|
|
15
|
+
details: { skipped: true, reason: "not a git repository" },
|
|
16
|
+
issues: [],
|
|
17
|
+
duration: Date.now() - start,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// 1. Check for merge conflict markers in source files
|
|
21
|
+
const files = getProductionFiles(cwd);
|
|
22
|
+
for (const f of files) {
|
|
23
|
+
const lines = f.content.split("\n");
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
if (/^<{7}\s|^={7}$|^>{7}\s/.test(lines[i])) {
|
|
26
|
+
issues.push({
|
|
27
|
+
severity: "error",
|
|
28
|
+
message: "Merge conflict marker found",
|
|
29
|
+
file: f.path,
|
|
30
|
+
line: i + 1,
|
|
31
|
+
rule: "merge-conflict",
|
|
32
|
+
});
|
|
33
|
+
break; // one per file is enough
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// 2. Check recent commit message quality (last 20 commits)
|
|
38
|
+
const { stdout: logOutput, ok: logOk } = run("git log --oneline -20 --format='%s' 2>/dev/null", cwd, 10_000);
|
|
39
|
+
if (logOk && logOutput.trim()) {
|
|
40
|
+
const messages = logOutput.trim().split("\n").filter(Boolean);
|
|
41
|
+
let poorMessages = 0;
|
|
42
|
+
for (const msg of messages) {
|
|
43
|
+
const trimmed = msg.trim().replace(/^'|'$/g, "");
|
|
44
|
+
// Flag very short or generic commit messages
|
|
45
|
+
if (trimmed.length < 5 || /^(fix|update|change|wip|test|stuff|asdf|temp|\.+)$/i.test(trimmed)) {
|
|
46
|
+
poorMessages++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (messages.length > 0) {
|
|
50
|
+
const poorRatio = poorMessages / messages.length;
|
|
51
|
+
if (poorRatio > 0.5) {
|
|
52
|
+
issues.push({
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: `${poorMessages}/${messages.length} recent commits have low-quality messages`,
|
|
55
|
+
rule: "poor-commit-messages",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 3. Check for large files tracked in git
|
|
61
|
+
const { stdout: lsOutput, ok: lsOk } = run("git ls-files -z 2>/dev/null | xargs -0 -I{} sh -c 'wc -c < \"{}\" | tr -d \" \" | xargs -I@ echo @\\t{}' 2>/dev/null | sort -rn | head -5", cwd, 15_000);
|
|
62
|
+
if (lsOk && lsOutput.trim()) {
|
|
63
|
+
for (const line of lsOutput.trim().split("\n")) {
|
|
64
|
+
const parts = line.split("\t");
|
|
65
|
+
if (parts.length < 2)
|
|
66
|
+
continue;
|
|
67
|
+
const size = parseInt(parts[0], 10);
|
|
68
|
+
const file = parts[1];
|
|
69
|
+
if (size > 5_000_000) { // 5MB
|
|
70
|
+
issues.push({
|
|
71
|
+
severity: "warning",
|
|
72
|
+
message: `Large file tracked in git: ${file} (${(size / 1_000_000).toFixed(1)}MB) — consider Git LFS`,
|
|
73
|
+
file,
|
|
74
|
+
rule: "large-file",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 4. Check for committed binary files
|
|
80
|
+
const binaryExts = new Set([".zip", ".tar", ".gz", ".jar", ".war", ".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".sqlite", ".db"]);
|
|
81
|
+
const { stdout: allFiles } = run("git ls-files 2>/dev/null", cwd, 10_000);
|
|
82
|
+
if (allFiles) {
|
|
83
|
+
for (const file of allFiles.trim().split("\n")) {
|
|
84
|
+
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
|
|
85
|
+
if (binaryExts.has(ext)) {
|
|
86
|
+
issues.push({
|
|
87
|
+
severity: "warning",
|
|
88
|
+
message: `Binary file tracked in git: ${file} — use .gitignore or Git LFS`,
|
|
89
|
+
file,
|
|
90
|
+
rule: "binary-in-git",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 5. Check .gitignore completeness
|
|
96
|
+
if (existsSync(join(cwd, ".gitignore"))) {
|
|
97
|
+
const gitignore = require("node:fs").readFileSync(join(cwd, ".gitignore"), "utf-8");
|
|
98
|
+
const missing = [];
|
|
99
|
+
if (!gitignore.includes("node_modules") && existsSync(join(cwd, "package.json")))
|
|
100
|
+
missing.push("node_modules");
|
|
101
|
+
if (!gitignore.includes(".env") && existsSync(join(cwd, ".env")))
|
|
102
|
+
missing.push(".env");
|
|
103
|
+
if (!gitignore.includes("dist") && !gitignore.includes("build"))
|
|
104
|
+
missing.push("dist/build");
|
|
105
|
+
if (missing.length > 0) {
|
|
106
|
+
issues.push({
|
|
107
|
+
severity: "warning",
|
|
108
|
+
message: `.gitignore missing common entries: ${missing.join(", ")}`,
|
|
109
|
+
file: ".gitignore",
|
|
110
|
+
rule: "gitignore-incomplete",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
115
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
116
|
+
const score = Math.max(0, 100 - errorCount * 30 - warnCount * 10);
|
|
117
|
+
return {
|
|
118
|
+
name: "git-hygiene",
|
|
119
|
+
score,
|
|
120
|
+
grade: gradeFromScore(score),
|
|
121
|
+
details: { commitCount: logOutput?.trim().split("\n").length || 0 },
|
|
122
|
+
issues,
|
|
123
|
+
duration: Date.now() - start,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Memory safety — detects resource leak patterns in TypeScript/JavaScript. */
|
|
2
|
+
import { getProductionFiles } from "../fs-utils.js";
|
|
3
|
+
import { gradeFromScore } from "../types.js";
|
|
4
|
+
const PATTERNS = [
|
|
5
|
+
{
|
|
6
|
+
name: "setInterval-no-clear",
|
|
7
|
+
pattern: /\bsetInterval\s*\(/g,
|
|
8
|
+
severity: "warning",
|
|
9
|
+
message: "setInterval without clearInterval — potential memory leak",
|
|
10
|
+
rule: "interval-leak",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: "addEventListener-no-remove",
|
|
14
|
+
pattern: /\.addEventListener\s*\(/g,
|
|
15
|
+
severity: "warning",
|
|
16
|
+
message: "addEventListener without removeEventListener — may leak if component unmounts",
|
|
17
|
+
rule: "listener-leak",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "global-var-assignment",
|
|
21
|
+
pattern: /(?:^|\n)\s*(?:window|globalThis|global)\.\w+\s*=/g,
|
|
22
|
+
severity: "warning",
|
|
23
|
+
message: "Global variable assignment — pollutes global scope, hard to garbage collect",
|
|
24
|
+
rule: "global-pollution",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "new-without-cleanup",
|
|
28
|
+
pattern: /new\s+(?:MutationObserver|IntersectionObserver|ResizeObserver|PerformanceObserver)\s*\(/g,
|
|
29
|
+
severity: "warning",
|
|
30
|
+
message: "Observer created — ensure .disconnect() is called on cleanup",
|
|
31
|
+
rule: "observer-leak",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "websocket-no-close",
|
|
35
|
+
pattern: /new\s+WebSocket\s*\(/g,
|
|
36
|
+
severity: "warning",
|
|
37
|
+
message: "WebSocket opened — ensure .close() is called on cleanup",
|
|
38
|
+
rule: "websocket-leak",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "event-emitter-leak",
|
|
42
|
+
pattern: /\.on\s*\(\s*['"`]/g,
|
|
43
|
+
severity: "warning",
|
|
44
|
+
message: "Event listener registered — ensure .off() or .removeListener() on cleanup",
|
|
45
|
+
rule: "emitter-leak",
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
export function runMemorySafety(cwd) {
|
|
49
|
+
const start = Date.now();
|
|
50
|
+
const issues = [];
|
|
51
|
+
const files = getProductionFiles(cwd);
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
if (f.isTest)
|
|
54
|
+
continue;
|
|
55
|
+
const lines = f.content.split("\n");
|
|
56
|
+
for (const pat of PATTERNS) {
|
|
57
|
+
// Check if the file has the pattern
|
|
58
|
+
const matches = f.content.match(pat.pattern);
|
|
59
|
+
if (!matches)
|
|
60
|
+
continue;
|
|
61
|
+
// For interval/listener leaks, check if cleanup exists in the same file
|
|
62
|
+
if (pat.rule === "interval-leak") {
|
|
63
|
+
if (f.content.includes("clearInterval"))
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (pat.rule === "listener-leak") {
|
|
67
|
+
if (f.content.includes("removeEventListener"))
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (pat.rule === "observer-leak") {
|
|
71
|
+
if (f.content.includes(".disconnect()"))
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (pat.rule === "websocket-leak") {
|
|
75
|
+
if (f.content.includes(".close()"))
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (pat.rule === "emitter-leak") {
|
|
79
|
+
// Skip if .off or .removeListener or .removeAllListeners in same file
|
|
80
|
+
if (f.content.includes(".off(") || f.content.includes(".removeListener(") || f.content.includes(".removeAllListeners("))
|
|
81
|
+
continue;
|
|
82
|
+
// Skip Node.js event emitter patterns (server.on, app.on, router.on)
|
|
83
|
+
if (/\b(?:server|app|router|express|fastify|hono)\b/.test(f.content))
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// Find first occurrence line number
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
if (pat.pattern.test(lines[i])) {
|
|
89
|
+
pat.pattern.lastIndex = 0; // reset regex
|
|
90
|
+
issues.push({
|
|
91
|
+
severity: pat.severity,
|
|
92
|
+
message: pat.message,
|
|
93
|
+
file: f.path,
|
|
94
|
+
line: i + 1,
|
|
95
|
+
rule: pat.rule,
|
|
96
|
+
});
|
|
97
|
+
break; // one per file per pattern
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const totalFiles = files.filter((f) => !f.isTest).length;
|
|
103
|
+
const affectedFiles = new Set(issues.map((i) => i.file)).size;
|
|
104
|
+
const ratio = totalFiles > 0 ? affectedFiles / totalFiles : 0;
|
|
105
|
+
const score = Math.round(Math.max(0, 100 - ratio * 200));
|
|
106
|
+
return {
|
|
107
|
+
name: "memory-safety",
|
|
108
|
+
score,
|
|
109
|
+
grade: gradeFromScore(score),
|
|
110
|
+
details: { totalFiles, affectedFiles, patterns: issues.length },
|
|
111
|
+
issues,
|
|
112
|
+
duration: Date.now() - start,
|
|
113
|
+
};
|
|
114
|
+
}
|