@vibecheckai/cli 3.1.0 → 3.1.2

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 (160) hide show
  1. package/bin/.generated +25 -25
  2. package/bin/dev/run-v2-torture.js +30 -30
  3. package/bin/registry.js +105 -105
  4. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -295
  5. package/bin/runners/lib/analysis-core.js +271 -271
  6. package/bin/runners/lib/analyzers.js +579 -579
  7. package/bin/runners/lib/auth-truth.js +193 -193
  8. package/bin/runners/lib/backup.js +62 -62
  9. package/bin/runners/lib/billing.js +107 -107
  10. package/bin/runners/lib/claims.js +118 -118
  11. package/bin/runners/lib/cli-output.js +368 -368
  12. package/bin/runners/lib/cli-ui.js +540 -540
  13. package/bin/runners/lib/contracts/auth-contract.js +202 -202
  14. package/bin/runners/lib/contracts/env-contract.js +181 -181
  15. package/bin/runners/lib/contracts/external-contract.js +206 -206
  16. package/bin/runners/lib/contracts/guard.js +168 -168
  17. package/bin/runners/lib/contracts/index.js +89 -89
  18. package/bin/runners/lib/contracts/plan-validator.js +311 -311
  19. package/bin/runners/lib/contracts/route-contract.js +199 -199
  20. package/bin/runners/lib/contracts.js +804 -804
  21. package/bin/runners/lib/detect.js +89 -89
  22. package/bin/runners/lib/detectors-v2.js +703 -703
  23. package/bin/runners/lib/doctor/autofix.js +254 -254
  24. package/bin/runners/lib/doctor/index.js +37 -37
  25. package/bin/runners/lib/doctor/modules/dependencies.js +325 -325
  26. package/bin/runners/lib/doctor/modules/index.js +46 -46
  27. package/bin/runners/lib/doctor/modules/network.js +250 -250
  28. package/bin/runners/lib/doctor/modules/project.js +312 -312
  29. package/bin/runners/lib/doctor/modules/runtime.js +224 -224
  30. package/bin/runners/lib/doctor/modules/security.js +348 -348
  31. package/bin/runners/lib/doctor/modules/system.js +213 -213
  32. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -394
  33. package/bin/runners/lib/doctor/reporter.js +262 -262
  34. package/bin/runners/lib/doctor/service.js +262 -262
  35. package/bin/runners/lib/doctor/types.js +113 -113
  36. package/bin/runners/lib/doctor/ui.js +263 -263
  37. package/bin/runners/lib/doctor-v2.js +608 -608
  38. package/bin/runners/lib/drift.js +425 -425
  39. package/bin/runners/lib/enforcement.js +72 -72
  40. package/bin/runners/lib/enterprise-detect.js +603 -603
  41. package/bin/runners/lib/enterprise-init.js +942 -942
  42. package/bin/runners/lib/entitlements-v2.js +490 -489
  43. package/bin/runners/lib/entitlements.js +6 -3
  44. package/bin/runners/lib/env-resolver.js +417 -417
  45. package/bin/runners/lib/env-template.js +66 -66
  46. package/bin/runners/lib/env.js +189 -189
  47. package/bin/runners/lib/extractors/client-calls.js +990 -990
  48. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -573
  49. package/bin/runners/lib/extractors/fastify-routes.js +426 -426
  50. package/bin/runners/lib/extractors/index.js +363 -363
  51. package/bin/runners/lib/extractors/next-routes.js +524 -524
  52. package/bin/runners/lib/extractors/proof-graph.js +431 -431
  53. package/bin/runners/lib/extractors/route-matcher.js +451 -451
  54. package/bin/runners/lib/extractors/truthpack-v2.js +377 -377
  55. package/bin/runners/lib/extractors/ui-bindings.js +547 -547
  56. package/bin/runners/lib/findings-schema.js +281 -281
  57. package/bin/runners/lib/firewall-prompt.js +50 -50
  58. package/bin/runners/lib/graph/graph-builder.js +265 -265
  59. package/bin/runners/lib/graph/html-renderer.js +413 -413
  60. package/bin/runners/lib/graph/index.js +32 -32
  61. package/bin/runners/lib/graph/runtime-collector.js +215 -215
  62. package/bin/runners/lib/graph/static-extractor.js +518 -518
  63. package/bin/runners/lib/html-report.js +650 -650
  64. package/bin/runners/lib/init-wizard.js +308 -308
  65. package/bin/runners/lib/llm.js +75 -75
  66. package/bin/runners/lib/meter.js +61 -61
  67. package/bin/runners/lib/missions/evidence.js +126 -126
  68. package/bin/runners/lib/missions/plan.js +69 -69
  69. package/bin/runners/lib/missions/templates.js +192 -192
  70. package/bin/runners/lib/patch.js +40 -40
  71. package/bin/runners/lib/permissions/auth-model.js +213 -213
  72. package/bin/runners/lib/permissions/idor-prover.js +205 -205
  73. package/bin/runners/lib/permissions/index.js +45 -45
  74. package/bin/runners/lib/permissions/matrix-builder.js +198 -198
  75. package/bin/runners/lib/pkgjson.js +28 -28
  76. package/bin/runners/lib/policy.js +295 -295
  77. package/bin/runners/lib/preflight.js +142 -142
  78. package/bin/runners/lib/reality/correlation-detectors.js +359 -359
  79. package/bin/runners/lib/reality/index.js +318 -318
  80. package/bin/runners/lib/reality/request-hashing.js +416 -416
  81. package/bin/runners/lib/reality/request-mapper.js +453 -453
  82. package/bin/runners/lib/reality/safety-rails.js +463 -463
  83. package/bin/runners/lib/reality/semantic-snapshot.js +408 -408
  84. package/bin/runners/lib/reality/toast-detector.js +393 -393
  85. package/bin/runners/lib/reality-findings.js +84 -84
  86. package/bin/runners/lib/receipts.js +179 -179
  87. package/bin/runners/lib/redact.js +29 -29
  88. package/bin/runners/lib/replay/capsule-manager.js +154 -154
  89. package/bin/runners/lib/replay/index.js +263 -263
  90. package/bin/runners/lib/replay/player.js +348 -348
  91. package/bin/runners/lib/replay/recorder.js +331 -331
  92. package/bin/runners/lib/report-engine.js +447 -447
  93. package/bin/runners/lib/report-html.js +1499 -1499
  94. package/bin/runners/lib/report-templates.js +969 -969
  95. package/bin/runners/lib/report.js +135 -135
  96. package/bin/runners/lib/route-detection.js +1140 -1140
  97. package/bin/runners/lib/route-truth.js +477 -477
  98. package/bin/runners/lib/sandbox/index.js +59 -59
  99. package/bin/runners/lib/sandbox/proof-chain.js +399 -399
  100. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -205
  101. package/bin/runners/lib/sandbox/worktree.js +174 -174
  102. package/bin/runners/lib/schema-validator.js +350 -350
  103. package/bin/runners/lib/schemas/contracts.schema.json +160 -160
  104. package/bin/runners/lib/schemas/finding.schema.json +100 -100
  105. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -206
  106. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -176
  107. package/bin/runners/lib/schemas/reality-report.schema.json +162 -162
  108. package/bin/runners/lib/schemas/share-pack.schema.json +180 -180
  109. package/bin/runners/lib/schemas/ship-report.schema.json +117 -117
  110. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -303
  111. package/bin/runners/lib/schemas/validator.js +438 -438
  112. package/bin/runners/lib/score-history.js +282 -282
  113. package/bin/runners/lib/server-usage.js +12 -0
  114. package/bin/runners/lib/share-pack.js +239 -239
  115. package/bin/runners/lib/snippets.js +67 -67
  116. package/bin/runners/lib/truth.js +667 -667
  117. package/bin/runners/lib/upsell.js +510 -510
  118. package/bin/runners/lib/usage.js +153 -153
  119. package/bin/runners/lib/validate-patch.js +156 -156
  120. package/bin/runners/lib/verdict-engine.js +628 -628
  121. package/bin/runners/reality/engine.js +917 -917
  122. package/bin/runners/reality/flows.js +122 -122
  123. package/bin/runners/reality/report.js +378 -378
  124. package/bin/runners/reality/session.js +193 -193
  125. package/bin/runners/runAuth.js +51 -0
  126. package/bin/runners/runClaimVerifier.js +483 -483
  127. package/bin/runners/runContext.js +56 -56
  128. package/bin/runners/runContextCompiler.js +385 -385
  129. package/bin/runners/runCtx.js +674 -674
  130. package/bin/runners/runCtxDiff.js +301 -301
  131. package/bin/runners/runCtxGuard.js +176 -176
  132. package/bin/runners/runCtxSync.js +116 -116
  133. package/bin/runners/runGate.js +17 -17
  134. package/bin/runners/runGraph.js +454 -454
  135. package/bin/runners/runGuard.js +168 -168
  136. package/bin/runners/runInitGha.js +164 -164
  137. package/bin/runners/runInstall.js +277 -277
  138. package/bin/runners/runInteractive.js +388 -388
  139. package/bin/runners/runLabs.js +340 -340
  140. package/bin/runners/runMissionGenerator.js +282 -282
  141. package/bin/runners/runPR.js +255 -255
  142. package/bin/runners/runPermissions.js +304 -304
  143. package/bin/runners/runPreflight.js +580 -553
  144. package/bin/runners/runProve.js +1252 -1252
  145. package/bin/runners/runReality.js +1328 -1328
  146. package/bin/runners/runReplay.js +499 -499
  147. package/bin/runners/runReport.js +584 -584
  148. package/bin/runners/runShare.js +212 -212
  149. package/bin/runners/runStatus.js +138 -138
  150. package/bin/runners/runTruthpack.js +636 -636
  151. package/bin/runners/runVerify.js +272 -272
  152. package/bin/runners/runWatch.js +407 -407
  153. package/bin/vibecheck.js +2 -1
  154. package/mcp-server/consolidated-tools.js +804 -804
  155. package/mcp-server/package.json +1 -1
  156. package/mcp-server/tools/index.js +72 -72
  157. package/mcp-server/truth-context.js +581 -581
  158. package/mcp-server/truth-firewall-tools.js +1500 -1500
  159. package/package.json +1 -1
  160. package/bin/runners/runProof.zip +0 -0
@@ -1,628 +1,628 @@
1
- /**
2
- * Verdict Engine v2
3
- *
4
- * Merges all findings from detectors, computes verdict, generates ship report.
5
- * Single source of truth for SHIP/WARN/BLOCK decisions.
6
- */
7
-
8
- "use strict";
9
-
10
- const fs = require("fs");
11
- const path = require("path");
12
- const crypto = require("crypto");
13
-
14
- const { createFinding, getSeverity, generateFingerprint } = require("./schemas/validator");
15
-
16
- // =============================================================================
17
- // VERDICT COMPUTATION
18
- // =============================================================================
19
-
20
- // Coverage thresholds defaults
21
- const DEFAULT_COVERAGE_THRESHOLDS = {
22
- minActionCoverage: 0, // --min-action-coverage
23
- minRouteCoverage: 0, // --min-route-coverage
24
- requireAuthCoverage: false, // --require-auth-coverage
25
- minAuthCoverage: 80, // Minimum % when requireAuthCoverage is true
26
- };
27
-
28
- /**
29
- * Compute verdict from findings
30
- */
31
- function computeVerdict(findings, options = {}) {
32
- const {
33
- failOnWarn = false,
34
- coverage = null,
35
- thresholds = {},
36
- } = options;
37
-
38
- const effectiveThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS, ...thresholds };
39
-
40
- const stats = {
41
- total: findings.length,
42
- bySeverity: { BLOCK: 0, WARN: 0, INFO: 0 },
43
- byCategory: {},
44
- byScope: {},
45
- };
46
-
47
- const blockReasons = [];
48
-
49
- for (const f of findings) {
50
- // Count by severity
51
- stats.bySeverity[f.severity] = (stats.bySeverity[f.severity] || 0) + 1;
52
-
53
- // Count by category
54
- stats.byCategory[f.category] = (stats.byCategory[f.category] || 0) + 1;
55
-
56
- // Count by scope
57
- stats.byScope[f.scope] = (stats.byScope[f.scope] || 0) + 1;
58
-
59
- // Collect block reasons
60
- if (f.severity === "BLOCK") {
61
- blockReasons.push(f.title);
62
- }
63
- }
64
-
65
- // Check coverage thresholds
66
- const coverageViolations = [];
67
- if (coverage) {
68
- if (effectiveThresholds.minActionCoverage > 0 &&
69
- coverage.uiActionsVerifiedPct < effectiveThresholds.minActionCoverage) {
70
- coverageViolations.push(
71
- `UI action coverage ${coverage.uiActionsVerifiedPct.toFixed(0)}% < ${effectiveThresholds.minActionCoverage}%`
72
- );
73
- }
74
- if (effectiveThresholds.minRouteCoverage > 0 &&
75
- coverage.clientCallsMappedPct < effectiveThresholds.minRouteCoverage) {
76
- coverageViolations.push(
77
- `Route coverage ${coverage.clientCallsMappedPct.toFixed(0)}% < ${effectiveThresholds.minRouteCoverage}%`
78
- );
79
- }
80
- if (effectiveThresholds.requireAuthCoverage &&
81
- coverage.authVerifiedPct !== null &&
82
- coverage.authVerifiedPct < effectiveThresholds.minAuthCoverage) {
83
- coverageViolations.push(
84
- `Auth coverage ${coverage.authVerifiedPct.toFixed(0)}% < ${effectiveThresholds.minAuthCoverage}%`
85
- );
86
- }
87
- }
88
-
89
- // Determine verdict
90
- let status = "SHIP";
91
- let exitCode = 0;
92
-
93
- if (stats.bySeverity.BLOCK > 0 || coverageViolations.length > 0) {
94
- status = "BLOCK";
95
- exitCode = 2;
96
- if (coverageViolations.length > 0) {
97
- blockReasons.push(...coverageViolations.map(v => `Coverage: ${v}`));
98
- }
99
- } else if (stats.bySeverity.WARN > 0) {
100
- status = "WARN";
101
- exitCode = failOnWarn ? 1 : 0;
102
- }
103
-
104
- // Generate summary
105
- const summary = generateVerdictSummary(status, stats);
106
-
107
- return {
108
- status,
109
- exitCode,
110
- summary,
111
- blockReasons: blockReasons.slice(0, 5),
112
- stats,
113
- coverageViolations,
114
- };
115
- }
116
-
117
- /**
118
- * Generate human-readable verdict summary
119
- */
120
- function generateVerdictSummary(status, stats) {
121
- const parts = [];
122
-
123
- if (status === "SHIP") {
124
- if (stats.bySeverity.WARN > 0) {
125
- parts.push(`✅ SHIP with ${stats.bySeverity.WARN} warning(s)`);
126
- } else {
127
- parts.push("✅ SHIP - All checks passed");
128
- }
129
- } else if (status === "WARN") {
130
- parts.push(`âš ī¸ WARN - ${stats.bySeverity.WARN} warning(s) found`);
131
- } else {
132
- parts.push(`đŸšĢ BLOCK - ${stats.bySeverity.BLOCK} blocker(s) found`);
133
- }
134
-
135
- // Add category breakdown
136
- const categories = Object.entries(stats.byCategory)
137
- .filter(([_, count]) => count > 0)
138
- .map(([cat, count]) => `${cat}: ${count}`)
139
- .join(", ");
140
-
141
- if (categories) {
142
- parts.push(`Categories: ${categories}`);
143
- }
144
-
145
- return parts.join("\n");
146
- }
147
-
148
- // =============================================================================
149
- // COVERAGE METRICS
150
- // =============================================================================
151
-
152
- /**
153
- * Compute coverage metrics from truthpack and reality data
154
- */
155
- function computeCoverageMetrics(options = {}) {
156
- const { truthpack, realityReport, proofGraph, findings } = options;
157
-
158
- const metrics = {
159
- clientCalls: {
160
- total: 0,
161
- mappedToServerRoutes: 0,
162
- mappedToServerRoutesPct: 0,
163
- },
164
- runtimeRequests: {
165
- total: 0,
166
- mappedToClientCalls: 0,
167
- mappedToClientCallsPct: 0,
168
- },
169
- uiActions: {
170
- total: 0,
171
- withMeaningfulChange: 0,
172
- withMeaningfulChangePct: 0,
173
- },
174
- auth: {
175
- protectedRoutes: 0,
176
- verified: 0,
177
- protectedRoutesVerifiedPct: null,
178
- },
179
- unmappedApiRequests: [],
180
- };
181
-
182
- // Client calls coverage
183
- if (truthpack?.clientCalls?.calls) {
184
- metrics.clientCalls.total = truthpack.clientCalls.calls.length;
185
-
186
- // Count those with linked server routes (from proof graph or matching)
187
- if (proofGraph?.edges) {
188
- const clientCallsWithMatches = new Set(
189
- proofGraph.edges
190
- .filter(e => e.type === "MATCHES" || e.type === "CALLS")
191
- .map(e => e.from)
192
- );
193
- metrics.clientCalls.mappedToServerRoutes = clientCallsWithMatches.size;
194
- }
195
-
196
- metrics.clientCalls.mappedToServerRoutesPct = metrics.clientCalls.total > 0
197
- ? (metrics.clientCalls.mappedToServerRoutes / metrics.clientCalls.total) * 100
198
- : 100;
199
- }
200
-
201
- // Runtime requests coverage
202
- if (realityReport?.requests) {
203
- metrics.runtimeRequests.total = realityReport.requests.length;
204
-
205
- // Count mapped requests
206
- const mapped = realityReport.requests.filter(r => r.mappedClientCallId || r.matched);
207
- metrics.runtimeRequests.mappedToClientCalls = mapped.length;
208
-
209
- metrics.runtimeRequests.mappedToClientCallsPct = metrics.runtimeRequests.total > 0
210
- ? (metrics.runtimeRequests.mappedToClientCalls / metrics.runtimeRequests.total) * 100
211
- : 100;
212
-
213
- // Top 5 unmapped requests
214
- const unmapped = realityReport.requests
215
- .filter(r => !r.mappedClientCallId && !r.matched)
216
- .map(r => r.canonicalPath || r.url)
217
- .filter(Boolean);
218
-
219
- // Dedupe and take top 5
220
- metrics.unmappedApiRequests = [...new Set(unmapped)].slice(0, 5);
221
- }
222
-
223
- // UI actions coverage
224
- if (realityReport?.actions) {
225
- metrics.uiActions.total = realityReport.actions.length;
226
-
227
- // Count actions with meaningful UI change
228
- const withChange = realityReport.actions.filter(a =>
229
- a.meaningfulChange || a.uiChangeScore > 0.5
230
- );
231
- metrics.uiActions.withMeaningfulChange = withChange.length;
232
-
233
- metrics.uiActions.withMeaningfulChangePct = metrics.uiActions.total > 0
234
- ? (metrics.uiActions.withMeaningfulChange / metrics.uiActions.total) * 100
235
- : 100;
236
- }
237
-
238
- // Auth coverage (only when verify-auth was used)
239
- if (truthpack?.auth?.protectedPatterns?.length > 0 && realityReport?.auth) {
240
- metrics.auth.protectedRoutes = truthpack.auth.protectedPatterns.length;
241
- metrics.auth.verified = realityReport.auth.verified?.length || 0;
242
-
243
- metrics.auth.protectedRoutesVerifiedPct = metrics.auth.protectedRoutes > 0
244
- ? (metrics.auth.verified / metrics.auth.protectedRoutes) * 100
245
- : null;
246
- }
247
-
248
- // Flattened percentage fields for easy access
249
- return {
250
- ...metrics,
251
- clientCallsMappedPct: metrics.clientCalls.mappedToServerRoutesPct,
252
- runtimeRequestsMappedPct: metrics.runtimeRequests.mappedToClientCallsPct,
253
- uiActionsVerifiedPct: metrics.uiActions.withMeaningfulChangePct,
254
- authVerifiedPct: metrics.auth.protectedRoutesVerifiedPct,
255
- };
256
- }
257
-
258
- // =============================================================================
259
- // SHIP REPORT GENERATION
260
- // =============================================================================
261
-
262
- /**
263
- * Generate ship report from findings and metadata
264
- */
265
- function generateShipReport(options = {}) {
266
- const {
267
- findings = [],
268
- truthpack = null,
269
- realityReport = null,
270
- proofGraph = null,
271
- repoRoot = process.cwd(),
272
- failOnWarn = false,
273
- startTime = Date.now(),
274
- } = options;
275
-
276
- const verdict = computeVerdict(findings, { failOnWarn });
277
-
278
- // Build proof chain for top blockers
279
- const proofChain = buildProofChain(findings, proofGraph);
280
-
281
- // Compute coverage metrics
282
- const coverage = computeCoverageMetrics({
283
- truthpack,
284
- realityReport,
285
- proofGraph,
286
- findings,
287
- });
288
-
289
- // Runtime summary
290
- const runtime = realityReport ? {
291
- ran: true,
292
- url: realityReport.meta?.baseUrl,
293
- actionsCount: realityReport.actions?.length || 0,
294
- requestsCount: realityReport.requests?.length || 0,
295
- toastsDetected: realityReport.signals?.filter(s => s.kind?.startsWith("toast_")).length || 0,
296
- } : { ran: false };
297
-
298
- const report = {
299
- meta: {
300
- version: "2.0.0",
301
- generatedAt: new Date().toISOString(),
302
- repoRoot,
303
- commit: {
304
- sha: process.env.VIBECHECK_COMMIT_SHA || getGitCommit(repoRoot),
305
- branch: process.env.VIBECHECK_BRANCH || getGitBranch(repoRoot),
306
- },
307
- durationMs: Date.now() - startTime,
308
- truthpackHash: truthpack?.index?.hashes?.truthpackHash || null,
309
- },
310
- verdict: {
311
- status: verdict.status,
312
- exitCode: verdict.exitCode,
313
- summary: verdict.summary,
314
- blockReasons: verdict.blockReasons,
315
- },
316
- findings,
317
- stats: verdict.stats,
318
- proofChain,
319
- coverage,
320
- artifacts: {
321
- truthpack: ".vibecheck/truthpack.json",
322
- realityReport: realityReport ? ".vibecheck/reality/last_reality.json" : null,
323
- proofGraph: proofGraph ? ".vibecheck/proof-graph.json" : null,
324
- missionPack: null, // Set by fix command
325
- },
326
- runtime,
327
- };
328
-
329
- return report;
330
- }
331
-
332
- /**
333
- * Build proof chain for top blockers
334
- */
335
- function buildProofChain(findings, proofGraph) {
336
- const blockers = findings
337
- .filter(f => f.severity === "BLOCK")
338
- .slice(0, 5);
339
-
340
- if (!proofGraph || blockers.length === 0) {
341
- return { topBlockers: [] };
342
- }
343
-
344
- const topBlockers = blockers.map(finding => {
345
- const chain = [];
346
-
347
- // Find proof node if referenced
348
- if (finding.proofNode && proofGraph.nodes) {
349
- const node = proofGraph.nodes.find(n => n.id === finding.proofNode);
350
- if (node) {
351
- chain.push({
352
- nodeType: node.type,
353
- label: node.label,
354
- evidence: node.data ? JSON.stringify(node.data).slice(0, 100) : null,
355
- });
356
-
357
- // Follow edges to build chain
358
- const edges = proofGraph.edges?.filter(e => e.source === node.id || e.target === node.id) || [];
359
- for (const edge of edges.slice(0, 3)) {
360
- const linkedNode = proofGraph.nodes.find(n => n.id === (edge.source === node.id ? edge.target : edge.source));
361
- if (linkedNode) {
362
- chain.push({
363
- nodeType: linkedNode.type,
364
- label: linkedNode.label,
365
- evidence: edge.type,
366
- });
367
- }
368
- }
369
- }
370
- }
371
-
372
- // Add evidence from finding
373
- if (chain.length === 0 && finding.evidence?.length > 0) {
374
- const ev = finding.evidence[0];
375
- chain.push({
376
- nodeType: ev.kind || "file",
377
- label: ev.file ? `${ev.file}:${ev.lines}` : ev.url || "unknown",
378
- evidence: ev.reason,
379
- });
380
- }
381
-
382
- return {
383
- findingId: finding.id,
384
- title: finding.title,
385
- chain,
386
- };
387
- });
388
-
389
- return { topBlockers };
390
- }
391
-
392
- /**
393
- * Write ship report to disk
394
- */
395
- function writeShipReport(repoRoot, report) {
396
- const dir = path.join(repoRoot, ".vibecheck");
397
- fs.mkdirSync(dir, { recursive: true });
398
-
399
- // Write last_ship.json
400
- const lastShipPath = path.join(dir, "last_ship.json");
401
- fs.writeFileSync(lastShipPath, JSON.stringify(report, null, 2));
402
-
403
- // Write timestamped copy
404
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
405
- const archiveDir = path.join(dir, "ship");
406
- fs.mkdirSync(archiveDir, { recursive: true });
407
- fs.writeFileSync(
408
- path.join(archiveDir, `ship_${timestamp}.json`),
409
- JSON.stringify(report, null, 2)
410
- );
411
-
412
- return lastShipPath;
413
- }
414
-
415
- // =============================================================================
416
- // PR COMMENT RENDERER
417
- // =============================================================================
418
-
419
- /**
420
- * Render PR comment markdown from ship report
421
- */
422
- function renderPRComment(report, options = {}) {
423
- const { includeDetails = true, maxFindings = 10 } = options;
424
-
425
- const lines = [];
426
-
427
- // Header with verdict
428
- const verdictEmoji = {
429
- SHIP: "✅",
430
- WARN: "âš ī¸",
431
- BLOCK: "đŸšĢ",
432
- }[report.verdict.status] || "❓";
433
-
434
- lines.push(`## ${verdictEmoji} Vibecheck: ${report.verdict.status}`);
435
- lines.push("");
436
-
437
- // Stats summary
438
- const { stats } = report;
439
- if (stats.total > 0) {
440
- lines.push(`**Findings:** ${stats.total} total`);
441
- if (stats.bySeverity.BLOCK > 0) lines.push(`- đŸšĢ ${stats.bySeverity.BLOCK} blocker(s)`);
442
- if (stats.bySeverity.WARN > 0) lines.push(`- âš ī¸ ${stats.bySeverity.WARN} warning(s)`);
443
- if (stats.bySeverity.INFO > 0) lines.push(`- â„šī¸ ${stats.bySeverity.INFO} info`);
444
- lines.push("");
445
- } else {
446
- lines.push("**No issues found!**");
447
- lines.push("");
448
- }
449
-
450
- // Top blockers with proof chain
451
- if (report.proofChain?.topBlockers?.length > 0) {
452
- lines.push("### Top Blockers");
453
- lines.push("");
454
-
455
- for (const blocker of report.proofChain.topBlockers.slice(0, 3)) {
456
- lines.push(`**${blocker.title}**`);
457
-
458
- if (blocker.chain?.length > 0) {
459
- const chainStr = blocker.chain
460
- .map(c => `${c.nodeType}: ${c.label}`)
461
- .join(" → ");
462
- lines.push(`> Proof: ${chainStr}`);
463
- }
464
- lines.push("");
465
- }
466
- }
467
-
468
- // Detailed findings (collapsible)
469
- if (includeDetails && report.findings.length > 0) {
470
- lines.push("<details>");
471
- lines.push("<summary>All Findings</summary>");
472
- lines.push("");
473
-
474
- const shown = report.findings.slice(0, maxFindings);
475
- for (const f of shown) {
476
- const emoji = f.severity === "BLOCK" ? "đŸšĢ" : f.severity === "WARN" ? "âš ī¸" : "â„šī¸";
477
- lines.push(`- ${emoji} **${f.category}**: ${f.title}`);
478
-
479
- if (f.evidence?.[0]?.file) {
480
- lines.push(` - 📁 \`${f.evidence[0].file}:${f.evidence[0].lines || ""}\``);
481
- }
482
- }
483
-
484
- if (report.findings.length > maxFindings) {
485
- lines.push("");
486
- lines.push(`_...and ${report.findings.length - maxFindings} more_`);
487
- }
488
-
489
- lines.push("");
490
- lines.push("</details>");
491
- lines.push("");
492
- }
493
-
494
- // Runtime coverage
495
- if (report.runtime?.ran) {
496
- lines.push("### Runtime Verification");
497
- lines.push(`- Actions: ${report.runtime.actionsCount}`);
498
- lines.push(`- Requests: ${report.runtime.requestsCount}`);
499
- lines.push(`- Toasts detected: ${report.runtime.toastsDetected}`);
500
-
501
- if (report.runtime.coverage?.percent !== undefined) {
502
- lines.push(`- Route coverage: ${report.runtime.coverage.percent.toFixed(0)}%`);
503
- }
504
- lines.push("");
505
- }
506
-
507
- // Artifacts
508
- lines.push("### Artifacts");
509
- lines.push(`- 📄 Ship report: \`${report.artifacts?.truthpack || ".vibecheck/truthpack.json"}\``);
510
- if (report.artifacts?.realityReport) {
511
- lines.push(`- 🎭 Reality report: \`${report.artifacts.realityReport}\``);
512
- }
513
- lines.push("");
514
-
515
- // Footer
516
- lines.push("---");
517
- lines.push(`_Generated by [Vibecheck](https://github.com/guardiavault-oss/Vibecheck) at ${report.meta.generatedAt}_`);
518
-
519
- return lines.join("\n");
520
- }
521
-
522
- /**
523
- * Write PR comment to file
524
- */
525
- function writePRComment(repoRoot, comment) {
526
- const dir = path.join(repoRoot, ".vibecheck");
527
- fs.mkdirSync(dir, { recursive: true });
528
-
529
- const commentPath = path.join(dir, "pr_comment.md");
530
- fs.writeFileSync(commentPath, comment);
531
-
532
- return commentPath;
533
- }
534
-
535
- // =============================================================================
536
- // HELPERS
537
- // =============================================================================
538
-
539
- function getGitCommit(repoRoot) {
540
- try {
541
- const headPath = path.join(repoRoot, ".git", "HEAD");
542
- const head = fs.readFileSync(headPath, "utf8").trim();
543
-
544
- if (head.startsWith("ref:")) {
545
- const refPath = path.join(repoRoot, ".git", head.slice(5).trim());
546
- return fs.readFileSync(refPath, "utf8").trim().slice(0, 12);
547
- }
548
- return head.slice(0, 12);
549
- } catch {
550
- return "unknown";
551
- }
552
- }
553
-
554
- function getGitBranch(repoRoot) {
555
- try {
556
- const headPath = path.join(repoRoot, ".git", "HEAD");
557
- const head = fs.readFileSync(headPath, "utf8").trim();
558
-
559
- if (head.startsWith("ref: refs/heads/")) {
560
- return head.slice("ref: refs/heads/".length);
561
- }
562
- return "HEAD";
563
- } catch {
564
- return "unknown";
565
- }
566
- }
567
-
568
- // =============================================================================
569
- // FINDING DEDUPLICATION
570
- // =============================================================================
571
-
572
- /**
573
- * Deduplicate findings by fingerprint
574
- */
575
- function deduplicateFindings(findings) {
576
- const seen = new Set();
577
- const deduped = [];
578
-
579
- for (const f of findings) {
580
- const fp = f.fingerprint || generateFingerprint([f.detectorId, f.title, f.evidence?.[0]?.file]);
581
- if (!seen.has(fp)) {
582
- seen.add(fp);
583
- deduped.push(f);
584
- }
585
- }
586
-
587
- return deduped;
588
- }
589
-
590
- /**
591
- * Merge findings from multiple sources
592
- */
593
- function mergeFindings(...findingArrays) {
594
- const all = [];
595
- for (const arr of findingArrays) {
596
- if (Array.isArray(arr)) {
597
- all.push(...arr);
598
- }
599
- }
600
- return deduplicateFindings(all);
601
- }
602
-
603
- // =============================================================================
604
- // EXPORTS
605
- // =============================================================================
606
-
607
- module.exports = {
608
- // Verdict
609
- computeVerdict,
610
- generateVerdictSummary,
611
- DEFAULT_COVERAGE_THRESHOLDS,
612
-
613
- // Coverage
614
- computeCoverageMetrics,
615
-
616
- // Ship report
617
- generateShipReport,
618
- writeShipReport,
619
- buildProofChain,
620
-
621
- // PR comment
622
- renderPRComment,
623
- writePRComment,
624
-
625
- // Finding utilities
626
- deduplicateFindings,
627
- mergeFindings,
628
- };
1
+ /**
2
+ * Verdict Engine v2
3
+ *
4
+ * Merges all findings from detectors, computes verdict, generates ship report.
5
+ * Single source of truth for SHIP/WARN/BLOCK decisions.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+
14
+ const { createFinding, getSeverity, generateFingerprint } = require("./schemas/validator");
15
+
16
+ // =============================================================================
17
+ // VERDICT COMPUTATION
18
+ // =============================================================================
19
+
20
+ // Coverage thresholds defaults
21
+ const DEFAULT_COVERAGE_THRESHOLDS = {
22
+ minActionCoverage: 0, // --min-action-coverage
23
+ minRouteCoverage: 0, // --min-route-coverage
24
+ requireAuthCoverage: false, // --require-auth-coverage
25
+ minAuthCoverage: 80, // Minimum % when requireAuthCoverage is true
26
+ };
27
+
28
+ /**
29
+ * Compute verdict from findings
30
+ */
31
+ function computeVerdict(findings, options = {}) {
32
+ const {
33
+ failOnWarn = false,
34
+ coverage = null,
35
+ thresholds = {},
36
+ } = options;
37
+
38
+ const effectiveThresholds = { ...DEFAULT_COVERAGE_THRESHOLDS, ...thresholds };
39
+
40
+ const stats = {
41
+ total: findings.length,
42
+ bySeverity: { BLOCK: 0, WARN: 0, INFO: 0 },
43
+ byCategory: {},
44
+ byScope: {},
45
+ };
46
+
47
+ const blockReasons = [];
48
+
49
+ for (const f of findings) {
50
+ // Count by severity
51
+ stats.bySeverity[f.severity] = (stats.bySeverity[f.severity] || 0) + 1;
52
+
53
+ // Count by category
54
+ stats.byCategory[f.category] = (stats.byCategory[f.category] || 0) + 1;
55
+
56
+ // Count by scope
57
+ stats.byScope[f.scope] = (stats.byScope[f.scope] || 0) + 1;
58
+
59
+ // Collect block reasons
60
+ if (f.severity === "BLOCK") {
61
+ blockReasons.push(f.title);
62
+ }
63
+ }
64
+
65
+ // Check coverage thresholds
66
+ const coverageViolations = [];
67
+ if (coverage) {
68
+ if (effectiveThresholds.minActionCoverage > 0 &&
69
+ coverage.uiActionsVerifiedPct < effectiveThresholds.minActionCoverage) {
70
+ coverageViolations.push(
71
+ `UI action coverage ${coverage.uiActionsVerifiedPct.toFixed(0)}% < ${effectiveThresholds.minActionCoverage}%`
72
+ );
73
+ }
74
+ if (effectiveThresholds.minRouteCoverage > 0 &&
75
+ coverage.clientCallsMappedPct < effectiveThresholds.minRouteCoverage) {
76
+ coverageViolations.push(
77
+ `Route coverage ${coverage.clientCallsMappedPct.toFixed(0)}% < ${effectiveThresholds.minRouteCoverage}%`
78
+ );
79
+ }
80
+ if (effectiveThresholds.requireAuthCoverage &&
81
+ coverage.authVerifiedPct !== null &&
82
+ coverage.authVerifiedPct < effectiveThresholds.minAuthCoverage) {
83
+ coverageViolations.push(
84
+ `Auth coverage ${coverage.authVerifiedPct.toFixed(0)}% < ${effectiveThresholds.minAuthCoverage}%`
85
+ );
86
+ }
87
+ }
88
+
89
+ // Determine verdict
90
+ let status = "SHIP";
91
+ let exitCode = 0;
92
+
93
+ if (stats.bySeverity.BLOCK > 0 || coverageViolations.length > 0) {
94
+ status = "BLOCK";
95
+ exitCode = 2;
96
+ if (coverageViolations.length > 0) {
97
+ blockReasons.push(...coverageViolations.map(v => `Coverage: ${v}`));
98
+ }
99
+ } else if (stats.bySeverity.WARN > 0) {
100
+ status = "WARN";
101
+ exitCode = failOnWarn ? 1 : 0;
102
+ }
103
+
104
+ // Generate summary
105
+ const summary = generateVerdictSummary(status, stats);
106
+
107
+ return {
108
+ status,
109
+ exitCode,
110
+ summary,
111
+ blockReasons: blockReasons.slice(0, 5),
112
+ stats,
113
+ coverageViolations,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Generate human-readable verdict summary
119
+ */
120
+ function generateVerdictSummary(status, stats) {
121
+ const parts = [];
122
+
123
+ if (status === "SHIP") {
124
+ if (stats.bySeverity.WARN > 0) {
125
+ parts.push(`✅ SHIP with ${stats.bySeverity.WARN} warning(s)`);
126
+ } else {
127
+ parts.push("✅ SHIP - All checks passed");
128
+ }
129
+ } else if (status === "WARN") {
130
+ parts.push(`âš ī¸ WARN - ${stats.bySeverity.WARN} warning(s) found`);
131
+ } else {
132
+ parts.push(`đŸšĢ BLOCK - ${stats.bySeverity.BLOCK} blocker(s) found`);
133
+ }
134
+
135
+ // Add category breakdown
136
+ const categories = Object.entries(stats.byCategory)
137
+ .filter(([_, count]) => count > 0)
138
+ .map(([cat, count]) => `${cat}: ${count}`)
139
+ .join(", ");
140
+
141
+ if (categories) {
142
+ parts.push(`Categories: ${categories}`);
143
+ }
144
+
145
+ return parts.join("\n");
146
+ }
147
+
148
+ // =============================================================================
149
+ // COVERAGE METRICS
150
+ // =============================================================================
151
+
152
+ /**
153
+ * Compute coverage metrics from truthpack and reality data
154
+ */
155
+ function computeCoverageMetrics(options = {}) {
156
+ const { truthpack, realityReport, proofGraph, findings } = options;
157
+
158
+ const metrics = {
159
+ clientCalls: {
160
+ total: 0,
161
+ mappedToServerRoutes: 0,
162
+ mappedToServerRoutesPct: 0,
163
+ },
164
+ runtimeRequests: {
165
+ total: 0,
166
+ mappedToClientCalls: 0,
167
+ mappedToClientCallsPct: 0,
168
+ },
169
+ uiActions: {
170
+ total: 0,
171
+ withMeaningfulChange: 0,
172
+ withMeaningfulChangePct: 0,
173
+ },
174
+ auth: {
175
+ protectedRoutes: 0,
176
+ verified: 0,
177
+ protectedRoutesVerifiedPct: null,
178
+ },
179
+ unmappedApiRequests: [],
180
+ };
181
+
182
+ // Client calls coverage
183
+ if (truthpack?.clientCalls?.calls) {
184
+ metrics.clientCalls.total = truthpack.clientCalls.calls.length;
185
+
186
+ // Count those with linked server routes (from proof graph or matching)
187
+ if (proofGraph?.edges) {
188
+ const clientCallsWithMatches = new Set(
189
+ proofGraph.edges
190
+ .filter(e => e.type === "MATCHES" || e.type === "CALLS")
191
+ .map(e => e.from)
192
+ );
193
+ metrics.clientCalls.mappedToServerRoutes = clientCallsWithMatches.size;
194
+ }
195
+
196
+ metrics.clientCalls.mappedToServerRoutesPct = metrics.clientCalls.total > 0
197
+ ? (metrics.clientCalls.mappedToServerRoutes / metrics.clientCalls.total) * 100
198
+ : 100;
199
+ }
200
+
201
+ // Runtime requests coverage
202
+ if (realityReport?.requests) {
203
+ metrics.runtimeRequests.total = realityReport.requests.length;
204
+
205
+ // Count mapped requests
206
+ const mapped = realityReport.requests.filter(r => r.mappedClientCallId || r.matched);
207
+ metrics.runtimeRequests.mappedToClientCalls = mapped.length;
208
+
209
+ metrics.runtimeRequests.mappedToClientCallsPct = metrics.runtimeRequests.total > 0
210
+ ? (metrics.runtimeRequests.mappedToClientCalls / metrics.runtimeRequests.total) * 100
211
+ : 100;
212
+
213
+ // Top 5 unmapped requests
214
+ const unmapped = realityReport.requests
215
+ .filter(r => !r.mappedClientCallId && !r.matched)
216
+ .map(r => r.canonicalPath || r.url)
217
+ .filter(Boolean);
218
+
219
+ // Dedupe and take top 5
220
+ metrics.unmappedApiRequests = [...new Set(unmapped)].slice(0, 5);
221
+ }
222
+
223
+ // UI actions coverage
224
+ if (realityReport?.actions) {
225
+ metrics.uiActions.total = realityReport.actions.length;
226
+
227
+ // Count actions with meaningful UI change
228
+ const withChange = realityReport.actions.filter(a =>
229
+ a.meaningfulChange || a.uiChangeScore > 0.5
230
+ );
231
+ metrics.uiActions.withMeaningfulChange = withChange.length;
232
+
233
+ metrics.uiActions.withMeaningfulChangePct = metrics.uiActions.total > 0
234
+ ? (metrics.uiActions.withMeaningfulChange / metrics.uiActions.total) * 100
235
+ : 100;
236
+ }
237
+
238
+ // Auth coverage (only when verify-auth was used)
239
+ if (truthpack?.auth?.protectedPatterns?.length > 0 && realityReport?.auth) {
240
+ metrics.auth.protectedRoutes = truthpack.auth.protectedPatterns.length;
241
+ metrics.auth.verified = realityReport.auth.verified?.length || 0;
242
+
243
+ metrics.auth.protectedRoutesVerifiedPct = metrics.auth.protectedRoutes > 0
244
+ ? (metrics.auth.verified / metrics.auth.protectedRoutes) * 100
245
+ : null;
246
+ }
247
+
248
+ // Flattened percentage fields for easy access
249
+ return {
250
+ ...metrics,
251
+ clientCallsMappedPct: metrics.clientCalls.mappedToServerRoutesPct,
252
+ runtimeRequestsMappedPct: metrics.runtimeRequests.mappedToClientCallsPct,
253
+ uiActionsVerifiedPct: metrics.uiActions.withMeaningfulChangePct,
254
+ authVerifiedPct: metrics.auth.protectedRoutesVerifiedPct,
255
+ };
256
+ }
257
+
258
+ // =============================================================================
259
+ // SHIP REPORT GENERATION
260
+ // =============================================================================
261
+
262
+ /**
263
+ * Generate ship report from findings and metadata
264
+ */
265
+ function generateShipReport(options = {}) {
266
+ const {
267
+ findings = [],
268
+ truthpack = null,
269
+ realityReport = null,
270
+ proofGraph = null,
271
+ repoRoot = process.cwd(),
272
+ failOnWarn = false,
273
+ startTime = Date.now(),
274
+ } = options;
275
+
276
+ const verdict = computeVerdict(findings, { failOnWarn });
277
+
278
+ // Build proof chain for top blockers
279
+ const proofChain = buildProofChain(findings, proofGraph);
280
+
281
+ // Compute coverage metrics
282
+ const coverage = computeCoverageMetrics({
283
+ truthpack,
284
+ realityReport,
285
+ proofGraph,
286
+ findings,
287
+ });
288
+
289
+ // Runtime summary
290
+ const runtime = realityReport ? {
291
+ ran: true,
292
+ url: realityReport.meta?.baseUrl,
293
+ actionsCount: realityReport.actions?.length || 0,
294
+ requestsCount: realityReport.requests?.length || 0,
295
+ toastsDetected: realityReport.signals?.filter(s => s.kind?.startsWith("toast_")).length || 0,
296
+ } : { ran: false };
297
+
298
+ const report = {
299
+ meta: {
300
+ version: "2.0.0",
301
+ generatedAt: new Date().toISOString(),
302
+ repoRoot,
303
+ commit: {
304
+ sha: process.env.VIBECHECK_COMMIT_SHA || getGitCommit(repoRoot),
305
+ branch: process.env.VIBECHECK_BRANCH || getGitBranch(repoRoot),
306
+ },
307
+ durationMs: Date.now() - startTime,
308
+ truthpackHash: truthpack?.index?.hashes?.truthpackHash || null,
309
+ },
310
+ verdict: {
311
+ status: verdict.status,
312
+ exitCode: verdict.exitCode,
313
+ summary: verdict.summary,
314
+ blockReasons: verdict.blockReasons,
315
+ },
316
+ findings,
317
+ stats: verdict.stats,
318
+ proofChain,
319
+ coverage,
320
+ artifacts: {
321
+ truthpack: ".vibecheck/truthpack.json",
322
+ realityReport: realityReport ? ".vibecheck/reality/last_reality.json" : null,
323
+ proofGraph: proofGraph ? ".vibecheck/proof-graph.json" : null,
324
+ missionPack: null, // Set by fix command
325
+ },
326
+ runtime,
327
+ };
328
+
329
+ return report;
330
+ }
331
+
332
+ /**
333
+ * Build proof chain for top blockers
334
+ */
335
+ function buildProofChain(findings, proofGraph) {
336
+ const blockers = findings
337
+ .filter(f => f.severity === "BLOCK")
338
+ .slice(0, 5);
339
+
340
+ if (!proofGraph || blockers.length === 0) {
341
+ return { topBlockers: [] };
342
+ }
343
+
344
+ const topBlockers = blockers.map(finding => {
345
+ const chain = [];
346
+
347
+ // Find proof node if referenced
348
+ if (finding.proofNode && proofGraph.nodes) {
349
+ const node = proofGraph.nodes.find(n => n.id === finding.proofNode);
350
+ if (node) {
351
+ chain.push({
352
+ nodeType: node.type,
353
+ label: node.label,
354
+ evidence: node.data ? JSON.stringify(node.data).slice(0, 100) : null,
355
+ });
356
+
357
+ // Follow edges to build chain
358
+ const edges = proofGraph.edges?.filter(e => e.source === node.id || e.target === node.id) || [];
359
+ for (const edge of edges.slice(0, 3)) {
360
+ const linkedNode = proofGraph.nodes.find(n => n.id === (edge.source === node.id ? edge.target : edge.source));
361
+ if (linkedNode) {
362
+ chain.push({
363
+ nodeType: linkedNode.type,
364
+ label: linkedNode.label,
365
+ evidence: edge.type,
366
+ });
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ // Add evidence from finding
373
+ if (chain.length === 0 && finding.evidence?.length > 0) {
374
+ const ev = finding.evidence[0];
375
+ chain.push({
376
+ nodeType: ev.kind || "file",
377
+ label: ev.file ? `${ev.file}:${ev.lines}` : ev.url || "unknown",
378
+ evidence: ev.reason,
379
+ });
380
+ }
381
+
382
+ return {
383
+ findingId: finding.id,
384
+ title: finding.title,
385
+ chain,
386
+ };
387
+ });
388
+
389
+ return { topBlockers };
390
+ }
391
+
392
+ /**
393
+ * Write ship report to disk
394
+ */
395
+ function writeShipReport(repoRoot, report) {
396
+ const dir = path.join(repoRoot, ".vibecheck");
397
+ fs.mkdirSync(dir, { recursive: true });
398
+
399
+ // Write last_ship.json
400
+ const lastShipPath = path.join(dir, "last_ship.json");
401
+ fs.writeFileSync(lastShipPath, JSON.stringify(report, null, 2));
402
+
403
+ // Write timestamped copy
404
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
405
+ const archiveDir = path.join(dir, "ship");
406
+ fs.mkdirSync(archiveDir, { recursive: true });
407
+ fs.writeFileSync(
408
+ path.join(archiveDir, `ship_${timestamp}.json`),
409
+ JSON.stringify(report, null, 2)
410
+ );
411
+
412
+ return lastShipPath;
413
+ }
414
+
415
+ // =============================================================================
416
+ // PR COMMENT RENDERER
417
+ // =============================================================================
418
+
419
+ /**
420
+ * Render PR comment markdown from ship report
421
+ */
422
+ function renderPRComment(report, options = {}) {
423
+ const { includeDetails = true, maxFindings = 10 } = options;
424
+
425
+ const lines = [];
426
+
427
+ // Header with verdict
428
+ const verdictEmoji = {
429
+ SHIP: "✅",
430
+ WARN: "âš ī¸",
431
+ BLOCK: "đŸšĢ",
432
+ }[report.verdict.status] || "❓";
433
+
434
+ lines.push(`## ${verdictEmoji} Vibecheck: ${report.verdict.status}`);
435
+ lines.push("");
436
+
437
+ // Stats summary
438
+ const { stats } = report;
439
+ if (stats.total > 0) {
440
+ lines.push(`**Findings:** ${stats.total} total`);
441
+ if (stats.bySeverity.BLOCK > 0) lines.push(`- đŸšĢ ${stats.bySeverity.BLOCK} blocker(s)`);
442
+ if (stats.bySeverity.WARN > 0) lines.push(`- âš ī¸ ${stats.bySeverity.WARN} warning(s)`);
443
+ if (stats.bySeverity.INFO > 0) lines.push(`- â„šī¸ ${stats.bySeverity.INFO} info`);
444
+ lines.push("");
445
+ } else {
446
+ lines.push("**No issues found!**");
447
+ lines.push("");
448
+ }
449
+
450
+ // Top blockers with proof chain
451
+ if (report.proofChain?.topBlockers?.length > 0) {
452
+ lines.push("### Top Blockers");
453
+ lines.push("");
454
+
455
+ for (const blocker of report.proofChain.topBlockers.slice(0, 3)) {
456
+ lines.push(`**${blocker.title}**`);
457
+
458
+ if (blocker.chain?.length > 0) {
459
+ const chainStr = blocker.chain
460
+ .map(c => `${c.nodeType}: ${c.label}`)
461
+ .join(" → ");
462
+ lines.push(`> Proof: ${chainStr}`);
463
+ }
464
+ lines.push("");
465
+ }
466
+ }
467
+
468
+ // Detailed findings (collapsible)
469
+ if (includeDetails && report.findings.length > 0) {
470
+ lines.push("<details>");
471
+ lines.push("<summary>All Findings</summary>");
472
+ lines.push("");
473
+
474
+ const shown = report.findings.slice(0, maxFindings);
475
+ for (const f of shown) {
476
+ const emoji = f.severity === "BLOCK" ? "đŸšĢ" : f.severity === "WARN" ? "âš ī¸" : "â„šī¸";
477
+ lines.push(`- ${emoji} **${f.category}**: ${f.title}`);
478
+
479
+ if (f.evidence?.[0]?.file) {
480
+ lines.push(` - 📁 \`${f.evidence[0].file}:${f.evidence[0].lines || ""}\``);
481
+ }
482
+ }
483
+
484
+ if (report.findings.length > maxFindings) {
485
+ lines.push("");
486
+ lines.push(`_...and ${report.findings.length - maxFindings} more_`);
487
+ }
488
+
489
+ lines.push("");
490
+ lines.push("</details>");
491
+ lines.push("");
492
+ }
493
+
494
+ // Runtime coverage
495
+ if (report.runtime?.ran) {
496
+ lines.push("### Runtime Verification");
497
+ lines.push(`- Actions: ${report.runtime.actionsCount}`);
498
+ lines.push(`- Requests: ${report.runtime.requestsCount}`);
499
+ lines.push(`- Toasts detected: ${report.runtime.toastsDetected}`);
500
+
501
+ if (report.runtime.coverage?.percent !== undefined) {
502
+ lines.push(`- Route coverage: ${report.runtime.coverage.percent.toFixed(0)}%`);
503
+ }
504
+ lines.push("");
505
+ }
506
+
507
+ // Artifacts
508
+ lines.push("### Artifacts");
509
+ lines.push(`- 📄 Ship report: \`${report.artifacts?.truthpack || ".vibecheck/truthpack.json"}\``);
510
+ if (report.artifacts?.realityReport) {
511
+ lines.push(`- 🎭 Reality report: \`${report.artifacts.realityReport}\``);
512
+ }
513
+ lines.push("");
514
+
515
+ // Footer
516
+ lines.push("---");
517
+ lines.push(`_Generated by [Vibecheck](https://github.com/guardiavault-oss/Vibecheck) at ${report.meta.generatedAt}_`);
518
+
519
+ return lines.join("\n");
520
+ }
521
+
522
+ /**
523
+ * Write PR comment to file
524
+ */
525
+ function writePRComment(repoRoot, comment) {
526
+ const dir = path.join(repoRoot, ".vibecheck");
527
+ fs.mkdirSync(dir, { recursive: true });
528
+
529
+ const commentPath = path.join(dir, "pr_comment.md");
530
+ fs.writeFileSync(commentPath, comment);
531
+
532
+ return commentPath;
533
+ }
534
+
535
+ // =============================================================================
536
+ // HELPERS
537
+ // =============================================================================
538
+
539
+ function getGitCommit(repoRoot) {
540
+ try {
541
+ const headPath = path.join(repoRoot, ".git", "HEAD");
542
+ const head = fs.readFileSync(headPath, "utf8").trim();
543
+
544
+ if (head.startsWith("ref:")) {
545
+ const refPath = path.join(repoRoot, ".git", head.slice(5).trim());
546
+ return fs.readFileSync(refPath, "utf8").trim().slice(0, 12);
547
+ }
548
+ return head.slice(0, 12);
549
+ } catch {
550
+ return "unknown";
551
+ }
552
+ }
553
+
554
+ function getGitBranch(repoRoot) {
555
+ try {
556
+ const headPath = path.join(repoRoot, ".git", "HEAD");
557
+ const head = fs.readFileSync(headPath, "utf8").trim();
558
+
559
+ if (head.startsWith("ref: refs/heads/")) {
560
+ return head.slice("ref: refs/heads/".length);
561
+ }
562
+ return "HEAD";
563
+ } catch {
564
+ return "unknown";
565
+ }
566
+ }
567
+
568
+ // =============================================================================
569
+ // FINDING DEDUPLICATION
570
+ // =============================================================================
571
+
572
+ /**
573
+ * Deduplicate findings by fingerprint
574
+ */
575
+ function deduplicateFindings(findings) {
576
+ const seen = new Set();
577
+ const deduped = [];
578
+
579
+ for (const f of findings) {
580
+ const fp = f.fingerprint || generateFingerprint([f.detectorId, f.title, f.evidence?.[0]?.file]);
581
+ if (!seen.has(fp)) {
582
+ seen.add(fp);
583
+ deduped.push(f);
584
+ }
585
+ }
586
+
587
+ return deduped;
588
+ }
589
+
590
+ /**
591
+ * Merge findings from multiple sources
592
+ */
593
+ function mergeFindings(...findingArrays) {
594
+ const all = [];
595
+ for (const arr of findingArrays) {
596
+ if (Array.isArray(arr)) {
597
+ all.push(...arr);
598
+ }
599
+ }
600
+ return deduplicateFindings(all);
601
+ }
602
+
603
+ // =============================================================================
604
+ // EXPORTS
605
+ // =============================================================================
606
+
607
+ module.exports = {
608
+ // Verdict
609
+ computeVerdict,
610
+ generateVerdictSummary,
611
+ DEFAULT_COVERAGE_THRESHOLDS,
612
+
613
+ // Coverage
614
+ computeCoverageMetrics,
615
+
616
+ // Ship report
617
+ generateShipReport,
618
+ writeShipReport,
619
+ buildProofChain,
620
+
621
+ // PR comment
622
+ renderPRComment,
623
+ writePRComment,
624
+
625
+ // Finding utilities
626
+ deduplicateFindings,
627
+ mergeFindings,
628
+ };