@vibecodeqa/cli 0.42.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.
package/dist/cli.js CHANGED
@@ -1,52 +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 { 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
14
  import { computeTrend, formatTrend } from "./trend.js";
43
- import { gradeFromScore } from "./types.js";
44
15
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
45
16
  const VERSION = pkg.version;
46
17
  function parseFlags() {
47
18
  const args = process.argv.slice(2);
48
19
  const flags = new Set(args.filter((a) => a.startsWith("--")));
49
- // Parse flags with value arguments
50
20
  const valueArgIndices = new Set();
51
21
  function parseValueFlag(flag, fallback) {
52
22
  const idx = args.indexOf(flag);
@@ -58,14 +28,12 @@ function parseFlags() {
58
28
  valueArgIndices.add(idx + 1);
59
29
  return parseInt(next, 10);
60
30
  }
61
- // Non-numeric value after flag — consume it to prevent misuse as cwd
62
31
  valueArgIndices.add(idx + 1);
63
32
  }
64
33
  return fallback ?? null;
65
34
  }
66
35
  const topN = parseValueFlag("--top", 5) ?? 0;
67
36
  const failUnder = parseValueFlag("--fail-under");
68
- // --diff [base] — only show issues in changed files
69
37
  let diffBase = null;
70
38
  const diffIdx = args.indexOf("--diff");
71
39
  if (diffIdx !== -1) {
@@ -75,7 +43,7 @@ function parseFlags() {
75
43
  valueArgIndices.add(diffIdx + 1);
76
44
  }
77
45
  else {
78
- diffBase = "HEAD"; // default: uncommitted changes
46
+ diffBase = "HEAD";
79
47
  }
80
48
  }
81
49
  const cwd = resolve(args.find((a, i) => !a.startsWith("--") && !valueArgIndices.has(i)) || ".");
@@ -97,6 +65,7 @@ function parseFlags() {
97
65
  annotations: flags.has("--annotations"),
98
66
  };
99
67
  }
68
+ // ── Output helpers ──
100
69
  function color(grade) {
101
70
  if (grade === "A")
102
71
  return "\x1b[32m";
@@ -104,136 +73,114 @@ function color(grade) {
104
73
  return "\x1b[33m";
105
74
  return "\x1b[31m";
106
75
  }
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}`);
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"})`);
191
131
  }
132
+ if (workspace.packages.length > 8)
133
+ console.log(` \x1b[2m...and ${workspace.packages.length - 8} more\x1b[0m`);
192
134
  }
193
- return checks;
135
+ console.log("");
194
136
  }
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 */
215
- }
216
- }
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";
217
149
  }
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);
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`;
226
157
  }
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);
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`;
232
169
  }
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));
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
+ }
237
184
  }
238
185
  }
239
186
  async function printResults(report, trend, flags, outputDir, interactive) {
@@ -247,7 +194,6 @@ async function printResults(report, trend, flags, outputDir, interactive) {
247
194
  console.log("");
248
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`);
249
196
  if (trend) {
250
- // Load history for sparkline
251
197
  const historyDir = join(outputDir, "history");
252
198
  const { loadHistory } = await import("./history.js");
253
199
  const history = loadHistory(historyDir);
@@ -257,12 +203,10 @@ async function printResults(report, trend, flags, outputDir, interactive) {
257
203
  console.log(formatTrend(trend, scores));
258
204
  }
259
205
  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.
206
+ const { getCheckMeta } = await import("./check-meta.js");
262
207
  const effectiveTopN = flags.topN > 0 ? flags.topN : interactive ? 3 : 0;
263
208
  if (effectiveTopN > 0) {
264
209
  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
210
  allIssues.sort((a, b) => {
267
211
  const sevOrder = { error: 0, warning: 1, info: 2 };
268
212
  const sevDiff = (sevOrder[a.severity] ?? 2) - (sevOrder[b.severity] ?? 2);
@@ -284,27 +228,21 @@ async function printResults(report, trend, flags, outputDir, interactive) {
284
228
  console.log("");
285
229
  }
286
230
  }
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
231
  if (!flags.ciMode) {
290
232
  const weakest = checks
291
- .filter((c) => {
292
- const det = c.details;
293
- return !det.skipped && !det.comingSoon && c.score < 70;
294
- })
233
+ .filter((c) => { const det = c.details; return !det.skipped && !det.comingSoon && c.score < 70; })
295
234
  .sort((a, b) => a.score - b.score)
296
235
  .slice(0, 3);
297
236
  if (weakest.length > 0) {
298
237
  console.log(" \x1b[1mWeakest areas:\x1b[0m");
299
238
  for (const c of weakest) {
300
- const gc = color(c.grade);
239
+ const gc2 = color(c.grade);
301
240
  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`);
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`);
303
242
  }
304
243
  console.log("");
305
244
  }
306
245
  }
307
- // Report paths + the interactive on-ramp.
308
246
  console.log(` \x1b[2mReport: ${join(outputDir, "report/index.html")}\x1b[0m`);
309
247
  console.log(` \x1b[2mJSON: ${join(outputDir, "report.json")}\x1b[0m`);
310
248
  if (flags.badgeMode)
@@ -317,18 +255,76 @@ async function printResults(report, trend, flags, outputDir, interactive) {
317
255
  console.log("");
318
256
  }
319
257
  }
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
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
+ }
324
322
  let sha;
325
323
  try {
326
324
  const { execSync } = await import("node:child_process");
327
325
  sha = execSync("git rev-parse HEAD", { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
328
326
  }
329
- catch {
330
- /* not a git repo */
331
- }
327
+ catch { /* not a git repo */ }
332
328
  try {
333
329
  const res = await fetch("https://api.vibecodeqa.online/api/reports", {
334
330
  method: "POST",
@@ -337,34 +333,33 @@ async function handleUpload(report, cwd, jsonOnly) {
337
333
  });
338
334
  if (res.ok) {
339
335
  const data = (await res.json());
340
- if (!jsonOnly)
336
+ if (!quietMode)
341
337
  console.log(` \x1b[32m\u2713 Uploaded to dashboard\x1b[0m \x1b[2m(${data.totalReports || 1} reports)\x1b[0m`);
342
338
  }
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`);
339
+ else if (!quietMode) {
340
+ console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\x1b[0m`);
345
341
  }
346
342
  }
347
343
  catch {
348
- if (!jsonOnly)
344
+ if (!quietMode)
349
345
  console.log(` \x1b[33m\u26a0 Upload failed (network error)\x1b[0m`);
350
346
  }
351
347
  }
348
+ // ── Watch mode ──
352
349
  async function startWatch(cwd) {
353
350
  const { watch } = await import("node:fs");
354
351
  const workspace = detectWorkspace(cwd);
355
352
  const watchDirs = workspace.isMonorepo
356
353
  ? workspace.srcRoots.map((d) => join(cwd, d)).filter((d) => existsSync(d))
357
354
  : ["src", "web/src"].map((d) => join(cwd, d)).filter((d) => existsSync(d));
358
- const srcDirs = watchDirs;
359
- if (srcDirs.length === 0) {
355
+ if (watchDirs.length === 0) {
360
356
  console.log(" \x1b[31mNo src/ directory to watch\x1b[0m");
361
357
  process.exit(1);
362
358
  }
363
- console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m");
364
- console.log("");
359
+ console.log(" \x1b[2mWatching for changes... (Ctrl+C to stop)\x1b[0m\n");
365
360
  let debounce = null;
366
361
  let running = false;
367
- for (const dir of srcDirs) {
362
+ for (const dir of watchDirs) {
368
363
  watch(dir, { recursive: true }, (_event, filename) => {
369
364
  if (!filename || filename.includes("node_modules") || filename.includes(".vibe-check"))
370
365
  return;
@@ -376,7 +371,7 @@ async function startWatch(cwd) {
376
371
  running = true;
377
372
  try {
378
373
  console.log(` \x1b[2mChanged: ${filename} — re-scanning...\x1b[0m`);
379
- await main().catch(() => { });
374
+ await main().catch((err) => { console.error("scan error:", err); });
380
375
  }
381
376
  finally {
382
377
  running = false;
@@ -384,471 +379,77 @@ async function startWatch(cwd) {
384
379
  }, 500);
385
380
  });
386
381
  }
387
- // Keep process alive
388
382
  await new Promise(() => { });
389
383
  }
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("");
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);