vibeclean 1.0.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/src/index.js ADDED
@@ -0,0 +1,302 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { scanProject } from "./scanner.js";
4
+ import { loadConfig, mergeConfig } from "./config.js";
5
+ import { analyzeNaming } from "./analyzers/naming.js";
6
+ import { analyzePatterns } from "./analyzers/patterns.js";
7
+ import { analyzeLeftovers } from "./analyzers/leftovers.js";
8
+ import { analyzeDependencies } from "./analyzers/dependencies.js";
9
+ import { analyzeDeadCode } from "./analyzers/deadcode.js";
10
+ import { analyzeErrorHandling } from "./analyzers/errorhandling.js";
11
+ import { generateRulesFiles } from "./rules-generator.js";
12
+ import { parseAstWithMeta } from "./analyzers/utils.js";
13
+ import { applySafeFixes } from "./fixers/safe-fixes.js";
14
+ import { compareAgainstBaseline, readBaselineSnapshot, resolveBaselinePath } from "./baseline.js";
15
+
16
+ const SEVERITY_RANK = {
17
+ low: 1,
18
+ medium: 2,
19
+ high: 3
20
+ };
21
+
22
+ async function loadPackageJson(rootDir) {
23
+ const packagePath = path.join(rootDir, "package.json");
24
+ try {
25
+ const raw = await fs.readFile(packagePath, "utf8");
26
+ return JSON.parse(raw);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function overallMessage(overallScore) {
33
+ if (overallScore >= 85) {
34
+ return "Clean and consistent. Keep the vibe under control.";
35
+ }
36
+ if (overallScore >= 65) {
37
+ return "Some inconsistency debt exists. A cleanup pass is worth it.";
38
+ }
39
+ if (overallScore >= 40) {
40
+ return "Your codebase has visible vibe coding debt and mixed patterns.";
41
+ }
42
+ return "Your codebase has significant vibe coding debt. Time for a cleanup sprint.";
43
+ }
44
+
45
+ function applySeverityFilter(categories, minimumSeverity = "low") {
46
+ const threshold = SEVERITY_RANK[minimumSeverity] || SEVERITY_RANK.low;
47
+
48
+ return categories
49
+ .map((category) => {
50
+ const findings = Array.isArray(category.findings) ? category.findings : [];
51
+ const filteredFindings =
52
+ threshold <= SEVERITY_RANK.low
53
+ ? findings
54
+ : findings.filter((finding) => (SEVERITY_RANK[finding.severity] || 1) >= threshold);
55
+
56
+ const keepCategory =
57
+ (SEVERITY_RANK[category.severity] || 1) >= threshold || filteredFindings.length > 0;
58
+
59
+ if (!keepCategory) {
60
+ return null;
61
+ }
62
+
63
+ const hasFindings = filteredFindings.length > 0;
64
+ const totalIssuesAtSeverity = hasFindings ? filteredFindings.length : 0;
65
+
66
+ return {
67
+ ...category,
68
+ findings: filteredFindings,
69
+ totalIssues:
70
+ threshold <= SEVERITY_RANK.low ? category.totalIssues : totalIssuesAtSeverity,
71
+ summary:
72
+ threshold <= SEVERITY_RANK.low || hasFindings
73
+ ? category.summary
74
+ : `No ${minimumSeverity}+ findings for this category.`
75
+ };
76
+ })
77
+ .filter(Boolean);
78
+ }
79
+
80
+ function collectParseWarnings(files) {
81
+ const warnings = [];
82
+ let hiddenCount = 0;
83
+
84
+ for (const file of files) {
85
+ const result = parseAstWithMeta(file.content);
86
+ if (!result.ast) {
87
+ if (warnings.length < 40) {
88
+ warnings.push(`Skipped AST analysis for ${file.relativePath} (syntax not parseable).`);
89
+ } else {
90
+ hiddenCount += 1;
91
+ }
92
+ continue;
93
+ }
94
+
95
+ if (result.usedTypeSyntaxFallback) {
96
+ if (warnings.length < 40) {
97
+ warnings.push(`Parsed ${file.relativePath} with TypeScript syntax fallback.`);
98
+ } else {
99
+ hiddenCount += 1;
100
+ }
101
+ }
102
+ }
103
+
104
+ if (hiddenCount > 0) {
105
+ warnings.push(`${hiddenCount} additional parse warnings were suppressed.`);
106
+ }
107
+
108
+ return warnings;
109
+ }
110
+
111
+ function collectGateFailures(report, config) {
112
+ const failures = [];
113
+
114
+ if (Number.isFinite(config.minScore) && report.overallScore < config.minScore) {
115
+ failures.push(
116
+ `Score gate failed: overall score ${report.overallScore} is below minimum ${config.minScore}.`
117
+ );
118
+ }
119
+
120
+ if (Number.isFinite(config.maxIssues) && report.totalIssues > config.maxIssues) {
121
+ failures.push(
122
+ `Issue gate failed: total issues ${report.totalIssues} exceeds maximum ${config.maxIssues}.`
123
+ );
124
+ }
125
+
126
+ if (config.failOn) {
127
+ const threshold = SEVERITY_RANK[config.failOn] || SEVERITY_RANK.high;
128
+ let matched = 0;
129
+ for (const category of report.categories) {
130
+ for (const finding of category.findings || []) {
131
+ const findingRank = SEVERITY_RANK[finding.severity] || SEVERITY_RANK.low;
132
+ if (findingRank >= threshold) {
133
+ matched += 1;
134
+ }
135
+ }
136
+ }
137
+
138
+ if (matched > 0) {
139
+ failures.push(
140
+ `Severity gate failed: found ${matched} finding(s) at ${config.failOn} severity or higher.`
141
+ );
142
+ }
143
+ }
144
+
145
+ return failures;
146
+ }
147
+
148
+ export async function runAudit(targetDir, cliOptions = {}) {
149
+ const startedAt = Date.now();
150
+ const rootDir = path.resolve(targetDir || process.cwd());
151
+
152
+ const baseConfig = await loadConfig(rootDir);
153
+ const config = mergeConfig(baseConfig, cliOptions);
154
+
155
+ const scanStart = Date.now();
156
+ const scanResult = await scanProject(rootDir, config);
157
+ scanResult.stats.durationMs = Date.now() - scanStart;
158
+ let fixesApplied = {
159
+ filesChanged: 0,
160
+ removedTodoLines: 0,
161
+ removedCommentedCodeLines: 0,
162
+ removedConsoleLines: 0
163
+ };
164
+
165
+ if (config.fix) {
166
+ fixesApplied = await applySafeFixes(scanResult.files);
167
+ if (fixesApplied.filesChanged > 0) {
168
+ scanResult.warnings.push(
169
+ `Applied safe fixes to ${fixesApplied.filesChanged} files before scoring.`
170
+ );
171
+ }
172
+ }
173
+
174
+ scanResult.warnings.push(...collectParseWarnings(scanResult.files));
175
+
176
+ if (scanResult.files.length === 0) {
177
+ const noFilesMessage = config.changedOnly
178
+ ? "No changed JS/TS source files found. Nothing to clean yet."
179
+ : "No JS/TS source files found. Nothing to clean yet.";
180
+ return {
181
+ rootDir,
182
+ config,
183
+ report: {
184
+ version: cliOptions.version || "1.0.0",
185
+ fileCount: 0,
186
+ durationSec: Number(((Date.now() - startedAt) / 1000).toFixed(2)),
187
+ categories: [],
188
+ totalIssues: 0,
189
+ overallScore: 100,
190
+ overallMessage: noFilesMessage,
191
+ scanWarnings: scanResult.warnings,
192
+ rulesGenerated: false,
193
+ fixesApplied,
194
+ profile: config.profile || "app",
195
+ gateFailures: [],
196
+ passedGates: true
197
+ },
198
+ generatedRules: []
199
+ };
200
+ }
201
+
202
+ const projectPackage = await loadPackageJson(rootDir);
203
+ const context = {
204
+ rootDir,
205
+ packageJson: projectPackage,
206
+ config
207
+ };
208
+
209
+ const enabledRules = config.rules || {};
210
+ const categoryResults = [];
211
+
212
+ if (enabledRules.naming !== false) {
213
+ categoryResults.push(analyzeNaming(scanResult.files));
214
+ }
215
+ if (enabledRules.patterns !== false) {
216
+ categoryResults.push(analyzePatterns(scanResult.files, context));
217
+ }
218
+ if (enabledRules.leftovers !== false) {
219
+ categoryResults.push(analyzeLeftovers(scanResult.files, context));
220
+ }
221
+ if (enabledRules.dependencies !== false) {
222
+ categoryResults.push(await analyzeDependencies(scanResult.files, context));
223
+ }
224
+ if (enabledRules.deadcode !== false) {
225
+ categoryResults.push(analyzeDeadCode(scanResult.files, context));
226
+ }
227
+ if (enabledRules.errorhandling !== false) {
228
+ categoryResults.push(analyzeErrorHandling(scanResult.files, context));
229
+ }
230
+
231
+ const filteredCategories = applySeverityFilter(categoryResults, config.severity || "low");
232
+
233
+ const averageIssueScore = filteredCategories.length
234
+ ? filteredCategories.reduce((sum, category) => sum + category.score, 0) / filteredCategories.length
235
+ : 0;
236
+
237
+ const overallScore = Math.max(0, Math.min(100, Math.round(100 - averageIssueScore * 10)));
238
+ const totalIssues = filteredCategories.reduce((sum, category) => sum + (category.totalIssues || 0), 0);
239
+
240
+ const report = {
241
+ version: cliOptions.version || "1.0.0",
242
+ rootDir,
243
+ fileCount: scanResult.files.length,
244
+ durationSec: Number(((Date.now() - startedAt) / 1000).toFixed(2)),
245
+ categories: filteredCategories,
246
+ totalIssues,
247
+ overallScore,
248
+ overallMessage: overallMessage(overallScore),
249
+ scanWarnings: scanResult.warnings,
250
+ rulesGenerated: false,
251
+ fixesApplied,
252
+ profile: config.profile || "app"
253
+ };
254
+
255
+ if (config.baseline) {
256
+ try {
257
+ const baselineData = await readBaselineSnapshot(rootDir, config.baselineFile);
258
+ report.baselineComparison = {
259
+ path: baselineData.path,
260
+ ...compareAgainstBaseline(report, baselineData.snapshot)
261
+ };
262
+ } catch {
263
+ const baselinePath = resolveBaselinePath(rootDir, config.baselineFile);
264
+ report.scanWarnings.push(`Baseline file not found or invalid: ${baselinePath}`);
265
+ report.baselineComparison = {
266
+ path: baselinePath,
267
+ regressions: [],
268
+ missing: true
269
+ };
270
+ }
271
+ }
272
+
273
+ report.gateFailures = collectGateFailures(report, config);
274
+ if (
275
+ config.failOnRegression !== false &&
276
+ report.baselineComparison &&
277
+ Array.isArray(report.baselineComparison.regressions) &&
278
+ report.baselineComparison.regressions.length > 0
279
+ ) {
280
+ report.gateFailures.push(...report.baselineComparison.regressions);
281
+ }
282
+ report.passedGates = report.gateFailures.length === 0;
283
+
284
+ let generatedRules = [];
285
+ if (cliOptions.rules || cliOptions.cursor || cliOptions.claude) {
286
+ const ruleOutput = await generateRulesFiles(report, {
287
+ rootDir,
288
+ cursor: Boolean(cliOptions.cursor),
289
+ claude: Boolean(cliOptions.claude),
290
+ config
291
+ });
292
+ generatedRules = ruleOutput.generated;
293
+ report.rulesGenerated = generatedRules.length > 0;
294
+ }
295
+
296
+ return {
297
+ rootDir,
298
+ config,
299
+ report,
300
+ generatedRules
301
+ };
302
+ }
@@ -0,0 +1,90 @@
1
+ function escapePipes(value = "") {
2
+ return String(value).replace(/\|/g, "\\|");
3
+ }
4
+
5
+ function topFindings(categories, limit = 8) {
6
+ const rows = [];
7
+ for (const category of categories || []) {
8
+ for (const finding of category.findings || []) {
9
+ rows.push({
10
+ category: category.id,
11
+ severity: finding.severity || "low",
12
+ message: finding.message || ""
13
+ });
14
+ }
15
+ }
16
+
17
+ const severityRank = { high: 3, medium: 2, low: 1 };
18
+ rows.sort((a, b) => (severityRank[b.severity] || 0) - (severityRank[a.severity] || 0));
19
+ return rows.slice(0, limit);
20
+ }
21
+
22
+ export function renderMarkdownReport(report) {
23
+ const lines = [];
24
+ const gateStatus = report.passedGates ? "PASS" : "FAIL";
25
+
26
+ lines.push("# Vibeclean PR Report");
27
+ lines.push("");
28
+ lines.push(`- **Score:** ${report.overallScore}/100`);
29
+ lines.push(`- **Total Issues:** ${report.totalIssues}`);
30
+ lines.push(`- **Files Scanned:** ${report.fileCount}`);
31
+ lines.push(`- **Profile:** ${report.profile || "app"}`);
32
+ lines.push(`- **Quality Gates:** ${gateStatus}`);
33
+ lines.push(`- **Duration:** ${report.durationSec}s`);
34
+
35
+ if (report.baselineComparison && !report.baselineComparison.missing) {
36
+ const deltas = report.baselineComparison.deltas || {};
37
+ const scoreDelta = Number.isFinite(deltas.score) ? deltas.score : 0;
38
+ const issueDelta = Number.isFinite(deltas.totalIssues) ? deltas.totalIssues : 0;
39
+ lines.push(
40
+ `- **Baseline:** score ${report.baselineComparison.baselineScore} -> ${report.baselineComparison.currentScore} (${scoreDelta >= 0 ? "+" : ""}${scoreDelta}), issues ${report.baselineComparison.baselineTotalIssues} -> ${report.baselineComparison.currentTotalIssues} (${issueDelta >= 0 ? "+" : ""}${issueDelta})`
41
+ );
42
+ } else if (report.baselineComparison?.missing) {
43
+ lines.push(`- **Baseline:** missing (${report.baselineComparison.path})`);
44
+ }
45
+
46
+ lines.push("");
47
+ lines.push("## Category Summary");
48
+ lines.push("");
49
+ lines.push("| Category | Score | Severity | Issues |");
50
+ lines.push("|---|---:|---|---:|");
51
+ for (const category of report.categories || []) {
52
+ lines.push(
53
+ `| ${escapePipes(category.title)} | ${category.score}/10 | ${String(
54
+ category.severity || "low"
55
+ ).toUpperCase()} | ${category.totalIssues || 0} |`
56
+ );
57
+ }
58
+
59
+ const findings = topFindings(report.categories);
60
+ if (findings.length > 0) {
61
+ lines.push("");
62
+ lines.push("## Top Findings");
63
+ lines.push("");
64
+ for (const finding of findings) {
65
+ lines.push(
66
+ `- [${String(finding.severity).toUpperCase()}] \`${finding.category}\` ${finding.message}`
67
+ );
68
+ }
69
+ }
70
+
71
+ if (report.gateFailures?.length) {
72
+ lines.push("");
73
+ lines.push("## Gate Failures");
74
+ lines.push("");
75
+ for (const failure of report.gateFailures) {
76
+ lines.push(`- ${failure}`);
77
+ }
78
+ }
79
+
80
+ if (report.scanWarnings?.length) {
81
+ lines.push("");
82
+ lines.push("## Scan Warnings");
83
+ lines.push("");
84
+ for (const warning of report.scanWarnings.slice(0, 10)) {
85
+ lines.push(`- ${warning}`);
86
+ }
87
+ }
88
+
89
+ return `${lines.join("\n")}\n`;
90
+ }
@@ -0,0 +1,200 @@
1
+ import chalk from "chalk";
2
+
3
+ function colorForScore(score) {
4
+ if (score >= 7) {
5
+ return chalk.red;
6
+ }
7
+ if (score >= 4) {
8
+ return chalk.yellow;
9
+ }
10
+ return chalk.green;
11
+ }
12
+
13
+ function iconForScore(score) {
14
+ if (score >= 7) {
15
+ return "🚨";
16
+ }
17
+ if (score >= 1) {
18
+ return "⚠️";
19
+ }
20
+ return "✅";
21
+ }
22
+
23
+ function divider() {
24
+ return chalk.gray("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
25
+ }
26
+
27
+ function renderFindings(category) {
28
+ const findings = Array.isArray(category.findings) ? category.findings.slice(0, 2) : [];
29
+ const lines = [];
30
+
31
+ for (const finding of findings) {
32
+ lines.push(
33
+ ` ${chalk.gray("•")} ${chalk.bold(
34
+ `[${(finding.severity || "low").toUpperCase()}]`
35
+ )} ${finding.message}`
36
+ );
37
+
38
+ const locations = Array.isArray(finding.locations) ? finding.locations.slice(0, 2) : [];
39
+ for (const location of locations) {
40
+ const snippet = location.snippet ? ` ${chalk.gray(`— ${location.snippet}`)}` : "";
41
+ lines.push(` ${chalk.gray("↳")} ${location.file}:${location.line}${snippet}`);
42
+ }
43
+ }
44
+
45
+ return lines;
46
+ }
47
+
48
+ function categoryDetailLines(category) {
49
+ const metrics = category.metrics || {};
50
+
51
+ switch (category.id) {
52
+ case "naming": {
53
+ const styles = metrics.identifierStyles || [];
54
+ const top = styles
55
+ .slice(0, 2)
56
+ .map((item) => `${item.style}: ${item.percent}% (${item.count})`)
57
+ .join(", ");
58
+ return [
59
+ top ? `├─ ${top}` : "├─ Not enough identifier samples",
60
+ `├─ Mixed directories: ${metrics.mixedDirectoryCount || 0}`,
61
+ `└─ Component filename mismatches: ${metrics.componentMismatchCount || 0}`
62
+ ];
63
+ }
64
+
65
+ case "patterns": {
66
+ const httpClients = metrics.httpClients || {};
67
+ const httpText = Object.entries(httpClients)
68
+ .map(([name, count]) => `${name} (${count})`)
69
+ .join(", ");
70
+ const asyncTotal = (metrics.asyncAwaitOps || 0) + (metrics.thenChains || 0);
71
+ const asyncAwaitPct = asyncTotal
72
+ ? Math.round(((metrics.asyncAwaitOps || 0) / asyncTotal) * 100)
73
+ : 100;
74
+ const thenPct = asyncTotal ? 100 - asyncAwaitPct : 0;
75
+ return [
76
+ `├─ HTTP clients: ${httpText || "none detected"}`,
77
+ `├─ Mixed async: async/await (${asyncAwaitPct}%), .then() (${thenPct}%)`,
78
+ `└─ Module style: import files ${metrics.filesUsingImport || 0}, require files ${metrics.filesUsingRequire || 0}`
79
+ ];
80
+ }
81
+
82
+ case "leftovers":
83
+ return [
84
+ `├─ console.* statements: ${metrics.consoleCount || 0}`,
85
+ `├─ TODO/FIXME markers: ${metrics.todoCount || 0} (${metrics.aiTodoCount || 0} AI-like)`,
86
+ `└─ Placeholders/localhost: ${(metrics.placeholderCount || 0) + (metrics.localhostCount || 0)}`
87
+ ];
88
+
89
+ case "dependencies":
90
+ return [
91
+ `├─ Unused packages: ${metrics.unusedCount || 0}`,
92
+ `├─ Duplicate groups: ${(metrics.duplicateGroups || []).length}`,
93
+ `└─ Estimated savings: ~${metrics.estimatedSavingsMb || 0}MB`
94
+ ];
95
+
96
+ case "deadcode":
97
+ return [
98
+ `├─ Orphan files: ${(metrics.orphanFiles || []).length}`,
99
+ `├─ Unused exports: ${(metrics.unusedExports || []).length}`,
100
+ `└─ Stub files: ${(metrics.stubFiles || []).length}`
101
+ ];
102
+
103
+ case "errorhandling":
104
+ return [
105
+ `├─ Functions with try/catch: ${metrics.handledRate || 0}%`,
106
+ `├─ Empty catch blocks: ${metrics.emptyCatch || 0}`,
107
+ `└─ Unhandled await signals: ${metrics.unhandledAwait || 0}`
108
+ ];
109
+
110
+ default:
111
+ return ["└─ No details available"];
112
+ }
113
+ }
114
+
115
+ function renderCategory(category, options = {}) {
116
+ const color = colorForScore(category.score);
117
+ const scoreText = color(`Score: ${category.score}/10`);
118
+
119
+ const header = `${iconForScore(category.score)} ${chalk.bold(category.title.padEnd(42))} ${scoreText}`;
120
+
121
+ if (options.quiet) {
122
+ return `${header}\n${chalk.gray(` ${category.summary}`)}`;
123
+ }
124
+
125
+ const detailLines = categoryDetailLines(category);
126
+ const findingLines = renderFindings(category);
127
+ const recommendation = category.recommendations?.[0]
128
+ ? ` ${chalk.cyan(`Recommendation: ${category.recommendations[0]}`)}`
129
+ : "";
130
+
131
+ return [header, ...detailLines.map((line) => ` ${line}`), ...findingLines, recommendation]
132
+ .filter(Boolean)
133
+ .join("\n");
134
+ }
135
+
136
+ export function renderReport(report, options = {}) {
137
+ const lines = [];
138
+
139
+ lines.push(` 🧹 ${chalk.bold(`vibeclean v${report.version}`)} ${chalk.gray("— Cleaning up the vibe")}`);
140
+ lines.push("");
141
+ lines.push(
142
+ ` Scanning project... ${chalk.green("✓")} Found ${chalk.bold(report.fileCount)} source files in ${chalk.bold(`${report.durationSec}s`)}`
143
+ );
144
+ lines.push(` Profile: ${chalk.bold(report.profile || "app")}`);
145
+
146
+ if (report.baselineComparison && !report.baselineComparison.missing) {
147
+ const deltas = report.baselineComparison.deltas || {};
148
+ const scoreDelta = Number.isFinite(deltas.score) ? deltas.score : 0;
149
+ const issueDelta = Number.isFinite(deltas.totalIssues) ? deltas.totalIssues : 0;
150
+ lines.push(
151
+ ` Baseline: score ${report.baselineComparison.baselineScore} -> ${report.baselineComparison.currentScore} (${scoreDelta >= 0 ? "+" : ""}${scoreDelta}), issues ${report.baselineComparison.baselineTotalIssues} -> ${report.baselineComparison.currentTotalIssues} (${issueDelta >= 0 ? "+" : ""}${issueDelta})`
152
+ );
153
+ }
154
+
155
+ if (report.scanWarnings?.length) {
156
+ for (const warning of report.scanWarnings.slice(0, 6)) {
157
+ lines.push(` ${chalk.yellow("•")} ${chalk.yellow(warning)}`);
158
+ }
159
+ if (report.scanWarnings.length > 6) {
160
+ lines.push(` ${chalk.yellow("•")} ${chalk.yellow(`+${report.scanWarnings.length - 6} more warnings`)}`);
161
+ }
162
+ }
163
+
164
+ lines.push("");
165
+ lines.push(` ${divider()}`);
166
+ lines.push("");
167
+
168
+ for (const category of report.categories) {
169
+ lines.push(` ${renderCategory(category, options)}`);
170
+ lines.push("");
171
+ }
172
+
173
+ lines.push(` ${divider()}`);
174
+ lines.push("");
175
+
176
+ const overallColor =
177
+ report.overallScore >= 80
178
+ ? chalk.green
179
+ : report.overallScore >= 60
180
+ ? chalk.yellow
181
+ : chalk.red;
182
+
183
+ lines.push(` 📊 ${chalk.bold("VIBE CLEANLINESS SCORE")}: ${overallColor.bold(`${report.overallScore}/100`)}`);
184
+ lines.push(` ${overallColor(report.overallMessage)}`);
185
+ lines.push("");
186
+ lines.push(` 🧹 Found ${chalk.bold(report.totalIssues)} issues across ${chalk.bold(report.categories.length)} categories`);
187
+
188
+ if (report.fixesApplied?.filesChanged > 0) {
189
+ const fixStats = report.fixesApplied;
190
+ lines.push(
191
+ ` 🛠️ Applied safe fixes in ${chalk.bold(fixStats.filesChanged)} files (${fixStats.removedTodoLines} TODO/comments, ${fixStats.removedConsoleLines} console lines, ${fixStats.removedCommentedCodeLines} commented code lines)`
192
+ );
193
+ }
194
+
195
+ if (!report.rulesGenerated) {
196
+ lines.push(` 📋 Run ${chalk.bold("vibeclean --rules")} to generate AI rules file`);
197
+ }
198
+
199
+ return lines.join("\n");
200
+ }