@vibecodeqa/cli 0.40.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.
@@ -79,7 +79,7 @@ export const CHECK_META = {
79
79
  label: "Duplication",
80
80
  category: "Quality",
81
81
  priority: "medium",
82
- weight: 5,
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: 15,
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: 6,
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 allCheckNames = [
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 allCheckNames) {
490
+ for (const name of Object.keys(CHECK_META)) {
491
491
  checksConfig[name] = {};
492
492
  }
493
493
  const config = {
package/dist/core.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** @vibecodeqa/cli/core — Programmatic scan API.
2
+ *
3
+ * Usage:
4
+ * import { scan } from "@vibecodeqa/cli/core";
5
+ * const report = await scan("./src");
6
+ * console.log(report.score, report.grade);
7
+ */
8
+ import { CHECK_META, getCheckMeta, type CheckMeta } from "./check-meta.js";
9
+ import { type VcqaConfig } from "./config.js";
10
+ import type { CheckResult, Issue, StackInfo, VibeReport, WorkspaceInfo, WorkspacePackage } from "./types.js";
11
+ export interface ScanOptions {
12
+ /** Skip test execution (faster scan). Default: false */
13
+ skipTests?: boolean;
14
+ /** Only run these checks (by name). Default: all checks */
15
+ checks?: string[];
16
+ /** Override config (instead of loading from .vcqa.json). */
17
+ config?: VcqaConfig;
18
+ /** Progress callback — called for each completed check. */
19
+ onProgress?: (check: string, result: CheckResult, index: number, total: number) => void;
20
+ }
21
+ /** Run a full code health scan. Returns a VibeReport with score, grade, and all check results. */
22
+ export declare function scan(cwd: string, options?: ScanOptions): Promise<VibeReport>;
23
+ export { CHECK_META, getCheckMeta, type CheckMeta };
24
+ export { computeScore } from "./score.js";
25
+ export { loadConfig, type VcqaConfig } from "./config.js";
26
+ export { detectStack, detectWorkspace } from "./detect.js";
27
+ export { gradeFromScore } from "./types.js";
28
+ export type { CheckResult, Issue, StackInfo, VibeReport, WorkspaceInfo, WorkspacePackage };
package/dist/core.js ADDED
@@ -0,0 +1,171 @@
1
+ /** @vibecodeqa/cli/core — Programmatic scan API.
2
+ *
3
+ * Usage:
4
+ * import { scan } from "@vibecodeqa/cli/core";
5
+ * const report = await scan("./src");
6
+ * console.log(report.score, report.grade);
7
+ */
8
+ import { resolve } from "node:path";
9
+ import { readFileSync } from "node:fs";
10
+ import { CHECK_META, getCheckMeta } from "./check-meta.js";
11
+ import { getCheckIgnore, isCheckEnabled, loadConfig } from "./config.js";
12
+ import { detectRepoUrl, detectStack, detectWorkspace } from "./detect.js";
13
+ import { setGlobalIgnore, setGlobalSrcRoots } from "./fs-utils.js";
14
+ import { runAccessibility } from "./runners/accessibility.js";
15
+ import { runArchitecture } from "./runners/architecture.js";
16
+ import { runBestPractices } from "./runners/best-practices.js";
17
+ import { runCodeCoherence } from "./runners/code-coherence.js";
18
+ import { runCommentStaleness } from "./runners/comment-staleness.js";
19
+ import { runComplexity } from "./runners/complexity.js";
20
+ import { runDeadPatterns } from "./runners/dead-patterns.js";
21
+ import { runTestAudit } from "./runners/test-audit.js";
22
+ import { runContainerHealth } from "./runners/container-health.js";
23
+ import { runConfusion } from "./runners/confusion.js";
24
+ import { runContext } from "./runners/context.js";
25
+ import { runDependencies } from "./runners/dependencies.js";
26
+ import { runEnvValidation } from "./runners/env-validation.js";
27
+ import { runGitHygiene } from "./runners/git-hygiene.js";
28
+ import { runDocCoherence } from "./runners/doc-coherence.js";
29
+ import { runDocs } from "./runners/docs.js";
30
+ import { runDuplication } from "./runners/duplication.js";
31
+ import { runErrorHandling } from "./runners/error-handling.js";
32
+ import { runLint } from "./runners/lint.js";
33
+ import { runMemorySafety } from "./runners/memory-safety.js";
34
+ import { runPerformance } from "./runners/performance.js";
35
+ import { runReact } from "./runners/react.js";
36
+ import { runSecrets } from "./runners/secrets.js";
37
+ import { runSecurity } from "./runners/security.js";
38
+ import { runStandards } from "./runners/standards.js";
39
+ import { runStructure } from "./runners/structure.js";
40
+ import { runTesting } from "./runners/testing.js";
41
+ import { runTypeSafety } from "./runners/type-safety.js";
42
+ import { runTypeCheck } from "./runners/types-check.js";
43
+ import { computeScore } from "./score.js";
44
+ import { gradeFromScore } from "./types.js";
45
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
46
+ const VERSION = pkg.version;
47
+ /** Run a full code health scan. Returns a VibeReport with score, grade, and all check results. */
48
+ export async function scan(cwd, options = {}) {
49
+ const start = Date.now();
50
+ const resolvedCwd = resolve(cwd);
51
+ const config = options.config ?? loadConfig(resolvedCwd);
52
+ const workspace = detectWorkspace(resolvedCwd);
53
+ const stack = detectStack(resolvedCwd, workspace);
54
+ const isDart = stack.language === "dart";
55
+ setGlobalSrcRoots(workspace.isMonorepo ? workspace.srcRoots : undefined);
56
+ setGlobalIgnore(config.ignore);
57
+ const srcRoots = workspace.isMonorepo ? workspace.srcRoots : undefined;
58
+ const skipTests = options.skipTests ?? false;
59
+ const allRunners = [
60
+ { name: "structure", fn: () => runStructure(resolvedCwd, stack, workspace) },
61
+ { name: "lint", fn: () => runLint(resolvedCwd, stack, workspace) },
62
+ { name: "types", fn: () => runTypeCheck(resolvedCwd, isDart, workspace) },
63
+ { name: "type-safety", fn: () => runTypeSafety(resolvedCwd, isDart) },
64
+ { name: "standards", fn: () => runStandards(resolvedCwd, stack) },
65
+ { name: "complexity", fn: () => runComplexity(resolvedCwd) },
66
+ { name: "duplication", fn: () => runDuplication(resolvedCwd) },
67
+ { name: "error-handling", fn: () => runErrorHandling(resolvedCwd, stack) },
68
+ { name: "react", fn: () => runReact(resolvedCwd, stack) },
69
+ { name: "accessibility", fn: () => runAccessibility(resolvedCwd) },
70
+ { name: "docs", fn: () => runDocs(resolvedCwd) },
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) },
75
+ { name: "testing", fn: () => runTesting(resolvedCwd, stack, skipTests, srcRoots) },
76
+ { name: "secrets", fn: () => runSecrets(resolvedCwd) },
77
+ { name: "security", fn: () => runSecurity(resolvedCwd) },
78
+ { name: "dependencies", fn: () => runDependencies(resolvedCwd, stack) },
79
+ { name: "architecture", fn: () => runArchitecture(resolvedCwd, workspace) },
80
+ { name: "performance", fn: () => runPerformance(resolvedCwd) },
81
+ { name: "container-health", fn: () => runContainerHealth(resolvedCwd) },
82
+ { name: "confusion", fn: () => runConfusion(resolvedCwd) },
83
+ { name: "context", fn: () => runContext(resolvedCwd) },
84
+ { name: "doc-coherence", fn: () => runDocCoherence(resolvedCwd) },
85
+ { name: "code-coherence", fn: () => runCodeCoherence(resolvedCwd) },
86
+ { name: "comment-staleness", fn: () => runCommentStaleness(resolvedCwd) },
87
+ { name: "dead-patterns", fn: () => runDeadPatterns(resolvedCwd) },
88
+ { name: "test-audit", fn: () => runTestAudit(resolvedCwd) },
89
+ ];
90
+ // Filter checks if specified
91
+ const checkFilter = options.checks ? new Set(options.checks) : null;
92
+ const runners = checkFilter
93
+ ? allRunners.filter((r) => checkFilter.has(r.name))
94
+ : allRunners;
95
+ const checks = [];
96
+ const total = runners.length;
97
+ for (let i = 0; i < total; i++) {
98
+ const runner = runners[i];
99
+ if (!isCheckEnabled(config, runner.name)) {
100
+ const skipped = {
101
+ name: runner.name,
102
+ score: 0,
103
+ grade: "F",
104
+ details: { skipped: true, reason: "disabled in config" },
105
+ issues: [],
106
+ duration: 0,
107
+ };
108
+ checks.push(skipped);
109
+ options.onProgress?.(runner.name, skipped, i, total);
110
+ continue;
111
+ }
112
+ let result;
113
+ try {
114
+ const maybeResult = runner.fn();
115
+ result = maybeResult instanceof Promise ? await maybeResult : maybeResult;
116
+ }
117
+ catch (err) {
118
+ result = {
119
+ name: runner.name,
120
+ score: 0,
121
+ grade: "F",
122
+ details: { skipped: true, reason: `runner error: ${err instanceof Error ? err.message : "unknown"}` },
123
+ issues: [],
124
+ duration: 0,
125
+ };
126
+ }
127
+ // Apply per-check ignore patterns
128
+ const patterns = getCheckIgnore(config, result.name);
129
+ if (patterns?.length) {
130
+ result.issues = result.issues.filter((issue) => {
131
+ if (!issue.file || typeof issue.file !== "string")
132
+ return true;
133
+ const f = issue.file;
134
+ return !patterns.some((p) => {
135
+ if (p.endsWith("/**"))
136
+ return f.startsWith(p.slice(0, -3) + "/");
137
+ if (p.startsWith("*"))
138
+ return f.endsWith(p.slice(1));
139
+ return f.startsWith(p);
140
+ });
141
+ });
142
+ }
143
+ checks.push(result);
144
+ options.onProgress?.(runner.name, result, i, total);
145
+ }
146
+ const score = computeScore(checks);
147
+ const grade = gradeFromScore(score);
148
+ const { repoUrl, branch } = detectRepoUrl(resolvedCwd);
149
+ return {
150
+ version: VERSION,
151
+ timestamp: new Date().toISOString(),
152
+ score,
153
+ grade,
154
+ checks,
155
+ meta: {
156
+ cwd: resolvedCwd,
157
+ node: process.version,
158
+ duration: Date.now() - start,
159
+ stack,
160
+ workspace,
161
+ repoUrl,
162
+ branch,
163
+ },
164
+ };
165
+ }
166
+ // ── Re-exports ──
167
+ export { CHECK_META, getCheckMeta };
168
+ export { computeScore } from "./score.js";
169
+ export { loadConfig } from "./config.js";
170
+ export { detectStack, detectWorkspace } from "./detect.js";
171
+ export { gradeFromScore } from "./types.js";
@@ -0,0 +1,3 @@
1
+ /** Container health — Dockerfile best practices, .dockerignore, base image hygiene. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runContainerHealth(cwd: string): CheckResult;
@@ -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,3 @@
1
+ /** Environment validation — checks .env hygiene, .env.example drift, and unsafe patterns. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runEnvValidation(cwd: string): CheckResult;
@@ -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,3 @@
1
+ /** Git hygiene — checks commit quality, large files, and repo health. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runGitHygiene(cwd: string): CheckResult;
@@ -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,3 @@
1
+ /** Memory safety — detects resource leak patterns in TypeScript/JavaScript. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runMemorySafety(cwd: string): CheckResult;
@@ -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
+ }
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@vibecodeqa/cli",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "description": "Code health scanner for the AI coding era. 25 checks, zero config, full report.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "vcqa": "./dist/cli.js",
8
8
  "vibe-check": "./dist/cli.js"
9
9
  },
10
+ "exports": {
11
+ ".": "./dist/cli.js",
12
+ "./core": "./dist/core.js"
13
+ },
10
14
  "files": [
11
15
  "dist",
12
16
  "LICENSE",