@vibecheckai/cli 3.1.8 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bin/registry.js +106 -116
  2. package/bin/runners/context/generators/mcp.js +18 -0
  3. package/bin/runners/context/index.js +72 -4
  4. package/bin/runners/context/proof-context.js +293 -1
  5. package/bin/runners/context/security-scanner.js +311 -73
  6. package/bin/runners/lib/analyzers.js +607 -20
  7. package/bin/runners/lib/detectors-v2.js +172 -15
  8. package/bin/runners/lib/entitlements-v2.js +48 -1
  9. package/bin/runners/lib/evidence-pack.js +678 -0
  10. package/bin/runners/lib/html-proof-report.js +913 -0
  11. package/bin/runners/lib/missions/plan.js +231 -41
  12. package/bin/runners/lib/missions/templates.js +125 -0
  13. package/bin/runners/lib/scan-output.js +492 -253
  14. package/bin/runners/lib/ship-output.js +901 -641
  15. package/bin/runners/runCheckpoint.js +44 -3
  16. package/bin/runners/runContext.d.ts +4 -0
  17. package/bin/runners/runDoctor.js +10 -2
  18. package/bin/runners/runFix.js +51 -341
  19. package/bin/runners/runInit.js +11 -0
  20. package/bin/runners/runPolish.d.ts +4 -0
  21. package/bin/runners/runPolish.js +608 -29
  22. package/bin/runners/runProve.js +210 -25
  23. package/bin/runners/runReality.js +846 -101
  24. package/bin/runners/runScan.js +238 -4
  25. package/bin/runners/runShip.js +19 -3
  26. package/bin/runners/runWatch.js +14 -1
  27. package/bin/vibecheck.js +32 -2
  28. package/mcp-server/consolidated-tools.js +408 -42
  29. package/mcp-server/index.js +152 -15
  30. package/mcp-server/proof-tools.js +571 -0
  31. package/mcp-server/tier-auth.js +22 -19
  32. package/mcp-server/tools-v3.js +744 -0
  33. package/mcp-server/truth-firewall-tools.js +190 -4
  34. package/package.json +3 -1
  35. package/bin/runners/runInstall.js +0 -281
  36. package/bin/runners/runLabs.js +0 -341
@@ -1,641 +1,901 @@
1
- /**
2
- * Ship Output - Premium Ship Command Display
3
- *
4
- * Handles all ship-specific output formatting:
5
- * - Verdict card (the hero moment)
6
- * - Proof graph visualization
7
- * - Findings breakdown by category
8
- * - Fix mode display
9
- * - Badge generation
10
- */
11
-
12
- const path = require('path');
13
- const {
14
- ansi,
15
- colors,
16
- box,
17
- icons,
18
- renderSection,
19
- formatDuration,
20
- truncate,
21
- } = require('./terminal-ui');
22
-
23
- // ═══════════════════════════════════════════════════════════════════════════════
24
- // SHIP-SPECIFIC ICONS
25
- // ═══════════════════════════════════════════════════════════════════════════════
26
-
27
- const shipIcons = {
28
- ship: '🚀',
29
- sparkle: '✨',
30
- fire: '🔥',
31
- lock: '🔐',
32
- key: '🔑',
33
- link: '🔗',
34
- graph: '📊',
35
- map: '🗺️',
36
- doc: '📄',
37
- folder: '📁',
38
- clock: '⏱',
39
- target: '🎯',
40
- shield: '🛡️',
41
- bug: '🐛',
42
- wrench: '🔧',
43
- lightning: '⚡',
44
- package: '📦',
45
- route: '🛤️',
46
- env: '🌍',
47
- auth: '🔒',
48
- money: '💰',
49
- ghost: '👻',
50
- dead: '💀',
51
- };
52
-
53
- // ═══════════════════════════════════════════════════════════════════════════════
54
- // VERDICT CONFIGURATION
55
- // ═══════════════════════════════════════════════════════════════════════════════
56
-
57
- function getVerdictConfig(verdict, score, blockers = 0, warnings = 0) {
58
- if (verdict === 'SHIP' || (score >= 90 && blockers === 0)) {
59
- return {
60
- verdict: 'SHIP',
61
- icon: '🚀',
62
- headline: 'CLEAR TO SHIP',
63
- tagline: 'Your app is production ready!',
64
- color: colors.success,
65
- borderColor: ansi.rgb(0, 200, 120),
66
- bgColor: ansi.bgRgb(0, 60, 40),
67
- };
68
- }
69
-
70
- if (verdict === 'WARN' || (score >= 50 && blockers <= 2)) {
71
- return {
72
- verdict: 'WARN',
73
- icon: '⚠️',
74
- headline: 'REVIEW BEFORE SHIP',
75
- tagline: `${warnings} warning${warnings !== 1 ? 's' : ''} to address`,
76
- color: colors.warning,
77
- borderColor: ansi.rgb(200, 160, 0),
78
- bgColor: ansi.bgRgb(60, 50, 0),
79
- };
80
- }
81
-
82
- return {
83
- verdict: 'BLOCK',
84
- icon: '🛑',
85
- headline: 'NOT SHIP READY',
86
- tagline: `${blockers} blocker${blockers !== 1 ? 's' : ''} must be fixed`,
87
- color: colors.error,
88
- borderColor: ansi.rgb(200, 60, 60),
89
- bgColor: ansi.bgRgb(60, 20, 20),
90
- };
91
- }
92
-
93
- // ═══════════════════════════════════════════════════════════════════════════════
94
- // VERDICT CARD - THE HERO MOMENT
95
- // ═══════════════════════════════════════════════════════════════════════════════
96
-
97
- function renderVerdictCard(options = {}) {
98
- const {
99
- verdict = 'WARN',
100
- score = 0,
101
- blockers = 0,
102
- warnings = 0,
103
- duration = 0,
104
- cached = false,
105
- } = options;
106
-
107
- const config = getVerdictConfig(verdict, score, blockers, warnings);
108
- const w = 68; // Inner width
109
-
110
- const lines = [];
111
- lines.push('');
112
- lines.push('');
113
-
114
- // Top border
115
- lines.push(` ${config.borderColor}${box.doubleTopLeft}${box.doubleHorizontal.repeat(w)}${box.doubleTopRight}${ansi.reset}`);
116
-
117
- // Empty line
118
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
119
-
120
- // Verdict icon and headline
121
- const headlineText = `${config.icon} ${config.headline}`;
122
- const headlinePadded = padCenter(headlineText, w);
123
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${config.color}${ansi.bold}${headlinePadded}${ansi.reset}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
124
-
125
- // Tagline
126
- const taglinePadded = padCenter(config.tagline, w);
127
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${ansi.dim}${taglinePadded}${ansi.reset}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
128
-
129
- // Empty line
130
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
131
-
132
- // Score bar
133
- const scoreBar = renderProgressBar(score, 35, config.color);
134
- const scoreLabel = `VIBE SCORE`;
135
- const scoreValue = `${score}/100`;
136
- const scoreLine = ` ${scoreLabel} ${scoreBar} ${config.color}${ansi.bold}${scoreValue}${ansi.reset}`;
137
- const scoreLinePadRight = ' '.repeat(Math.max(0, w - stripAnsi(scoreLine).length + 4));
138
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${scoreLine}${scoreLinePadRight}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
139
-
140
- // Empty line
141
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
142
-
143
- // Stats row
144
- const blockerColor = blockers > 0 ? colors.error : colors.success;
145
- const warningColor = warnings > 0 ? colors.warning : colors.success;
146
- const statsLine = ` ${ansi.dim}Blockers:${ansi.reset} ${blockerColor}${ansi.bold}${blockers}${ansi.reset} ${ansi.dim}Warnings:${ansi.reset} ${warningColor}${ansi.bold}${warnings}${ansi.reset} ${ansi.dim}Duration:${ansi.reset} ${colors.accent}${ansi.bold}${formatDuration(duration)}${ansi.reset}${cached ? ` ${ansi.dim}(cached)${ansi.reset}` : ''}`;
147
- const statsLinePadRight = ' '.repeat(Math.max(0, w - stripAnsi(statsLine).length + 4));
148
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${statsLine}${statsLinePadRight}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
149
-
150
- // Empty line
151
- lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
152
-
153
- // Bottom border
154
- lines.push(` ${config.borderColor}${box.doubleBottomLeft}${box.doubleHorizontal.repeat(w)}${box.doubleBottomRight}${ansi.reset}`);
155
-
156
- lines.push('');
157
- return lines.join('\n');
158
- }
159
-
160
- // ═══════════════════════════════════════════════════════════════════════════════
161
- // PROOF GRAPH VISUALIZATION
162
- // ═══════════════════════════════════════════════════════════════════════════════
163
-
164
- function renderProofGraph(proofGraph) {
165
- if (!proofGraph || !proofGraph.summary) return '';
166
-
167
- const lines = [];
168
- lines.push(renderSection('PROOF GRAPH', shipIcons.graph));
169
- lines.push('');
170
-
171
- const { summary } = proofGraph;
172
-
173
- // Confidence gauge
174
- const confidence = Math.round((summary.confidence || 0) * 100);
175
- const confColor = confidence >= 80 ? colors.success : confidence >= 50 ? colors.warning : colors.error;
176
-
177
- lines.push(` ${ansi.bold}Analysis Confidence${ansi.reset}`);
178
- lines.push(` ${renderProgressBar(confidence, 40, confColor)} ${confColor}${ansi.bold}${confidence}%${ansi.reset}`);
179
- lines.push('');
180
-
181
- // Claims summary
182
- const verified = summary.verifiedClaims || 0;
183
- const failed = summary.failedClaims || 0;
184
- const total = summary.totalClaims || 0;
185
-
186
- lines.push(` ${ansi.dim}Claims:${ansi.reset} ${colors.success}${verified}${ansi.reset} verified ${ansi.dim}/${ansi.reset} ${colors.error}${failed}${ansi.reset} failed ${ansi.dim}of${ansi.reset} ${total} total`);
187
- lines.push(` ${ansi.dim}Gaps:${ansi.reset} ${summary.gaps || 0} identified`);
188
- lines.push(` ${ansi.dim}Risk Score:${ansi.reset} ${summary.riskScore || 0}/100`);
189
-
190
- // Top blockers
191
- if (proofGraph.topBlockers && proofGraph.topBlockers.length > 0) {
192
- lines.push('');
193
- lines.push(` ${ansi.bold}Top Unverified Claims:${ansi.reset}`);
194
- for (const blocker of proofGraph.topBlockers.slice(0, 3)) {
195
- lines.push(` ${colors.error}${icons.error}${ansi.reset} ${truncate(blocker.assertion, 50)}`);
196
- }
197
- }
198
-
199
- return lines.join('\n');
200
- }
201
-
202
- // ═══════════════════════════════════════════════════════════════════════════════
203
- // ROUTE TRUTH MAP
204
- // ═══════════════════════════════════════════════════════════════════════════════
205
-
206
- function renderRouteTruthMap(truthpack) {
207
- if (!truthpack) return '';
208
-
209
- const lines = [];
210
- lines.push(renderSection('ROUTE TRUTH MAP', shipIcons.map));
211
- lines.push('');
212
-
213
- const serverRoutes = truthpack.routes?.server?.length || 0;
214
- const clientRefs = truthpack.routes?.clientRefs?.length || 0;
215
- const envVars = truthpack.env?.vars?.length || 0;
216
- const envDeclared = truthpack.env?.declared?.length || 0;
217
-
218
- // Routes coverage
219
- const routeCoverage = serverRoutes > 0 ? Math.round((clientRefs / serverRoutes) * 100) : 100;
220
- const routeColor = routeCoverage >= 80 ? colors.success : routeCoverage >= 50 ? colors.warning : colors.error;
221
-
222
- lines.push(` ${shipIcons.route} ${ansi.bold}Routes${ansi.reset}`);
223
- lines.push(` Server: ${colors.accent}${serverRoutes}${ansi.reset} defined`);
224
- lines.push(` Client: ${colors.accent}${clientRefs}${ansi.reset} references`);
225
- lines.push(` Coverage: ${renderProgressBar(routeCoverage, 20, routeColor)} ${routeColor}${routeCoverage}%${ansi.reset}`);
226
- lines.push('');
227
-
228
- // Env coverage
229
- const envCoverage = envVars > 0 ? Math.round((envDeclared / envVars) * 100) : 100;
230
- const envColor = envCoverage >= 80 ? colors.success : envCoverage >= 50 ? colors.warning : colors.error;
231
-
232
- lines.push(` ${shipIcons.env} ${ansi.bold}Environment${ansi.reset}`);
233
- lines.push(` Used: ${colors.accent}${envVars}${ansi.reset} variables`);
234
- lines.push(` Declared: ${colors.accent}${envDeclared}${ansi.reset} in .env`);
235
- lines.push(` Coverage: ${renderProgressBar(envCoverage, 20, envColor)} ${envColor}${envCoverage}%${ansi.reset}`);
236
-
237
- return lines.join('\n');
238
- }
239
-
240
- // ═══════════════════════════════════════════════════════════════════════════════
241
- // FINDINGS BREAKDOWN
242
- // ═══════════════════════════════════════════════════════════════════════════════
243
-
244
- function getCategoryIcon(category) {
245
- const iconMap = {
246
- 'MissingRoute': shipIcons.route,
247
- 'EnvContract': shipIcons.env,
248
- 'EnvGap': shipIcons.env,
249
- 'FakeSuccess': shipIcons.ghost,
250
- 'GhostAuth': shipIcons.auth,
251
- 'StripeWebhook': shipIcons.money,
252
- 'PaidSurface': shipIcons.money,
253
- 'OwnerModeBypass': shipIcons.lock,
254
- 'DeadUI': shipIcons.dead,
255
- 'ContractDrift': icons.warning,
256
- 'Security': shipIcons.shield,
257
- 'Auth': shipIcons.lock,
258
- 'SECRET': shipIcons.key,
259
- 'ROUTE': shipIcons.route,
260
- 'BILLING': shipIcons.money,
261
- 'MOCK': shipIcons.ghost,
262
- };
263
- return iconMap[category] || shipIcons.bug;
264
- }
265
-
266
- function renderFindingsBreakdown(findings) {
267
- if (!findings || findings.length === 0) {
268
- const lines = [];
269
- lines.push(renderSection('FINDINGS', icons.success));
270
- lines.push('');
271
- lines.push(` ${colors.success}${ansi.bold}${shipIcons.sparkle} No issues found! Your code is clean.${ansi.reset}`);
272
- return lines.join('\n');
273
- }
274
-
275
- // Group by category
276
- const byCategory = {};
277
- for (const f of findings) {
278
- const cat = f.category || 'Other';
279
- if (!byCategory[cat]) byCategory[cat] = [];
280
- byCategory[cat].push(f);
281
- }
282
-
283
- const blockers = findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
284
- const warnings = findings.filter(f => f.severity === 'WARN' || f.severity === 'warning');
285
-
286
- const lines = [];
287
- lines.push(renderSection(`FINDINGS (${blockers.length} blockers, ${warnings.length} warnings)`, shipIcons.graph));
288
- lines.push('');
289
-
290
- // Sort categories by severity
291
- const categories = Object.entries(byCategory).sort((a, b) => {
292
- const aBlockers = a[1].filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
293
- const bBlockers = b[1].filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
294
- return bBlockers - aBlockers;
295
- });
296
-
297
- for (const [category, catFindings] of categories) {
298
- const catBlockers = catFindings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
299
- const catWarnings = catFindings.filter(f => f.severity === 'WARN' || f.severity === 'warning').length;
300
- const icon = getCategoryIcon(category);
301
-
302
- const statusColor = catBlockers > 0 ? colors.error : catWarnings > 0 ? colors.warning : colors.success;
303
- const statusIcon = catBlockers > 0 ? icons.error : catWarnings > 0 ? icons.warning : icons.success;
304
-
305
- let stats = '';
306
- if (catBlockers > 0) stats += `${colors.error}${catBlockers} blockers${ansi.reset}`;
307
- if (catWarnings > 0) stats += `${stats ? ' ' : ''}${colors.warning}${catWarnings} warnings${ansi.reset}`;
308
-
309
- lines.push(` ${statusColor}${statusIcon}${ansi.reset} ${icon} ${ansi.bold}${category.padEnd(20)}${ansi.reset} ${stats}`);
310
- }
311
-
312
- return lines.join('\n');
313
- }
314
-
315
- // ═══════════════════════════════════════════════════════════════════════════════
316
- // BLOCKER DETAILS
317
- // ═══════════════════════════════════════════════════════════════════════════════
318
-
319
- function renderBlockerDetails(findings, maxShow = 8) {
320
- const blockers = findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
321
-
322
- if (blockers.length === 0) return '';
323
-
324
- const lines = [];
325
- lines.push(renderSection(`BLOCKERS (${blockers.length})`, '🚨'));
326
- lines.push('');
327
-
328
- for (const blocker of blockers.slice(0, maxShow)) {
329
- const icon = getCategoryIcon(blocker.category);
330
- const severityBg = ansi.bgRgb(80, 20, 20);
331
-
332
- // Severity badge + title
333
- lines.push(` ${severityBg}${ansi.bold} BLOCKER ${ansi.reset} ${icon} ${ansi.bold}${truncate(blocker.title || blocker.message, 50)}${ansi.reset}`);
334
-
335
- // Why/description
336
- if (blocker.why || blocker.description) {
337
- lines.push(` ${ansi.dim}${truncate(blocker.why || blocker.description, 55)}${ansi.reset}`);
338
- }
339
-
340
- // File location
341
- if (blocker.evidence && blocker.evidence.length > 0) {
342
- const ev = blocker.evidence[0];
343
- const file = ev.file || blocker.file;
344
- if (file) {
345
- const fileDisplay = `${path.basename(file)}${ev.lines ? `:${ev.lines}` : blocker.line ? `:${blocker.line}` : ''}`;
346
- lines.push(` ${colors.accent}${shipIcons.doc} ${fileDisplay}${ansi.reset}`);
347
- }
348
- } else if (blocker.file) {
349
- const fileDisplay = `${path.basename(blocker.file)}${blocker.line ? `:${blocker.line}` : ''}`;
350
- lines.push(` ${colors.accent}${shipIcons.doc} ${fileDisplay}${ansi.reset}`);
351
- }
352
-
353
- // Fix hint
354
- if (blocker.fixHints && blocker.fixHints.length > 0) {
355
- lines.push(` ${colors.success}→ ${truncate(blocker.fixHints[0], 50)}${ansi.reset}`);
356
- } else if (blocker.fix) {
357
- lines.push(` ${colors.success}→ ${truncate(blocker.fix, 50)}${ansi.reset}`);
358
- }
359
-
360
- lines.push('');
361
- }
362
-
363
- if (blockers.length > maxShow) {
364
- lines.push(` ${ansi.dim}... and ${blockers.length - maxShow} more blockers (see full report)${ansi.reset}`);
365
- lines.push('');
366
- }
367
-
368
- return lines.join('\n');
369
- }
370
-
371
- // ═══════════════════════════════════════════════════════════════════════════════
372
- // FIX MODE DISPLAY
373
- // ═══════════════════════════════════════════════════════════════════════════════
374
-
375
- function renderFixModeHeader() {
376
- return `\n ${ansi.bgRgb(40, 80, 120)}${ansi.bold} ${shipIcons.wrench} AUTO-FIX MODE ${ansi.reset}\n`;
377
- }
378
-
379
- function renderFixResults(fixResults) {
380
- if (!fixResults) return '';
381
-
382
- const lines = [];
383
- lines.push(renderSection('FIX RESULTS', shipIcons.wrench));
384
- lines.push('');
385
-
386
- // Errors
387
- if (fixResults.errors && fixResults.errors.length > 0) {
388
- for (const err of fixResults.errors) {
389
- lines.push(` ${colors.error}${icons.error}${ansi.reset} ${err}`);
390
- }
391
- lines.push('');
392
- }
393
-
394
- // Actions taken
395
- const actions = [
396
- { done: fixResults.envExampleCreated, label: 'Created .env.example', icon: shipIcons.env },
397
- { done: fixResults.gitignoreUpdated, label: 'Updated .gitignore', icon: shipIcons.shield },
398
- { done: fixResults.fixesMdCreated, label: 'Generated fixes.md', icon: shipIcons.doc },
399
- ];
400
-
401
- for (const action of actions) {
402
- const statusIcon = action.done ? `${colors.success}${icons.success}` : `${ansi.dim}•`;
403
- const label = action.done ? `${ansi.reset}${action.label}` : `${ansi.dim}${action.label}${ansi.reset}`;
404
- lines.push(` ${statusIcon}${ansi.reset} ${action.icon} ${label}`);
405
- }
406
-
407
- // Secrets found
408
- if (fixResults.secretsFound && fixResults.secretsFound.length > 0) {
409
- lines.push('');
410
- lines.push(` ${ansi.bold}Secrets to migrate:${ansi.reset}`);
411
- for (const secret of fixResults.secretsFound.slice(0, 5)) {
412
- lines.push(` ${shipIcons.key} ${secret.varName} ${ansi.dim}(${secret.type})${ansi.reset}`);
413
- }
414
- }
415
-
416
- lines.push('');
417
- lines.push(` ${colors.success}${icons.success}${ansi.reset} ${ansi.bold}Safe fixes applied!${ansi.reset}`);
418
- lines.push(` ${ansi.dim}Review changes and follow instructions in ${colors.accent}.vibecheck/fixes.md${ansi.reset}`);
419
-
420
- return lines.join('\n');
421
- }
422
-
423
- // ═══════════════════════════════════════════════════════════════════════════════
424
- // BADGE OUTPUT
425
- // ═══════════════════════════════════════════════════════════════════════════════
426
-
427
- function renderBadgeOutput(projectPath, verdict, score) {
428
- const projectName = path.basename(projectPath);
429
- const projectId = projectName.toLowerCase().replace(/[^a-z0-9]/g, '-');
430
-
431
- const config = getVerdictConfig(verdict, score);
432
-
433
- const lines = [];
434
- lines.push(renderSection('SHIP BADGE', '📛'));
435
- lines.push('');
436
-
437
- // Badge preview
438
- const badgeText = `vibecheck | ${verdict} | ${score}`;
439
- lines.push(` ${config.bgColor}${ansi.bold} ${badgeText} ${ansi.reset}`);
440
- lines.push('');
441
-
442
- const badgeUrl = `https://vibecheck.dev/badge/${projectId}.svg`;
443
- const reportUrl = `https://vibecheck.dev/report/${projectId}`;
444
- const markdown = `[![Vibecheck](${badgeUrl})](${reportUrl})`;
445
-
446
- lines.push(` ${ansi.dim}Badge URL:${ansi.reset}`);
447
- lines.push(` ${colors.accent}${badgeUrl}${ansi.reset}`);
448
- lines.push('');
449
- lines.push(` ${ansi.dim}Report URL:${ansi.reset}`);
450
- lines.push(` ${colors.accent}${reportUrl}${ansi.reset}`);
451
- lines.push('');
452
- lines.push(` ${ansi.dim}Add to README.md:${ansi.reset}`);
453
- lines.push(` ${colors.success}${markdown}${ansi.reset}`);
454
-
455
- return {
456
- output: lines.join('\n'),
457
- data: { projectId, badgeUrl, reportUrl, markdown },
458
- };
459
- }
460
-
461
- // ═══════════════════════════════════════════════════════════════════════════════
462
- // NEXT STEPS
463
- // ═══════════════════════════════════════════════════════════════════════════════
464
-
465
- function renderNextSteps(canShip, hasFix = false) {
466
- if (canShip) return '';
467
-
468
- const lines = [];
469
- lines.push(renderSection('NEXT STEPS', shipIcons.lightning));
470
- lines.push('');
471
-
472
- if (!hasFix) {
473
- lines.push(` ${colors.accent}vibecheck ship --fix${ansi.reset} ${ansi.dim}Auto-fix what can be fixed${ansi.reset}`);
474
- }
475
- lines.push(` ${colors.accent}vibecheck ship --assist${ansi.reset} ${ansi.dim}Get AI help for complex issues${ansi.reset}`);
476
- lines.push(` ${colors.accent}vibecheck report${ansi.reset} ${ansi.dim}Generate detailed HTML report${ansi.reset}`);
477
- lines.push('');
478
-
479
- return lines.join('\n');
480
- }
481
-
482
- // ═══════════════════════════════════════════════════════════════════════════════
483
- // REPORT LINKS
484
- // ═══════════════════════════════════════════════════════════════════════════════
485
-
486
- function renderReportLinks(outputDir, hasFix = false) {
487
- const lines = [];
488
- lines.push(renderSection('REPORTS', shipIcons.doc));
489
- lines.push('');
490
- lines.push(` ${colors.accent}vibecheck report${ansi.reset} ${ansi.dim}Generate HTML report${ansi.reset}`);
491
- lines.push(` ${ansi.dim}${outputDir}/last_ship.json${ansi.reset}`);
492
- if (hasFix) {
493
- lines.push(` ${colors.accent}${outputDir}/fixes.md${ansi.reset}`);
494
- }
495
- lines.push('');
496
- return lines.join('\n');
497
- }
498
-
499
- // ═══════════════════════════════════════════════════════════════════════════════
500
- // FULL SHIP OUTPUT
501
- // ═══════════════════════════════════════════════════════════════════════════════
502
-
503
- function formatShipOutput(result, options = {}) {
504
- const { verbose = false, showFix = false, showBadge = false, outputDir = '.vibecheck', projectPath = '.' } = options;
505
-
506
- const {
507
- verdict,
508
- score,
509
- findings = [],
510
- blockers = [],
511
- warnings = [],
512
- truthpack,
513
- proofGraph,
514
- fixResults,
515
- duration = 0,
516
- cached = false,
517
- } = result;
518
-
519
- const lines = [];
520
-
521
- // Verdict card (hero moment)
522
- lines.push(renderVerdictCard({
523
- verdict,
524
- score,
525
- blockers: blockers.length || findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length,
526
- warnings: warnings.length || findings.filter(f => f.severity === 'WARN' || f.severity === 'warning').length,
527
- duration,
528
- cached,
529
- }));
530
-
531
- // Findings breakdown
532
- lines.push(renderFindingsBreakdown(findings));
533
-
534
- // Blocker details
535
- lines.push(renderBlockerDetails(findings));
536
-
537
- // Verbose: Route truth map
538
- if (verbose && truthpack) {
539
- lines.push('');
540
- lines.push(renderRouteTruthMap(truthpack));
541
- }
542
-
543
- // Verbose: Proof graph
544
- if (verbose && proofGraph) {
545
- lines.push('');
546
- lines.push(renderProofGraph(proofGraph));
547
- }
548
-
549
- // Fix results
550
- if (showFix && fixResults) {
551
- lines.push(renderFixResults(fixResults));
552
- }
553
-
554
- // Badge (if requested)
555
- if (showBadge) {
556
- const badgeResult = renderBadgeOutput(projectPath, verdict, score);
557
- lines.push(badgeResult.output);
558
- }
559
-
560
- // Report links
561
- lines.push(renderReportLinks(outputDir, showFix));
562
-
563
- // Next steps (if not shipping)
564
- const canShip = verdict === 'SHIP';
565
- lines.push(renderNextSteps(canShip, showFix));
566
-
567
- return lines.filter(Boolean).join('\n');
568
- }
569
-
570
- // ═══════════════════════════════════════════════════════════════════════════════
571
- // UTILITIES
572
- // ═══════════════════════════════════════════════════════════════════════════════
573
-
574
- function renderProgressBar(percent, width, color) {
575
- const filled = Math.round((percent / 100) * width);
576
- const empty = width - filled;
577
- return `${color}${'█'.repeat(filled)}${ansi.dim}${''.repeat(empty)}${ansi.reset}`;
578
- }
579
-
580
- function padCenter(str, width) {
581
- const visibleLen = stripAnsi(str).length;
582
- const padding = Math.max(0, width - visibleLen);
583
- const left = Math.floor(padding / 2);
584
- const right = padding - left;
585
- return ' '.repeat(left) + str + ' '.repeat(right);
586
- }
587
-
588
- function stripAnsi(str) {
589
- return str.replace(/\x1b\[[0-9;]*m/g, '');
590
- }
591
-
592
- // ═══════════════════════════════════════════════════════════════════════════════
593
- // EXIT CODES
594
- // ═══════════════════════════════════════════════════════════════════════════════
595
-
596
- const EXIT_CODES = {
597
- SHIP: 0,
598
- WARN: 1,
599
- BLOCK: 2,
600
- ERROR: 3,
601
- };
602
-
603
- function getExitCode(verdict) {
604
- return EXIT_CODES[verdict] || EXIT_CODES.ERROR;
605
- }
606
-
607
- function verdictFromExitCode(code) {
608
- const map = { 0: 'SHIP', 1: 'WARN', 2: 'BLOCK', 3: 'ERROR' };
609
- return map[code] || 'ERROR';
610
- }
611
-
612
- // ═══════════════════════════════════════════════════════════════════════════════
613
- // EXPORTS
614
- // ═══════════════════════════════════════════════════════════════════════════════
615
-
616
- module.exports = {
617
- // Main formatters
618
- formatShipOutput,
619
-
620
- // Component renderers
621
- renderVerdictCard,
622
- renderProofGraph,
623
- renderRouteTruthMap,
624
- renderFindingsBreakdown,
625
- renderBlockerDetails,
626
- renderFixModeHeader,
627
- renderFixResults,
628
- renderBadgeOutput,
629
- renderNextSteps,
630
- renderReportLinks,
631
-
632
- // Utilities
633
- getVerdictConfig,
634
- getCategoryIcon,
635
- getExitCode,
636
- verdictFromExitCode,
637
- EXIT_CODES,
638
-
639
- // Icons
640
- shipIcons,
641
- };
1
+ /**
2
+ * Ship Output - Premium Ship Command Display
3
+ *
4
+ * Handles all ship-specific output formatting:
5
+ * - Verdict card (the hero moment)
6
+ * - Proof graph visualization
7
+ * - Findings breakdown by category
8
+ * - Fix mode display
9
+ * - Badge generation
10
+ */
11
+
12
+ const path = require('path');
13
+ const {
14
+ ansi,
15
+ colors,
16
+ box,
17
+ icons,
18
+ renderSection,
19
+ formatDuration,
20
+ truncate,
21
+ } = require('./terminal-ui');
22
+
23
+ // ═══════════════════════════════════════════════════════════════════════════════
24
+ // SHIP-SPECIFIC ICONS
25
+ // ═══════════════════════════════════════════════════════════════════════════════
26
+
27
+ const shipIcons = {
28
+ ship: '🚀',
29
+ sparkle: '✨',
30
+ fire: '🔥',
31
+ lock: '🔐',
32
+ key: '🔑',
33
+ link: '🔗',
34
+ graph: '📊',
35
+ map: '🗺️',
36
+ doc: '📄',
37
+ folder: '📁',
38
+ clock: '⏱',
39
+ target: '🎯',
40
+ shield: '🛡️',
41
+ bug: '🐛',
42
+ wrench: '🔧',
43
+ lightning: '⚡',
44
+ package: '📦',
45
+ route: '🛤️',
46
+ env: '🌍',
47
+ auth: '🔒',
48
+ money: '💰',
49
+ ghost: '👻',
50
+ dead: '💀',
51
+ };
52
+
53
+ // ═══════════════════════════════════════════════════════════════════════════════
54
+ // VERDICT CONFIGURATION
55
+ // ═══════════════════════════════════════════════════════════════════════════════
56
+
57
+ function getVerdictConfig(verdict, score, blockers = 0, warnings = 0) {
58
+ if (verdict === 'SHIP' || (score >= 90 && blockers === 0)) {
59
+ return {
60
+ verdict: 'SHIP',
61
+ icon: '🚀',
62
+ headline: 'CLEAR TO SHIP',
63
+ tagline: 'Your app is production ready!',
64
+ color: colors.success,
65
+ borderColor: ansi.rgb(0, 200, 120),
66
+ bgColor: ansi.bgRgb(0, 60, 40),
67
+ };
68
+ }
69
+
70
+ if (verdict === 'WARN' || (score >= 50 && blockers <= 2)) {
71
+ return {
72
+ verdict: 'WARN',
73
+ icon: '⚠️',
74
+ headline: 'REVIEW BEFORE SHIP',
75
+ tagline: `${warnings} warning${warnings !== 1 ? 's' : ''} to address`,
76
+ color: colors.warning,
77
+ borderColor: ansi.rgb(200, 160, 0),
78
+ bgColor: ansi.bgRgb(60, 50, 0),
79
+ };
80
+ }
81
+
82
+ return {
83
+ verdict: 'BLOCK',
84
+ icon: '🛑',
85
+ headline: 'NOT SHIP READY',
86
+ tagline: `${blockers} blocker${blockers !== 1 ? 's' : ''} must be fixed`,
87
+ color: colors.error,
88
+ borderColor: ansi.rgb(200, 60, 60),
89
+ bgColor: ansi.bgRgb(60, 20, 20),
90
+ };
91
+ }
92
+
93
+ // ═══════════════════════════════════════════════════════════════════════════════
94
+ // VERDICT CARD - THE HERO MOMENT
95
+ // ═══════════════════════════════════════════════════════════════════════════════
96
+
97
+ function renderVerdictCard(options = {}) {
98
+ const {
99
+ verdict = 'WARN',
100
+ score = 0,
101
+ blockers = 0,
102
+ warnings = 0,
103
+ duration = 0,
104
+ cached = false,
105
+ } = options;
106
+
107
+ const config = getVerdictConfig(verdict, score, blockers, warnings);
108
+ const w = 68; // Inner width
109
+
110
+ const lines = [];
111
+ lines.push('');
112
+ lines.push('');
113
+
114
+ // Top border
115
+ lines.push(` ${config.borderColor}${box.doubleTopLeft}${box.doubleHorizontal.repeat(w)}${box.doubleTopRight}${ansi.reset}`);
116
+
117
+ // Empty line
118
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
119
+
120
+ // Verdict icon and headline
121
+ const headlineText = `${config.icon} ${config.headline}`;
122
+ const headlinePadded = padCenter(headlineText, w);
123
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${config.color}${ansi.bold}${headlinePadded}${ansi.reset}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
124
+
125
+ // Tagline
126
+ const taglinePadded = padCenter(config.tagline, w);
127
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${ansi.dim}${taglinePadded}${ansi.reset}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
128
+
129
+ // Empty line
130
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
131
+
132
+ // Score bar
133
+ const scoreBar = renderProgressBar(score, 35, config.color);
134
+ const scoreLabel = `VIBE SCORE`;
135
+ const scoreValue = `${score}/100`;
136
+ const scoreLine = ` ${scoreLabel} ${scoreBar} ${config.color}${ansi.bold}${scoreValue}${ansi.reset}`;
137
+ const scoreLinePadRight = ' '.repeat(Math.max(0, w - stripAnsi(scoreLine).length + 4));
138
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${scoreLine}${scoreLinePadRight}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
139
+
140
+ // Empty line
141
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
142
+
143
+ // Stats row
144
+ const blockerColor = blockers > 0 ? colors.error : colors.success;
145
+ const warningColor = warnings > 0 ? colors.warning : colors.success;
146
+ const statsLine = ` ${ansi.dim}Blockers:${ansi.reset} ${blockerColor}${ansi.bold}${blockers}${ansi.reset} ${ansi.dim}Warnings:${ansi.reset} ${warningColor}${ansi.bold}${warnings}${ansi.reset} ${ansi.dim}Duration:${ansi.reset} ${colors.accent}${ansi.bold}${formatDuration(duration)}${ansi.reset}${cached ? ` ${ansi.dim}(cached)${ansi.reset}` : ''}`;
147
+ const statsLinePadRight = ' '.repeat(Math.max(0, w - stripAnsi(statsLine).length + 4));
148
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${statsLine}${statsLinePadRight}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
149
+
150
+ // Empty line
151
+ lines.push(` ${config.borderColor}${box.doubleVertical}${ansi.reset}${' '.repeat(w)}${config.borderColor}${box.doubleVertical}${ansi.reset}`);
152
+
153
+ // Bottom border
154
+ lines.push(` ${config.borderColor}${box.doubleBottomLeft}${box.doubleHorizontal.repeat(w)}${box.doubleBottomRight}${ansi.reset}`);
155
+
156
+ lines.push('');
157
+ return lines.join('\n');
158
+ }
159
+
160
+ // ═══════════════════════════════════════════════════════════════════════════════
161
+ // PROOF GRAPH VISUALIZATION
162
+ // ═══════════════════════════════════════════════════════════════════════════════
163
+
164
+ function renderProofGraph(proofGraph) {
165
+ if (!proofGraph || !proofGraph.summary) return '';
166
+
167
+ const lines = [];
168
+ lines.push(renderSection('PROOF GRAPH', shipIcons.graph));
169
+ lines.push('');
170
+
171
+ const { summary } = proofGraph;
172
+
173
+ // Confidence gauge
174
+ const confidence = Math.round((summary.confidence || 0) * 100);
175
+ const confColor = confidence >= 80 ? colors.success : confidence >= 50 ? colors.warning : colors.error;
176
+
177
+ lines.push(` ${ansi.bold}Analysis Confidence${ansi.reset}`);
178
+ lines.push(` ${renderProgressBar(confidence, 40, confColor)} ${confColor}${ansi.bold}${confidence}%${ansi.reset}`);
179
+ lines.push('');
180
+
181
+ // Claims summary
182
+ const verified = summary.verifiedClaims || 0;
183
+ const failed = summary.failedClaims || 0;
184
+ const total = summary.totalClaims || 0;
185
+
186
+ lines.push(` ${ansi.dim}Claims:${ansi.reset} ${colors.success}${verified}${ansi.reset} verified ${ansi.dim}/${ansi.reset} ${colors.error}${failed}${ansi.reset} failed ${ansi.dim}of${ansi.reset} ${total} total`);
187
+ lines.push(` ${ansi.dim}Gaps:${ansi.reset} ${summary.gaps || 0} identified`);
188
+ lines.push(` ${ansi.dim}Risk Score:${ansi.reset} ${summary.riskScore || 0}/100`);
189
+
190
+ // Top blockers
191
+ if (proofGraph.topBlockers && proofGraph.topBlockers.length > 0) {
192
+ lines.push('');
193
+ lines.push(` ${ansi.bold}Top Unverified Claims:${ansi.reset}`);
194
+ for (const blocker of proofGraph.topBlockers.slice(0, 3)) {
195
+ lines.push(` ${colors.error}${icons.error}${ansi.reset} ${truncate(blocker.assertion, 50)}`);
196
+ }
197
+ }
198
+
199
+ return lines.join('\n');
200
+ }
201
+
202
+ // ═══════════════════════════════════════════════════════════════════════════════
203
+ // ROUTE TRUTH MAP
204
+ // ═══════════════════════════════════════════════════════════════════════════════
205
+
206
+ function renderRouteTruthMap(truthpack) {
207
+ if (!truthpack) return '';
208
+
209
+ const lines = [];
210
+ lines.push(renderSection('ROUTE TRUTH MAP', shipIcons.map));
211
+ lines.push('');
212
+
213
+ const serverRoutes = truthpack.routes?.server?.length || 0;
214
+ const clientRefs = truthpack.routes?.clientRefs?.length || 0;
215
+ const envVars = truthpack.env?.vars?.length || 0;
216
+ const envDeclared = truthpack.env?.declared?.length || 0;
217
+
218
+ // Routes coverage
219
+ const routeCoverage = serverRoutes > 0 ? Math.round((clientRefs / serverRoutes) * 100) : 100;
220
+ const routeColor = routeCoverage >= 80 ? colors.success : routeCoverage >= 50 ? colors.warning : colors.error;
221
+
222
+ lines.push(` ${shipIcons.route} ${ansi.bold}Routes${ansi.reset}`);
223
+ lines.push(` Server: ${colors.accent}${serverRoutes}${ansi.reset} defined`);
224
+ lines.push(` Client: ${colors.accent}${clientRefs}${ansi.reset} references`);
225
+ lines.push(` Coverage: ${renderProgressBar(routeCoverage, 20, routeColor)} ${routeColor}${routeCoverage}%${ansi.reset}`);
226
+ lines.push('');
227
+
228
+ // Env coverage
229
+ const envCoverage = envVars > 0 ? Math.round((envDeclared / envVars) * 100) : 100;
230
+ const envColor = envCoverage >= 80 ? colors.success : envCoverage >= 50 ? colors.warning : colors.error;
231
+
232
+ lines.push(` ${shipIcons.env} ${ansi.bold}Environment${ansi.reset}`);
233
+ lines.push(` Used: ${colors.accent}${envVars}${ansi.reset} variables`);
234
+ lines.push(` Declared: ${colors.accent}${envDeclared}${ansi.reset} in .env`);
235
+ lines.push(` Coverage: ${renderProgressBar(envCoverage, 20, envColor)} ${envColor}${envCoverage}%${ansi.reset}`);
236
+
237
+ return lines.join('\n');
238
+ }
239
+
240
+ // ═══════════════════════════════════════════════════════════════════════════════
241
+ // FINDINGS BREAKDOWN
242
+ // ═══════════════════════════════════════════════════════════════════════════════
243
+
244
+ function getCategoryIcon(category) {
245
+ const iconMap = {
246
+ 'MissingRoute': shipIcons.route,
247
+ 'EnvContract': shipIcons.env,
248
+ 'EnvGap': shipIcons.env,
249
+ 'FakeSuccess': shipIcons.ghost,
250
+ 'GhostAuth': shipIcons.auth,
251
+ 'StripeWebhook': shipIcons.money,
252
+ 'PaidSurface': shipIcons.money,
253
+ 'OwnerModeBypass': shipIcons.lock,
254
+ 'DeadUI': shipIcons.dead,
255
+ 'ContractDrift': icons.warning,
256
+ 'Security': shipIcons.shield,
257
+ 'Auth': shipIcons.lock,
258
+ 'SECRET': shipIcons.key,
259
+ 'ROUTE': shipIcons.route,
260
+ 'BILLING': shipIcons.money,
261
+ 'MOCK': shipIcons.ghost,
262
+ };
263
+ return iconMap[category] || shipIcons.bug;
264
+ }
265
+
266
+ function renderFindingsBreakdown(findings) {
267
+ if (!findings || findings.length === 0) {
268
+ const lines = [];
269
+ lines.push(renderSection('FINDINGS', icons.success));
270
+ lines.push('');
271
+ lines.push(` ${colors.success}${ansi.bold}${shipIcons.sparkle} No issues found! Your code is clean.${ansi.reset}`);
272
+ return lines.join('\n');
273
+ }
274
+
275
+ // Group by category
276
+ const byCategory = {};
277
+ for (const f of findings) {
278
+ const cat = f.category || 'Other';
279
+ if (!byCategory[cat]) byCategory[cat] = [];
280
+ byCategory[cat].push(f);
281
+ }
282
+
283
+ const blockers = findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
284
+ const warnings = findings.filter(f => f.severity === 'WARN' || f.severity === 'warning');
285
+
286
+ const lines = [];
287
+ lines.push(renderSection(`FINDINGS (${blockers.length} blockers, ${warnings.length} warnings)`, shipIcons.graph));
288
+ lines.push('');
289
+
290
+ // Sort categories by severity
291
+ const categories = Object.entries(byCategory).sort((a, b) => {
292
+ const aBlockers = a[1].filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
293
+ const bBlockers = b[1].filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
294
+ return bBlockers - aBlockers;
295
+ });
296
+
297
+ for (const [category, catFindings] of categories) {
298
+ const catBlockers = catFindings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length;
299
+ const catWarnings = catFindings.filter(f => f.severity === 'WARN' || f.severity === 'warning').length;
300
+ const icon = getCategoryIcon(category);
301
+
302
+ const statusColor = catBlockers > 0 ? colors.error : catWarnings > 0 ? colors.warning : colors.success;
303
+ const statusIcon = catBlockers > 0 ? icons.error : catWarnings > 0 ? icons.warning : icons.success;
304
+
305
+ let stats = '';
306
+ if (catBlockers > 0) stats += `${colors.error}${catBlockers} blockers${ansi.reset}`;
307
+ if (catWarnings > 0) stats += `${stats ? ' ' : ''}${colors.warning}${catWarnings} warnings${ansi.reset}`;
308
+
309
+ lines.push(` ${statusColor}${statusIcon}${ansi.reset} ${icon} ${ansi.bold}${category.padEnd(20)}${ansi.reset} ${stats}`);
310
+ }
311
+
312
+ return lines.join('\n');
313
+ }
314
+
315
+ // ═══════════════════════════════════════════════════════════════════════════════
316
+ // BLOCKER DETAILS
317
+ // ═══════════════════════════════════════════════════════════════════════════════
318
+
319
+ function renderBlockerDetails(findings, maxShow = 8, options = {}) {
320
+ const { tier = 'free' } = options;
321
+ const blockers = findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
322
+
323
+ if (blockers.length === 0) return '';
324
+
325
+ // Fix hints are STARTER+ only
326
+ const canShowFixHints = tier === 'starter' || tier === 'pro' || tier === 'compliance';
327
+
328
+ const lines = [];
329
+ lines.push(renderSection(`BLOCKERS (${blockers.length})`, '🚨'));
330
+ lines.push('');
331
+
332
+ for (const blocker of blockers.slice(0, maxShow)) {
333
+ const icon = getCategoryIcon(blocker.category);
334
+ const severityBg = ansi.bgRgb(80, 20, 20);
335
+
336
+ // Check if this is a common AI hallucination pattern
337
+ const isAIPattern = AI_HALLUCINATION_CATEGORIES[blocker.category];
338
+ const aiTag = isAIPattern ? `${ansi.bgRgb(60, 40, 80)}${ansi.bold} 🤖 AI ${ansi.reset} ` : '';
339
+
340
+ // Severity badge + AI tag + title
341
+ lines.push(` ${severityBg}${ansi.bold} BLOCKER ${ansi.reset} ${aiTag}${icon} ${ansi.bold}${truncate(blocker.title || blocker.message, 45)}${ansi.reset}`);
342
+
343
+ // Why/description - emphasize AI-specific messaging
344
+ if (blocker.why || blocker.description) {
345
+ const desc = blocker.why || blocker.description;
346
+ lines.push(` ${ansi.dim}${truncate(desc, 55)}${ansi.reset}`);
347
+ }
348
+
349
+ // AI-specific warning for known patterns
350
+ if (isAIPattern) {
351
+ lines.push(` ${colors.warning}⚠ ${isAIPattern.desc}${ansi.reset}`);
352
+ }
353
+
354
+ // File location
355
+ if (blocker.evidence && blocker.evidence.length > 0) {
356
+ const ev = blocker.evidence[0];
357
+ const file = ev.file || blocker.file;
358
+ if (file) {
359
+ const fileDisplay = `${path.basename(file)}${ev.lines ? `:${ev.lines}` : blocker.line ? `:${blocker.line}` : ''}`;
360
+ lines.push(` ${colors.accent}${shipIcons.doc} ${fileDisplay}${ansi.reset}`);
361
+ }
362
+ } else if (blocker.file) {
363
+ const fileDisplay = `${path.basename(blocker.file)}${blocker.line ? `:${blocker.line}` : ''}`;
364
+ lines.push(` ${colors.accent}${shipIcons.doc} ${fileDisplay}${ansi.reset}`);
365
+ }
366
+
367
+ // Fix hint - STARTER+ only, show locked message for FREE
368
+ if (canShowFixHints) {
369
+ if (blocker.fixHints && blocker.fixHints.length > 0) {
370
+ lines.push(` ${colors.success}→ ${truncate(blocker.fixHints[0], 50)}${ansi.reset}`);
371
+ } else if (blocker.fix) {
372
+ lines.push(` ${colors.success}→ ${truncate(blocker.fix, 50)}${ansi.reset}`);
373
+ }
374
+ } else {
375
+ // Show locked fix hint for FREE tier
376
+ lines.push(` ${ansi.dim}🔒 Fix hint available with STARTER${ansi.reset}`);
377
+ }
378
+
379
+ lines.push('');
380
+ }
381
+
382
+ if (blockers.length > maxShow) {
383
+ lines.push(` ${ansi.dim}... and ${blockers.length - maxShow} more blockers (see full report)${ansi.reset}`);
384
+ lines.push('');
385
+ }
386
+
387
+ // Show upsell for fix hints if FREE tier
388
+ if (!canShowFixHints && blockers.length > 0) {
389
+ lines.push(` ${ansi.dim}─────────────────────────────────────────${ansi.reset}`);
390
+ lines.push(` ${colors.accent}💡${ansi.reset} ${ansi.bold}Unlock AI fix suggestions${ansi.reset}`);
391
+ lines.push(` ${ansi.dim}STARTER users get specific fix hints and${ansi.reset}`);
392
+ lines.push(` ${ansi.dim}AI mission prompts for every issue.${ansi.reset}`);
393
+ lines.push(` ${colors.accent}vibecheck login${ansi.reset} ${ansi.dim}to upgrade${ansi.reset}`);
394
+ lines.push('');
395
+ }
396
+
397
+ return lines.join('\n');
398
+ }
399
+
400
+ // ═══════════════════════════════════════════════════════════════════════════════
401
+ // FIX MODE DISPLAY
402
+ // ═══════════════════════════════════════════════════════════════════════════════
403
+
404
+ function renderFixModeHeader() {
405
+ return `\n ${ansi.bgRgb(40, 80, 120)}${ansi.bold} ${shipIcons.wrench} AUTO-FIX MODE ${ansi.reset}\n`;
406
+ }
407
+
408
+ function renderFixResults(fixResults) {
409
+ if (!fixResults) return '';
410
+
411
+ const lines = [];
412
+ lines.push(renderSection('FIX RESULTS', shipIcons.wrench));
413
+ lines.push('');
414
+
415
+ // Errors
416
+ if (fixResults.errors && fixResults.errors.length > 0) {
417
+ for (const err of fixResults.errors) {
418
+ lines.push(` ${colors.error}${icons.error}${ansi.reset} ${err}`);
419
+ }
420
+ lines.push('');
421
+ }
422
+
423
+ // Actions taken
424
+ const actions = [
425
+ { done: fixResults.envExampleCreated, label: 'Created .env.example', icon: shipIcons.env },
426
+ { done: fixResults.gitignoreUpdated, label: 'Updated .gitignore', icon: shipIcons.shield },
427
+ { done: fixResults.fixesMdCreated, label: 'Generated fixes.md', icon: shipIcons.doc },
428
+ ];
429
+
430
+ for (const action of actions) {
431
+ const statusIcon = action.done ? `${colors.success}${icons.success}` : `${ansi.dim}•`;
432
+ const label = action.done ? `${ansi.reset}${action.label}` : `${ansi.dim}${action.label}${ansi.reset}`;
433
+ lines.push(` ${statusIcon}${ansi.reset} ${action.icon} ${label}`);
434
+ }
435
+
436
+ // Secrets found
437
+ if (fixResults.secretsFound && fixResults.secretsFound.length > 0) {
438
+ lines.push('');
439
+ lines.push(` ${ansi.bold}Secrets to migrate:${ansi.reset}`);
440
+ for (const secret of fixResults.secretsFound.slice(0, 5)) {
441
+ lines.push(` ${shipIcons.key} ${secret.varName} ${ansi.dim}(${secret.type})${ansi.reset}`);
442
+ }
443
+ }
444
+
445
+ lines.push('');
446
+ lines.push(` ${colors.success}${icons.success}${ansi.reset} ${ansi.bold}Safe fixes applied!${ansi.reset}`);
447
+ lines.push(` ${ansi.dim}Review changes and follow instructions in ${colors.accent}.vibecheck/fixes.md${ansi.reset}`);
448
+
449
+ return lines.join('\n');
450
+ }
451
+
452
+ // ═══════════════════════════════════════════════════════════════════════════════
453
+ // BADGE OUTPUT
454
+ // ═══════════════════════════════════════════════════════════════════════════════
455
+
456
+ function renderBadgeOutput(projectPath, verdict, score, options = {}) {
457
+ const { tier = 'free', isVerified = false } = options;
458
+ const projectName = path.basename(projectPath);
459
+ const projectId = projectName.toLowerCase().replace(/[^a-z0-9]/g, '-');
460
+
461
+ const config = getVerdictConfig(verdict, score);
462
+ const isPro = tier === 'pro' || tier === 'compliance';
463
+
464
+ const lines = [];
465
+
466
+ // Different header for verified vs basic badge
467
+ if (isPro && isVerified) {
468
+ lines.push(renderSection('VERIFIED BADGE', '✅'));
469
+ lines.push('');
470
+ lines.push(` ${ansi.bgRgb(40, 100, 60)}${ansi.bold} ✅ VERIFIED ${ansi.reset} ${ansi.dim}Reality-tested by AI${ansi.reset}`);
471
+ lines.push('');
472
+ } else {
473
+ lines.push(renderSection('SHIP BADGE', '📛'));
474
+ lines.push('');
475
+ }
476
+
477
+ // Badge preview
478
+ const badgeText = isPro && isVerified
479
+ ? `vibecheck | ✅ VERIFIED | ${score}`
480
+ : `vibecheck | ${verdict} | ${score}`;
481
+ lines.push(` ${config.bgColor}${ansi.bold} ${badgeText} ${ansi.reset}`);
482
+ lines.push('');
483
+
484
+ // Different URLs for verified vs basic badge
485
+ const badgeSuffix = (isPro && isVerified) ? '-verified' : '';
486
+ const badgeUrl = `https://vibecheck.dev/badge/${projectId}${badgeSuffix}.svg`;
487
+ const reportUrl = `https://vibecheck.dev/report/${projectId}`;
488
+ const markdown = `[![Vibecheck](${badgeUrl})](${reportUrl})`;
489
+
490
+ lines.push(` ${ansi.dim}Badge URL:${ansi.reset}`);
491
+ lines.push(` ${colors.accent}${badgeUrl}${ansi.reset}`);
492
+ lines.push('');
493
+ lines.push(` ${ansi.dim}Report URL:${ansi.reset}`);
494
+ lines.push(` ${colors.accent}${reportUrl}${ansi.reset}`);
495
+ lines.push('');
496
+ lines.push(` ${ansi.dim}Add to README.md:${ansi.reset}`);
497
+ lines.push(` ${colors.success}${markdown}${ansi.reset}`);
498
+
499
+ // Show verified badge upsell if not PRO
500
+ if (!isPro) {
501
+ lines.push('');
502
+ lines.push(` ${ansi.dim}─────────────────────────────────────────${ansi.reset}`);
503
+ lines.push(` ${colors.accent}✨${ansi.reset} ${ansi.bold}Want a verified badge?${ansi.reset}`);
504
+ lines.push(` ${ansi.dim}PRO users get a${ansi.reset} ${ansi.bgRgb(40, 100, 60)}${ansi.bold} VERIFIED ${ansi.reset} ${ansi.dim}badge${ansi.reset}`);
505
+ lines.push(` ${ansi.dim}proving your app was reality-tested by AI.${ansi.reset}`);
506
+ lines.push(` ${colors.accent}vibecheck login${ansi.reset} ${ansi.dim}to upgrade${ansi.reset}`);
507
+ }
508
+
509
+ return {
510
+ output: lines.join('\n'),
511
+ data: { projectId, badgeUrl, reportUrl, markdown, isVerified: isPro && isVerified },
512
+ };
513
+ }
514
+
515
+ // ═══════════════════════════════════════════════════════════════════════════════
516
+ // NEXT STEPS
517
+ // ═══════════════════════════════════════════════════════════════════════════════
518
+
519
+ function renderNextSteps(canShip, hasFix = false, options = {}) {
520
+ const { tier = 'free', showBadge = false } = options;
521
+ const isPro = tier === 'pro' || tier === 'compliance';
522
+
523
+ const lines = [];
524
+
525
+ // If codebase PASSED - show celebration and PRO upsell for verified badge
526
+ if (canShip) {
527
+ lines.push('');
528
+ lines.push(renderSection('WHAT\'S NEXT?', shipIcons.sparkle));
529
+ lines.push('');
530
+ lines.push(` ${colors.success}${icons.success}${ansi.reset} ${ansi.bold}Congratulations! Your code is ship-ready.${ansi.reset}`);
531
+ lines.push('');
532
+
533
+ if (!isPro) {
534
+ // Upsell PRO for verified badge
535
+ lines.push(` ${ansi.dim}─────────────────────────────────────────${ansi.reset}`);
536
+ lines.push(` ${colors.accent}🏆${ansi.reset} ${ansi.bold}Prove it to the world!${ansi.reset}`);
537
+ lines.push('');
538
+ lines.push(` ${ansi.dim}Upgrade to${ansi.reset} ${colors.accent}PRO${ansi.reset} ${ansi.dim}for a${ansi.reset} ${ansi.bgRgb(40, 100, 60)}${ansi.bold} ✅ VERIFIED ${ansi.reset} ${ansi.dim}badge${ansi.reset}`);
539
+ lines.push('');
540
+ lines.push(` ${ansi.dim}The verified badge proves:${ansi.reset}`);
541
+ lines.push(` ${colors.success}→${ansi.reset} AI reality-tested your app's actual behavior`);
542
+ lines.push(` ${colors.success}→${ansi.reset} Video proof of working features`);
543
+ lines.push(` ${colors.success}→${ansi.reset} Automated auth boundary verification`);
544
+ lines.push('');
545
+ lines.push(` ${colors.accent}vibecheck login${ansi.reset} ${ansi.dim}to start your PRO trial${ansi.reset}`);
546
+ lines.push('');
547
+ } else if (!showBadge) {
548
+ // PRO user who passed - prompt them to generate badge
549
+ lines.push(` ${colors.accent}vibecheck ship --badge${ansi.reset} ${ansi.dim}Generate your verified badge${ansi.reset}`);
550
+ lines.push(` ${colors.accent}vibecheck prove${ansi.reset} ${ansi.dim}Create video proof of working features${ansi.reset}`);
551
+ lines.push('');
552
+ }
553
+
554
+ return lines.join('\n');
555
+ }
556
+
557
+ // If codebase FAILED - show next steps to fix
558
+ lines.push(renderSection('NEXT STEPS', shipIcons.lightning));
559
+ lines.push('');
560
+
561
+ if (!hasFix) {
562
+ lines.push(` ${colors.accent}vibecheck ship --fix${ansi.reset} ${ansi.dim}Auto-fix what can be fixed${ansi.reset}`);
563
+ }
564
+ lines.push(` ${colors.accent}vibecheck ship --assist${ansi.reset} ${ansi.dim}Get AI help for complex issues${ansi.reset}`);
565
+ lines.push(` ${colors.accent}vibecheck report${ansi.reset} ${ansi.dim}Generate detailed HTML report${ansi.reset}`);
566
+ lines.push('');
567
+
568
+ return lines.join('\n');
569
+ }
570
+
571
+ // ═══════════════════════════════════════════════════════════════════════════════
572
+ // REPORT LINKS
573
+ // ═══════════════════════════════════════════════════════════════════════════════
574
+
575
+ function renderReportLinks(outputDir, hasFix = false) {
576
+ const lines = [];
577
+ lines.push(renderSection('REPORTS', shipIcons.doc));
578
+ lines.push('');
579
+ lines.push(` ${colors.accent}vibecheck report${ansi.reset} ${ansi.dim}Generate HTML report${ansi.reset}`);
580
+ lines.push(` ${ansi.dim}${outputDir}/last_ship.json${ansi.reset}`);
581
+ if (hasFix) {
582
+ lines.push(` ${colors.accent}${outputDir}/fixes.md${ansi.reset}`);
583
+ }
584
+ lines.push('');
585
+ return lines.join('\n');
586
+ }
587
+
588
+ // ═══════════════════════════════════════════════════════════════════════════════
589
+ // AI HALLUCINATION SCORE - Key metric for detecting AI-generated issues
590
+ // ═══════════════════════════════════════════════════════════════════════════════
591
+
592
+ // Categories that indicate common AI hallucination patterns
593
+ const AI_HALLUCINATION_CATEGORIES = {
594
+ 'MissingRoute': { weight: 25, label: 'Invented endpoints', desc: 'AI created API routes that don\'t exist' },
595
+ 'FakeSuccess': { weight: 20, label: 'Fake success UI', desc: 'Shows success without verifying the operation' },
596
+ 'GhostAuth': { weight: 15, label: 'Missing auth', desc: 'Sensitive endpoints without protection' },
597
+ 'EnvGap': { weight: 10, label: 'Undefined env vars', desc: 'Uses environment variables that don\'t exist' },
598
+ 'DeadUI': { weight: 10, label: 'Dead UI elements', desc: 'Buttons/forms that do nothing' },
599
+ 'OwnerModeBypass': { weight: 15, label: 'Backdoor access', desc: 'Production security bypasses' },
600
+ 'PaidSurface': { weight: 5, label: 'Billing bypass', desc: 'Paid features without enforcement' },
601
+ };
602
+
603
+ function calculateAIHallucinationScore(findings) {
604
+ if (!findings || findings.length === 0) return { score: 100, breakdown: [], totalIssues: 0 };
605
+
606
+ const breakdown = [];
607
+ let deductions = 0;
608
+
609
+ for (const [category, config] of Object.entries(AI_HALLUCINATION_CATEGORIES)) {
610
+ const categoryFindings = findings.filter(f => f.category === category);
611
+ const blockers = categoryFindings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
612
+ const warnings = categoryFindings.filter(f => f.severity === 'WARN' || f.severity === 'warning');
613
+
614
+ if (categoryFindings.length > 0) {
615
+ // Blockers count more than warnings
616
+ const blockerDeduction = blockers.length * config.weight;
617
+ const warningDeduction = warnings.length * Math.floor(config.weight / 2);
618
+ const totalDeduction = blockerDeduction + warningDeduction;
619
+
620
+ deductions += totalDeduction;
621
+ breakdown.push({
622
+ category,
623
+ label: config.label,
624
+ desc: config.desc,
625
+ count: categoryFindings.length,
626
+ blockers: blockers.length,
627
+ warnings: warnings.length,
628
+ deduction: totalDeduction,
629
+ });
630
+ }
631
+ }
632
+
633
+ // Cap at 0
634
+ const score = Math.max(0, 100 - deductions);
635
+
636
+ return {
637
+ score,
638
+ breakdown: breakdown.sort((a, b) => b.deduction - a.deduction),
639
+ totalIssues: findings.length,
640
+ };
641
+ }
642
+
643
+ function renderAIHallucinationScore(findings) {
644
+ const { score, breakdown, totalIssues } = calculateAIHallucinationScore(findings);
645
+
646
+ const lines = [];
647
+ lines.push('');
648
+ lines.push(renderSection('AI REALITY CHECK', '🤖'));
649
+ lines.push('');
650
+
651
+ // Score display with color coding
652
+ const scoreColor = score >= 80 ? colors.success : score >= 50 ? colors.warning : colors.error;
653
+ const scoreLabel = score >= 80 ? 'Low Risk' : score >= 50 ? 'Medium Risk' : 'High Risk';
654
+
655
+ // Visual score bar
656
+ lines.push(` ${ansi.bold}AI Hallucination Risk${ansi.reset}`);
657
+ lines.push(` ${renderProgressBar(100 - score, 40, scoreColor)} ${scoreColor}${ansi.bold}${100 - score}%${ansi.reset} risk`);
658
+ lines.push('');
659
+
660
+ if (breakdown.length === 0) {
661
+ lines.push(` ${colors.success}${icons.success}${ansi.reset} ${ansi.bold}No AI hallucination patterns detected!${ansi.reset}`);
662
+ lines.push(` ${ansi.dim}Your code appears to be grounded in reality.${ansi.reset}`);
663
+ } else {
664
+ lines.push(` ${ansi.dim}Common AI mistakes found:${ansi.reset}`);
665
+ lines.push('');
666
+
667
+ for (const item of breakdown.slice(0, 5)) {
668
+ const itemColor = item.blockers > 0 ? colors.error : colors.warning;
669
+ const severityBadge = item.blockers > 0
670
+ ? `${colors.error}${ansi.bold}${item.blockers} BLOCK${ansi.reset}`
671
+ : `${colors.warning}${item.warnings} warn${ansi.reset}`;
672
+
673
+ lines.push(` ${itemColor}•${ansi.reset} ${ansi.bold}${item.label}${ansi.reset} ${ansi.dim}(${item.count})${ansi.reset} ${severityBadge}`);
674
+ lines.push(` ${ansi.dim}${item.desc}${ansi.reset}`);
675
+ }
676
+
677
+ if (breakdown.length > 5) {
678
+ lines.push(` ${ansi.dim}... and ${breakdown.length - 5} more categories${ansi.reset}`);
679
+ }
680
+ }
681
+
682
+ lines.push('');
683
+ return lines.join('\n');
684
+ }
685
+
686
+ // ═══════════════════════════════════════════════════════════════════════════════
687
+ // UPGRADE PROMPTS - Show FREE users what they're missing
688
+ // ═══════════════════════════════════════════════════════════════════════════════
689
+
690
+ function renderUpgradePrompts(findings, verdict) {
691
+ const lines = [];
692
+
693
+ // Only show if there are issues to fix
694
+ if (!findings || findings.length === 0) return '';
695
+
696
+ const blockers = findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical');
697
+ const fixableCount = findings.length;
698
+
699
+ lines.push('');
700
+ lines.push(` ${ansi.dim}${'─'.repeat(60)}${ansi.reset}`);
701
+ lines.push('');
702
+ lines.push(` ${colors.accent}⚡${ansi.reset} ${ansi.bold}Want to fix these automatically?${ansi.reset}`);
703
+ lines.push('');
704
+
705
+ // STARTER tier benefits
706
+ lines.push(` ${colors.accent}STARTER${ansi.reset} ${ansi.dim}($39/mo)${ansi.reset}`);
707
+ lines.push(` ${colors.success}→${ansi.reset} ${ansi.bold}vibecheck fix${ansi.reset} - AI auto-fix for ${fixableCount} issues`);
708
+ lines.push(` ${colors.success}→${ansi.reset} ${ansi.bold}vibecheck guard${ansi.reset} - Prevent AI hallucinations before commit`);
709
+ lines.push(` ${colors.success}→${ansi.reset} ${ansi.bold}vibecheck report${ansi.reset} - SARIF export for GitHub code scanning`);
710
+ lines.push('');
711
+
712
+ // PRO tier benefits (only if there are blockers)
713
+ if (blockers.length > 0 || verdict === 'BLOCK') {
714
+ lines.push(` ${colors.accent}PRO${ansi.reset} ${ansi.dim}($99/mo)${ansi.reset}`);
715
+ lines.push(` ${colors.success}→${ansi.reset} ${ansi.bold}vibecheck prove${ansi.reset} - Video proof your app actually works`);
716
+ lines.push(` ${colors.success}→${ansi.reset} ${ansi.bold}vibecheck reality --agent${ansi.reset} - AI tests your app autonomously`);
717
+ lines.push('');
718
+ }
719
+
720
+ lines.push(` ${ansi.dim}Try STARTER free for 14 days: ${colors.accent}vibecheck login${ansi.reset}`);
721
+ lines.push('');
722
+
723
+ return lines.join('\n');
724
+ }
725
+
726
+ // ═══════════════════════════════════════════════════════════════════════════════
727
+ // FULL SHIP OUTPUT
728
+ // ═══════════════════════════════════════════════════════════════════════════════
729
+
730
+ function formatShipOutput(result, options = {}) {
731
+ const {
732
+ verbose = false,
733
+ showFix = false,
734
+ showBadge = false,
735
+ outputDir = '.vibecheck',
736
+ projectPath = '.',
737
+ tier = 'free', // User's current tier
738
+ isVerified = false, // Whether reality testing was done (for verified badge)
739
+ } = options;
740
+
741
+ const {
742
+ verdict,
743
+ score,
744
+ findings = [],
745
+ blockers = [],
746
+ warnings = [],
747
+ truthpack,
748
+ proofGraph,
749
+ fixResults,
750
+ duration = 0,
751
+ cached = false,
752
+ } = result;
753
+
754
+ const isPro = tier === 'pro' || tier === 'compliance';
755
+ const isStarter = tier === 'starter' || isPro;
756
+ const canShip = verdict === 'SHIP';
757
+
758
+ const lines = [];
759
+
760
+ // Verdict card (hero moment)
761
+ lines.push(renderVerdictCard({
762
+ verdict,
763
+ score,
764
+ blockers: blockers.length || findings.filter(f => f.severity === 'BLOCK' || f.severity === 'critical').length,
765
+ warnings: warnings.length || findings.filter(f => f.severity === 'WARN' || f.severity === 'warning').length,
766
+ duration,
767
+ cached,
768
+ }));
769
+
770
+ // AI Hallucination Score (always show - this is a key selling point)
771
+ lines.push(renderAIHallucinationScore(findings));
772
+
773
+ // Findings breakdown
774
+ lines.push(renderFindingsBreakdown(findings));
775
+
776
+ // Blocker details (with AI attribution, tier-gated fix hints)
777
+ lines.push(renderBlockerDetails(findings, 8, { tier }));
778
+
779
+ // Upgrade prompts for FREE tier users (if there are fixable issues)
780
+ if (findings.length > 0 && !isStarter) {
781
+ lines.push(renderUpgradePrompts(findings, verdict));
782
+ }
783
+
784
+ // Verbose: Route truth map
785
+ if (verbose && truthpack) {
786
+ lines.push('');
787
+ lines.push(renderRouteTruthMap(truthpack));
788
+ }
789
+
790
+ // Verbose: Proof graph
791
+ if (verbose && proofGraph) {
792
+ lines.push('');
793
+ lines.push(renderProofGraph(proofGraph));
794
+ }
795
+
796
+ // Fix results
797
+ if (showFix && fixResults) {
798
+ lines.push(renderFixResults(fixResults));
799
+ }
800
+
801
+ // Badge (if requested and tier allows)
802
+ if (showBadge && isStarter) {
803
+ const badgeResult = renderBadgeOutput(projectPath, verdict, score, { tier, isVerified });
804
+ lines.push(badgeResult.output);
805
+ } else if (showBadge && !isStarter) {
806
+ // Show badge upsell for FREE users
807
+ lines.push('');
808
+ lines.push(renderSection('BADGE', '📛'));
809
+ lines.push('');
810
+ lines.push(` ${ansi.dim}🔒 Ship badges require STARTER tier${ansi.reset}`);
811
+ lines.push(` ${colors.accent}vibecheck login${ansi.reset} ${ansi.dim}to upgrade${ansi.reset}`);
812
+ lines.push('');
813
+ }
814
+
815
+ // Report links
816
+ lines.push(renderReportLinks(outputDir, showFix));
817
+
818
+ // Next steps (shows PRO upsell for verified badge if SHIP passed)
819
+ lines.push(renderNextSteps(canShip, showFix, { tier, showBadge }));
820
+
821
+ return lines.filter(Boolean).join('\n');
822
+ }
823
+
824
+ // ═══════════════════════════════════════════════════════════════════════════════
825
+ // UTILITIES
826
+ // ═══════════════════════════════════════════════════════════════════════════════
827
+
828
+ function renderProgressBar(percent, width, color) {
829
+ const filled = Math.round((percent / 100) * width);
830
+ const empty = width - filled;
831
+ return `${color}${'█'.repeat(filled)}${ansi.dim}${'░'.repeat(empty)}${ansi.reset}`;
832
+ }
833
+
834
+ function padCenter(str, width) {
835
+ const visibleLen = stripAnsi(str).length;
836
+ const padding = Math.max(0, width - visibleLen);
837
+ const left = Math.floor(padding / 2);
838
+ const right = padding - left;
839
+ return ' '.repeat(left) + str + ' '.repeat(right);
840
+ }
841
+
842
+ function stripAnsi(str) {
843
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
844
+ }
845
+
846
+ // ═══════════════════════════════════════════════════════════════════════════════
847
+ // EXIT CODES
848
+ // ═══════════════════════════════════════════════════════════════════════════════
849
+
850
+ const EXIT_CODES = {
851
+ SHIP: 0,
852
+ WARN: 1,
853
+ BLOCK: 2,
854
+ ERROR: 3,
855
+ };
856
+
857
+ function getExitCode(verdict) {
858
+ return EXIT_CODES[verdict] || EXIT_CODES.ERROR;
859
+ }
860
+
861
+ function verdictFromExitCode(code) {
862
+ const map = { 0: 'SHIP', 1: 'WARN', 2: 'BLOCK', 3: 'ERROR' };
863
+ return map[code] || 'ERROR';
864
+ }
865
+
866
+ // ═══════════════════════════════════════════════════════════════════════════════
867
+ // EXPORTS
868
+ // ═══════════════════════════════════════════════════════════════════════════════
869
+
870
+ module.exports = {
871
+ // Main formatters
872
+ formatShipOutput,
873
+
874
+ // Component renderers
875
+ renderVerdictCard,
876
+ renderProofGraph,
877
+ renderRouteTruthMap,
878
+ renderFindingsBreakdown,
879
+ renderBlockerDetails,
880
+ renderFixModeHeader,
881
+ renderFixResults,
882
+ renderBadgeOutput,
883
+ renderNextSteps,
884
+ renderReportLinks,
885
+ renderAIHallucinationScore,
886
+ renderUpgradePrompts,
887
+
888
+ // AI Hallucination scoring
889
+ calculateAIHallucinationScore,
890
+ AI_HALLUCINATION_CATEGORIES,
891
+
892
+ // Utilities
893
+ getVerdictConfig,
894
+ getCategoryIcon,
895
+ getExitCode,
896
+ verdictFromExitCode,
897
+ EXIT_CODES,
898
+
899
+ // Icons
900
+ shipIcons,
901
+ };