@vibecheckai/cli 3.1.2 โ†’ 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +60 -33
  2. package/bin/registry.js +319 -34
  3. package/bin/runners/CLI_REFACTOR_SUMMARY.md +229 -0
  4. package/bin/runners/REPORT_AUDIT.md +64 -0
  5. package/bin/runners/lib/entitlements-v2.js +97 -28
  6. package/bin/runners/lib/entitlements.js +3 -6
  7. package/bin/runners/lib/init-wizard.js +1 -1
  8. package/bin/runners/lib/report-engine.js +459 -280
  9. package/bin/runners/lib/report-html.js +1154 -1423
  10. package/bin/runners/lib/report-output.js +187 -0
  11. package/bin/runners/lib/report-templates.js +848 -850
  12. package/bin/runners/lib/scan-output.js +545 -0
  13. package/bin/runners/lib/server-usage.js +0 -12
  14. package/bin/runners/lib/ship-output.js +641 -0
  15. package/bin/runners/lib/status-output.js +253 -0
  16. package/bin/runners/lib/terminal-ui.js +853 -0
  17. package/bin/runners/runCheckpoint.js +502 -0
  18. package/bin/runners/runContracts.js +105 -0
  19. package/bin/runners/runExport.js +93 -0
  20. package/bin/runners/runFix.js +31 -24
  21. package/bin/runners/runInit.js +377 -112
  22. package/bin/runners/runInstall.js +1 -5
  23. package/bin/runners/runLabs.js +3 -3
  24. package/bin/runners/runPolish.js +2452 -0
  25. package/bin/runners/runProve.js +2 -2
  26. package/bin/runners/runReport.js +251 -200
  27. package/bin/runners/runRuntime.js +110 -0
  28. package/bin/runners/runScan.js +477 -379
  29. package/bin/runners/runSecurity.js +92 -0
  30. package/bin/runners/runShip.js +137 -207
  31. package/bin/runners/runStatus.js +16 -68
  32. package/bin/runners/utils.js +5 -5
  33. package/bin/vibecheck.js +25 -11
  34. package/mcp-server/index.js +150 -18
  35. package/mcp-server/package.json +2 -2
  36. package/mcp-server/premium-tools.js +13 -13
  37. package/mcp-server/tier-auth.js +292 -27
  38. package/mcp-server/vibecheck-tools.js +9 -9
  39. package/package.json +1 -1
  40. package/bin/runners/runClaimVerifier.js +0 -483
  41. package/bin/runners/runContextCompiler.js +0 -385
  42. package/bin/runners/runGate.js +0 -17
  43. package/bin/runners/runInitGha.js +0 -164
  44. package/bin/runners/runInteractive.js +0 -388
  45. package/bin/runners/runMdc.js +0 -204
  46. package/bin/runners/runMissionGenerator.js +0 -282
  47. package/bin/runners/runTruthpack.js +0 -636
@@ -1,59 +1,99 @@
1
1
  /**
2
- * Report Engine - World-Class Report Generation
2
+ * Report Engine - Enterprise Data Processing
3
3
  *
4
- * Enterprise-grade reporting with:
5
- * - Multiple output formats (HTML, PDF, MD, JSON, SARIF, CSV)
6
- * - Beautiful interactive HTML with Chart.js
7
- * - Executive, Technical, Compliance, and Trend reports
8
- * - Historical analysis and fix time estimates
9
- * - Print-optimized layouts
4
+ * Handles:
5
+ * - Data normalization from multiple scan sources
6
+ * - Trend analysis from historical reports
7
+ * - Export to multiple formats (JSON, SARIF, CSV, Markdown)
8
+ * - Fix time estimation based on severity and type
9
+ * - Category scoring and aggregation
10
10
  */
11
11
 
12
12
  const fs = require("fs");
13
13
  const path = require("path");
14
14
 
15
- // ============================================================================
16
- // REPORT DATA MODEL
17
- // ============================================================================
15
+ // Severity weights for scoring
16
+ const SEVERITY_WEIGHTS = {
17
+ critical: 25,
18
+ high: 15,
19
+ medium: 5,
20
+ low: 1,
21
+ info: 0,
22
+ };
23
+
24
+ // Fix time estimates in minutes by severity
25
+ const FIX_TIME_ESTIMATES = {
26
+ critical: { min: 45, max: 120, avg: 60 },
27
+ high: { min: 30, max: 60, avg: 45 },
28
+ medium: { min: 15, max: 30, avg: 20 },
29
+ low: { min: 5, max: 15, avg: 10 },
30
+ info: { min: 2, max: 10, avg: 5 },
31
+ };
32
+
33
+ // Fix time by finding type
34
+ const TYPE_MULTIPLIERS = {
35
+ secret: 1.5, // Requires key rotation
36
+ auth: 2.0, // Complex logic
37
+ billing: 2.5, // Critical + testing
38
+ injection: 1.5,
39
+ xss: 1.2,
40
+ config: 0.8,
41
+ quality: 0.5,
42
+ mock: 0.6,
43
+ error: 0.7,
44
+ };
18
45
 
19
46
  /**
20
- * Build comprehensive report data from ship results
47
+ * Build comprehensive report data from scan results
21
48
  */
22
- function buildReportData(shipResults, options = {}) {
23
- const findings = shipResults?.findings || [];
24
- const truthpack = shipResults?.truthpack || {};
49
+ function buildReportData(shipResults, opts = {}) {
50
+ const { projectName = "Unknown", repoRoot = ".", includeTrends = false } = opts;
51
+
52
+ // Normalize findings
53
+ const rawFindings = shipResults?.findings || [];
54
+ const findings = rawFindings.map((f, i) => normalizeFinding(f, i));
25
55
 
26
- // Severity counts
56
+ // Calculate severity counts
27
57
  const severityCounts = {
28
- critical: findings.filter(f => f.severity === "BLOCK" || f.severity === "critical").length,
58
+ critical: findings.filter(f => f.severity === "critical").length,
29
59
  high: findings.filter(f => f.severity === "high").length,
30
- medium: findings.filter(f => f.severity === "WARN" || f.severity === "medium").length,
31
- low: findings.filter(f => f.severity === "low" || f.severity === "INFO").length,
60
+ medium: findings.filter(f => f.severity === "medium").length,
61
+ low: findings.filter(f => f.severity === "low").length,
62
+ info: findings.filter(f => f.severity === "info").length,
32
63
  };
33
64
 
34
- // Category scores (invert finding counts to scores)
35
- const categoryFindings = groupFindingsByCategory(findings);
36
- const categoryScores = calculateCategoryScores(categoryFindings);
65
+ // Calculate score
66
+ const score = calculateScore(severityCounts, findings.length);
37
67
 
38
- // Overall score
39
- const score = calculateOverallScore(severityCounts, categoryScores);
68
+ // Determine verdict
69
+ const verdict = determineVerdict(severityCounts, score);
40
70
 
41
- // Verdict
42
- const verdict = severityCounts.critical > 0 ? "BLOCK" :
43
- severityCounts.high > 0 || severityCounts.medium > 5 ? "WARN" : "SHIP";
71
+ // Group by category
72
+ const byCategory = groupByCategory(findings);
73
+ const categoryScores = calculateCategoryScores(byCategory);
44
74
 
45
- // Fix time estimates
46
- const fixEstimates = estimateFixTimes(findings);
75
+ // Calculate fix estimates
76
+ const fixEstimates = calculateFixEstimates(findings);
47
77
 
48
- // Coverage stats
49
- const coverage = extractCoverageStats(truthpack);
78
+ // Process truthpack
79
+ const truthpack = processTruthpack(shipResults?.truthpack);
80
+
81
+ // Process reality data
82
+ const reality = processReality(shipResults?.reality);
83
+
84
+ // Load trends if requested
85
+ let trends = null;
86
+ if (includeTrends) {
87
+ trends = loadTrends(repoRoot);
88
+ }
50
89
 
51
90
  return {
52
91
  meta: {
53
- projectName: options.projectName || truthpack?.project?.name || path.basename(process.cwd()),
92
+ projectName,
54
93
  generatedAt: new Date().toISOString(),
55
94
  version: "2.0.0",
56
95
  reportId: generateReportId(),
96
+ repoRoot,
57
97
  },
58
98
  summary: {
59
99
  score,
@@ -61,294 +101,416 @@ function buildReportData(shipResults, options = {}) {
61
101
  totalFindings: findings.length,
62
102
  severityCounts,
63
103
  categoryScores,
104
+ topBlockers: findings.filter(f => f.severity === "critical").slice(0, 5),
64
105
  },
65
- findings: enrichFindings(findings),
66
- coverage,
106
+ findings,
67
107
  fixEstimates,
68
- truthpack: {
69
- routes: truthpack?.routes?.server?.length || 0,
70
- envVars: truthpack?.env?.vars?.length || 0,
71
- hasAuth: !!(truthpack?.auth?.nextMiddleware?.length || truthpack?.auth?.fastify?.hooks?.length),
72
- hasBilling: !!truthpack?.billing?.webhooks?.length,
73
- },
74
- trends: options.includeTrends ? loadHistoricalTrends(options.repoRoot) : null,
108
+ truthpack,
109
+ reality,
110
+ trends,
75
111
  };
76
112
  }
77
113
 
78
- function groupFindingsByCategory(findings) {
79
- const groups = {
80
- security: [],
81
- auth: [],
82
- billing: [],
83
- routes: [],
84
- env: [],
85
- quality: [],
86
- other: [],
87
- };
88
-
89
- for (const f of findings) {
90
- const cat = categorizeFindings(f);
91
- if (groups[cat]) {
92
- groups[cat].push(f);
93
- } else {
94
- groups.other.push(f);
95
- }
96
- }
114
+ /**
115
+ * Normalize a finding to consistent format
116
+ */
117
+ function normalizeFinding(f, index) {
118
+ const severity = normalizeSeverity(f.severity);
119
+ const type = normalizeType(f.type || f.category);
97
120
 
98
- return groups;
121
+ return {
122
+ id: f.id || `F${String(index + 1).padStart(3, "0")}`,
123
+ severity,
124
+ type,
125
+ title: f.title || f.message || "Unknown issue",
126
+ message: f.message || f.title || "",
127
+ description: f.description || "",
128
+ file: f.file || f.path || null,
129
+ line: f.line || f.lineNumber || null,
130
+ column: f.column || null,
131
+ fix: f.fix || f.recommendation || f.suggestion || null,
132
+ cwe: f.cwe || null,
133
+ references: f.references || [],
134
+ confidence: f.confidence || "high",
135
+ falsePositive: f.falsePositive || false,
136
+ };
99
137
  }
100
138
 
101
- function categorizeFindings(finding) {
102
- const type = (finding.type || finding.id || "").toLowerCase();
103
- const msg = (finding.message || finding.title || "").toLowerCase();
104
-
105
- if (type.includes("auth") || msg.includes("auth") || msg.includes("session")) return "auth";
106
- if (type.includes("billing") || type.includes("stripe") || msg.includes("payment")) return "billing";
107
- if (type.includes("route") || type.includes("endpoint") || msg.includes("route")) return "routes";
108
- if (type.includes("env") || msg.includes("environment")) return "env";
109
- if (type.includes("secret") || type.includes("security") || msg.includes("secret")) return "security";
110
- if (type.includes("mock") || type.includes("fake") || msg.includes("console")) return "quality";
111
- return "other";
139
+ /**
140
+ * Normalize severity to standard values
141
+ */
142
+ function normalizeSeverity(sev) {
143
+ const s = String(sev || "").toLowerCase();
144
+ if (s === "block" || s === "critical" || s === "blocker") return "critical";
145
+ if (s === "high" || s === "error") return "high";
146
+ if (s === "warn" || s === "warning" || s === "medium") return "medium";
147
+ if (s === "info" || s === "low" || s === "note") return "low";
148
+ return "info";
112
149
  }
113
150
 
114
- function calculateCategoryScores(categoryFindings) {
115
- const scores = {};
116
- const weights = {
117
- security: { max: 100, perFinding: 20 },
118
- auth: { max: 100, perFinding: 15 },
119
- billing: { max: 100, perFinding: 15 },
120
- routes: { max: 100, perFinding: 10 },
121
- env: { max: 100, perFinding: 5 },
122
- quality: { max: 100, perFinding: 5 },
151
+ /**
152
+ * Normalize finding type
153
+ */
154
+ function normalizeType(type) {
155
+ const t = String(type || "").toLowerCase();
156
+ const typeMap = {
157
+ secret: ["secret", "credential", "key", "token", "password", "api_key"],
158
+ auth: ["auth", "authentication", "authorization", "session", "jwt"],
159
+ billing: ["billing", "payment", "stripe", "subscription", "checkout"],
160
+ injection: ["injection", "sql", "nosql", "command", "ldap"],
161
+ xss: ["xss", "cross-site", "script"],
162
+ config: ["config", "configuration", "env", "environment"],
163
+ quality: ["quality", "lint", "style", "format"],
164
+ mock: ["mock", "stub", "fake", "dummy", "placeholder"],
165
+ error: ["error", "exception", "catch", "handling"],
166
+ route: ["route", "endpoint", "path", "url"],
123
167
  };
124
168
 
125
- for (const [cat, findings] of Object.entries(categoryFindings)) {
126
- if (cat === "other") continue;
127
- const w = weights[cat] || { max: 100, perFinding: 10 };
128
- scores[cat] = Math.max(0, w.max - (findings.length * w.perFinding));
169
+ for (const [category, keywords] of Object.entries(typeMap)) {
170
+ if (keywords.some(k => t.includes(k))) return category;
129
171
  }
130
-
131
- return scores;
172
+ return "other";
132
173
  }
133
174
 
134
- function calculateOverallScore(severityCounts, categoryScores) {
135
- // Weighted average of category scores with severity penalties
136
- const categoryAvg = Object.values(categoryScores).reduce((a, b) => a + b, 0) /
137
- Math.max(1, Object.keys(categoryScores).length);
175
+ /**
176
+ * Calculate overall score
177
+ */
178
+ function calculateScore(severityCounts, totalFindings) {
179
+ if (totalFindings === 0) return 100;
138
180
 
139
- // Severity penalties
140
- const penalties =
141
- (severityCounts.critical * 25) +
142
- (severityCounts.high * 10) +
143
- (severityCounts.medium * 3) +
144
- (severityCounts.low * 1);
181
+ const deductions =
182
+ severityCounts.critical * SEVERITY_WEIGHTS.critical +
183
+ severityCounts.high * SEVERITY_WEIGHTS.high +
184
+ severityCounts.medium * SEVERITY_WEIGHTS.medium +
185
+ severityCounts.low * SEVERITY_WEIGHTS.low;
145
186
 
146
- return Math.max(0, Math.min(100, Math.round(categoryAvg - penalties)));
187
+ // Cap deductions at 100
188
+ const score = Math.max(0, 100 - deductions);
189
+ return Math.round(score);
147
190
  }
148
191
 
149
- function enrichFindings(findings) {
150
- return findings.map((f, idx) => ({
151
- id: f.id || `F${String(idx + 1).padStart(3, "0")}`,
152
- severity: normalizeSeverity(f.severity),
153
- category: categorizeFindings(f),
154
- title: f.title || f.message || "Finding",
155
- description: f.description || f.message || "",
156
- file: f.file || f.evidence?.file || null,
157
- line: f.line || f.evidence?.line || null,
158
- fix: f.fix || f.recommendation || null,
159
- fixTime: estimateSingleFixTime(f),
160
- evidence: f.evidence || null,
161
- }));
192
+ /**
193
+ * Determine verdict from score and findings
194
+ */
195
+ function determineVerdict(severityCounts, score) {
196
+ if (severityCounts.critical > 0) return "BLOCK";
197
+ if (severityCounts.high > 2 || score < 60) return "BLOCK";
198
+ if (severityCounts.high > 0 || severityCounts.medium > 5 || score < 80) return "WARN";
199
+ return "SHIP";
162
200
  }
163
201
 
164
- function normalizeSeverity(sev) {
165
- const s = String(sev || "medium").toLowerCase();
166
- if (s === "block" || s === "critical") return "critical";
167
- if (s === "high") return "high";
168
- if (s === "warn" || s === "warning" || s === "medium") return "medium";
169
- return "low";
202
+ /**
203
+ * Group findings by category
204
+ */
205
+ function groupByCategory(findings) {
206
+ const groups = {};
207
+ for (const f of findings) {
208
+ const cat = f.type || "other";
209
+ if (!groups[cat]) groups[cat] = [];
210
+ groups[cat].push(f);
211
+ }
212
+ return groups;
170
213
  }
171
214
 
172
- function estimateFixTimes(findings) {
173
- const times = {
174
- critical: { count: 0, totalMinutes: 0 },
175
- high: { count: 0, totalMinutes: 0 },
176
- medium: { count: 0, totalMinutes: 0 },
177
- low: { count: 0, totalMinutes: 0 },
178
- };
215
+ /**
216
+ * Calculate category scores
217
+ */
218
+ function calculateCategoryScores(byCategory) {
219
+ const scores = {};
179
220
 
180
- for (const f of findings) {
181
- const sev = normalizeSeverity(f.severity);
182
- const mins = estimateSingleFixTime(f);
183
- if (times[sev]) {
184
- times[sev].count++;
185
- times[sev].totalMinutes += mins;
186
- }
221
+ for (const [cat, findings] of Object.entries(byCategory)) {
222
+ const deductions = findings.reduce((sum, f) => {
223
+ return sum + (SEVERITY_WEIGHTS[f.severity] || 0);
224
+ }, 0);
225
+
226
+ scores[cat] = Math.max(0, Math.min(100, 100 - deductions));
187
227
  }
188
228
 
189
- const totalMinutes = Object.values(times).reduce((a, t) => a + t.totalMinutes, 0);
190
-
191
- return {
192
- bySeverity: times,
193
- totalMinutes,
194
- totalHours: Math.round(totalMinutes / 60 * 10) / 10,
195
- humanReadable: formatDuration(totalMinutes),
196
- };
229
+ return scores;
197
230
  }
198
231
 
199
- function estimateSingleFixTime(finding) {
200
- const sev = normalizeSeverity(finding.severity);
201
- const type = (finding.type || finding.id || "").toLowerCase();
232
+ /**
233
+ * Calculate fix time estimates
234
+ */
235
+ function calculateFixEstimates(findings) {
236
+ let totalMin = 0;
237
+ let totalMax = 0;
238
+ let totalAvg = 0;
202
239
 
203
- // Base times by severity
204
- const baseTimes = { critical: 60, high: 30, medium: 15, low: 5 };
205
- let mins = baseTimes[sev] || 15;
240
+ const bySeverity = {};
206
241
 
207
- // Adjustments by type
208
- if (type.includes("auth") || type.includes("security")) mins *= 1.5;
209
- if (type.includes("mock") || type.includes("console")) mins *= 0.5;
242
+ for (const f of findings) {
243
+ const sev = f.severity;
244
+ const type = f.type;
245
+ const base = FIX_TIME_ESTIMATES[sev] || FIX_TIME_ESTIMATES.medium;
246
+ const multiplier = TYPE_MULTIPLIERS[type] || 1.0;
247
+
248
+ totalMin += Math.round(base.min * multiplier);
249
+ totalMax += Math.round(base.max * multiplier);
250
+ totalAvg += Math.round(base.avg * multiplier);
251
+
252
+ if (!bySeverity[sev]) bySeverity[sev] = { count: 0, minutes: 0 };
253
+ bySeverity[sev].count++;
254
+ bySeverity[sev].minutes += Math.round(base.avg * multiplier);
255
+ }
210
256
 
211
- return Math.round(mins);
257
+ return {
258
+ totalMinutes: totalAvg,
259
+ range: { min: totalMin, max: totalMax },
260
+ humanReadable: formatDuration(totalAvg),
261
+ rangeReadable: `${formatDuration(totalMin)} - ${formatDuration(totalMax)}`,
262
+ bySeverity,
263
+ };
212
264
  }
213
265
 
266
+ /**
267
+ * Format duration in minutes to human readable
268
+ */
214
269
  function formatDuration(minutes) {
215
- if (minutes < 60) return `${minutes} min`;
216
- const hours = Math.floor(minutes / 60);
217
- const mins = minutes % 60;
218
- if (mins === 0) return `${hours}h`;
219
- return `${hours}h ${mins}m`;
270
+ if (minutes < 60) return `${minutes}m`;
271
+ const hours = Math.round(minutes / 60 * 10) / 10;
272
+ if (hours < 8) return `${hours}h`;
273
+ const days = Math.round(hours / 8 * 10) / 10;
274
+ return `${days}d`;
220
275
  }
221
276
 
222
- function extractCoverageStats(truthpack) {
277
+ /**
278
+ * Process truthpack data
279
+ */
280
+ function processTruthpack(truthpack) {
281
+ if (!truthpack) return null;
282
+
223
283
  return {
224
- routes: {
225
- total: truthpack?.routes?.server?.length || 0,
226
- protected: truthpack?.routes?.server?.filter(r => r.protected)?.length || 0,
227
- tested: truthpack?.routes?.coverage?.hit || 0,
228
- },
229
- env: {
230
- total: truthpack?.env?.vars?.length || 0,
231
- declared: truthpack?.env?.declared?.length || 0,
232
- },
233
- auth: {
234
- middlewareCount: truthpack?.auth?.nextMiddleware?.length || 0,
235
- patterns: truthpack?.auth?.nextMatcherPatterns?.length || 0,
284
+ routes: Array.isArray(truthpack.routes?.server) ? truthpack.routes.server.length : 0,
285
+ envVars: Array.isArray(truthpack.env?.vars) ? truthpack.env.vars.length : 0,
286
+ hasAuth: !!(truthpack.auth?.nextMiddleware?.length || truthpack.auth?.middleware?.length),
287
+ hasBilling: !!(truthpack.billing?.webhooks?.length || truthpack.billing?.stripeConfig),
288
+ rawCounts: {
289
+ serverRoutes: truthpack.routes?.server?.length || 0,
290
+ clientRoutes: truthpack.routes?.client?.length || 0,
291
+ envVars: truthpack.env?.vars?.length || 0,
292
+ authMiddleware: truthpack.auth?.nextMiddleware?.length || truthpack.auth?.middleware?.length || 0,
293
+ webhooks: truthpack.billing?.webhooks?.length || 0,
236
294
  },
237
295
  };
238
296
  }
239
297
 
240
- function generateReportId() {
241
- const date = new Date().toISOString().split("T")[0].replace(/-/g, "");
242
- const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
243
- return `VC-${date}-${rand}`;
298
+ /**
299
+ * Process reality mode data
300
+ */
301
+ function processReality(reality) {
302
+ if (!reality) return null;
303
+
304
+ return {
305
+ coverage: reality.coverage || {},
306
+ brokenFlows: reality.brokenFlows || [],
307
+ unmappedRequests: reality.unmappedRequests || [],
308
+ latencyP95: reality.latencyP95 || null,
309
+ latencySparkline: reality.latencySparkline || [],
310
+ requestsOverTime: reality.requestsOverTime || [],
311
+ totalRequests: reality.totalRequests || 0,
312
+ };
244
313
  }
245
314
 
246
- function loadHistoricalTrends(repoRoot) {
247
- // Try to load previous reports for trend analysis
248
- const historyPath = path.join(repoRoot || process.cwd(), ".vibecheck", "history");
249
- if (!fs.existsSync(historyPath)) return null;
315
+ /**
316
+ * Load historical trends
317
+ */
318
+ function loadTrends(repoRoot) {
319
+ const historyDir = path.join(repoRoot, ".vibecheck", "history");
320
+ if (!fs.existsSync(historyDir)) return null;
250
321
 
251
322
  try {
252
- const files = fs.readdirSync(historyPath)
323
+ const files = fs.readdirSync(historyDir)
253
324
  .filter(f => f.endsWith(".json"))
254
325
  .sort()
255
- .slice(-10);
326
+ .slice(-30); // Last 30 entries
327
+
328
+ const dataPoints = files.map(f => {
329
+ try {
330
+ const data = JSON.parse(fs.readFileSync(path.join(historyDir, f), "utf8"));
331
+ return {
332
+ date: f.replace(".json", ""),
333
+ score: data.summary?.score || 0,
334
+ findings: data.summary?.totalFindings || 0,
335
+ critical: data.summary?.severityCounts?.critical || 0,
336
+ };
337
+ } catch {
338
+ return null;
339
+ }
340
+ }).filter(Boolean);
341
+
342
+ if (dataPoints.length === 0) return null;
343
+
344
+ // Calculate trend
345
+ const scores = dataPoints.map(d => d.score);
346
+ const trend = scores.length >= 2 ? scores[scores.length - 1] - scores[0] : 0;
256
347
 
257
- return files.map(f => {
258
- const data = JSON.parse(fs.readFileSync(path.join(historyPath, f), "utf8"));
259
- return {
260
- date: data.meta?.generatedAt || f.replace(".json", ""),
261
- score: data.summary?.score || 0,
262
- findings: data.summary?.totalFindings || 0,
263
- };
264
- });
348
+ return {
349
+ dataPoints,
350
+ trend,
351
+ trendDirection: trend > 0 ? "improving" : trend < 0 ? "declining" : "stable",
352
+ averageScore: Math.round(scores.reduce((a, b) => a + b, 0) / scores.length),
353
+ };
265
354
  } catch {
266
355
  return null;
267
356
  }
268
357
  }
269
358
 
359
+ /**
360
+ * Generate unique report ID
361
+ */
362
+ function generateReportId() {
363
+ const timestamp = Date.now().toString(36).toUpperCase();
364
+ const random = Math.random().toString(36).substring(2, 6).toUpperCase();
365
+ return `VC-${timestamp}-${random}`;
366
+ }
367
+
270
368
  // ============================================================================
271
369
  // EXPORT FORMATS
272
370
  // ============================================================================
273
371
 
274
372
  /**
275
- * Export to SARIF format (Static Analysis Results Interchange Format)
373
+ * Export to JSON
374
+ */
375
+ function exportToJSON(reportData) {
376
+ return JSON.stringify(reportData, null, 2);
377
+ }
378
+
379
+ /**
380
+ * Export to SARIF format
276
381
  */
277
382
  function exportToSARIF(reportData) {
383
+ const rules = new Map();
384
+ const results = [];
385
+
386
+ for (const f of reportData.findings) {
387
+ // Build rule if not exists
388
+ if (!rules.has(f.id)) {
389
+ rules.set(f.id, {
390
+ id: f.id,
391
+ name: f.type ? `${f.type}/${f.id}` : f.id,
392
+ shortDescription: { text: f.title },
393
+ fullDescription: { text: f.description || f.message },
394
+ defaultConfiguration: {
395
+ level: sarifLevel(f.severity),
396
+ },
397
+ helpUri: f.references?.[0] || "https://vibecheck.dev/docs",
398
+ properties: {
399
+ category: f.type,
400
+ cwe: f.cwe,
401
+ },
402
+ });
403
+ }
404
+
405
+ // Build result
406
+ const result = {
407
+ ruleId: f.id,
408
+ level: sarifLevel(f.severity),
409
+ message: { text: f.message || f.title },
410
+ locations: [],
411
+ };
412
+
413
+ if (f.file) {
414
+ result.locations.push({
415
+ physicalLocation: {
416
+ artifactLocation: { uri: f.file },
417
+ region: f.line ? { startLine: f.line, startColumn: f.column || 1 } : undefined,
418
+ },
419
+ });
420
+ }
421
+
422
+ if (f.fix) {
423
+ result.fixes = [{
424
+ description: { text: f.fix },
425
+ }];
426
+ }
427
+
428
+ results.push(result);
429
+ }
430
+
278
431
  return {
279
432
  $schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
280
433
  version: "2.1.0",
281
434
  runs: [{
282
435
  tool: {
283
436
  driver: {
284
- name: "vibecheck",
437
+ name: "VibeCheck",
285
438
  version: reportData.meta.version,
286
439
  informationUri: "https://vibecheck.dev",
287
- rules: generateSARIFRules(reportData.findings),
440
+ rules: Array.from(rules.values()),
288
441
  },
289
442
  },
290
- results: reportData.findings.map(f => ({
291
- ruleId: f.id,
292
- level: sarifLevel(f.severity),
293
- message: { text: f.title },
294
- locations: f.file ? [{
295
- physicalLocation: {
296
- artifactLocation: { uri: f.file },
297
- region: f.line ? { startLine: f.line } : undefined,
298
- },
299
- }] : [],
300
- })),
443
+ results,
444
+ invocations: [{
445
+ executionSuccessful: true,
446
+ endTimeUtc: reportData.meta.generatedAt,
447
+ }],
301
448
  }],
302
449
  };
303
450
  }
304
451
 
305
- function generateSARIFRules(findings) {
306
- const seen = new Set();
307
- return findings
308
- .filter(f => {
309
- if (seen.has(f.id)) return false;
310
- seen.add(f.id);
311
- return true;
312
- })
313
- .map(f => ({
314
- id: f.id,
315
- shortDescription: { text: f.title },
316
- fullDescription: { text: f.description || f.title },
317
- defaultConfiguration: { level: sarifLevel(f.severity) },
318
- }));
319
- }
320
-
452
+ /**
453
+ * Convert severity to SARIF level
454
+ */
321
455
  function sarifLevel(severity) {
322
- const levels = { critical: "error", high: "error", medium: "warning", low: "note" };
456
+ const levels = { critical: "error", high: "error", medium: "warning", low: "note", info: "none" };
323
457
  return levels[severity] || "warning";
324
458
  }
325
459
 
326
460
  /**
327
- * Export to CSV format
461
+ * Export to CSV
328
462
  */
329
463
  function exportToCSV(reportData) {
330
- const headers = ["ID", "Severity", "Category", "Title", "File", "Line", "Fix Time (min)"];
464
+ const headers = [
465
+ "ID",
466
+ "Severity",
467
+ "Type",
468
+ "Title",
469
+ "File",
470
+ "Line",
471
+ "Fix",
472
+ "CWE",
473
+ "Confidence",
474
+ ];
475
+
331
476
  const rows = reportData.findings.map(f => [
332
477
  f.id,
333
478
  f.severity,
334
- f.category,
335
- `"${(f.title || "").replace(/"/g, '""')}"`,
479
+ f.type,
480
+ csvEscape(f.title),
336
481
  f.file || "",
337
482
  f.line || "",
338
- f.fixTime || "",
483
+ csvEscape(f.fix || ""),
484
+ f.cwe || "",
485
+ f.confidence,
339
486
  ]);
340
487
 
341
- return [headers.join(","), ...rows.map(r => r.join(","))].join("\n");
488
+ return [
489
+ headers.join(","),
490
+ ...rows.map(r => r.join(",")),
491
+ ].join("\n");
342
492
  }
343
493
 
344
494
  /**
345
- * Export to Markdown format
495
+ * Escape CSV value
346
496
  */
347
- function exportToMarkdown(reportData, options = {}) {
348
- const { meta, summary, findings, fixEstimates, coverage } = reportData;
349
- const verdictEmoji = summary.verdict === "SHIP" ? "โœ…" : summary.verdict === "WARN" ? "โš ๏ธ" : "๐Ÿšซ";
497
+ function csvEscape(val) {
498
+ if (!val) return "";
499
+ const str = String(val);
500
+ if (str.includes(",") || str.includes('"') || str.includes("\n")) {
501
+ return `"${str.replace(/"/g, '""')}"`;
502
+ }
503
+ return str;
504
+ }
505
+
506
+ /**
507
+ * Export to Markdown
508
+ */
509
+ function exportToMarkdown(reportData, opts = {}) {
510
+ const { meta, summary, findings, fixEstimates, reality } = reportData;
511
+ const redactPaths = opts.redactPaths || false;
350
512
 
351
- let md = `# ${options.title || "Vibecheck Report"}
513
+ let md = `# VibeCheck Report
352
514
 
353
515
  **Project:** ${meta.projectName}
354
516
  **Generated:** ${new Date(meta.generatedAt).toLocaleString()}
@@ -360,25 +522,14 @@ function exportToMarkdown(reportData, options = {}) {
360
522
 
361
523
  | Metric | Value |
362
524
  |--------|-------|
363
- | **Score** | ${summary.score}/100 |
364
- | **Verdict** | ${verdictEmoji} ${summary.verdict} |
365
- | **Total Findings** | ${summary.totalFindings} |
366
- | **Est. Fix Time** | ${fixEstimates.humanReadable} |
367
-
368
- ### Severity Breakdown
369
-
370
- | Severity | Count |
371
- |----------|-------|
372
- | ๐Ÿ”ด Critical | ${summary.severityCounts.critical} |
373
- | ๐ŸŸ  High | ${summary.severityCounts.high} |
374
- | ๐ŸŸก Medium | ${summary.severityCounts.medium} |
375
- | โšช Low | ${summary.severityCounts.low} |
376
-
377
- ### Category Scores
378
-
379
- | Category | Score |
380
- |----------|-------|
381
- ${Object.entries(summary.categoryScores).map(([k, v]) => `| ${formatCategoryName(k)} | ${v}% |`).join("\n")}
525
+ | Score | **${summary.score}/100** |
526
+ | Verdict | **${summary.verdict}** |
527
+ | Total Findings | ${summary.totalFindings} |
528
+ | Critical | ${summary.severityCounts.critical} |
529
+ | High | ${summary.severityCounts.high} |
530
+ | Medium | ${summary.severityCounts.medium} |
531
+ | Low | ${summary.severityCounts.low} |
532
+ | Est. Fix Time | ${fixEstimates?.humanReadable || 'N/A'} |
382
533
 
383
534
  ---
384
535
 
@@ -387,61 +538,89 @@ ${Object.entries(summary.categoryScores).map(([k, v]) => `| ${formatCategoryName
387
538
  `;
388
539
 
389
540
  // Group by severity
390
- const bySeverity = { critical: [], high: [], medium: [], low: [] };
391
- for (const f of findings) {
392
- if (bySeverity[f.severity]) bySeverity[f.severity].push(f);
393
- }
541
+ const bySeverity = {
542
+ critical: findings.filter(f => f.severity === "critical"),
543
+ high: findings.filter(f => f.severity === "high"),
544
+ medium: findings.filter(f => f.severity === "medium"),
545
+ low: findings.filter(f => f.severity === "low"),
546
+ };
394
547
 
395
548
  for (const [sev, items] of Object.entries(bySeverity)) {
396
549
  if (items.length === 0) continue;
397
- md += `### ${sev.charAt(0).toUpperCase() + sev.slice(1)} (${items.length})\n\n`;
398
- for (const f of items.slice(0, options.maxFindings || 20)) {
399
- md += `#### ${f.id}: ${f.title}\n`;
400
- if (f.file) md += `- **File:** \`${f.file}${f.line ? `:${f.line}` : ""}\`\n`;
401
- if (f.fix) md += `- **Fix:** ${f.fix}\n`;
402
- md += `\n`;
550
+
551
+ md += `### ${sev.toUpperCase()} (${items.length})\n\n`;
552
+
553
+ for (const f of items.slice(0, 20)) {
554
+ const file = redactPaths && f.file ? redactPath(f.file) : f.file;
555
+ md += `- **${f.title}**\n`;
556
+ if (file) md += ` - File: \`${file}${f.line ? `:${f.line}` : ''}\`\n`;
557
+ if (f.fix) md += ` - Fix: ${f.fix}\n`;
558
+ md += "\n";
559
+ }
560
+
561
+ if (items.length > 20) {
562
+ md += `_...and ${items.length - 20} more ${sev} findings_\n\n`;
403
563
  }
404
564
  }
405
565
 
406
- md += `---
407
-
408
- *Generated by [Vibecheck](https://vibecheck.dev) ยท ${meta.reportId}*
409
- `;
566
+ // Reality section if available
567
+ if (reality && (reality.brokenFlows?.length > 0 || Object.keys(reality.coverage).length > 0)) {
568
+ md += `---\n\n## Reality Mode\n\n`;
569
+
570
+ if (Object.keys(reality.coverage).length > 0) {
571
+ md += `### Coverage\n\n`;
572
+ for (const [key, val] of Object.entries(reality.coverage)) {
573
+ md += `- ${formatCoverageKey(key)}: **${val}%**\n`;
574
+ }
575
+ md += "\n";
576
+ }
577
+
578
+ if (reality.brokenFlows?.length > 0) {
579
+ md += `### Broken Flows (${reality.brokenFlows.length})\n\n`;
580
+ for (const flow of reality.brokenFlows.slice(0, 5)) {
581
+ md += `- **${flow.title}** (${flow.severity})\n`;
582
+ if (flow.steps) {
583
+ md += ` - Steps: ${flow.steps.map(s => s.label).join(' โ†’ ')}\n`;
584
+ }
585
+ }
586
+ md += "\n";
587
+ }
588
+ }
589
+
590
+ md += `---\n\n*Generated by [VibeCheck](https://vibecheck.dev)*\n`;
410
591
 
411
592
  return md;
412
593
  }
413
594
 
414
595
  /**
415
- * Export to JSON format
596
+ * Helper: redact file path
416
597
  */
417
- function exportToJSON(reportData) {
418
- return JSON.stringify(reportData, null, 2);
598
+ function redactPath(p) {
599
+ if (!p) return p;
600
+ const parts = p.split("/");
601
+ if (parts.length <= 2) return p;
602
+ return ".../" + parts.slice(-2).join("/");
419
603
  }
420
604
 
421
- function formatCategoryName(name) {
422
- const names = {
423
- security: "Security",
424
- auth: "Authentication",
425
- billing: "Billing/Payments",
426
- routes: "Route Integrity",
427
- env: "Environment",
428
- quality: "Code Quality",
429
- };
430
- return names[name] || name.charAt(0).toUpperCase() + name.slice(1);
605
+ /**
606
+ * Helper: format coverage key
607
+ */
608
+ function formatCoverageKey(key) {
609
+ return key
610
+ .replace(/([A-Z])/g, " $1")
611
+ .replace(/^./, s => s.toUpperCase())
612
+ .trim();
431
613
  }
432
614
 
433
- // ============================================================================
434
- // EXPORTS
435
- // ============================================================================
436
-
437
615
  module.exports = {
438
616
  buildReportData,
617
+ exportToJSON,
439
618
  exportToSARIF,
440
619
  exportToCSV,
441
620
  exportToMarkdown,
442
- exportToJSON,
443
- formatCategoryName,
621
+ // Expose helpers for testing
622
+ normalizeFinding,
444
623
  normalizeSeverity,
445
- estimateFixTimes,
446
- generateReportId,
624
+ calculateScore,
625
+ calculateFixEstimates,
447
626
  };