@vibecodeqa/cli 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +130 -165
  2. package/dist/check-meta.js +99 -6
  3. package/dist/cli.js +268 -761
  4. package/dist/commands/explain.d.ts +2 -0
  5. package/dist/commands/explain.js +33 -0
  6. package/dist/commands/fix.d.ts +6 -0
  7. package/dist/commands/fix.js +131 -0
  8. package/dist/commands/init.d.ts +2 -0
  9. package/dist/commands/init.js +96 -0
  10. package/dist/commands/shared.d.ts +4 -0
  11. package/dist/commands/shared.js +80 -0
  12. package/dist/core.js +18 -0
  13. package/dist/runners/accessibility.js +4 -1
  14. package/dist/runners/container-health.d.ts +3 -0
  15. package/dist/runners/container-health.js +141 -0
  16. package/dist/runners/design-consistency.d.ts +12 -0
  17. package/dist/runners/design-consistency.js +125 -0
  18. package/dist/runners/env-validation.d.ts +3 -0
  19. package/dist/runners/env-validation.js +122 -0
  20. package/dist/runners/error-handling.js +18 -2
  21. package/dist/runners/file-cohesion.d.ts +17 -0
  22. package/dist/runners/file-cohesion.js +177 -0
  23. package/dist/runners/frontend-health.d.ts +14 -0
  24. package/dist/runners/frontend-health.js +206 -0
  25. package/dist/runners/git-hygiene.d.ts +3 -0
  26. package/dist/runners/git-hygiene.js +125 -0
  27. package/dist/runners/html-quality.d.ts +8 -0
  28. package/dist/runners/html-quality.js +203 -0
  29. package/dist/runners/memory-safety.d.ts +3 -0
  30. package/dist/runners/memory-safety.js +114 -0
  31. package/dist/runners/react.js +1 -0
  32. package/dist/runners/secrets.js +7 -2
  33. package/dist/runners/security.js +7 -1
  34. package/dist/runners/standards.js +29 -9
  35. package/dist/runners/styling.d.ts +15 -0
  36. package/dist/runners/styling.js +280 -0
  37. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,48 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  /** vibe-check — code health scanner for the AI coding era. */
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
- import { aiFixIssues, collectFixableIssues } from "./ai-fix.js";
6
- import { getCheckMeta } from "./check-meta.js";
7
- import { getCheckIgnore, isCheckEnabled, loadConfig } from "./config.js";
8
- import { detectRepoUrl, detectStack, detectWorkspace } from "./detect.js";
9
- import { setGlobalIgnore, setGlobalSrcRoots } from "./fs-utils.js";
5
+ import { runExplain } from "./commands/explain.js";
6
+ import { runFix } from "./commands/fix.js";
7
+ import { runInit } from "./commands/init.js";
8
+ import { validateCwd } from "./commands/shared.js";
9
+ import { loadConfig } from "./config.js";
10
+ import { scan } from "./core.js";
11
+ import { detectStack, detectWorkspace } from "./detect.js";
10
12
  import { postPRComment } from "./pr-comment.js";
11
13
  import { generatePages } from "./report/html.js";
12
- import { runAccessibility } from "./runners/accessibility.js";
13
- import { runArchitecture } from "./runners/architecture.js";
14
- import { runBestPractices } from "./runners/best-practices.js";
15
- import { runCodeCoherence } from "./runners/code-coherence.js";
16
- import { runCommentStaleness } from "./runners/comment-staleness.js";
17
- import { runComplexity } from "./runners/complexity.js";
18
- import { runDeadPatterns } from "./runners/dead-patterns.js";
19
- import { runTestAudit } from "./runners/test-audit.js";
20
- import { runConfusion } from "./runners/confusion.js";
21
- import { runContext } from "./runners/context.js";
22
- import { runDependencies } from "./runners/dependencies.js";
23
- import { runDocCoherence } from "./runners/doc-coherence.js";
24
- import { runDocs } from "./runners/docs.js";
25
- import { runDuplication } from "./runners/duplication.js";
26
- import { runErrorHandling } from "./runners/error-handling.js";
27
- import { runLint } from "./runners/lint.js";
28
- import { runPerformance } from "./runners/performance.js";
29
- import { runReact } from "./runners/react.js";
30
- import { runSecrets } from "./runners/secrets.js";
31
- import { runSecurity } from "./runners/security.js";
32
- import { runStandards } from "./runners/standards.js";
33
- import { runStructure } from "./runners/structure.js";
34
- import { runTesting } from "./runners/testing.js";
35
- import { runTypeSafety } from "./runners/type-safety.js";
36
- import { runTypeCheck } from "./runners/types-check.js";
37
- import { computeScore } from "./score.js";
38
14
  import { computeTrend, formatTrend } from "./trend.js";
39
- import { gradeFromScore } from "./types.js";
40
15
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
41
16
  const VERSION = pkg.version;
42
17
  function parseFlags() {
43
18
  const args = process.argv.slice(2);
44
19
  const flags = new Set(args.filter((a) => a.startsWith("--")));
45
- // Parse flags with value arguments
46
20
  const valueArgIndices = new Set();
47
21
  function parseValueFlag(flag, fallback) {
48
22
  const idx = args.indexOf(flag);
@@ -54,14 +28,12 @@ function parseFlags() {
54
28
  valueArgIndices.add(idx + 1);
55
29
  return parseInt(next, 10);
56
30
  }
57
- // Non-numeric value after flag — consume it to prevent misuse as cwd
58
31
  valueArgIndices.add(idx + 1);
59
32
  }
60
33
  return fallback ?? null;
61
34
  }
62
35
  const topN = parseValueFlag("--top", 5) ?? 0;
63
36
  const failUnder = parseValueFlag("--fail-under");
64
- // --diff [base] — only show issues in changed files
65
37
  let diffBase = null;
66
38
  const diffIdx = args.indexOf("--diff");
67
39
  if (diffIdx !== -1) {
@@ -71,7 +43,7 @@ function parseFlags() {
71
43
  valueArgIndices.add(diffIdx + 1);
72
44
  }
73
45
  else {
74
- diffBase = "HEAD"; // default: uncommitted changes
46
+ diffBase = "HEAD";
75
47
  }
76
48
  }
77
49
  const cwd = resolve(args.find((a, i) => !a.startsWith("--") && !valueArgIndices.has(i)) || ".");
@@ -93,6 +65,7 @@ function parseFlags() {
93
65
  annotations: flags.has("--annotations"),
94
66
  };
95
67
  }
68
+ // ── Output helpers ──
96
69
  function color(grade) {
97
70
  if (grade === "A")
98
71
  return "\x1b[32m";
@@ -100,132 +73,114 @@ function color(grade) {
100
73
  return "\x1b[33m";
101
74
  return "\x1b[31m";
102
75
  }
103
- async function runChecks(cwd, stack, workspace, skipTests, isDart, jsonOnly, config) {
104
- const srcRoots = workspace.isMonorepo ? workspace.srcRoots : undefined;
105
- const runners = [
106
- // Foundations
107
- { name: "structure", fn: () => runStructure(cwd, stack, workspace) },
108
- { name: "lint", fn: () => runLint(cwd, stack, workspace) },
109
- { name: "types", fn: () => runTypeCheck(cwd, isDart, workspace) },
110
- { name: "type-safety", fn: () => runTypeSafety(cwd, isDart) },
111
- { name: "standards", fn: () => runStandards(cwd, stack) },
112
- // Quality
113
- { name: "complexity", fn: () => runComplexity(cwd) },
114
- { name: "duplication", fn: () => runDuplication(cwd) },
115
- { name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
116
- { name: "react", fn: () => runReact(cwd, stack) },
117
- { name: "accessibility", fn: () => runAccessibility(cwd) },
118
- { name: "docs", fn: () => runDocs(cwd) },
119
- { name: "best-practices", fn: () => runBestPractices(cwd, workspace) },
120
- // Testing
121
- { name: "testing", fn: () => runTesting(cwd, stack, skipTests, srcRoots) },
122
- // Security
123
- { name: "secrets", fn: () => runSecrets(cwd) },
124
- { name: "security", fn: () => runSecurity(cwd) },
125
- { name: "dependencies", fn: () => runDependencies(cwd, stack) },
126
- // Architecture
127
- { name: "architecture", fn: () => runArchitecture(cwd, workspace) },
128
- { name: "performance", fn: () => runPerformance(cwd) },
129
- // LLM Readiness
130
- { name: "confusion", fn: () => runConfusion(cwd) },
131
- { name: "context", fn: () => runContext(cwd) },
132
- // AI Analysis (premium)
133
- { name: "doc-coherence", fn: () => runDocCoherence(cwd) },
134
- { name: "code-coherence", fn: () => runCodeCoherence(cwd) },
135
- { name: "comment-staleness", fn: () => runCommentStaleness(cwd) },
136
- { name: "dead-patterns", fn: () => runDeadPatterns(cwd) },
137
- { name: "test-audit", fn: () => runTestAudit(cwd) },
138
- ];
139
- const checks = [];
140
- for (const runner of runners) {
141
- // Skip checks disabled in config
142
- if (config && !isCheckEnabled(config, runner.name)) {
143
- checks.push({
144
- name: runner.name,
145
- score: 0,
146
- grade: "F",
147
- details: { skipped: true, reason: "disabled in config" },
148
- issues: [],
149
- duration: 0,
150
- });
151
- if (!jsonOnly)
152
- console.log(` ${runner.name.padEnd(14)}\x1b[2mskip — disabled\x1b[0m`);
153
- continue;
154
- }
155
- if (!jsonOnly)
156
- process.stdout.write(` ${runner.name.padEnd(14)}`);
157
- let result;
158
- try {
159
- const maybeResult = runner.fn();
160
- result = maybeResult instanceof Promise ? await maybeResult : maybeResult;
161
- }
162
- catch (err) {
163
- // Runner crashed — record as errored, don't kill the scan
164
- result = {
165
- name: runner.name,
166
- score: 0,
167
- grade: "F",
168
- details: { skipped: true, reason: `runner error: ${err instanceof Error ? err.message : "unknown"}` },
169
- issues: [],
170
- duration: 0,
171
- };
172
- }
173
- checks.push(result);
174
- if (!jsonOnly) {
175
- const det = result.details;
176
- const skipped = det.skipped;
177
- const premium = det.comingSoon;
178
- const c = premium ? "\x1b[2m" : skipped ? "\x1b[2m" : color(result.grade);
179
- const label = premium ? "soon" : skipped ? "skip" : result.grade;
180
- const scoreStr = premium ? "PRO" : skipped ? "—" : `${result.score}/100`;
181
- const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
182
- console.log(`${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
76
+ function printHelp() {
77
+ console.log(`
78
+ \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION} code health scanner
79
+
80
+ \x1b[1mUsage:\x1b[0m npx @vibecodeqa/cli [command] [path] [flags]
81
+
82
+ \x1b[1mCommands:\x1b[0m
83
+ init [path] Set up CI workflow + recommended configs
84
+ fix [path] Auto-fix (.gitignore, strict mode, biome/eslint, suggestions)
85
+ --ai Use Claude to fix remaining issues (needs ANTHROPIC_API_KEY)
86
+ --check NAME Only fix issues from a specific check (e.g. --check security)
87
+ --dry-run Show what AI would fix without applying changes
88
+ explain [check] Deep-dive explanation of a check (what/risk/fix)
89
+ monitor [path] Live quality control panel re-scans on file changes
90
+
91
+ \x1b[1mFlags:\x1b[0m
92
+ --skip-tests Skip test execution (faster scan)
93
+ --ci CI mode (exit 1 if score < 60)
94
+ --fail-under N Exit 1 if score below N (e.g. --fail-under 80)
95
+ --json Output JSON only (no terminal UI)
96
+ --badge Generate SVG badge
97
+ --sarif Generate SARIF for GitHub Code Scanning
98
+ --upload Upload report to app.vibecodeqa.online
99
+ --top [N] Show top N issues to fix (default: 5)
100
+ --diff [base] Only show issues in changed files (vs HEAD or branch)
101
+ --markdown Output markdown summary (pipe to file or clipboard)
102
+ --pr-comment Post score as GitHub PR comment (needs GITHUB_TOKEN)
103
+ --annotations Emit GitHub Actions ::warning/::error annotations
104
+ --watch Re-scan on file changes
105
+ -v, --version Print version
106
+ -h, --help Show this help
107
+
108
+ \x1b[1mExamples:\x1b[0m
109
+ npx @vibecodeqa/cli # scan current directory
110
+ npx @vibecodeqa/cli init # set up CI + configs
111
+ npx @vibecodeqa/cli fix # auto-fix what's fixable
112
+ npx @vibecodeqa/cli fix --ai # AI-powered fix (uses Claude)
113
+ npx @vibecodeqa/cli fix --ai --check security # fix only security issues
114
+ npx @vibecodeqa/cli fix --ai --dry-run # preview AI fixes without applying
115
+ npx @vibecodeqa/cli --skip-tests --top # fast scan with top issues
116
+ npx @vibecodeqa/cli --ci --fail-under 80 # CI with quality gate
117
+ `);
118
+ }
119
+ function printHeader(cwd, stack, workspace) {
120
+ console.log("");
121
+ console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
122
+ console.log(` \x1b[2m${cwd}\x1b[0m`);
123
+ console.log("");
124
+ const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
125
+ console.log(` stack: ${parts.join(" + ")}`);
126
+ if (workspace.isMonorepo) {
127
+ console.log(` workspace: ${workspace.tool} monorepo — ${workspace.packages.length} packages`);
128
+ for (const p of workspace.packages.slice(0, 8)) {
129
+ const f = [p.hasSrc && "src", p.hasTests && "tests", p.hasLinter && "linter"].filter(Boolean).join(", ");
130
+ console.log(` \x1b[2m${p.path}\x1b[0m (${f || "empty"})`);
183
131
  }
132
+ if (workspace.packages.length > 8)
133
+ console.log(` \x1b[2m...and ${workspace.packages.length - 8} more\x1b[0m`);
184
134
  }
185
- return checks;
135
+ console.log("");
186
136
  }
187
- async function writeOutputs(report, outputDir, flags) {
188
- if (!existsSync(outputDir))
189
- mkdirSync(outputDir, { recursive: true });
190
- // Save to history before overwriting current report
191
- const historyDir = join(outputDir, "history");
192
- if (!existsSync(historyDir))
193
- mkdirSync(historyDir, { recursive: true });
194
- const historyFile = join(historyDir, `${report.timestamp.replace(/[:.]/g, "-")}.json`);
195
- writeFileSync(historyFile, JSON.stringify(report, null, 2));
196
- // Keep only last 30 history entries
197
- const historyFiles = readdirSync(historyDir)
198
- .filter((f) => f.endsWith(".json"))
199
- .sort();
200
- if (historyFiles.length > 30) {
201
- for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
202
- try {
203
- unlinkSync(join(historyDir, old));
204
- }
205
- catch {
206
- /* ignore */
207
- }
208
- }
137
+ function generateMarkdown(report, trend) {
138
+ const { score, grade, checks } = report;
139
+ const gradeEmoji = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : "🔴";
140
+ let md = `# ${gradeEmoji} VibeCode QA: ${grade} ${score}/100\n\n`;
141
+ if (trend) {
142
+ const arrow = trend.scoreDelta > 0 ? "📈" : trend.scoreDelta < 0 ? "📉" : "➡️";
143
+ md += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`;
144
+ if (trend.fixedIssues > 0)
145
+ md += ` · ${trend.fixedIssues} fixed`;
146
+ if (trend.newIssues > 0)
147
+ md += ` · ${trend.newIssues} new`;
148
+ md += "\n\n";
209
149
  }
210
- writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
211
- // Generate multi-page HTML report
212
- const reportDir = join(outputDir, "report");
213
- if (!existsSync(reportDir))
214
- mkdirSync(reportDir, { recursive: true });
215
- const pages = generatePages(report, historyDir);
216
- for (const [filename, html] of pages) {
217
- writeFileSync(join(reportDir, filename), html);
150
+ md += "| Check | Score | Grade |\n|-------|-------|-------|\n";
151
+ for (const c of checks) {
152
+ const det = c.details;
153
+ if (det.skipped || det.comingSoon)
154
+ continue;
155
+ const emoji = c.score >= 90 ? "🟢" : c.score >= 75 ? "🟡" : c.score >= 60 ? "🟠" : "🔴";
156
+ md += `| ${emoji} ${c.name} | ${c.score}/100 | ${c.grade} |\n`;
218
157
  }
219
- // Badge SVG
220
- if (flags.badgeMode) {
221
- const { buildBadge } = await import("./report/svg.js");
222
- const badgeSvg = buildBadge(report.score, report.grade);
223
- writeFileSync(join(outputDir, "badge.svg"), badgeSvg);
158
+ const errors = checks.flatMap((c) => c.issues.filter((i) => i.severity === "error"));
159
+ const warnings = checks.flatMap((c) => c.issues.filter((i) => i.severity === "warning"));
160
+ if (errors.length + warnings.length > 0) {
161
+ md += `\n## Issues (${errors.length} errors, ${warnings.length} warnings)\n\n`;
162
+ for (const i of [...errors, ...warnings].slice(0, 15)) {
163
+ const loc = i.file ? ` \`${i.file}${i.line ? `:${i.line}` : ""}\`` : "";
164
+ md += `- ${i.severity === "error" ? "❌" : "⚠️"} ${i.message}${loc}\n`;
165
+ }
166
+ const remaining = errors.length + warnings.length - 15;
167
+ if (remaining > 0)
168
+ md += `\n*...and ${remaining} more*\n`;
224
169
  }
225
- // SARIF output for GitHub Code Scanning
226
- if (flags.sarifMode) {
227
- const { generateSARIF } = await import("./report/sarif.js");
228
- writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
170
+ md += `\n---\n*vcqa v${report.version} · ${report.meta.duration}ms*\n`;
171
+ return md;
172
+ }
173
+ function emitAnnotations(report) {
174
+ for (const c of report.checks) {
175
+ for (const i of c.issues) {
176
+ if (i.severity === "info")
177
+ continue;
178
+ const level = i.severity === "error" ? "error" : "warning";
179
+ const file = i.file && typeof i.file === "string" ? i.file : "";
180
+ const line = i.line || "";
181
+ const loc = file ? ` file=${file}${line ? `,line=${line}` : ""}` : "";
182
+ console.log(`::${level}${loc ? loc : ""}::${c.name}: ${i.message}`);
183
+ }
229
184
  }
230
185
  }
231
186
  async function printResults(report, trend, flags, outputDir, interactive) {
@@ -239,7 +194,6 @@ async function printResults(report, trend, flags, outputDir, interactive) {
239
194
  console.log("");
240
195
  console.log(` ${gc}\x1b[1m${grade}\x1b[0m ${gc}${score}/100\x1b[0m \x1b[2m${checks.length} checks · ${totalIssues} issues · ${report.meta.duration}ms\x1b[0m`);
241
196
  if (trend) {
242
- // Load history for sparkline
243
197
  const historyDir = join(outputDir, "history");
244
198
  const { loadHistory } = await import("./history.js");
245
199
  const history = loadHistory(historyDir);
@@ -249,12 +203,10 @@ async function printResults(report, trend, flags, outputDir, interactive) {
249
203
  console.log(formatTrend(trend, scores));
250
204
  }
251
205
  console.log("");
252
- // Top actionable issues. Explicit --top wins; otherwise show a few by default
253
- // in an interactive terminal so the scan never dead-ends at a file path.
206
+ const { getCheckMeta } = await import("./check-meta.js");
254
207
  const effectiveTopN = flags.topN > 0 ? flags.topN : interactive ? 3 : 0;
255
208
  if (effectiveTopN > 0) {
256
209
  const allIssues = checks.flatMap((c) => c.issues.map((iss) => ({ check: c.name, weight: getCheckMeta(c.name).weight, ...iss })));
257
- // Sort by: errors first, then by check weight (highest-impact first)
258
210
  allIssues.sort((a, b) => {
259
211
  const sevOrder = { error: 0, warning: 1, info: 2 };
260
212
  const sevDiff = (sevOrder[a.severity] ?? 2) - (sevOrder[b.severity] ?? 2);
@@ -276,27 +228,21 @@ async function printResults(report, trend, flags, outputDir, interactive) {
276
228
  console.log("");
277
229
  }
278
230
  }
279
- // Next steps: surface the weakest scored dimensions and how to dig into each.
280
- // Skipped in CI (clean machine-readable-ish output) — interactive runs get the on-ramp.
281
231
  if (!flags.ciMode) {
282
232
  const weakest = checks
283
- .filter((c) => {
284
- const det = c.details;
285
- return !det.skipped && !det.comingSoon && c.score < 70;
286
- })
233
+ .filter((c) => { const det = c.details; return !det.skipped && !det.comingSoon && c.score < 70; })
287
234
  .sort((a, b) => a.score - b.score)
288
235
  .slice(0, 3);
289
236
  if (weakest.length > 0) {
290
237
  console.log(" \x1b[1mWeakest areas:\x1b[0m");
291
238
  for (const c of weakest) {
292
- const gc = color(c.grade);
239
+ const gc2 = color(c.grade);
293
240
  const label = getCheckMeta(c.name).label || c.name;
294
- console.log(` ${gc}${c.grade}\x1b[0m \x1b[2m${String(c.score).padStart(3)}\x1b[0m ${label.padEnd(18)}\x1b[2m→ vcqa explain ${c.name}\x1b[0m`);
241
+ console.log(` ${gc2}${c.grade}\x1b[0m \x1b[2m${String(c.score).padStart(3)}\x1b[0m ${label.padEnd(18)}\x1b[2m→ vcqa explain ${c.name}\x1b[0m`);
295
242
  }
296
243
  console.log("");
297
244
  }
298
245
  }
299
- // Report paths + the interactive on-ramp.
300
246
  console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
301
247
  console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
302
248
  if (flags.badgeMode)
@@ -309,18 +255,76 @@ async function printResults(report, trend, flags, outputDir, interactive) {
309
255
  console.log("");
310
256
  }
311
257
  }
312
- async function handleUpload(report, cwd, jsonOnly) {
313
- const repo = report.meta.repoUrl?.replace(/^https:\/\/github\.com\//, "") || cwd.split("/").pop() || "project";
314
- const token = process.env.VCQA_TOKEN || "";
315
- // Get current commit SHA for quality gate status
258
+ function getChangedFiles(cwd, base) {
259
+ try {
260
+ const { execSync } = require("node:child_process");
261
+ const cmd = base === "HEAD" ? "git diff --name-only" : `git diff --name-only ${base}...HEAD`;
262
+ const stdout = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
263
+ if (!stdout)
264
+ return new Set();
265
+ return new Set(stdout.split("\n").filter((f) => f.length > 0));
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ // ── Report output ──
272
+ async function writeOutputs(report, outputDir, flags) {
273
+ mkdirSync(outputDir, { recursive: true });
274
+ // Always write JSON
275
+ writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
276
+ // Save history
277
+ const historyDir = join(outputDir, "history");
278
+ mkdirSync(historyDir, { recursive: true });
279
+ writeFileSync(join(historyDir, `${report.timestamp}.json`), JSON.stringify({ score: report.score, grade: report.grade, timestamp: report.timestamp, checks: report.checks.map((c) => ({ name: c.name, score: c.score, issues: c.issues.length })) }));
280
+ const historyFiles = readdirSync(historyDir).sort();
281
+ for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
282
+ try {
283
+ unlinkSync(join(historyDir, old));
284
+ }
285
+ catch { /* ignore */ }
286
+ }
287
+ // HTML report
288
+ if (!flags.jsonOnly) {
289
+ const reportDir = join(outputDir, "report");
290
+ mkdirSync(reportDir, { recursive: true });
291
+ const historyDir = join(outputDir, "history");
292
+ const pages = generatePages(report, historyDir);
293
+ for (const [filename, html] of pages) {
294
+ writeFileSync(join(reportDir, filename), html);
295
+ }
296
+ }
297
+ // Badge
298
+ if (flags.badgeMode) {
299
+ const { buildBadge } = await import("./report/svg.js");
300
+ writeFileSync(join(outputDir, "badge.svg"), buildBadge(report.score, report.grade));
301
+ }
302
+ // SARIF
303
+ if (flags.sarifMode) {
304
+ const { generateSARIF } = await import("./report/sarif.js");
305
+ writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
306
+ }
307
+ }
308
+ // ── Upload ──
309
+ async function handleUpload(report, cwd, quietMode) {
310
+ const token = process.env.VCQA_TOKEN || process.env.GITHUB_TOKEN;
311
+ if (!token) {
312
+ if (!quietMode)
313
+ console.log(" \x1b[33m\u26a0 Set VCQA_TOKEN to enable upload\x1b[0m");
314
+ return;
315
+ }
316
+ const repo = report.meta.repoUrl?.replace(/^https?:\/\/github\.com\//, "")?.replace(/\.git$/, "") || "";
317
+ if (!repo) {
318
+ if (!quietMode)
319
+ console.log(" \x1b[33m\u26a0 No git remote — can't upload\x1b[0m");
320
+ return;
321
+ }
316
322
  let sha;
317
323
  try {
318
324
  const { execSync } = await import("node:child_process");
319
325
  sha = execSync("git rev-parse HEAD", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
320
326
  }
321
- catch {
322
- /* not a git repo */
323
- }
327
+ catch { /* not a git repo */ }
324
328
  try {
325
329
  const res = await fetch("https://api.vibecodeqa.online/api/reports", {
326
330
  method: "POST",
@@ -329,34 +333,33 @@ async function handleUpload(report, cwd, jsonOnly) {
329
333
  });
330
334
  if (res.ok) {
331
335
  const data = (await res.json());
332
- if (!jsonOnly)
336
+ if (!quietMode)
333
337
  console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
334
338
  }
335
- else if (!jsonOnly) {
336
- console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m \x1b[2m(set VCQA_TOKEN env var)\x1b[0m`);
339
+ else if (!quietMode) {
340
+ console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m`);
337
341
  }
338
342
  }
339
343
  catch {
340
- if (!jsonOnly)
344
+ if (!quietMode)
341
345
  console.log(` \x1b[33m\u26a0 Upload failed (network error)\x1b[0m`);
342
346
  }
343
347
  }
348
+ // ── Watch mode ──
344
349
  async function startWatch(cwd) {
345
350
  const { watch } = await import("node:fs");
346
351
  const workspace = detectWorkspace(cwd);
347
352
  const watchDirs = workspace.isMonorepo
348
353
  ? workspace.srcRoots.map((d) => join(cwd, d)).filter((d) => existsSync(d))
349
354
  : ["src", "web/src"].map((d) => join(cwd, d)).filter((d) => existsSync(d));
350
- const srcDirs = watchDirs;
351
- if (srcDirs.length === 0) {
355
+ if (watchDirs.length === 0) {
352
356
  console.log(" \x1b[31mNo src/ directory to watch\x1b[0m");
353
357
  process.exit(1);
354
358
  }
355
- console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
356
- console.log("");
359
+ console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m\n");
357
360
  let debounce = null;
358
361
  let running = false;
359
- for (const dir of srcDirs) {
362
+ for (const dir of watchDirs) {
360
363
  watch(dir, { recursive: true }, (_event, filename) => {
361
364
  if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
362
365
  return;
@@ -368,7 +371,7 @@ async function startWatch(cwd) {
368
371
  running = true;
369
372
  try {
370
373
  console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
371
- await main().catch(() => { });
374
+ await main().catch((err) => { console.error("scan error:", err); });
372
375
  }
373
376
  finally {
374
377
  running = false;
@@ -376,479 +379,77 @@ async function startWatch(cwd) {
376
379
  }, 500);
377
380
  });
378
381
  }
379
- // Keep process alive
380
382
  await new Promise(() => { });
381
383
  }
382
- function printHelp() {
383
- console.log(`
384
- \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION} code health scanner
385
-
386
- \x1b[1mUsage:\x1b[0m npx @vibecodeqa/cli [command] [path] [flags]
387
-
388
- \x1b[1mCommands:\x1b[0m
389
- init [path] Set up CI workflow + recommended configs
390
- fix [path] Auto-fix (.gitignore, strict mode, biome/eslint, suggestions)
391
- --ai Use Claude to fix remaining issues (needs ANTHROPIC_API_KEY)
392
- --check NAME Only fix issues from a specific check (e.g. --check security)
393
- --dry-run Show what AI would fix without applying changes
394
- explain [check] Deep-dive explanation of a check (what/risk/fix)
395
- monitor [path] Live quality control panel — re-scans on file changes
396
-
397
- \x1b[1mFlags:\x1b[0m
398
- --skip-tests Skip test execution (faster scan)
399
- --ci CI mode (exit 1 if score < 60)
400
- --fail-under N Exit 1 if score below N (e.g. --fail-under 80)
401
- --json Output JSON only (no terminal UI)
402
- --badge Generate SVG badge
403
- --sarif Generate SARIF for GitHub Code Scanning
404
- --upload Upload report to app.vibecodeqa.online
405
- --top [N] Show top N issues to fix (default: 5)
406
- --diff [base] Only show issues in changed files (vs HEAD or branch)
407
- --markdown Output markdown summary (pipe to file or clipboard)
408
- --pr-comment Post score as GitHub PR comment (needs GITHUB_TOKEN)
409
- --annotations Emit GitHub Actions ::warning/::error annotations
410
- --watch Re-scan on file changes
411
- -v, --version Print version
412
- -h, --help Show this help
413
-
414
- \x1b[1mExamples:\x1b[0m
415
- npx @vibecodeqa/cli # scan current directory
416
- npx @vibecodeqa/cli init # set up CI + configs
417
- npx @vibecodeqa/cli fix # auto-fix what's fixable
418
- npx @vibecodeqa/cli fix --ai # AI-powered fix (uses Claude)
419
- npx @vibecodeqa/cli fix --ai --check security # fix only security issues
420
- npx @vibecodeqa/cli fix --ai --dry-run # preview AI fixes without applying
421
- npx @vibecodeqa/cli --skip-tests --top # fast scan with top issues
422
- npx @vibecodeqa/cli --ci --fail-under 80 # CI with quality gate
423
- `);
424
- }
425
- // ── init command ──
426
- async function runInit(cwd) {
427
- console.log("");
428
- console.log(` \x1b[1m\x1b[38;5;141mvcqa init\x1b[0m`);
429
- console.log(` \x1b[2m${cwd}\x1b[0m`);
430
- console.log("");
431
- validateCwd(cwd);
432
- const stack = detectStack(cwd);
433
- let created = 0;
434
- // 1. GitHub Actions workflow
435
- const workflowDir = join(cwd, ".github", "workflows");
436
- const workflowPath = join(workflowDir, "vibecodeqa.yml");
437
- if (!existsSync(workflowPath)) {
438
- try {
439
- mkdirSync(workflowDir, { recursive: true });
440
- writeFileSync(workflowPath, `name: VibeCode QA
441
- on: [pull_request]
442
- permissions: { contents: read }
443
- jobs:
444
- scan:
445
- runs-on: ubuntu-latest
446
- steps:
447
- - uses: actions/checkout@v4
448
- - run: npx @vibecodeqa/cli --ci --fail-under 70 --sarif --badge
449
- - uses: github/codeql-action/upload-sarif@v3
450
- if: always()
451
- with:
452
- sarif_file: .vibe-check/report.sarif
453
- `);
454
- console.log(` \x1b[32m+\x1b[0m .github/workflows/vibecodeqa.yml`);
455
- created++;
456
- }
457
- catch {
458
- console.log(` \x1b[31m!\x1b[0m .github/workflows/vibecodeqa.yml (write failed — check permissions)`);
459
- }
460
- }
461
- else {
462
- console.log(` \x1b[2m=\x1b[0m .github/workflows/vibecodeqa.yml (exists)`);
463
- }
464
- // 2. Biome config (if biome is a dep but no config exists)
465
- if ((stack.linter === "biome" || existsSync(join(cwd, "node_modules", "@biomejs", "biome"))) &&
466
- !existsSync(join(cwd, "biome.json")) &&
467
- !existsSync(join(cwd, "biome.jsonc"))) {
468
- writeFileSync(join(cwd, "biome.json"), JSON.stringify({
469
- $schema: "https://biomejs.dev/schemas/2.0.0/schema.json",
470
- formatter: { indentStyle: "tab", lineWidth: 120 },
471
- linter: { enabled: true, rules: { recommended: true } },
472
- organizeImports: { enabled: true },
473
- }, null, "\t") + "\n");
474
- console.log(` \x1b[32m+\x1b[0m biome.json`);
475
- created++;
476
- }
477
- // 3. Create .vcqa.json if not present
478
- const vcqaConfigPath = join(cwd, ".vcqa.json");
479
- 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
- ];
489
- const checksConfig = {};
490
- for (const name of allCheckNames) {
491
- checksConfig[name] = {};
492
- }
493
- const config = {
494
- _comment: "vcqa config — docs: https://vibecodeqa.online/skills",
495
- checks: checksConfig,
496
- _checks_help: "Set { \"enabled\": false } to disable. Add \"ignore\": [\"generated/**\"] to skip files per-check.",
497
- ignore: [],
498
- _ignore_help: "Global file patterns to skip: [\"vendor/**\", \"*.generated.ts\", \"proto/**\"]",
499
- failUnder: 60,
500
- _failUnder_help: "Exit with code 1 if score below this. Overridden by --fail-under flag.",
501
- };
502
- writeFileSync(vcqaConfigPath, JSON.stringify(config, null, 2) + "\n");
503
- console.log(` \x1b[32m+\x1b[0m .vcqa.json`);
504
- created++;
505
- }
506
- // 4. Add .vibe-check to .gitignore
507
- const gitignorePath = join(cwd, ".gitignore");
508
- if (existsSync(gitignorePath)) {
509
- const content = readFileSync(gitignorePath, "utf-8");
510
- if (!content.includes(".vibe-check")) {
511
- writeFileSync(gitignorePath, content.trimEnd() + "\n.vibe-check/\n");
512
- console.log(` \x1b[32m+\x1b[0m .gitignore (added .vibe-check/)`);
513
- created++;
514
- }
515
- }
516
- console.log("");
517
- if (created > 0) {
518
- console.log(` \x1b[32mCreated ${created} file(s).\x1b[0m Run \x1b[1mnpx @vibecodeqa/cli\x1b[0m to scan.`);
519
- }
520
- else {
521
- console.log(` \x1b[2mAlready set up. Run npx @vibecodeqa/cli to scan.\x1b[0m`);
522
- }
523
- console.log("");
524
- }
525
- // ── explain command ──
526
- async function runExplain(checkName) {
527
- if (!checkName) {
528
- console.log("\n \x1b[1mUsage:\x1b[0m vcqa explain <check>\n");
529
- console.log(" Available checks:");
530
- const { CHECK_META } = await import("./check-meta.js");
531
- for (const [name, meta] of Object.entries(CHECK_META)) {
532
- console.log(` \x1b[1m${name.padEnd(16)}\x1b[0m ${meta.label} (${meta.category}, ${meta.weight}%)`);
533
- }
534
- console.log("");
535
- return;
536
- }
537
- const meta = getCheckMeta(checkName);
538
- if (!meta.description || meta.description.length < 20) {
539
- console.log(`\n \x1b[31mUnknown check: ${checkName}\x1b[0m`);
540
- console.log(" Run \x1b[1mvcqa explain\x1b[0m to see available checks.\n");
541
- return;
542
- }
543
- console.log("");
544
- console.log(` \x1b[1m\x1b[38;5;141m${meta.label}\x1b[0m \x1b[2m${meta.category} · ${meta.priority} priority · ${meta.weight}% weight\x1b[0m`);
545
- console.log("");
546
- console.log(` \x1b[1mWhat:\x1b[0m ${meta.description}`);
547
- console.log("");
548
- console.log(` \x1b[1mRisk:\x1b[0m ${meta.risk}`);
549
- console.log("");
550
- console.log(` \x1b[1mFix:\x1b[0m ${meta.recommendation}`);
551
- if (meta.deeperTools?.length) {
552
- console.log("");
553
- console.log(` \x1b[1mGo deeper:\x1b[0m ${meta.deeperTools.join(", ")}`);
554
- }
555
- console.log("");
384
+ // ── Interactive prompt ──
385
+ function readKey() {
386
+ return new Promise((resolve) => {
387
+ const stdin = process.stdin;
388
+ const wasRaw = stdin.isRaw;
389
+ stdin.setRawMode?.(true);
390
+ stdin.resume();
391
+ stdin.once("data", (buf) => {
392
+ stdin.setRawMode?.(wasRaw ?? false);
393
+ stdin.pause();
394
+ resolve(buf.toString("utf-8"));
395
+ });
396
+ });
556
397
  }
557
- // ── fix command ──
558
- async function runFix(cwd, opts = {}) {
559
- console.log("");
560
- console.log(` \x1b[1m\x1b[38;5;141mvcqa fix${opts.ai ? " --ai" : ""}${opts.dryRun ? " --dry-run" : ""}${opts.checkFilter ? ` --check ${opts.checkFilter}` : ""}\x1b[0m`);
561
- console.log(` \x1b[2m${cwd}\x1b[0m`);
562
- console.log("");
563
- validateCwd(cwd);
564
- const stack = detectStack(cwd);
565
- let fixed = 0;
566
- // 0. Auto-fix structure issues (missing files)
567
- if (!existsSync(join(cwd, ".gitignore"))) {
568
- writeFileSync(join(cwd, ".gitignore"), "node_modules\ndist\n.vibe-check\ncoverage\n.env\n.env.local\n");
569
- console.log(" \x1b[32m\u2713 Created .gitignore\x1b[0m");
570
- fixed++;
571
- }
572
- // Add .vibe-check to existing .gitignore if missing
573
- if (existsSync(join(cwd, ".gitignore"))) {
574
- const gi = readFileSync(join(cwd, ".gitignore"), "utf-8");
575
- if (!gi.includes(".vibe-check")) {
576
- writeFileSync(join(cwd, ".gitignore"), gi.trimEnd() + "\n.vibe-check/\n");
577
- console.log(" \x1b[32m\u2713 Added .vibe-check/ to .gitignore\x1b[0m");
578
- fixed++;
579
- }
580
- }
581
- // Enable strict mode if tsconfig exists without it
582
- const tsconfigPath = join(cwd, "tsconfig.json");
583
- if (existsSync(tsconfigPath)) {
584
- try {
585
- const raw = readFileSync(tsconfigPath, "utf-8");
586
- const tsconfig = JSON.parse(raw);
587
- if (!tsconfig.compilerOptions?.strict) {
588
- tsconfig.compilerOptions = { ...tsconfig.compilerOptions, strict: true };
589
- writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
590
- console.log(' \x1b[32m\u2713 Enabled "strict": true in tsconfig.json\x1b[0m');
591
- fixed++;
592
- }
593
- }
594
- catch {
595
- /* can't parse tsconfig */
596
- }
597
- }
598
- // 1. Run biome format (auto-fixable lint + format issues)
599
- if (stack.linter === "biome") {
600
- console.log(" \x1b[1mFormatting with Biome...\x1b[0m");
601
- const { execSync } = await import("node:child_process");
602
- try {
603
- execSync("npx biome check --write .", { cwd, stdio: "inherit", timeout: 30_000 });
604
- fixed++;
605
- }
606
- catch {
607
- console.log(" \x1b[33mBiome had issues (some may be unfixable)\x1b[0m");
608
- }
609
- }
610
- else if (stack.linter === "eslint") {
611
- console.log(" \x1b[1mFixing with ESLint...\x1b[0m");
612
- const { execSync } = await import("node:child_process");
398
+ function openPath(target) {
399
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
400
+ import("node:child_process").then(({ spawn }) => {
613
401
  try {
614
- execSync("npx eslint --fix src/", { cwd, stdio: "inherit", timeout: 30_000 });
615
- fixed++;
616
- }
617
- catch {
618
- console.log(" \x1b[33mESLint had issues (some may be unfixable)\x1b[0m");
619
- }
620
- }
621
- // 2. Scan to find remaining issues and generate fix suggestions
622
- console.log("");
623
- console.log(" \x1b[1mScanning for remaining issues...\x1b[0m");
624
- console.log("");
625
- const workspace = detectWorkspace(cwd);
626
- setGlobalSrcRoots(workspace.isMonorepo ? workspace.srcRoots : undefined);
627
- const enrichedStack = detectStack(cwd, workspace);
628
- const isDart = enrichedStack.language === "dart";
629
- const checks = await runChecks(cwd, enrichedStack, workspace, true, isDart, true);
630
- const score = computeScore(checks);
631
- // AI-powered fix mode
632
- if (opts.ai) {
633
- const aiIssues = collectFixableIssues(checks, suggestFix, opts.checkFilter);
634
- if (aiIssues.length === 0) {
635
- console.log(" \x1b[2mNo fixable issues found.\x1b[0m");
636
- }
637
- else {
638
- console.log(` \x1b[1mAI fixing ${Math.min(aiIssues.length, 10)} issues${opts.dryRun ? " (dry run)" : ""}...\x1b[0m`);
639
- console.log("");
640
- const results = await aiFixIssues(cwd, aiIssues, { dryRun: opts.dryRun || false });
641
- const applied = results.filter((r) => r.applied).length;
642
- if (applied > 0) {
643
- // Re-scan to show new score
644
- console.log("");
645
- console.log(" \x1b[1mRe-scanning...\x1b[0m");
646
- const reChecks = await runChecks(cwd, enrichedStack, workspace, true, isDart, true);
647
- const newScore = computeScore(reChecks);
648
- const newGrade = gradeFromScore(newScore);
649
- const delta = newScore - score;
650
- console.log(` Score: \x1b[${newScore >= 75 ? "32" : newScore >= 60 ? "33" : "31"}m${newGrade} ${newScore}/100\x1b[0m${delta > 0 ? ` \x1b[32m(+${delta})\x1b[0m` : ""}`);
651
- console.log(` \x1b[32m${applied} AI fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
652
- }
653
- else {
654
- const grade = gradeFromScore(score);
655
- console.log(`\n Score: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
656
- if (opts.dryRun)
657
- console.log(" \x1b[2mDry run — no files modified. Remove --dry-run to apply.\x1b[0m");
658
- }
659
- }
660
- console.log("");
661
- return;
662
- }
663
- // Collect actionable issues with fix suggestions (non-AI mode)
664
- const fixable = [];
665
- for (const c of checks) {
666
- for (const iss of c.issues) {
667
- if (!iss.file || typeof iss.file !== "string" || !iss.line)
668
- continue;
669
- const fix = suggestFix(c.name, iss.rule || "", iss.message);
670
- if (fix)
671
- fixable.push({ check: c.name, file: iss.file, line: iss.line, message: iss.message, fix });
672
- }
673
- }
674
- // Print top fixable issues
675
- const top = fixable.slice(0, 10);
676
- if (top.length > 0) {
677
- console.log(` \x1b[1m${top.length} issues with fix suggestions:\x1b[0m`);
678
- console.log("");
679
- for (const f of top) {
680
- console.log(` \x1b[2m${f.file}:${f.line}\x1b[0m`);
681
- console.log(` ${f.message}`);
682
- console.log(` \x1b[32mFix: ${f.fix}\x1b[0m`);
683
- console.log("");
402
+ spawn(cmd, [target], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
684
403
  }
685
- }
686
- const grade = gradeFromScore(score);
687
- console.log(` Score after fix: \x1b[${score >= 75 ? "32" : score >= 60 ? "33" : "31"}m${grade} ${score}/100\x1b[0m`);
688
- if (fixed > 0)
689
- console.log(` \x1b[32m${fixed} auto-fix(es) applied.\x1b[0m Re-run \x1b[1mnpx @vibecodeqa/cli\x1b[0m for full report.`);
690
- console.log("");
691
- }
692
- function suggestFix(check, rule, message) {
693
- // Map common issues to actionable fixes
694
- if (rule === "empty-catch")
695
- return "Add error logging: catch(e) { console.error(e); }";
696
- if (rule === "throw-string")
697
- return 'Replace throw "msg" with throw new Error("msg")';
698
- if (rule === "swallowed-promise")
699
- return "Add logging: .catch((e) => { console.error(e); })";
700
- if (rule === "floating-promise")
701
- return "Add await or .catch() to handle the promise";
702
- if (rule === "unsafe-json-parse")
703
- return "Wrap in try-catch: try { JSON.parse(x) } catch { /* handle */ }";
704
- if (rule === "no-error-boundary")
705
- return "Add <ErrorBoundary> wrapper in your React app root";
706
- if (rule === "img-alt")
707
- return 'Add alt attribute: <img alt="description" ...>';
708
- if (rule === "click-events")
709
- return 'Add role="button" and onKeyDown handler';
710
- if (rule === "vue-v-for-key")
711
- return 'Add :key="item.id" to the v-for element';
712
- if (rule === "missing-key")
713
- return "Add key={item.id} to the JSX element in .map()";
714
- if (rule === "index-key")
715
- return "Use a stable unique ID instead of array index for key";
716
- if (rule === "conditional-hook")
717
- return "Move the hook call before any conditional (if/switch)";
718
- if (rule === "no-tests")
719
- return "Create a test file: src/__tests__/example.test.ts";
720
- if (rule === "no-readme")
721
- return "Create README.md with: project description, install, usage";
722
- if (rule === "no-changelog")
723
- return "Create CHANGELOG.md or use changesets: npx changeset init";
724
- if (rule === "env-not-ignored")
725
- return "Add .env to .gitignore";
726
- if (rule === "secret-detected")
727
- return "Move to environment variable, rotate the exposed secret";
728
- if (rule === "no-ci")
729
- return "Run: npx @vibecodeqa/cli init";
730
- if (rule === "missing-lockfile")
731
- return "Run: pnpm install (or npm install) to generate lockfile";
732
- if (rule === "missing-file" && message.includes("LICENSE"))
733
- return "Add LICENSE file: https://choosealicense.com/";
734
- if (rule === "long-function")
735
- return "Extract logic into smaller helper functions";
736
- if (rule === "high-complexity")
737
- return "Reduce nesting: use early returns, extract conditions";
738
- if (rule === "duplicate-code")
739
- return "Extract shared logic into a helper function";
740
- if (rule === "circular-dep")
741
- return "Extract shared types to a separate file both modules import";
742
- if (rule === "god-module")
743
- return "Split into focused interfaces — one responsibility per module";
744
- if (rule === "process-exit")
745
- return "Replace process.exit() with throw new Error()";
746
- if (check === "security" && message.includes("innerHTML"))
747
- return "Use textContent or DOM APIs instead";
748
- if (check === "security" && message.includes("ev" + "al"))
749
- return `Remove ${"ev" + "al"}() — use a safer alternative`;
750
- if (check === "security" && message.includes("v-html"))
751
- return 'Sanitize with DOMPurify: v-html="DOMPurify.sanitize(input)"';
752
- return null;
404
+ catch { /* best-effort */ }
405
+ });
753
406
  }
754
- function validateCwd(cwd) {
755
- if (!existsSync(cwd)) {
756
- console.error(` \x1b[31mError: path does not exist: ${cwd}\x1b[0m`);
757
- process.exit(1);
758
- }
407
+ async function promptNextAction(cwd, outputDir) {
408
+ process.stdout.write(" \x1b[1m[m]\x1b[0m\x1b[2m monitor\x1b[0m \x1b[1m[o]\x1b[0m\x1b[2m open report\x1b[0m \x1b[1m[f]\x1b[0m\x1b[2m fix --ai\x1b[0m \x1b[1m[enter]\x1b[0m\x1b[2m quit\x1b[0m ");
409
+ let key;
759
410
  try {
760
- if (!statSync(cwd).isDirectory()) {
761
- console.error(` \x1b[31mError: not a directory: ${cwd}\x1b[0m`);
762
- process.exit(1);
763
- }
411
+ key = await readKey();
764
412
  }
765
413
  catch {
766
- console.error(` \x1b[31mError: cannot access: ${cwd}\x1b[0m`);
767
- process.exit(1);
768
- }
769
- }
770
- function generateMarkdown(report, trend) {
771
- const { score, grade, checks } = report;
772
- const gradeEmoji = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : "🔴";
773
- let md = `# ${gradeEmoji} VibeCode QA: ${grade} ${score}/100\n\n`;
774
- if (trend) {
775
- const arrow = trend.scoreDelta > 0 ? "📈" : trend.scoreDelta < 0 ? "📉" : "➡️";
776
- md += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`;
777
- if (trend.fixedIssues > 0)
778
- md += ` · ${trend.fixedIssues} fixed`;
779
- if (trend.newIssues > 0)
780
- md += ` · ${trend.newIssues} new`;
781
- md += "\n\n";
414
+ process.stdout.write("\n");
415
+ return;
782
416
  }
783
- md += "| Check | Score | Grade |\n|-------|-------|-------|\n";
784
- for (const c of checks) {
785
- const det = c.details;
786
- if (det.skipped || det.comingSoon)
787
- continue;
788
- const emoji = c.score >= 90 ? "🟢" : c.score >= 75 ? "🟡" : c.score >= 60 ? "🟠" : "🔴";
789
- md += `| ${emoji} ${c.name} | ${c.score}/100 | ${c.grade} |\n`;
417
+ process.stdout.write("\n");
418
+ const k = key.toLowerCase();
419
+ if (k === "m") {
420
+ const { startMonitor } = await import("./monitor.js");
421
+ await startMonitor(cwd);
790
422
  }
791
- const errors = checks.flatMap((c) => c.issues.filter((i) => i.severity === "error"));
792
- const warnings = checks.flatMap((c) => c.issues.filter((i) => i.severity === "warning"));
793
- if (errors.length + warnings.length > 0) {
794
- md += `\n## Issues (${errors.length} errors, ${warnings.length} warnings)\n\n`;
795
- for (const i of [...errors, ...warnings].slice(0, 15)) {
796
- const loc = i.file ? ` \`${i.file}${i.line ? `:${i.line}` : ""}\`` : "";
797
- md += `- ${i.severity === "error" ? "❌" : "⚠️"} ${i.message}${loc}\n`;
798
- }
799
- const remaining = errors.length + warnings.length - 15;
800
- if (remaining > 0)
801
- md += `\n*...and ${remaining} more*\n`;
423
+ else if (k === "o") {
424
+ const reportPath = join(outputDir, "report/index.html");
425
+ openPath(reportPath);
426
+ console.log(` \x1b[2mOpening ${reportPath}\x1b[0m`);
802
427
  }
803
- md += `\n---\n*vcqa v${report.version} · ${report.meta.duration}ms*\n`;
804
- return md;
805
- }
806
- function emitAnnotations(report) {
807
- for (const c of report.checks) {
808
- for (const i of c.issues) {
809
- if (i.severity === "info")
810
- continue;
811
- const level = i.severity === "error" ? "error" : "warning";
812
- const file = i.file && typeof i.file === "string" ? i.file : "";
813
- const line = i.line || "";
814
- const loc = file ? ` file=${file}${line ? `,line=${line}` : ""}` : "";
815
- // GitHub Actions annotation format
816
- console.log(`::${level}${loc ? loc : ""}::${c.name}: ${i.message}`);
817
- }
428
+ else if (k === "f") {
429
+ console.log("");
430
+ await runFix(cwd, { ai: true });
818
431
  }
819
432
  }
820
- /** Get changed files from git diff. Returns null if git unavailable. */
821
- function getChangedFiles(cwd, base) {
433
+ // ── Update check ──
434
+ async function checkForUpdate(currentVersion) {
822
435
  try {
823
- const { execSync } = require("node:child_process");
824
- const cmd = base === "HEAD" ? "git diff --name-only" : `git diff --name-only ${base}...HEAD`;
825
- const stdout = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
826
- if (!stdout)
827
- return new Set();
828
- return new Set(stdout.split("\n").filter((f) => f.length > 0));
829
- }
830
- catch {
831
- return null;
832
- }
833
- }
834
- function printHeader(cwd, stack, workspace) {
835
- console.log("");
836
- console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
837
- console.log(` \x1b[2m${cwd}\x1b[0m`);
838
- console.log("");
839
- const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
840
- console.log(` stack: ${parts.join(" + ")}`);
841
- if (workspace.isMonorepo) {
842
- console.log(` workspace: ${workspace.tool} monorepo — ${workspace.packages.length} packages`);
843
- for (const pkg of workspace.packages.slice(0, 8)) {
844
- const f = [pkg.hasSrc && "src", pkg.hasTests && "tests", pkg.hasLinter && "linter"].filter(Boolean).join(", ");
845
- console.log(` \x1b[2m${pkg.path}\x1b[0m (${f || "empty"})`);
436
+ const res = await fetch("https://registry.npmjs.org/@vibecodeqa/cli/latest", { signal: AbortSignal.timeout(3000) });
437
+ if (!res.ok)
438
+ return;
439
+ const data = (await res.json());
440
+ const latest = data.version;
441
+ if (!latest || latest === currentVersion)
442
+ return;
443
+ const cur = currentVersion.split(".").map(Number);
444
+ const lat = latest.split(".").map(Number);
445
+ const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) || (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
446
+ if (isNewer) {
447
+ console.log(` \x1b[33mUpdate available: ${currentVersion} → ${latest}\x1b[0m Run \x1b[1mnpx @vibecodeqa/cli@latest\x1b[0m\n`);
846
448
  }
847
- if (workspace.packages.length > 8)
848
- console.log(` \x1b[2m...and ${workspace.packages.length - 8} more\x1b[0m`);
849
449
  }
850
- console.log("");
450
+ catch { /* network error — silently ignore */ }
851
451
  }
452
+ // ── Main ──
852
453
  async function main() {
853
454
  const args = process.argv.slice(2);
854
455
  if (args.includes("--version") || args.includes("-v")) {
@@ -860,8 +461,7 @@ async function main() {
860
461
  return;
861
462
  }
862
463
  if (args[0] === "init") {
863
- const path = args.slice(1).find((a) => !a.startsWith("-")) || ".";
864
- await runInit(resolve(path));
464
+ await runInit(resolve(args.slice(1).find((a) => !a.startsWith("-")) || "."));
865
465
  return;
866
466
  }
867
467
  if (args[0] === "fix") {
@@ -879,43 +479,35 @@ async function main() {
879
479
  return;
880
480
  }
881
481
  if (args[0] === "monitor") {
882
- const path = args.slice(1).find((a) => !a.startsWith("-")) || ".";
883
482
  const { startMonitor } = await import("./monitor.js");
884
- await startMonitor(resolve(path));
483
+ await startMonitor(resolve(args.slice(1).find((a) => !a.startsWith("-")) || "."));
885
484
  return;
886
485
  }
887
486
  const flags = parseFlags();
888
487
  const { cwd, outputDir, jsonOnly, ciMode, skipTests, watchMode, diffBase } = flags;
889
- const start = Date.now();
890
488
  validateCwd(cwd);
891
489
  const config = loadConfig(cwd);
892
490
  const workspace = detectWorkspace(cwd);
893
491
  const stack = detectStack(cwd, workspace);
894
- setGlobalSrcRoots(workspace.isMonorepo ? workspace.srcRoots : undefined);
895
- setGlobalIgnore(config.ignore);
896
492
  const quietMode = jsonOnly || flags.markdownMode;
897
493
  if (!quietMode)
898
494
  printHeader(cwd, stack, workspace);
899
- const isDart = stack.language === "dart";
900
- const checks = await runChecks(cwd, stack, workspace, skipTests, isDart, quietMode, config);
901
- // Per-check ignore: filter issues matching check-specific ignore patterns
902
- for (const c of checks) {
903
- const patterns = getCheckIgnore(config, c.name);
904
- if (!patterns?.length)
905
- continue;
906
- c.issues = c.issues.filter((i) => {
907
- if (!i.file || typeof i.file !== "string")
908
- return true;
909
- const f = i.file;
910
- return !patterns.some((p) => {
911
- if (p.endsWith("/**"))
912
- return f.startsWith(p.slice(0, -3) + "/");
913
- if (p.startsWith("*"))
914
- return f.endsWith(p.slice(1));
915
- return f.startsWith(p);
916
- });
917
- });
918
- }
495
+ // Run scan using core API with progress output
496
+ const report = await scan(cwd, {
497
+ skipTests,
498
+ config,
499
+ onProgress: quietMode ? undefined : (check, result) => {
500
+ const det = result.details;
501
+ const premium = det.comingSoon;
502
+ const skipped = det.skipped;
503
+ const c = premium ? "\x1b[2m" : skipped ? "\x1b[2m" : color(result.grade);
504
+ const label = premium ? "soon" : skipped ? "skip" : result.grade;
505
+ const scoreStr = premium ? "PRO" : skipped ? "—" : `${result.score}/100`;
506
+ const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
507
+ console.log(` ${check.padEnd(14)}${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
508
+ },
509
+ });
510
+ const { score, checks } = report;
919
511
  // --diff: filter issues to only changed files
920
512
  if (diffBase) {
921
513
  const changedFiles = getChangedFiles(cwd, diffBase);
@@ -925,21 +517,8 @@ async function main() {
925
517
  }
926
518
  }
927
519
  }
928
- const score = computeScore(checks);
929
- const grade = gradeFromScore(score);
930
- const duration = Date.now() - start;
931
- const report = {
932
- version: VERSION,
933
- timestamp: new Date().toISOString(),
934
- score,
935
- grade,
936
- checks,
937
- meta: { cwd, node: process.version, duration, stack, workspace, ...detectRepoUrl(cwd) },
938
- };
939
520
  const trend = computeTrend(report, outputDir);
940
521
  await writeOutputs(report, outputDir, flags);
941
- // Interactive = a real terminal session (not piped, CI, JSON, or watch). Gates the
942
- // post-scan prompt and the default top-issues view.
943
522
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !quietMode && !ciMode && !watchMode;
944
523
  if (flags.markdownMode) {
945
524
  console.log(generateMarkdown(report, trend));
@@ -947,12 +526,10 @@ async function main() {
947
526
  else {
948
527
  await printResults(report, trend, flags, outputDir, interactive);
949
528
  }
950
- if (flags.annotations) {
529
+ if (flags.annotations)
951
530
  emitAnnotations(report);
952
- }
953
- if (flags.uploadMode) {
531
+ if (flags.uploadMode)
954
532
  await handleUpload(report, cwd, quietMode);
955
- }
956
533
  if (flags.prComment) {
957
534
  const posted = await postPRComment(report, trend, cwd);
958
535
  if (!quietMode) {
@@ -962,97 +539,27 @@ async function main() {
962
539
  console.log(" \x1b[2mNo PR detected or no GITHUB_TOKEN — skipping PR comment\x1b[0m");
963
540
  }
964
541
  }
965
- // CI exit code: fail if score below threshold (skip in watch mode)
966
542
  const failUnder = flags.failUnder ?? (ciMode ? 60 : (config.failUnder ?? 0));
967
543
  if (failUnder > 0 && score < failUnder && !watchMode) {
968
544
  if (!quietMode)
969
545
  console.log(` \x1b[31mFailing: score ${score} < ${failUnder}\x1b[0m\n`);
970
546
  process.exit(1);
971
547
  }
972
- // Non-blocking update check (don't slow down the scan)
973
548
  if (!quietMode && !ciMode && !watchMode && !process.env.VCQA_NO_UPDATE_CHECK) {
974
- checkForUpdate(VERSION).catch(() => { });
549
+ checkForUpdate(VERSION).catch(() => { }); // ok — non-blocking, best-effort
975
550
  }
976
551
  if (watchMode) {
977
552
  await startWatch(cwd);
978
553
  return;
979
554
  }
980
- // Interactive on-ramp: offer to open the live monitor or the HTML report.
555
+ if (interactive && existsSync(join(cwd, ".git")) && !existsSync(join(cwd, ".github", "workflows", "vibecodeqa.yml"))) {
556
+ console.log(` \x1b[2mTip: Add CI scanning with one line:\x1b[0m \x1b[1m- uses: vibecodeqa/action@v1\x1b[0m`);
557
+ console.log("");
558
+ }
981
559
  if (interactive && !flags.uploadMode && !flags.prComment) {
982
560
  await promptNextAction(cwd, outputDir);
983
561
  }
984
562
  }
985
- /** Read a single keypress from a TTY, restoring stdin state afterward. */
986
- function readKey() {
987
- return new Promise((resolve) => {
988
- const stdin = process.stdin;
989
- const wasRaw = stdin.isRaw;
990
- stdin.setRawMode?.(true);
991
- stdin.resume();
992
- stdin.once("data", (buf) => {
993
- stdin.setRawMode?.(wasRaw ?? false);
994
- stdin.pause();
995
- resolve(buf.toString("utf-8"));
996
- });
997
- });
998
- }
999
- /** Open a file/URL with the OS default handler (detached). */
1000
- function openPath(target) {
1001
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1002
- import("node:child_process").then(({ spawn }) => {
1003
- try {
1004
- spawn(cmd, [target], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
1005
- }
1006
- catch {
1007
- /* opening is best-effort */
1008
- }
1009
- });
1010
- }
1011
- /** Post-scan prompt: [m] monitor · [o] open report · anything else quits. */
1012
- async function promptNextAction(cwd, outputDir) {
1013
- process.stdout.write(" \x1b[1m[m]\x1b[0m\x1b[2m monitor\x1b[0m \x1b[1m[o]\x1b[0m\x1b[2m open report\x1b[0m \x1b[1m[enter]\x1b[0m\x1b[2m quit\x1b[0m ");
1014
- let key;
1015
- try {
1016
- key = await readKey();
1017
- }
1018
- catch {
1019
- process.stdout.write("\n");
1020
- return;
1021
- }
1022
- process.stdout.write("\n");
1023
- const k = key.toLowerCase();
1024
- if (k === "m") {
1025
- const { startMonitor } = await import("./monitor.js");
1026
- await startMonitor(cwd);
1027
- }
1028
- else if (k === "o") {
1029
- const reportPath = join(outputDir, "report/index.html");
1030
- openPath(reportPath);
1031
- console.log(` \x1b[2mOpening ${reportPath}\x1b[0m`);
1032
- }
1033
- // any other key (enter, q, ctrl-c, …) → quit
1034
- }
1035
- async function checkForUpdate(currentVersion) {
1036
- try {
1037
- const res = await fetch("https://registry.npmjs.org/@vibecodeqa/cli/latest", { signal: AbortSignal.timeout(3000) });
1038
- if (!res.ok)
1039
- return;
1040
- const data = (await res.json());
1041
- const latest = data.version;
1042
- if (!latest || latest === currentVersion)
1043
- return;
1044
- // Only show if npm version is actually newer (semver compare)
1045
- const cur = currentVersion.split(".").map(Number);
1046
- const lat = latest.split(".").map(Number);
1047
- const isNewer = lat[0] > cur[0] || (lat[0] === cur[0] && lat[1] > cur[1]) || (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
1048
- if (isNewer) {
1049
- console.log(` \x1b[33mUpdate available: ${currentVersion} → ${latest}\x1b[0m Run \x1b[1mnpx @vibecodeqa/cli@latest\x1b[0m\n`);
1050
- }
1051
- }
1052
- catch {
1053
- /* network error — silently ignore */
1054
- }
1055
- }
1056
563
  main().catch((err) => {
1057
564
  console.error("vibe-check error:", err);
1058
565
  process.exit(1);