@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.
- package/README.md +130 -165
- package/dist/check-meta.js +59 -6
- package/dist/cli.js +299 -762
- package/dist/commands/explain.d.ts +2 -0
- package/dist/commands/explain.js +33 -0
- package/dist/commands/fix.d.ts +6 -0
- package/dist/commands/fix.js +157 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/shared.d.ts +4 -0
- package/dist/commands/shared.js +80 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +12 -1
- package/dist/delta.d.ts +45 -0
- package/dist/delta.js +158 -0
- package/dist/detect.js +2 -2
- package/dist/pr-comment.d.ts +1 -1
- package/dist/pr-comment.js +23 -4
- package/dist/report/html.d.ts +1 -1
- package/dist/report/html.js +7 -2
- package/dist/report/pages.d.ts +2 -0
- package/dist/report/pages.js +167 -0
- package/dist/report/styles.d.ts +1 -1
- package/dist/report/styles.js +37 -0
- package/dist/runners/accessibility.js +4 -1
- package/dist/runners/best-practices.js +1 -1
- package/dist/runners/confusion.js +28 -17
- package/dist/runners/design-consistency.d.ts +12 -0
- package/dist/runners/design-consistency.js +125 -0
- package/dist/runners/error-handling.js +18 -2
- package/dist/runners/file-cohesion.d.ts +17 -0
- package/dist/runners/file-cohesion.js +177 -0
- package/dist/runners/frontend-health.d.ts +14 -0
- package/dist/runners/frontend-health.js +206 -0
- package/dist/runners/html-quality.d.ts +8 -0
- package/dist/runners/html-quality.js +203 -0
- package/dist/runners/lint.js +6 -1
- package/dist/runners/react.js +1 -0
- package/dist/runners/secrets.js +7 -2
- package/dist/runners/security.js +7 -1
- package/dist/runners/standards.d.ts +2 -2
- package/dist/runners/standards.js +45 -12
- package/dist/runners/structure.js +1 -1
- package/dist/runners/styling.d.ts +15 -0
- package/dist/runners/styling.js +280 -0
- package/dist/runners/testing.js +3 -1
- 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,
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
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";
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
136
|
+
console.log("");
|
|
194
137
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
|
260
|
+
const gc2 = color(c.grade);
|
|
301
261
|
const label = getCheckMeta(c.name).label || c.name;
|
|
302
|
-
console.log(` ${
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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 (!
|
|
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 (!
|
|
344
|
-
console.log(` \x1b[33m\u26a0 Upload failed: ${res.status}\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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
-
|
|
821
|
-
function
|
|
454
|
+
// ── Update check ──
|
|
455
|
+
async function checkForUpdate(currentVersion) {
|
|
822
456
|
try {
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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);
|