@vibecheckai/cli 3.2.5 → 3.3.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 (197) hide show
  1. package/bin/.generated +25 -25
  2. package/bin/dev/run-v2-torture.js +30 -30
  3. package/bin/registry.js +192 -5
  4. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -295
  5. package/bin/runners/lib/agent-firewall/change-packet/builder.js +280 -6
  6. package/bin/runners/lib/agent-firewall/critic/index.js +151 -0
  7. package/bin/runners/lib/agent-firewall/critic/judge.js +432 -0
  8. package/bin/runners/lib/agent-firewall/critic/prompts.js +305 -0
  9. package/bin/runners/lib/agent-firewall/lawbook/distributor.js +465 -0
  10. package/bin/runners/lib/agent-firewall/lawbook/evaluator.js +604 -0
  11. package/bin/runners/lib/agent-firewall/lawbook/index.js +304 -0
  12. package/bin/runners/lib/agent-firewall/lawbook/registry.js +514 -0
  13. package/bin/runners/lib/agent-firewall/lawbook/schema.js +420 -0
  14. package/bin/runners/lib/agent-firewall/logger.js +141 -0
  15. package/bin/runners/lib/agent-firewall/policy/loader.js +312 -4
  16. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +113 -1
  17. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +133 -6
  18. package/bin/runners/lib/agent-firewall/proposal/extractor.js +394 -0
  19. package/bin/runners/lib/agent-firewall/proposal/index.js +212 -0
  20. package/bin/runners/lib/agent-firewall/proposal/schema.js +251 -0
  21. package/bin/runners/lib/agent-firewall/proposal/validator.js +386 -0
  22. package/bin/runners/lib/agent-firewall/reality/index.js +332 -0
  23. package/bin/runners/lib/agent-firewall/reality/state.js +625 -0
  24. package/bin/runners/lib/agent-firewall/reality/watcher.js +322 -0
  25. package/bin/runners/lib/agent-firewall/risk/index.js +173 -0
  26. package/bin/runners/lib/agent-firewall/risk/scorer.js +328 -0
  27. package/bin/runners/lib/agent-firewall/risk/thresholds.js +321 -0
  28. package/bin/runners/lib/agent-firewall/risk/vectors.js +421 -0
  29. package/bin/runners/lib/agent-firewall/simulator/diff-simulator.js +472 -0
  30. package/bin/runners/lib/agent-firewall/simulator/import-resolver.js +346 -0
  31. package/bin/runners/lib/agent-firewall/simulator/index.js +181 -0
  32. package/bin/runners/lib/agent-firewall/simulator/route-validator.js +380 -0
  33. package/bin/runners/lib/agent-firewall/time-machine/incident-correlator.js +661 -0
  34. package/bin/runners/lib/agent-firewall/time-machine/index.js +267 -0
  35. package/bin/runners/lib/agent-firewall/time-machine/replay-engine.js +436 -0
  36. package/bin/runners/lib/agent-firewall/time-machine/state-reconstructor.js +490 -0
  37. package/bin/runners/lib/agent-firewall/time-machine/timeline-builder.js +530 -0
  38. package/bin/runners/lib/analyzers.js +81 -18
  39. package/bin/runners/lib/api-client.js +269 -0
  40. package/bin/runners/lib/auth-truth.js +193 -193
  41. package/bin/runners/lib/authority-badge.js +425 -0
  42. package/bin/runners/lib/backup.js +62 -62
  43. package/bin/runners/lib/billing.js +107 -107
  44. package/bin/runners/lib/claims.js +118 -118
  45. package/bin/runners/lib/cli-output.js +7 -1
  46. package/bin/runners/lib/cli-ui.js +540 -540
  47. package/bin/runners/lib/contracts/auth-contract.js +202 -202
  48. package/bin/runners/lib/contracts/env-contract.js +181 -181
  49. package/bin/runners/lib/contracts/external-contract.js +206 -206
  50. package/bin/runners/lib/contracts/guard.js +168 -168
  51. package/bin/runners/lib/contracts/index.js +89 -89
  52. package/bin/runners/lib/contracts/plan-validator.js +311 -311
  53. package/bin/runners/lib/contracts/route-contract.js +199 -199
  54. package/bin/runners/lib/contracts.js +804 -804
  55. package/bin/runners/lib/detect.js +89 -89
  56. package/bin/runners/lib/doctor/autofix.js +254 -254
  57. package/bin/runners/lib/doctor/index.js +37 -37
  58. package/bin/runners/lib/doctor/modules/dependencies.js +325 -325
  59. package/bin/runners/lib/doctor/modules/index.js +46 -46
  60. package/bin/runners/lib/doctor/modules/network.js +250 -250
  61. package/bin/runners/lib/doctor/modules/project.js +312 -312
  62. package/bin/runners/lib/doctor/modules/runtime.js +224 -224
  63. package/bin/runners/lib/doctor/modules/security.js +348 -348
  64. package/bin/runners/lib/doctor/modules/system.js +213 -213
  65. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -394
  66. package/bin/runners/lib/doctor/reporter.js +262 -262
  67. package/bin/runners/lib/doctor/service.js +262 -262
  68. package/bin/runners/lib/doctor/types.js +113 -113
  69. package/bin/runners/lib/doctor/ui.js +263 -263
  70. package/bin/runners/lib/doctor-v2.js +608 -608
  71. package/bin/runners/lib/drift.js +425 -425
  72. package/bin/runners/lib/enforcement.js +72 -72
  73. package/bin/runners/lib/enterprise-detect.js +603 -603
  74. package/bin/runners/lib/enterprise-init.js +942 -942
  75. package/bin/runners/lib/env-resolver.js +417 -417
  76. package/bin/runners/lib/env-template.js +66 -66
  77. package/bin/runners/lib/env.js +189 -189
  78. package/bin/runners/lib/error-handler.js +16 -9
  79. package/bin/runners/lib/exit-codes.js +275 -0
  80. package/bin/runners/lib/extractors/client-calls.js +990 -990
  81. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -573
  82. package/bin/runners/lib/extractors/fastify-routes.js +426 -426
  83. package/bin/runners/lib/extractors/index.js +363 -363
  84. package/bin/runners/lib/extractors/next-routes.js +524 -524
  85. package/bin/runners/lib/extractors/proof-graph.js +431 -431
  86. package/bin/runners/lib/extractors/route-matcher.js +451 -451
  87. package/bin/runners/lib/extractors/truthpack-v2.js +377 -377
  88. package/bin/runners/lib/extractors/ui-bindings.js +547 -547
  89. package/bin/runners/lib/findings-schema.js +281 -281
  90. package/bin/runners/lib/firewall-prompt.js +50 -50
  91. package/bin/runners/lib/global-flags.js +37 -0
  92. package/bin/runners/lib/graph/graph-builder.js +265 -265
  93. package/bin/runners/lib/graph/html-renderer.js +413 -413
  94. package/bin/runners/lib/graph/index.js +32 -32
  95. package/bin/runners/lib/graph/runtime-collector.js +215 -215
  96. package/bin/runners/lib/graph/static-extractor.js +518 -518
  97. package/bin/runners/lib/help-formatter.js +413 -0
  98. package/bin/runners/lib/html-report.js +650 -650
  99. package/bin/runners/lib/llm.js +75 -75
  100. package/bin/runners/lib/logger.js +38 -0
  101. package/bin/runners/lib/meter.js +61 -61
  102. package/bin/runners/lib/missions/evidence.js +126 -126
  103. package/bin/runners/lib/patch.js +40 -40
  104. package/bin/runners/lib/permissions/auth-model.js +213 -213
  105. package/bin/runners/lib/permissions/idor-prover.js +205 -205
  106. package/bin/runners/lib/permissions/index.js +45 -45
  107. package/bin/runners/lib/permissions/matrix-builder.js +198 -198
  108. package/bin/runners/lib/pkgjson.js +28 -28
  109. package/bin/runners/lib/policy.js +295 -295
  110. package/bin/runners/lib/preflight.js +142 -142
  111. package/bin/runners/lib/reality/correlation-detectors.js +359 -359
  112. package/bin/runners/lib/reality/index.js +318 -318
  113. package/bin/runners/lib/reality/request-hashing.js +416 -416
  114. package/bin/runners/lib/reality/request-mapper.js +453 -453
  115. package/bin/runners/lib/reality/safety-rails.js +463 -463
  116. package/bin/runners/lib/reality/semantic-snapshot.js +408 -408
  117. package/bin/runners/lib/reality/toast-detector.js +393 -393
  118. package/bin/runners/lib/reality-findings.js +84 -84
  119. package/bin/runners/lib/receipts.js +179 -179
  120. package/bin/runners/lib/redact.js +29 -29
  121. package/bin/runners/lib/replay/capsule-manager.js +154 -154
  122. package/bin/runners/lib/replay/index.js +263 -263
  123. package/bin/runners/lib/replay/player.js +348 -348
  124. package/bin/runners/lib/replay/recorder.js +331 -331
  125. package/bin/runners/lib/report.js +135 -135
  126. package/bin/runners/lib/route-detection.js +1140 -1140
  127. package/bin/runners/lib/sandbox/index.js +59 -59
  128. package/bin/runners/lib/sandbox/proof-chain.js +399 -399
  129. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -205
  130. package/bin/runners/lib/sandbox/worktree.js +174 -174
  131. package/bin/runners/lib/schema-validator.js +350 -350
  132. package/bin/runners/lib/schemas/contracts.schema.json +160 -160
  133. package/bin/runners/lib/schemas/finding.schema.json +100 -100
  134. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -206
  135. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -176
  136. package/bin/runners/lib/schemas/reality-report.schema.json +162 -162
  137. package/bin/runners/lib/schemas/share-pack.schema.json +180 -180
  138. package/bin/runners/lib/schemas/ship-report.schema.json +117 -117
  139. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -303
  140. package/bin/runners/lib/schemas/validator.js +438 -438
  141. package/bin/runners/lib/score-history.js +282 -282
  142. package/bin/runners/lib/share-pack.js +239 -239
  143. package/bin/runners/lib/snippets.js +67 -67
  144. package/bin/runners/lib/unified-cli-output.js +604 -0
  145. package/bin/runners/lib/upsell.js +658 -510
  146. package/bin/runners/lib/usage.js +153 -153
  147. package/bin/runners/lib/validate-patch.js +156 -156
  148. package/bin/runners/lib/verdict-engine.js +628 -628
  149. package/bin/runners/reality/engine.js +917 -917
  150. package/bin/runners/reality/flows.js +122 -122
  151. package/bin/runners/reality/report.js +378 -378
  152. package/bin/runners/reality/session.js +193 -193
  153. package/bin/runners/runAgent.d.ts +5 -0
  154. package/bin/runners/runApprove.js +1200 -0
  155. package/bin/runners/runAuth.js +324 -95
  156. package/bin/runners/runCheckpoint.js +39 -21
  157. package/bin/runners/runClassify.js +859 -0
  158. package/bin/runners/runContext.js +136 -24
  159. package/bin/runners/runDoctor.js +108 -68
  160. package/bin/runners/runFirewall.d.ts +5 -0
  161. package/bin/runners/runFirewallHook.d.ts +5 -0
  162. package/bin/runners/runFix.js +6 -5
  163. package/bin/runners/runGuard.js +262 -168
  164. package/bin/runners/runInit.js +3 -2
  165. package/bin/runners/runMcp.js +130 -52
  166. package/bin/runners/runPolish.js +43 -20
  167. package/bin/runners/runProve.js +1 -2
  168. package/bin/runners/runReport.js +3 -2
  169. package/bin/runners/runScan.js +145 -44
  170. package/bin/runners/runShip.js +3 -4
  171. package/bin/runners/runTruth.d.ts +5 -0
  172. package/bin/runners/runValidate.js +19 -2
  173. package/bin/runners/runWatch.js +104 -53
  174. package/bin/vibecheck.js +106 -19
  175. package/mcp-server/HARDENING_SUMMARY.md +299 -0
  176. package/mcp-server/agent-firewall-interceptor.js +367 -31
  177. package/mcp-server/authority-tools.js +569 -0
  178. package/mcp-server/conductor/conflict-resolver.js +588 -0
  179. package/mcp-server/conductor/execution-planner.js +544 -0
  180. package/mcp-server/conductor/index.js +377 -0
  181. package/mcp-server/conductor/lock-manager.js +615 -0
  182. package/mcp-server/conductor/request-queue.js +550 -0
  183. package/mcp-server/conductor/session-manager.js +500 -0
  184. package/mcp-server/conductor/tools.js +510 -0
  185. package/mcp-server/index.js +1199 -208
  186. package/mcp-server/lib/api-client.cjs +305 -0
  187. package/mcp-server/lib/logger.cjs +30 -0
  188. package/mcp-server/logger.js +173 -0
  189. package/mcp-server/package.json +2 -2
  190. package/mcp-server/premium-tools.js +2 -2
  191. package/mcp-server/tier-auth.js +351 -136
  192. package/mcp-server/tools/index.js +72 -72
  193. package/mcp-server/truth-firewall-tools.js +145 -15
  194. package/mcp-server/vibecheck-tools.js +2 -2
  195. package/package.json +2 -3
  196. package/mcp-server/index.old.js +0 -4137
  197. package/mcp-server/package-lock.json +0 -165
@@ -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
+ };