@vibecodeqa/cli 0.42.0 → 0.44.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 (47) hide show
  1. package/README.md +130 -165
  2. package/dist/check-meta.js +59 -6
  3. package/dist/cli.js +299 -762
  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 +157 -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.d.ts +1 -0
  13. package/dist/core.js +12 -1
  14. package/dist/delta.d.ts +45 -0
  15. package/dist/delta.js +158 -0
  16. package/dist/detect.js +2 -2
  17. package/dist/pr-comment.d.ts +1 -1
  18. package/dist/pr-comment.js +23 -4
  19. package/dist/report/html.d.ts +1 -1
  20. package/dist/report/html.js +7 -2
  21. package/dist/report/pages.d.ts +2 -0
  22. package/dist/report/pages.js +167 -0
  23. package/dist/report/styles.d.ts +1 -1
  24. package/dist/report/styles.js +37 -0
  25. package/dist/runners/accessibility.js +4 -1
  26. package/dist/runners/best-practices.js +1 -1
  27. package/dist/runners/confusion.js +28 -17
  28. package/dist/runners/design-consistency.d.ts +12 -0
  29. package/dist/runners/design-consistency.js +125 -0
  30. package/dist/runners/error-handling.js +18 -2
  31. package/dist/runners/file-cohesion.d.ts +17 -0
  32. package/dist/runners/file-cohesion.js +177 -0
  33. package/dist/runners/frontend-health.d.ts +14 -0
  34. package/dist/runners/frontend-health.js +206 -0
  35. package/dist/runners/html-quality.d.ts +8 -0
  36. package/dist/runners/html-quality.js +203 -0
  37. package/dist/runners/lint.js +6 -1
  38. package/dist/runners/react.js +1 -0
  39. package/dist/runners/secrets.js +7 -2
  40. package/dist/runners/security.js +7 -1
  41. package/dist/runners/standards.d.ts +2 -2
  42. package/dist/runners/standards.js +45 -12
  43. package/dist/runners/structure.js +1 -1
  44. package/dist/runners/styling.d.ts +15 -0
  45. package/dist/runners/styling.js +280 -0
  46. package/dist/runners/testing.js +3 -1
  47. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,52 +1,23 @@
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 { computeDelta } from "./delta.js";
12
+ import { detectStack, detectWorkspace } from "./detect.js";
10
13
  import { postPRComment } from "./pr-comment.js";
11
14
  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 { runContainerHealth } from "./runners/container-health.js";
22
- import { runContext } from "./runners/context.js";
23
- import { runDependencies } from "./runners/dependencies.js";
24
- import { runEnvValidation } from "./runners/env-validation.js";
25
- import { runGitHygiene } from "./runners/git-hygiene.js";
26
- import { runDocCoherence } from "./runners/doc-coherence.js";
27
- import { runDocs } from "./runners/docs.js";
28
- import { runDuplication } from "./runners/duplication.js";
29
- import { runErrorHandling } from "./runners/error-handling.js";
30
- import { runLint } from "./runners/lint.js";
31
- import { runMemorySafety } from "./runners/memory-safety.js";
32
- import { runPerformance } from "./runners/performance.js";
33
- import { runReact } from "./runners/react.js";
34
- import { runSecrets } from "./runners/secrets.js";
35
- import { runSecurity } from "./runners/security.js";
36
- import { runStandards } from "./runners/standards.js";
37
- import { runStructure } from "./runners/structure.js";
38
- import { runTesting } from "./runners/testing.js";
39
- import { runTypeSafety } from "./runners/type-safety.js";
40
- import { runTypeCheck } from "./runners/types-check.js";
41
- import { computeScore } from "./score.js";
42
15
  import { computeTrend, formatTrend } from "./trend.js";
43
- import { gradeFromScore } from "./types.js";
44
16
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
45
17
  const VERSION = pkg.version;
46
18
  function parseFlags() {
47
19
  const args = process.argv.slice(2);
48
20
  const flags = new Set(args.filter((a) => a.startsWith("--")));
49
- // Parse flags with value arguments
50
21
  const valueArgIndices = new Set();
51
22
  function parseValueFlag(flag, fallback) {
52
23
  const idx = args.indexOf(flag);
@@ -58,14 +29,12 @@ function parseFlags() {
58
29
  valueArgIndices.add(idx + 1);
59
30
  return parseInt(next, 10);
60
31
  }
61
- // Non-numeric value after flag — consume it to prevent misuse as cwd
62
32
  valueArgIndices.add(idx + 1);
63
33
  }
64
34
  return fallback ?? null;
65
35
  }
66
36
  const topN = parseValueFlag("--top", 5) ?? 0;
67
37
  const failUnder = parseValueFlag("--fail-under");
68
- // --diff [base] — only show issues in changed files
69
38
  let diffBase = null;
70
39
  const diffIdx = args.indexOf("--diff");
71
40
  if (diffIdx !== -1) {
@@ -75,7 +44,7 @@ function parseFlags() {
75
44
  valueArgIndices.add(diffIdx + 1);
76
45
  }
77
46
  else {
78
- diffBase = "HEAD"; // default: uncommitted changes
47
+ diffBase = "HEAD";
79
48
  }
80
49
  }
81
50
  const cwd = resolve(args.find((a, i) => !a.startsWith("--") && !valueArgIndices.has(i)) || ".");
@@ -97,6 +66,7 @@ function parseFlags() {
97
66
  annotations: flags.has("--annotations"),
98
67
  };
99
68
  }
69
+ // ── Output helpers ──
100
70
  function color(grade) {
101
71
  if (grade === "A")
102
72
  return "\x1b[32m";
@@ -104,136 +74,134 @@ function color(grade) {
104
74
  return "\x1b[33m";
105
75
  return "\x1b[31m";
106
76
  }
107
- async function runChecks(cwd, stack, workspace, skipTests, isDart, jsonOnly, config) {
108
- const srcRoots = workspace.isMonorepo ? workspace.srcRoots : undefined;
109
- const runners = [
110
- // Foundations
111
- { name: "structure", fn: () => runStructure(cwd, stack, workspace) },
112
- { name: "lint", fn: () => runLint(cwd, stack, workspace) },
113
- { name: "types", fn: () => runTypeCheck(cwd, isDart, workspace) },
114
- { name: "type-safety", fn: () => runTypeSafety(cwd, isDart) },
115
- { name: "standards", fn: () => runStandards(cwd, stack) },
116
- // Quality
117
- { name: "complexity", fn: () => runComplexity(cwd) },
118
- { name: "duplication", fn: () => runDuplication(cwd) },
119
- { name: "error-handling", fn: () => runErrorHandling(cwd, stack) },
120
- { name: "react", fn: () => runReact(cwd, stack) },
121
- { name: "accessibility", fn: () => runAccessibility(cwd) },
122
- { name: "docs", fn: () => runDocs(cwd) },
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) },
127
- // Testing
128
- { name: "testing", fn: () => runTesting(cwd, stack, skipTests, srcRoots) },
129
- // Security
130
- { name: "secrets", fn: () => runSecrets(cwd) },
131
- { name: "security", fn: () => runSecurity(cwd) },
132
- { name: "dependencies", fn: () => runDependencies(cwd, stack) },
133
- // Architecture
134
- { name: "architecture", fn: () => runArchitecture(cwd, workspace) },
135
- { name: "performance", fn: () => runPerformance(cwd) },
136
- { name: "container-health", fn: () => runContainerHealth(cwd) },
137
- // LLM Readiness
138
- { name: "confusion", fn: () => runConfusion(cwd) },
139
- { name: "context", fn: () => runContext(cwd) },
140
- // AI Analysis (premium)
141
- { name: "doc-coherence", fn: () => runDocCoherence(cwd) },
142
- { name: "code-coherence", fn: () => runCodeCoherence(cwd) },
143
- { name: "comment-staleness", fn: () => runCommentStaleness(cwd) },
144
- { name: "dead-patterns", fn: () => runDeadPatterns(cwd) },
145
- { name: "test-audit", fn: () => runTestAudit(cwd) },
146
- ];
147
- const checks = [];
148
- for (const runner of runners) {
149
- // Skip checks disabled in config
150
- if (config && !isCheckEnabled(config, runner.name)) {
151
- checks.push({
152
- name: runner.name,
153
- score: 0,
154
- grade: "F",
155
- details: { skipped: true, reason: "disabled in config" },
156
- issues: [],
157
- duration: 0,
158
- });
159
- if (!jsonOnly)
160
- console.log(` ${runner.name.padEnd(14)}\x1b[2mskip — disabled\x1b[0m`);
161
- continue;
162
- }
163
- if (!jsonOnly)
164
- process.stdout.write(` ${runner.name.padEnd(14)}`);
165
- let result;
166
- try {
167
- const maybeResult = runner.fn();
168
- result = maybeResult instanceof Promise ? await maybeResult : maybeResult;
169
- }
170
- catch (err) {
171
- // Runner crashed — record as errored, don't kill the scan
172
- result = {
173
- name: runner.name,
174
- score: 0,
175
- grade: "F",
176
- details: { skipped: true, reason: `runner error: ${err instanceof Error ? err.message : "unknown"}` },
177
- issues: [],
178
- duration: 0,
179
- };
180
- }
181
- checks.push(result);
182
- if (!jsonOnly) {
183
- const det = result.details;
184
- const skipped = det.skipped;
185
- const premium = det.comingSoon;
186
- const c = premium ? "\x1b[2m" : skipped ? "\x1b[2m" : color(result.grade);
187
- const label = premium ? "soon" : skipped ? "skip" : result.grade;
188
- const scoreStr = premium ? "PRO" : skipped ? "—" : `${result.score}/100`;
189
- const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
190
- console.log(`${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
77
+ function printHelp() {
78
+ console.log(`
79
+ \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION} code health scanner
80
+
81
+ \x1b[1mUsage:\x1b[0m npx @vibecodeqa/cli [command] [path] [flags]
82
+
83
+ \x1b[1mCommands:\x1b[0m
84
+ init [path] Set up CI workflow + recommended configs
85
+ fix [path] Auto-fix (.gitignore, strict mode, biome/eslint, suggestions)
86
+ --ai Use Claude to fix remaining issues (needs ANTHROPIC_API_KEY)
87
+ --check NAME Only fix issues from a specific check (e.g. --check security)
88
+ --dry-run Show what AI would fix without applying changes
89
+ explain [check] Deep-dive explanation of a check (what/risk/fix)
90
+ monitor [path] Live quality control panel re-scans on file changes
91
+
92
+ \x1b[1mFlags:\x1b[0m
93
+ --skip-tests Skip test execution (faster scan)
94
+ --ci CI mode (exit 1 if score < 60)
95
+ --fail-under N Exit 1 if score below N (e.g. --fail-under 80)
96
+ --json Output JSON only (no terminal UI)
97
+ --badge Generate SVG badge
98
+ --sarif Generate SARIF for GitHub Code Scanning
99
+ --upload Upload report to app.vibecodeqa.online
100
+ --top [N] Show top N issues to fix (default: 5)
101
+ --diff [base] Only show issues in changed files (vs HEAD or branch)
102
+ --markdown Output markdown summary (pipe to file or clipboard)
103
+ --pr-comment Post score as GitHub PR comment (needs GITHUB_TOKEN)
104
+ --annotations Emit GitHub Actions ::warning/::error annotations
105
+ --watch Re-scan on file changes
106
+ -v, --version Print version
107
+ -h, --help Show this help
108
+
109
+ \x1b[1mExamples:\x1b[0m
110
+ npx @vibecodeqa/cli # scan current directory
111
+ npx @vibecodeqa/cli init # set up CI + configs
112
+ npx @vibecodeqa/cli fix # auto-fix what's fixable
113
+ npx @vibecodeqa/cli fix --ai # AI-powered fix (uses Claude)
114
+ npx @vibecodeqa/cli fix --ai --check security # fix only security issues
115
+ npx @vibecodeqa/cli fix --ai --dry-run # preview AI fixes without applying
116
+ npx @vibecodeqa/cli --skip-tests --top # fast scan with top issues
117
+ npx @vibecodeqa/cli --ci --fail-under 80 # CI with quality gate
118
+ `);
119
+ }
120
+ function printHeader(cwd, stack, workspace) {
121
+ console.log("");
122
+ console.log(` \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION}`);
123
+ console.log(` \x1b[2m${cwd}\x1b[0m`);
124
+ console.log("");
125
+ const parts = [stack.language, stack.framework, stack.bundler, stack.testRunner, stack.linter, stack.packageManager].filter((v) => v !== "none" && v !== "unknown");
126
+ console.log(` stack: ${parts.join(" + ")}`);
127
+ if (workspace.isMonorepo) {
128
+ console.log(` workspace: ${workspace.tool} monorepo — ${workspace.packages.length} packages`);
129
+ for (const p of workspace.packages.slice(0, 8)) {
130
+ const f = [p.hasSrc && "src", p.hasTests && "tests", p.hasLinter && "linter"].filter(Boolean).join(", ");
131
+ console.log(` \x1b[2m${p.path}\x1b[0m (${f || "empty"})`);
191
132
  }
133
+ if (workspace.packages.length > 8)
134
+ console.log(` \x1b[2m...and ${workspace.packages.length - 8} more\x1b[0m`);
192
135
  }
193
- return checks;
136
+ console.log("");
194
137
  }
195
- async function writeOutputs(report, outputDir, flags) {
196
- if (!existsSync(outputDir))
197
- mkdirSync(outputDir, { recursive: true });
198
- // Save to history before overwriting current report
199
- const historyDir = join(outputDir, "history");
200
- if (!existsSync(historyDir))
201
- mkdirSync(historyDir, { recursive: true });
202
- const historyFile = join(historyDir, `${report.timestamp.replace(/[:.]/g, "-")}.json`);
203
- writeFileSync(historyFile, JSON.stringify(report, null, 2));
204
- // Keep only last 30 history entries
205
- const historyFiles = readdirSync(historyDir)
206
- .filter((f) => f.endsWith(".json"))
207
- .sort();
208
- if (historyFiles.length > 30) {
209
- for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
210
- try {
211
- unlinkSync(join(historyDir, old));
212
- }
213
- catch {
214
- /* ignore */
138
+ function generateMarkdown(report, trend, prevReport) {
139
+ const { score, grade, checks } = report;
140
+ const gradeEmoji = grade === "A" ? "🟢" : grade === "B" ? "🟡" : grade === "C" ? "🟠" : "🔴";
141
+ let md = `# ${gradeEmoji} VibeCode QA: ${grade} ${score}/100\n\n`;
142
+ // Delta section shows what changed with specific fixed/new issues
143
+ if (prevReport) {
144
+ const delta = computeDelta(prevReport, report);
145
+ const arrow = delta.scoreDelta > 0 ? "📈" : delta.scoreDelta < 0 ? "📉" : "➡️";
146
+ md += `${arrow} **${delta.scoreDelta > 0 ? "+" : ""}${delta.scoreDelta}** vs previous`;
147
+ if (delta.fixed.length > 0)
148
+ md += ` · **${delta.fixed.length} fixed**`;
149
+ if (delta.introduced.length > 0)
150
+ md += ` · ${delta.introduced.length} new`;
151
+ md += "\n\n";
152
+ // Per-check changes
153
+ const changed = delta.checks.filter((c) => c.delta !== 0).sort((a, b) => b.delta - a.delta);
154
+ if (changed.length > 0) {
155
+ for (const c of changed.slice(0, 8)) {
156
+ const a = c.delta > 0 ? "+" : "";
157
+ md += `- ${c.delta > 0 ? "✅" : "⚠️"} ${c.name}: ${c.before} → ${c.after} (${a}${c.delta})\n`;
215
158
  }
159
+ md += "\n";
216
160
  }
217
161
  }
218
- writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
219
- // Generate multi-page HTML report
220
- const reportDir = join(outputDir, "report");
221
- if (!existsSync(reportDir))
222
- mkdirSync(reportDir, { recursive: true });
223
- const pages = generatePages(report, historyDir);
224
- for (const [filename, html] of pages) {
225
- writeFileSync(join(reportDir, filename), html);
162
+ else if (trend) {
163
+ const arrow = trend.scoreDelta > 0 ? "📈" : trend.scoreDelta < 0 ? "📉" : "➡️";
164
+ md += `${arrow} **${trend.scoreDelta > 0 ? "+" : ""}${trend.scoreDelta}** vs previous`;
165
+ if (trend.fixedIssues > 0)
166
+ md += ` · ${trend.fixedIssues} fixed`;
167
+ if (trend.newIssues > 0)
168
+ md += ` · ${trend.newIssues} new`;
169
+ md += "\n\n";
226
170
  }
227
- // Badge SVG
228
- if (flags.badgeMode) {
229
- const { buildBadge } = await import("./report/svg.js");
230
- const badgeSvg = buildBadge(report.score, report.grade);
231
- writeFileSync(join(outputDir, "badge.svg"), badgeSvg);
171
+ md += "| Check | Score | Grade |\n|-------|-------|-------|\n";
172
+ for (const c of checks) {
173
+ const det = c.details;
174
+ if (det.skipped || det.comingSoon)
175
+ continue;
176
+ const emoji = c.score >= 90 ? "🟢" : c.score >= 75 ? "🟡" : c.score >= 60 ? "🟠" : "🔴";
177
+ md += `| ${emoji} ${c.name} | ${c.score}/100 | ${c.grade} |\n`;
232
178
  }
233
- // SARIF output for GitHub Code Scanning
234
- if (flags.sarifMode) {
235
- const { generateSARIF } = await import("./report/sarif.js");
236
- writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
179
+ const errors = checks.flatMap((c) => c.issues.filter((i) => i.severity === "error"));
180
+ const warnings = checks.flatMap((c) => c.issues.filter((i) => i.severity === "warning"));
181
+ if (errors.length + warnings.length > 0) {
182
+ md += `\n## Issues (${errors.length} errors, ${warnings.length} warnings)\n\n`;
183
+ for (const i of [...errors, ...warnings].slice(0, 15)) {
184
+ const loc = i.file ? ` \`${i.file}${i.line ? `:${i.line}` : ""}\`` : "";
185
+ md += `- ${i.severity === "error" ? "❌" : "⚠️"} ${i.message}${loc}\n`;
186
+ }
187
+ const remaining = errors.length + warnings.length - 15;
188
+ if (remaining > 0)
189
+ md += `\n*...and ${remaining} more*\n`;
190
+ }
191
+ md += `\n---\n*vcqa v${report.version} · ${report.meta.duration}ms*\n`;
192
+ return md;
193
+ }
194
+ function emitAnnotations(report) {
195
+ for (const c of report.checks) {
196
+ for (const i of c.issues) {
197
+ if (i.severity === "info")
198
+ continue;
199
+ const level = i.severity === "error" ? "error" : "warning";
200
+ const file = i.file && typeof i.file === "string" ? i.file : "";
201
+ const line = i.line || "";
202
+ const loc = file ? ` file=${file}${line ? `,line=${line}` : ""}` : "";
203
+ console.log(`::${level}${loc ? loc : ""}::${c.name}: ${i.message}`);
204
+ }
237
205
  }
238
206
  }
239
207
  async function printResults(report, trend, flags, outputDir, interactive) {
@@ -247,7 +215,6 @@ async function printResults(report, trend, flags, outputDir, interactive) {
247
215
  console.log("");
248
216
  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`);
249
217
  if (trend) {
250
- // Load history for sparkline
251
218
  const historyDir = join(outputDir, "history");
252
219
  const { loadHistory } = await import("./history.js");
253
220
  const history = loadHistory(historyDir);
@@ -257,12 +224,10 @@ async function printResults(report, trend, flags, outputDir, interactive) {
257
224
  console.log(formatTrend(trend, scores));
258
225
  }
259
226
  console.log("");
260
- // Top actionable issues. Explicit --top wins; otherwise show a few by default
261
- // in an interactive terminal so the scan never dead-ends at a file path.
227
+ const { getCheckMeta } = await import("./check-meta.js");
262
228
  const effectiveTopN = flags.topN > 0 ? flags.topN : interactive ? 3 : 0;
263
229
  if (effectiveTopN > 0) {
264
230
  const allIssues = checks.flatMap((c) => c.issues.map((iss) => ({ check: c.name, weight: getCheckMeta(c.name).weight, ...iss })));
265
- // Sort by: errors first, then by check weight (highest-impact first)
266
231
  allIssues.sort((a, b) => {
267
232
  const sevOrder = { error: 0, warning: 1, info: 2 };
268
233
  const sevDiff = (sevOrder[a.severity] ?? 2) - (sevOrder[b.severity] ?? 2);
@@ -284,27 +249,21 @@ async function printResults(report, trend, flags, outputDir, interactive) {
284
249
  console.log("");
285
250
  }
286
251
  }
287
- // Next steps: surface the weakest scored dimensions and how to dig into each.
288
- // Skipped in CI (clean machine-readable-ish output) — interactive runs get the on-ramp.
289
252
  if (!flags.ciMode) {
290
253
  const weakest = checks
291
- .filter((c) => {
292
- const det = c.details;
293
- return !det.skipped && !det.comingSoon && c.score < 70;
294
- })
254
+ .filter((c) => { const det = c.details; return !det.skipped && !det.comingSoon && c.score < 70; })
295
255
  .sort((a, b) => a.score - b.score)
296
256
  .slice(0, 3);
297
257
  if (weakest.length > 0) {
298
258
  console.log(" \x1b[1mWeakest areas:\x1b[0m");
299
259
  for (const c of weakest) {
300
- const gc = color(c.grade);
260
+ const gc2 = color(c.grade);
301
261
  const label = getCheckMeta(c.name).label || c.name;
302
- 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`);
262
+ 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`);
303
263
  }
304
264
  console.log("");
305
265
  }
306
266
  }
307
- // Report paths + the interactive on-ramp.
308
267
  console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
309
268
  console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
310
269
  if (flags.badgeMode)
@@ -317,18 +276,76 @@ async function printResults(report, trend, flags, outputDir, interactive) {
317
276
  console.log("");
318
277
  }
319
278
  }
320
- async function handleUpload(report, cwd, jsonOnly) {
321
- const repo = report.meta.repoUrl?.replace(/^https:\/\/github\.com\//, "") || cwd.split("/").pop() || "project";
322
- const token = process.env.VCQA_TOKEN || "";
323
- // Get current commit SHA for quality gate status
279
+ function getChangedFiles(cwd, base) {
280
+ try {
281
+ const { execSync } = require("node:child_process");
282
+ const cmd = base === "HEAD" ? "git diff --name-only" : `git diff --name-only ${base}...HEAD`;
283
+ const stdout = execSync(cmd, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
284
+ if (!stdout)
285
+ return new Set();
286
+ return new Set(stdout.split("\n").filter((f) => f.length > 0));
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ }
292
+ // ── Report output ──
293
+ async function writeOutputs(report, outputDir, flags, prevReport) {
294
+ mkdirSync(outputDir, { recursive: true });
295
+ // Always write JSON
296
+ writeFileSync(join(outputDir, "report.json"), JSON.stringify(report, null, 2));
297
+ // Save history
298
+ const historyDir = join(outputDir, "history");
299
+ mkdirSync(historyDir, { recursive: true });
300
+ 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 })) }));
301
+ const historyFiles = readdirSync(historyDir).sort();
302
+ for (const old of historyFiles.slice(0, historyFiles.length - 30)) {
303
+ try {
304
+ unlinkSync(join(historyDir, old));
305
+ }
306
+ catch { /* ignore */ }
307
+ }
308
+ // HTML report
309
+ if (!flags.jsonOnly) {
310
+ const reportDir = join(outputDir, "report");
311
+ mkdirSync(reportDir, { recursive: true });
312
+ const historyDir = join(outputDir, "history");
313
+ const pages = generatePages(report, historyDir, prevReport);
314
+ for (const [filename, html] of pages) {
315
+ writeFileSync(join(reportDir, filename), html);
316
+ }
317
+ }
318
+ // Badge
319
+ if (flags.badgeMode) {
320
+ const { buildBadge } = await import("./report/svg.js");
321
+ writeFileSync(join(outputDir, "badge.svg"), buildBadge(report.score, report.grade));
322
+ }
323
+ // SARIF
324
+ if (flags.sarifMode) {
325
+ const { generateSARIF } = await import("./report/sarif.js");
326
+ writeFileSync(join(outputDir, "report.sarif"), generateSARIF(report));
327
+ }
328
+ }
329
+ // ── Upload ──
330
+ async function handleUpload(report, cwd, quietMode) {
331
+ const token = process.env.VCQA_TOKEN || process.env.GITHUB_TOKEN;
332
+ if (!token) {
333
+ if (!quietMode)
334
+ console.log(" \x1b[33m\u26a0 Set VCQA_TOKEN to enable upload\x1b[0m");
335
+ return;
336
+ }
337
+ const repo = report.meta.repoUrl?.replace(/^https?:\/\/github\.com\//, "")?.replace(/\.git$/, "") || "";
338
+ if (!repo) {
339
+ if (!quietMode)
340
+ console.log(" \x1b[33m\u26a0 No git remote — can't upload\x1b[0m");
341
+ return;
342
+ }
324
343
  let sha;
325
344
  try {
326
345
  const { execSync } = await import("node:child_process");
327
346
  sha = execSync("git rev-parse HEAD", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
328
347
  }
329
- catch {
330
- /* not a git repo */
331
- }
348
+ catch { /* not a git repo */ }
332
349
  try {
333
350
  const res = await fetch("https://api.vibecodeqa.online/api/reports", {
334
351
  method: "POST",
@@ -337,34 +354,33 @@ async function handleUpload(report, cwd, jsonOnly) {
337
354
  });
338
355
  if (res.ok) {
339
356
  const data = (await res.json());
340
- if (!jsonOnly)
357
+ if (!quietMode)
341
358
  console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
342
359
  }
343
- else if (!jsonOnly) {
344
- console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m \x1b[2m(set VCQA_TOKEN env var)\x1b[0m`);
360
+ else if (!quietMode) {
361
+ console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m`);
345
362
  }
346
363
  }
347
364
  catch {
348
- if (!jsonOnly)
365
+ if (!quietMode)
349
366
  console.log(` \x1b[33m\u26a0 Upload failed (network error)\x1b[0m`);
350
367
  }
351
368
  }
369
+ // ── Watch mode ──
352
370
  async function startWatch(cwd) {
353
371
  const { watch } = await import("node:fs");
354
372
  const workspace = detectWorkspace(cwd);
355
373
  const watchDirs = workspace.isMonorepo
356
374
  ? workspace.srcRoots.map((d) => join(cwd, d)).filter((d) => existsSync(d))
357
375
  : ["src", "web/src"].map((d) => join(cwd, d)).filter((d) => existsSync(d));
358
- const srcDirs = watchDirs;
359
- if (srcDirs.length === 0) {
376
+ if (watchDirs.length === 0) {
360
377
  console.log(" \x1b[31mNo src/ directory to watch\x1b[0m");
361
378
  process.exit(1);
362
379
  }
363
- console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
364
- console.log("");
380
+ console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m\n");
365
381
  let debounce = null;
366
382
  let running = false;
367
- for (const dir of srcDirs) {
383
+ for (const dir of watchDirs) {
368
384
  watch(dir, { recursive: true }, (_event, filename) => {
369
385
  if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
370
386
  return;
@@ -376,7 +392,7 @@ async function startWatch(cwd) {
376
392
  running = true;
377
393
  try {
378
394
  console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
379
- await main().catch(() => { });
395
+ await main().catch((err) => { console.error("scan error:", err); });
380
396
  }
381
397
  finally {
382
398
  running = false;
@@ -384,471 +400,77 @@ async function startWatch(cwd) {
384
400
  }, 500);
385
401
  });
386
402
  }
387
- // Keep process alive
388
403
  await new Promise(() => { });
389
404
  }
390
- function printHelp() {
391
- console.log(`
392
- \x1b[1m\x1b[38;5;141mvcqa\x1b[0m v${VERSION} code health scanner
393
-
394
- \x1b[1mUsage:\x1b[0m npx @vibecodeqa/cli [command] [path] [flags]
395
-
396
- \x1b[1mCommands:\x1b[0m
397
- init [path] Set up CI workflow + recommended configs
398
- fix [path] Auto-fix (.gitignore, strict mode, biome/eslint, suggestions)
399
- --ai Use Claude to fix remaining issues (needs ANTHROPIC_API_KEY)
400
- --check NAME Only fix issues from a specific check (e.g. --check security)
401
- --dry-run Show what AI would fix without applying changes
402
- explain [check] Deep-dive explanation of a check (what/risk/fix)
403
- monitor [path] Live quality control panel — re-scans on file changes
404
-
405
- \x1b[1mFlags:\x1b[0m
406
- --skip-tests Skip test execution (faster scan)
407
- --ci CI mode (exit 1 if score < 60)
408
- --fail-under N Exit 1 if score below N (e.g. --fail-under 80)
409
- --json Output JSON only (no terminal UI)
410
- --badge Generate SVG badge
411
- --sarif Generate SARIF for GitHub Code Scanning
412
- --upload Upload report to app.vibecodeqa.online
413
- --top [N] Show top N issues to fix (default: 5)
414
- --diff [base] Only show issues in changed files (vs HEAD or branch)
415
- --markdown Output markdown summary (pipe to file or clipboard)
416
- --pr-comment Post score as GitHub PR comment (needs GITHUB_TOKEN)
417
- --annotations Emit GitHub Actions ::warning/::error annotations
418
- --watch Re-scan on file changes
419
- -v, --version Print version
420
- -h, --help Show this help
421
-
422
- \x1b[1mExamples:\x1b[0m
423
- npx @vibecodeqa/cli # scan current directory
424
- npx @vibecodeqa/cli init # set up CI + configs
425
- npx @vibecodeqa/cli fix # auto-fix what's fixable
426
- npx @vibecodeqa/cli fix --ai # AI-powered fix (uses Claude)
427
- npx @vibecodeqa/cli fix --ai --check security # fix only security issues
428
- npx @vibecodeqa/cli fix --ai --dry-run # preview AI fixes without applying
429
- npx @vibecodeqa/cli --skip-tests --top # fast scan with top issues
430
- npx @vibecodeqa/cli --ci --fail-under 80 # CI with quality gate
431
- `);
432
- }
433
- // ── init command ──
434
- async function runInit(cwd) {
435
- console.log("");
436
- console.log(` \x1b[1m\x1b[38;5;141mvcqa init\x1b[0m`);
437
- console.log(` \x1b[2m${cwd}\x1b[0m`);
438
- console.log("");
439
- validateCwd(cwd);
440
- const stack = detectStack(cwd);
441
- let created = 0;
442
- // 1. GitHub Actions workflow
443
- const workflowDir = join(cwd, ".github", "workflows");
444
- const workflowPath = join(workflowDir, "vibecodeqa.yml");
445
- if (!existsSync(workflowPath)) {
446
- try {
447
- mkdirSync(workflowDir, { recursive: true });
448
- writeFileSync(workflowPath, `name: VibeCode QA
449
- on: [pull_request]
450
- permissions: { contents: read }
451
- jobs:
452
- scan:
453
- runs-on: ubuntu-latest
454
- steps:
455
- - uses: actions/checkout@v4
456
- - run: npx @vibecodeqa/cli --ci --fail-under 70 --sarif --badge
457
- - uses: github/codeql-action/upload-sarif@v3
458
- if: always()
459
- with:
460
- sarif_file: .vibe-check/report.sarif
461
- `);
462
- console.log(` \x1b[32m+\x1b[0m .github/workflows/vibecodeqa.yml`);
463
- created++;
464
- }
465
- catch {
466
- console.log(` \x1b[31m!\x1b[0m .github/workflows/vibecodeqa.yml (write failed — check permissions)`);
467
- }
468
- }
469
- else {
470
- console.log(` \x1b[2m=\x1b[0m .github/workflows/vibecodeqa.yml (exists)`);
471
- }
472
- // 2. Biome config (if biome is a dep but no config exists)
473
- if ((stack.linter === "biome" || existsSync(join(cwd, "node_modules", "@biomejs", "biome"))) &&
474
- !existsSync(join(cwd, "biome.json")) &&
475
- !existsSync(join(cwd, "biome.jsonc"))) {
476
- writeFileSync(join(cwd, "biome.json"), JSON.stringify({
477
- $schema: "https://biomejs.dev/schemas/2.0.0/schema.json",
478
- formatter: { indentStyle: "tab", lineWidth: 120 },
479
- linter: { enabled: true, rules: { recommended: true } },
480
- organizeImports: { enabled: true },
481
- }, null, "\t") + "\n");
482
- console.log(` \x1b[32m+\x1b[0m biome.json`);
483
- created++;
484
- }
485
- // 3. Create .vcqa.json if not present
486
- const vcqaConfigPath = join(cwd, ".vcqa.json");
487
- if (!existsSync(vcqaConfigPath)) {
488
- const { CHECK_META } = await import("./check-meta.js");
489
- const checksConfig = {};
490
- for (const name of Object.keys(CHECK_META)) {
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("");
405
+ // ── Interactive prompt ──
406
+ function readKey() {
407
+ return new Promise((resolve) => {
408
+ const stdin = process.stdin;
409
+ const wasRaw = stdin.isRaw;
410
+ stdin.setRawMode?.(true);
411
+ stdin.resume();
412
+ stdin.once("data", (buf) => {
413
+ stdin.setRawMode?.(wasRaw ?? false);
414
+ stdin.pause();
415
+ resolve(buf.toString("utf-8"));
416
+ });
417
+ });
556
418
  }
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");
419
+ function openPath(target) {
420
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
421
+ import("node:child_process").then(({ spawn }) => {
613
422
  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("");
423
+ spawn(cmd, [target], { detached: true, stdio: "ignore", shell: process.platform === "win32" }).unref();
684
424
  }
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;
425
+ catch { /* best-effort */ }
426
+ });
753
427
  }
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
- }
428
+ async function promptNextAction(cwd, outputDir) {
429
+ 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 ");
430
+ let key;
759
431
  try {
760
- if (!statSync(cwd).isDirectory()) {
761
- console.error(` \x1b[31mError: not a directory: ${cwd}\x1b[0m`);
762
- process.exit(1);
763
- }
432
+ key = await readKey();
764
433
  }
765
434
  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";
435
+ process.stdout.write("\n");
436
+ return;
782
437
  }
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`;
438
+ process.stdout.write("\n");
439
+ const k = key.toLowerCase();
440
+ if (k === "m") {
441
+ const { startMonitor } = await import("./monitor.js");
442
+ await startMonitor(cwd);
790
443
  }
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`;
444
+ else if (k === "o") {
445
+ const reportPath = join(outputDir, "report/index.html");
446
+ openPath(reportPath);
447
+ console.log(` \x1b[2mOpening ${reportPath}\x1b[0m`);
802
448
  }
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
- }
449
+ else if (k === "f") {
450
+ console.log("");
451
+ await runFix(cwd, { ai: true });
818
452
  }
819
453
  }
820
- /** Get changed files from git diff. Returns null if git unavailable. */
821
- function getChangedFiles(cwd, base) {
454
+ // ── Update check ──
455
+ async function checkForUpdate(currentVersion) {
822
456
  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"})`);
457
+ const res = await fetch("https://registry.npmjs.org/@vibecodeqa/cli/latest", { signal: AbortSignal.timeout(3000) });
458
+ if (!res.ok)
459
+ return;
460
+ const data = (await res.json());
461
+ const latest = data.version;
462
+ if (!latest || latest === currentVersion)
463
+ return;
464
+ const cur = currentVersion.split(".").map(Number);
465
+ const lat = latest.split(".").map(Number);
466
+ 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]);
467
+ if (isNewer) {
468
+ console.log(` \x1b[33mUpdate available: ${currentVersion} → ${latest}\x1b[0m Run \x1b[1mnpx @vibecodeqa/cli@latest\x1b[0m\n`);
846
469
  }
847
- if (workspace.packages.length > 8)
848
- console.log(` \x1b[2m...and ${workspace.packages.length - 8} more\x1b[0m`);
849
470
  }
850
- console.log("");
471
+ catch { /* network error — silently ignore */ }
851
472
  }
473
+ // ── Main ──
852
474
  async function main() {
853
475
  const args = process.argv.slice(2);
854
476
  if (args.includes("--version") || args.includes("-v")) {
@@ -860,8 +482,7 @@ async function main() {
860
482
  return;
861
483
  }
862
484
  if (args[0] === "init") {
863
- const path = args.slice(1).find((a) => !a.startsWith("-")) || ".";
864
- await runInit(resolve(path));
485
+ await runInit(resolve(args.slice(1).find((a) => !a.startsWith("-")) || "."));
865
486
  return;
866
487
  }
867
488
  if (args[0] === "fix") {
@@ -879,43 +500,35 @@ async function main() {
879
500
  return;
880
501
  }
881
502
  if (args[0] === "monitor") {
882
- const path = args.slice(1).find((a) => !a.startsWith("-")) || ".";
883
503
  const { startMonitor } = await import("./monitor.js");
884
- await startMonitor(resolve(path));
504
+ await startMonitor(resolve(args.slice(1).find((a) => !a.startsWith("-")) || "."));
885
505
  return;
886
506
  }
887
507
  const flags = parseFlags();
888
508
  const { cwd, outputDir, jsonOnly, ciMode, skipTests, watchMode, diffBase } = flags;
889
- const start = Date.now();
890
509
  validateCwd(cwd);
891
510
  const config = loadConfig(cwd);
892
511
  const workspace = detectWorkspace(cwd);
893
512
  const stack = detectStack(cwd, workspace);
894
- setGlobalSrcRoots(workspace.isMonorepo ? workspace.srcRoots : undefined);
895
- setGlobalIgnore(config.ignore);
896
513
  const quietMode = jsonOnly || flags.markdownMode;
897
514
  if (!quietMode)
898
515
  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
- }
516
+ // Run scan using core API with progress output
517
+ const report = await scan(cwd, {
518
+ skipTests,
519
+ config,
520
+ onProgress: quietMode ? undefined : (check, result) => {
521
+ const det = result.details;
522
+ const premium = det.comingSoon;
523
+ const skipped = det.skipped;
524
+ const c = premium ? "\x1b[2m" : skipped ? "\x1b[2m" : color(result.grade);
525
+ const label = premium ? "soon" : skipped ? "skip" : result.grade;
526
+ const scoreStr = premium ? "PRO" : skipped ? "—" : `${result.score}/100`;
527
+ const issueStr = result.issues.length > 0 ? ` \x1b[2m${result.issues.length} issues\x1b[0m` : "";
528
+ console.log(` ${check.padEnd(14)}${c}${label.padEnd(5)}${scoreStr}\x1b[0m \x1b[2m${result.duration}ms\x1b[0m${issueStr}`);
529
+ },
530
+ });
531
+ const { score, checks } = report;
919
532
  // --diff: filter issues to only changed files
920
533
  if (diffBase) {
921
534
  const changedFiles = getChangedFiles(cwd, diffBase);
@@ -925,36 +538,30 @@ async function main() {
925
538
  }
926
539
  }
927
540
  }
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
541
  const trend = computeTrend(report, outputDir);
940
- 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.
542
+ // Load previous report BEFORE writeOutputs overwrites it (for delta in markdown/PR)
543
+ let prevReport;
544
+ const prevReportPath = join(outputDir, "report.json");
545
+ if (existsSync(prevReportPath)) {
546
+ try {
547
+ prevReport = JSON.parse(readFileSync(prevReportPath, "utf-8"));
548
+ }
549
+ catch { /* corrupt */ }
550
+ }
551
+ await writeOutputs(report, outputDir, flags, prevReport);
943
552
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !quietMode && !ciMode && !watchMode;
944
553
  if (flags.markdownMode) {
945
- console.log(generateMarkdown(report, trend));
554
+ console.log(generateMarkdown(report, trend, prevReport));
946
555
  }
947
556
  else {
948
557
  await printResults(report, trend, flags, outputDir, interactive);
949
558
  }
950
- if (flags.annotations) {
559
+ if (flags.annotations)
951
560
  emitAnnotations(report);
952
- }
953
- if (flags.uploadMode) {
561
+ if (flags.uploadMode)
954
562
  await handleUpload(report, cwd, quietMode);
955
- }
956
563
  if (flags.prComment) {
957
- const posted = await postPRComment(report, trend, cwd);
564
+ const posted = await postPRComment(report, trend, cwd, prevReport);
958
565
  if (!quietMode) {
959
566
  if (posted)
960
567
  console.log(" \x1b[32m\u2713 PR comment posted\x1b[0m");
@@ -962,97 +569,27 @@ async function main() {
962
569
  console.log(" \x1b[2mNo PR detected or no GITHUB_TOKEN — skipping PR comment\x1b[0m");
963
570
  }
964
571
  }
965
- // CI exit code: fail if score below threshold (skip in watch mode)
966
572
  const failUnder = flags.failUnder ?? (ciMode ? 60 : (config.failUnder ?? 0));
967
573
  if (failUnder > 0 && score < failUnder && !watchMode) {
968
574
  if (!quietMode)
969
575
  console.log(` \x1b[31mFailing: score ${score} < ${failUnder}\x1b[0m\n`);
970
576
  process.exit(1);
971
577
  }
972
- // Non-blocking update check (don't slow down the scan)
973
578
  if (!quietMode && !ciMode && !watchMode && !process.env.VCQA_NO_UPDATE_CHECK) {
974
- checkForUpdate(VERSION).catch(() => { });
579
+ checkForUpdate(VERSION).catch(() => { }); // ok — non-blocking, best-effort
975
580
  }
976
581
  if (watchMode) {
977
582
  await startWatch(cwd);
978
583
  return;
979
584
  }
980
- // Interactive on-ramp: offer to open the live monitor or the HTML report.
585
+ if (interactive && existsSync(join(cwd, ".git")) && !existsSync(join(cwd, ".github", "workflows", "vibecodeqa.yml"))) {
586
+ console.log(` \x1b[2mTip: Add CI scanning with one line:\x1b[0m \x1b[1m- uses: vibecodeqa/action@v1\x1b[0m`);
587
+ console.log("");
588
+ }
981
589
  if (interactive && !flags.uploadMode && !flags.prComment) {
982
590
  await promptNextAction(cwd, outputDir);
983
591
  }
984
592
  }
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
593
  main().catch((err) => {
1057
594
  console.error("vibe-check error:", err);
1058
595
  process.exit(1);