@vibecheckai/cli 3.2.4 → 3.2.6

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 (123) hide show
  1. package/bin/.generated +25 -25
  2. package/bin/dev/run-v2-torture.js +30 -30
  3. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -295
  4. package/bin/runners/lib/api-client.js +269 -0
  5. package/bin/runners/lib/auth-truth.js +193 -193
  6. package/bin/runners/lib/backup.js +62 -62
  7. package/bin/runners/lib/billing.js +107 -107
  8. package/bin/runners/lib/claims.js +118 -118
  9. package/bin/runners/lib/cli-ui.js +540 -540
  10. package/bin/runners/lib/contracts/auth-contract.js +202 -202
  11. package/bin/runners/lib/contracts/env-contract.js +181 -181
  12. package/bin/runners/lib/contracts/external-contract.js +206 -206
  13. package/bin/runners/lib/contracts/guard.js +168 -168
  14. package/bin/runners/lib/contracts/index.js +89 -89
  15. package/bin/runners/lib/contracts/plan-validator.js +311 -311
  16. package/bin/runners/lib/contracts/route-contract.js +199 -199
  17. package/bin/runners/lib/contracts.js +804 -804
  18. package/bin/runners/lib/detect.js +89 -89
  19. package/bin/runners/lib/doctor/autofix.js +254 -254
  20. package/bin/runners/lib/doctor/index.js +37 -37
  21. package/bin/runners/lib/doctor/modules/dependencies.js +325 -325
  22. package/bin/runners/lib/doctor/modules/index.js +46 -46
  23. package/bin/runners/lib/doctor/modules/network.js +250 -250
  24. package/bin/runners/lib/doctor/modules/project.js +312 -312
  25. package/bin/runners/lib/doctor/modules/runtime.js +224 -224
  26. package/bin/runners/lib/doctor/modules/security.js +348 -348
  27. package/bin/runners/lib/doctor/modules/system.js +213 -213
  28. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -394
  29. package/bin/runners/lib/doctor/reporter.js +262 -262
  30. package/bin/runners/lib/doctor/service.js +262 -262
  31. package/bin/runners/lib/doctor/types.js +113 -113
  32. package/bin/runners/lib/doctor/ui.js +263 -263
  33. package/bin/runners/lib/doctor-v2.js +608 -608
  34. package/bin/runners/lib/drift.js +425 -425
  35. package/bin/runners/lib/enforcement.js +72 -72
  36. package/bin/runners/lib/enterprise-detect.js +603 -603
  37. package/bin/runners/lib/enterprise-init.js +942 -942
  38. package/bin/runners/lib/env-resolver.js +417 -417
  39. package/bin/runners/lib/env-template.js +66 -66
  40. package/bin/runners/lib/env.js +189 -189
  41. package/bin/runners/lib/extractors/client-calls.js +990 -990
  42. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -573
  43. package/bin/runners/lib/extractors/fastify-routes.js +426 -426
  44. package/bin/runners/lib/extractors/index.js +363 -363
  45. package/bin/runners/lib/extractors/next-routes.js +524 -524
  46. package/bin/runners/lib/extractors/proof-graph.js +431 -431
  47. package/bin/runners/lib/extractors/route-matcher.js +451 -451
  48. package/bin/runners/lib/extractors/truthpack-v2.js +377 -377
  49. package/bin/runners/lib/extractors/ui-bindings.js +547 -547
  50. package/bin/runners/lib/findings-schema.js +281 -281
  51. package/bin/runners/lib/firewall-prompt.js +50 -50
  52. package/bin/runners/lib/graph/graph-builder.js +265 -265
  53. package/bin/runners/lib/graph/html-renderer.js +413 -413
  54. package/bin/runners/lib/graph/index.js +32 -32
  55. package/bin/runners/lib/graph/runtime-collector.js +215 -215
  56. package/bin/runners/lib/graph/static-extractor.js +518 -518
  57. package/bin/runners/lib/html-report.js +650 -650
  58. package/bin/runners/lib/llm.js +75 -75
  59. package/bin/runners/lib/meter.js +61 -61
  60. package/bin/runners/lib/missions/evidence.js +126 -126
  61. package/bin/runners/lib/patch.js +40 -40
  62. package/bin/runners/lib/permissions/auth-model.js +213 -213
  63. package/bin/runners/lib/permissions/idor-prover.js +205 -205
  64. package/bin/runners/lib/permissions/index.js +45 -45
  65. package/bin/runners/lib/permissions/matrix-builder.js +198 -198
  66. package/bin/runners/lib/pkgjson.js +28 -28
  67. package/bin/runners/lib/policy.js +295 -295
  68. package/bin/runners/lib/preflight.js +142 -142
  69. package/bin/runners/lib/reality/correlation-detectors.js +359 -359
  70. package/bin/runners/lib/reality/index.js +318 -318
  71. package/bin/runners/lib/reality/request-hashing.js +416 -416
  72. package/bin/runners/lib/reality/request-mapper.js +453 -453
  73. package/bin/runners/lib/reality/safety-rails.js +463 -463
  74. package/bin/runners/lib/reality/semantic-snapshot.js +408 -408
  75. package/bin/runners/lib/reality/toast-detector.js +393 -393
  76. package/bin/runners/lib/reality-findings.js +84 -84
  77. package/bin/runners/lib/receipts.js +179 -179
  78. package/bin/runners/lib/redact.js +29 -29
  79. package/bin/runners/lib/replay/capsule-manager.js +154 -154
  80. package/bin/runners/lib/replay/index.js +263 -263
  81. package/bin/runners/lib/replay/player.js +348 -348
  82. package/bin/runners/lib/replay/recorder.js +331 -331
  83. package/bin/runners/lib/report.js +135 -135
  84. package/bin/runners/lib/route-detection.js +1140 -1140
  85. package/bin/runners/lib/sandbox/index.js +59 -59
  86. package/bin/runners/lib/sandbox/proof-chain.js +399 -399
  87. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -205
  88. package/bin/runners/lib/sandbox/worktree.js +174 -174
  89. package/bin/runners/lib/schema-validator.js +350 -350
  90. package/bin/runners/lib/schemas/contracts.schema.json +160 -160
  91. package/bin/runners/lib/schemas/finding.schema.json +100 -100
  92. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -206
  93. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -176
  94. package/bin/runners/lib/schemas/reality-report.schema.json +162 -162
  95. package/bin/runners/lib/schemas/share-pack.schema.json +180 -180
  96. package/bin/runners/lib/schemas/ship-report.schema.json +117 -117
  97. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -303
  98. package/bin/runners/lib/schemas/validator.js +438 -438
  99. package/bin/runners/lib/score-history.js +282 -282
  100. package/bin/runners/lib/share-pack.js +239 -239
  101. package/bin/runners/lib/snippets.js +67 -67
  102. package/bin/runners/lib/upsell.js +510 -510
  103. package/bin/runners/lib/usage.js +153 -153
  104. package/bin/runners/lib/validate-patch.js +156 -156
  105. package/bin/runners/lib/verdict-engine.js +628 -628
  106. package/bin/runners/reality/engine.js +917 -917
  107. package/bin/runners/reality/flows.js +122 -122
  108. package/bin/runners/reality/report.js +378 -378
  109. package/bin/runners/reality/session.js +193 -193
  110. package/bin/runners/runAgent.d.ts +5 -0
  111. package/bin/runners/runFirewall.d.ts +5 -0
  112. package/bin/runners/runFirewallHook.d.ts +5 -0
  113. package/bin/runners/runGuard.js +168 -168
  114. package/bin/runners/runScan.js +82 -0
  115. package/bin/runners/runTruth.d.ts +5 -0
  116. package/bin/vibecheck.js +45 -20
  117. package/mcp-server/index.js +85 -0
  118. package/mcp-server/lib/api-client.js +269 -0
  119. package/mcp-server/package.json +1 -1
  120. package/mcp-server/tier-auth.js +173 -113
  121. package/mcp-server/tools/index.js +72 -72
  122. package/mcp-server/vibecheck-mcp-server-3.2.0.tgz +0 -0
  123. package/package.json +1 -1
@@ -1,804 +1,804 @@
1
- /**
2
- * Contracts Generator + Drift Detector v2
3
- *
4
- * Generates contracts from truthpack and detects drift between runs.
5
- * The hallucination stopper - AI can't invent endpoints, env vars, or auth.
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 } = require("./schemas/validator");
15
-
16
- // =============================================================================
17
- // CONTRACT TYPES
18
- // =============================================================================
19
-
20
- const CONTRACT_TYPES = {
21
- ROUTES: "routes",
22
- ENV: "env",
23
- AUTH: "auth",
24
- EXTERNAL: "external",
25
- };
26
-
27
- // =============================================================================
28
- // CONTRACT GENERATION
29
- // =============================================================================
30
-
31
- /**
32
- * Generate all contracts from truthpack
33
- */
34
- function generateContracts(truthpack, options = {}) {
35
- const { includeInternal = false } = options;
36
-
37
- const contracts = {
38
- meta: {
39
- version: "2.0.0",
40
- generatedAt: new Date().toISOString(),
41
- truthpackHash: computeHash(JSON.stringify(truthpack)),
42
- },
43
- routes: generateRouteContract(truthpack),
44
- env: generateEnvContract(truthpack),
45
- auth: generateAuthContract(truthpack),
46
- external: generateExternalContract(truthpack),
47
- };
48
-
49
- // Compute contract hashes
50
- contracts.hashes = {
51
- routes: computeHash(JSON.stringify(contracts.routes)),
52
- env: computeHash(JSON.stringify(contracts.env)),
53
- auth: computeHash(JSON.stringify(contracts.auth)),
54
- external: computeHash(JSON.stringify(contracts.external)),
55
- };
56
-
57
- return contracts;
58
- }
59
-
60
- /**
61
- * Generate route contract
62
- */
63
- function generateRouteContract(truthpack) {
64
- const routes = truthpack?.routes?.server || [];
65
-
66
- return {
67
- endpoints: routes.map(r => ({
68
- method: r.method,
69
- path: r.path,
70
- file: r.file,
71
- confidence: r.confidence,
72
- authRequired: r.authRequired || false,
73
- paidOnly: r.paidOnly || false,
74
- })),
75
- count: routes.length,
76
- byMethod: countByField(routes, "method"),
77
- byConfidence: countByField(routes, "confidence"),
78
- };
79
- }
80
-
81
- /**
82
- * Generate env contract
83
- */
84
- function generateEnvContract(truthpack) {
85
- const env = truthpack?.env || {};
86
- const vars = env.vars || [];
87
- const declared = env.declared || [];
88
-
89
- // Classify vars by prefix
90
- const classified = {
91
- public: vars.filter(v => v.name.startsWith("NEXT_PUBLIC_") || v.name.startsWith("VITE_")),
92
- server: vars.filter(v => !v.name.startsWith("NEXT_PUBLIC_") && !v.name.startsWith("VITE_")),
93
- };
94
-
95
- // Required vs optional (heuristic: used in multiple files = required)
96
- const required = vars.filter(v => v.usageCount > 1 || v.required);
97
- const optional = vars.filter(v => v.usageCount <= 1 && !v.required);
98
-
99
- return {
100
- variables: vars.map(v => ({
101
- name: v.name,
102
- required: v.required || v.usageCount > 1,
103
- public: v.name.startsWith("NEXT_PUBLIC_") || v.name.startsWith("VITE_"),
104
- declaredIn: declared.includes(v.name) ? ".env.example" : null,
105
- usageCount: v.usageCount || 1,
106
- })),
107
- count: vars.length,
108
- requiredCount: required.length,
109
- publicCount: classified.public.length,
110
- declared: declared,
111
- };
112
- }
113
-
114
- /**
115
- * Generate auth contract
116
- */
117
- function generateAuthContract(truthpack) {
118
- const auth = truthpack?.auth || {};
119
-
120
- return {
121
- providers: auth.providers || [],
122
- protectedPatterns: auth.nextMatcherPatterns || [],
123
- middleware: {
124
- next: auth.nextMiddleware?.length > 0,
125
- fastify: auth.fastify?.preHandlerHooks?.length > 0,
126
- },
127
- rbacDetected: auth.rbacPatterns?.length > 0,
128
- signals: {
129
- sessionChecks: auth.sessionChecks || [],
130
- redirects: auth.redirects || [],
131
- },
132
- };
133
- }
134
-
135
- /**
136
- * Generate external services contract
137
- */
138
- function generateExternalContract(truthpack) {
139
- const billing = truthpack?.billing || {};
140
- const integrations = truthpack?.integrations || [];
141
-
142
- return {
143
- stripe: {
144
- detected: billing.stripeDetected || false,
145
- webhookVerified: billing.webhookSignatureVerification || false,
146
- products: billing.products || [],
147
- },
148
- services: integrations.map(i => ({
149
- name: i.name,
150
- type: i.type,
151
- envVars: i.envVars || [],
152
- })),
153
- };
154
- }
155
-
156
- // =============================================================================
157
- // DRIFT DETECTION
158
- // =============================================================================
159
-
160
- /**
161
- * Detect drift between two contract sets
162
- */
163
- function detectDrift(currentContracts, previousContracts, options = {}) {
164
- const { strict = false } = options;
165
-
166
- const findings = [];
167
-
168
- // Route drift
169
- const routeDrift = detectRouteDrift(
170
- currentContracts.routes,
171
- previousContracts.routes,
172
- { strict }
173
- );
174
- findings.push(...routeDrift);
175
-
176
- // Env drift
177
- const envDrift = detectEnvDrift(
178
- currentContracts.env,
179
- previousContracts.env,
180
- { strict }
181
- );
182
- findings.push(...envDrift);
183
-
184
- // Auth drift
185
- const authDrift = detectAuthDrift(
186
- currentContracts.auth,
187
- previousContracts.auth,
188
- { strict }
189
- );
190
- findings.push(...authDrift);
191
-
192
- // External drift
193
- const externalDrift = detectExternalDrift(
194
- currentContracts.external,
195
- previousContracts.external,
196
- { strict }
197
- );
198
- findings.push(...externalDrift);
199
-
200
- return {
201
- hasDrift: findings.length > 0,
202
- findings,
203
- summary: {
204
- routes: routeDrift.length,
205
- env: envDrift.length,
206
- auth: authDrift.length,
207
- external: externalDrift.length,
208
- },
209
- };
210
- }
211
-
212
- /**
213
- * Detect route drift
214
- */
215
- function detectRouteDrift(current, previous, options = {}) {
216
- const findings = [];
217
- const { strict } = options;
218
-
219
- const currentPaths = new Set(current.endpoints.map(e => `${e.method}:${e.path}`));
220
- const previousPaths = new Set(previous.endpoints.map(e => `${e.method}:${e.path}`));
221
-
222
- // New routes (not in previous)
223
- for (const ep of current.endpoints) {
224
- const key = `${ep.method}:${ep.path}`;
225
- if (!previousPaths.has(key)) {
226
- findings.push(createFinding({
227
- detectorId: "D_DRIFT_ROUTE_NEW",
228
- severity: "INFO",
229
- category: "ContractDrift",
230
- scope: "contracts",
231
- title: `New route added: ${ep.method} ${ep.path}`,
232
- why: "Route was added since last contract sync",
233
- confidence: "high",
234
- path: ep.path,
235
- method: ep.method,
236
- evidence: [{
237
- kind: "file",
238
- file: ep.file,
239
- reason: "New route definition",
240
- }],
241
- }));
242
- }
243
- }
244
-
245
- // Removed routes (in previous but not current)
246
- for (const ep of previous.endpoints) {
247
- const key = `${ep.method}:${ep.path}`;
248
- if (!currentPaths.has(key)) {
249
- findings.push(createFinding({
250
- detectorId: "D_DRIFT_ROUTE_REMOVED",
251
- severity: strict ? "BLOCK" : "WARN",
252
- category: "ContractDrift",
253
- scope: "contracts",
254
- title: `Route removed: ${ep.method} ${ep.path}`,
255
- why: "Route existed in previous contract but is now missing",
256
- confidence: "high",
257
- path: ep.path,
258
- method: ep.method,
259
- evidence: [{
260
- kind: "file",
261
- file: ep.file,
262
- reason: "Route no longer exists",
263
- }],
264
- }));
265
- }
266
- }
267
-
268
- // Auth requirement changes
269
- for (const ep of current.endpoints) {
270
- const key = `${ep.method}:${ep.path}`;
271
- const prev = previous.endpoints.find(p => `${p.method}:${p.path}` === key);
272
-
273
- if (prev && prev.authRequired && !ep.authRequired) {
274
- findings.push(createFinding({
275
- detectorId: "D_DRIFT_AUTH_REMOVED",
276
- severity: "BLOCK",
277
- category: "ContractDrift",
278
- scope: "contracts",
279
- title: `Auth removed from route: ${ep.method} ${ep.path}`,
280
- why: "Route previously required auth but no longer does",
281
- confidence: "high",
282
- path: ep.path,
283
- method: ep.method,
284
- evidence: [{
285
- kind: "file",
286
- file: ep.file,
287
- reason: "Auth requirement removed",
288
- }],
289
- }));
290
- }
291
- }
292
-
293
- return findings;
294
- }
295
-
296
- /**
297
- * Detect env drift
298
- */
299
- function detectEnvDrift(current, previous, options = {}) {
300
- const findings = [];
301
- const { strict } = options;
302
-
303
- const currentVars = new Set(current.variables.map(v => v.name));
304
- const previousVars = new Set(previous.variables.map(v => v.name));
305
-
306
- // New required env vars
307
- for (const v of current.variables) {
308
- if (v.required && !previousVars.has(v.name)) {
309
- findings.push(createFinding({
310
- detectorId: "D_DRIFT_ENV_NEW_REQUIRED",
311
- severity: strict ? "BLOCK" : "WARN",
312
- category: "ContractDrift",
313
- scope: "contracts",
314
- title: `New required env var: ${v.name}`,
315
- why: "New required environment variable added",
316
- confidence: "high",
317
- evidence: [{
318
- kind: "file",
319
- reason: `${v.name} is required but not in previous contract`,
320
- }],
321
- }));
322
- }
323
- }
324
-
325
- // Required vars removed (may break existing deployments)
326
- for (const v of previous.variables) {
327
- if (v.required && !currentVars.has(v.name)) {
328
- findings.push(createFinding({
329
- detectorId: "D_DRIFT_ENV_REMOVED",
330
- severity: "INFO",
331
- category: "ContractDrift",
332
- scope: "contracts",
333
- title: `Env var removed: ${v.name}`,
334
- why: "Environment variable no longer used",
335
- confidence: "high",
336
- evidence: [{
337
- kind: "file",
338
- reason: `${v.name} was in previous contract but not current`,
339
- }],
340
- }));
341
- }
342
- }
343
-
344
- // Public → Server (security concern)
345
- for (const v of current.variables) {
346
- const prev = previous.variables.find(p => p.name === v.name);
347
- if (prev && prev.public && !v.public) {
348
- findings.push(createFinding({
349
- detectorId: "D_DRIFT_ENV_PUBLIC_TO_SERVER",
350
- severity: "INFO",
351
- category: "ContractDrift",
352
- scope: "contracts",
353
- title: `Env var moved from public to server: ${v.name}`,
354
- why: "Variable changed from public to server-only (good for security)",
355
- confidence: "medium",
356
- evidence: [{
357
- kind: "file",
358
- reason: `${v.name} prefix changed`,
359
- }],
360
- }));
361
- }
362
- }
363
-
364
- return findings;
365
- }
366
-
367
- /**
368
- * Detect auth drift
369
- */
370
- function detectAuthDrift(current, previous, options = {}) {
371
- const findings = [];
372
-
373
- // Protected patterns removed
374
- const currentPatterns = new Set(current.protectedPatterns);
375
- const previousPatterns = new Set(previous.protectedPatterns);
376
-
377
- for (const pattern of previousPatterns) {
378
- if (!currentPatterns.has(pattern)) {
379
- findings.push(createFinding({
380
- detectorId: "D_DRIFT_AUTH_PATTERN_REMOVED",
381
- severity: "BLOCK",
382
- category: "ContractDrift",
383
- scope: "contracts",
384
- title: `Protected pattern removed: ${pattern}`,
385
- why: "Auth protection pattern was removed from middleware",
386
- confidence: "high",
387
- evidence: [{
388
- kind: "file",
389
- reason: `Pattern "${pattern}" no longer in matcher`,
390
- }],
391
- }));
392
- }
393
- }
394
-
395
- // Middleware disabled
396
- if (previous.middleware.next && !current.middleware.next) {
397
- findings.push(createFinding({
398
- detectorId: "D_DRIFT_AUTH_MIDDLEWARE_DISABLED",
399
- severity: "BLOCK",
400
- category: "ContractDrift",
401
- scope: "contracts",
402
- title: "Next.js auth middleware disabled",
403
- why: "Auth middleware was active but is now disabled",
404
- confidence: "high",
405
- evidence: [{
406
- kind: "file",
407
- reason: "middleware.ts no longer exports auth matcher",
408
- }],
409
- }));
410
- }
411
-
412
- return findings;
413
- }
414
-
415
- /**
416
- * Detect external service drift
417
- */
418
- function detectExternalDrift(current, previous, options = {}) {
419
- const findings = [];
420
-
421
- // Stripe webhook verification removed
422
- if (previous.stripe.webhookVerified && !current.stripe.webhookVerified) {
423
- findings.push(createFinding({
424
- detectorId: "D_DRIFT_STRIPE_VERIFY_REMOVED",
425
- severity: "BLOCK",
426
- category: "ContractDrift",
427
- scope: "contracts",
428
- title: "Stripe webhook verification removed",
429
- why: "Stripe webhook signature verification was present but removed",
430
- confidence: "high",
431
- evidence: [{
432
- kind: "file",
433
- reason: "constructEvent or verifyHeader no longer called",
434
- }],
435
- }));
436
- }
437
-
438
- return findings;
439
- }
440
-
441
- // =============================================================================
442
- // CONTRACT SYNC (ctx sync)
443
- // =============================================================================
444
-
445
- /**
446
- * Sync contracts - generate and save
447
- */
448
- function syncContracts(truthpack, repoRoot) {
449
- const contracts = generateContracts(truthpack);
450
-
451
- const contractsDir = path.join(repoRoot, ".vibecheck", "contracts");
452
- fs.mkdirSync(contractsDir, { recursive: true });
453
-
454
- // Write individual contract files
455
- for (const type of Object.values(CONTRACT_TYPES)) {
456
- if (contracts[type]) {
457
- fs.writeFileSync(
458
- path.join(contractsDir, `${type}.json`),
459
- JSON.stringify(contracts[type], null, 2)
460
- );
461
- }
462
- }
463
-
464
- // Write combined contracts
465
- fs.writeFileSync(
466
- path.join(contractsDir, "contracts.json"),
467
- JSON.stringify(contracts, null, 2)
468
- );
469
-
470
- // Write hashes for quick drift check
471
- fs.writeFileSync(
472
- path.join(contractsDir, "hashes.json"),
473
- JSON.stringify(contracts.hashes, null, 2)
474
- );
475
-
476
- return contracts;
477
- }
478
-
479
- /**
480
- * Load previous contracts for drift comparison
481
- */
482
- function loadPreviousContracts(repoRoot) {
483
- const contractsPath = path.join(repoRoot, ".vibecheck", "contracts", "contracts.json");
484
-
485
- if (!fs.existsSync(contractsPath)) {
486
- return null;
487
- }
488
-
489
- try {
490
- return JSON.parse(fs.readFileSync(contractsPath, "utf8"));
491
- } catch {
492
- return null;
493
- }
494
- }
495
-
496
- /**
497
- * Guard contracts - check for drift and generate findings
498
- */
499
- function guardContracts(truthpack, repoRoot, options = {}) {
500
- const previous = loadPreviousContracts(repoRoot);
501
-
502
- if (!previous) {
503
- // No previous contracts, just sync
504
- const contracts = syncContracts(truthpack, repoRoot);
505
- return {
506
- isFirstRun: true,
507
- hasDrift: false,
508
- findings: [],
509
- contracts,
510
- };
511
- }
512
-
513
- const current = generateContracts(truthpack);
514
- const drift = detectDrift(current, previous, options);
515
-
516
- // If no blockers, update contracts
517
- const hasBlockers = drift.findings.some(f => f.severity === "BLOCK");
518
- if (!hasBlockers) {
519
- syncContracts(truthpack, repoRoot);
520
- }
521
-
522
- return {
523
- isFirstRun: false,
524
- hasDrift: drift.hasDrift,
525
- hasBlockers,
526
- findings: drift.findings,
527
- contracts: current,
528
- previous,
529
- };
530
- }
531
-
532
- // =============================================================================
533
- // DRIFT EXPLAINABILITY
534
- // =============================================================================
535
-
536
- /**
537
- * Generate detailed drift explanation with remediation
538
- */
539
- function explainDrift(driftResult) {
540
- const { findings, summary } = driftResult;
541
-
542
- const explanation = {
543
- summary: {
544
- hasDrift: driftResult.hasDrift,
545
- totalChanges: findings.length,
546
- byCategory: summary,
547
- verdict: findings.some(f => f.severity === "BLOCK") ? "BLOCK" :
548
- findings.some(f => f.severity === "WARN") ? "WARN" : "OK",
549
- },
550
- changes: [],
551
- remediation: [],
552
- };
553
-
554
- for (const finding of findings) {
555
- const change = {
556
- type: getChangeType(finding.detectorId),
557
- severity: finding.severity,
558
- title: finding.title,
559
- why: finding.why || getWhyMessage(finding),
560
- impact: getImpactMessage(finding),
561
- remediation: getRemediationCommand(finding),
562
- };
563
- explanation.changes.push(change);
564
-
565
- if (change.remediation) {
566
- explanation.remediation.push(change.remediation);
567
- }
568
- }
569
-
570
- return explanation;
571
- }
572
-
573
- /**
574
- * Get change type from detector ID
575
- */
576
- function getChangeType(detectorId) {
577
- const types = {
578
- "D_DRIFT_ROUTE_NEW": "added",
579
- "D_DRIFT_ROUTE_REMOVED": "removed",
580
- "D_DRIFT_AUTH_REMOVED": "modified",
581
- "D_DRIFT_ENV_NEW_REQUIRED": "added",
582
- "D_DRIFT_ENV_REMOVED": "removed",
583
- "D_DRIFT_ENV_PUBLIC_TO_SERVER": "modified",
584
- "D_DRIFT_AUTH_PATTERN_REMOVED": "removed",
585
- "D_DRIFT_AUTH_PATTERN_NEW": "added",
586
- "D_DRIFT_EXTERNAL_NEW": "added",
587
- "D_DRIFT_EXTERNAL_REMOVED": "removed",
588
- };
589
- return types[detectorId] || "unknown";
590
- }
591
-
592
- /**
593
- * Get why message for finding
594
- */
595
- function getWhyMessage(finding) {
596
- const messages = {
597
- "D_DRIFT_ROUTE_NEW": "New endpoint referenced by client but not in previous contract",
598
- "D_DRIFT_ROUTE_REMOVED": "Endpoint was in contract but no longer exists in codebase",
599
- "D_DRIFT_AUTH_REMOVED": "Route lost auth protection - potential security regression",
600
- "D_DRIFT_ENV_NEW_REQUIRED": "New required env var may break deployments without it",
601
- "D_DRIFT_ENV_REMOVED": "Env var no longer used - can be cleaned up",
602
- "D_DRIFT_AUTH_PATTERN_REMOVED": "Auth protection pattern removed from middleware",
603
- "D_DRIFT_AUTH_PATTERN_NEW": "New route pattern added to auth middleware",
604
- "D_DRIFT_EXTERNAL_NEW": "New external API dependency detected",
605
- "D_DRIFT_EXTERNAL_REMOVED": "External API dependency removed",
606
- };
607
- return messages[finding.detectorId] || "Contract changed since last sync";
608
- }
609
-
610
- /**
611
- * Get impact message for finding
612
- */
613
- function getImpactMessage(finding) {
614
- if (finding.severity === "BLOCK") {
615
- return "This change may cause runtime failures or security issues";
616
- }
617
- if (finding.severity === "WARN") {
618
- return "This change should be reviewed before shipping";
619
- }
620
- return "This change is informational";
621
- }
622
-
623
- /**
624
- * Get remediation command for finding
625
- */
626
- function getRemediationCommand(finding) {
627
- const detectorId = finding.detectorId;
628
-
629
- // If the change is intentional, sync contracts
630
- if (detectorId.includes("NEW") || detectorId.includes("ADDED")) {
631
- return {
632
- ifIntentional: "vibecheck ctx sync",
633
- description: "If this change is intentional, sync contracts",
634
- };
635
- }
636
-
637
- if (detectorId.includes("REMOVED")) {
638
- if (detectorId.includes("ROUTE")) {
639
- return {
640
- ifIntentional: "vibecheck ctx sync",
641
- ifAccidental: `Add route back or remove client call to ${finding.evidence?.[0]?.path || "the endpoint"}`,
642
- description: "Route was removed - verify this is intentional",
643
- };
644
- }
645
- if (detectorId.includes("AUTH")) {
646
- return {
647
- ifIntentional: "vibecheck ctx sync --force",
648
- ifAccidental: "Restore auth protection to the route",
649
- description: "Auth was removed - this is usually NOT intentional",
650
- };
651
- }
652
- }
653
-
654
- return {
655
- ifIntentional: "vibecheck ctx sync",
656
- description: "Review the change and sync if intentional",
657
- };
658
- }
659
-
660
- /**
661
- * Write contracts diff to disk
662
- */
663
- function writeContractsDiff(repoRoot, driftResult) {
664
- const dir = path.join(repoRoot, ".vibecheck");
665
- fs.mkdirSync(dir, { recursive: true });
666
-
667
- const explanation = explainDrift(driftResult);
668
-
669
- const diff = {
670
- meta: {
671
- generatedAt: new Date().toISOString(),
672
- verdict: explanation.summary.verdict,
673
- },
674
- ...explanation,
675
- };
676
-
677
- const diffPath = path.join(dir, "contracts_diff.json");
678
- fs.writeFileSync(diffPath, JSON.stringify(diff, null, 2));
679
-
680
- return { path: diffPath, explanation };
681
- }
682
-
683
- /**
684
- * Print drift report to console
685
- */
686
- function printDriftReport(driftResult) {
687
- const explanation = explainDrift(driftResult);
688
-
689
- console.log("\n" + "=".repeat(60));
690
- console.log("šŸ“‹ CONTRACT DRIFT REPORT");
691
- console.log("=".repeat(60));
692
-
693
- if (!driftResult.hasDrift) {
694
- console.log("\nāœ… No drift detected - contracts are in sync\n");
695
- return;
696
- }
697
-
698
- const verdictEmoji = {
699
- BLOCK: "🚫",
700
- WARN: "āš ļø",
701
- OK: "āœ…",
702
- }[explanation.summary.verdict];
703
-
704
- console.log(`\nVerdict: ${verdictEmoji} ${explanation.summary.verdict}`);
705
- console.log(`Changes: ${explanation.summary.totalChanges}`);
706
-
707
- // Group by severity
708
- const blockers = explanation.changes.filter(c => c.severity === "BLOCK");
709
- const warnings = explanation.changes.filter(c => c.severity === "WARN");
710
- const infos = explanation.changes.filter(c => c.severity === "INFO");
711
-
712
- if (blockers.length > 0) {
713
- console.log("\n🚫 BLOCKERS:");
714
- for (const change of blockers) {
715
- console.log(` ${change.type.toUpperCase()}: ${change.title}`);
716
- console.log(` └─ Why: ${change.why}`);
717
- if (change.remediation?.ifAccidental) {
718
- console.log(` └─ Fix: ${change.remediation.ifAccidental}`);
719
- }
720
- }
721
- }
722
-
723
- if (warnings.length > 0) {
724
- console.log("\nāš ļø WARNINGS:");
725
- for (const change of warnings) {
726
- console.log(` ${change.type.toUpperCase()}: ${change.title}`);
727
- console.log(` └─ Why: ${change.why}`);
728
- }
729
- }
730
-
731
- if (infos.length > 0) {
732
- console.log("\nā„¹ļø INFO:");
733
- for (const change of infos) {
734
- console.log(` ${change.type.toUpperCase()}: ${change.title}`);
735
- }
736
- }
737
-
738
- // Remediation
739
- console.log("\n" + "-".repeat(60));
740
- console.log("REMEDIATION:");
741
-
742
- if (explanation.summary.verdict === "BLOCK") {
743
- console.log("\n If changes are INTENTIONAL:");
744
- console.log(" $ vibecheck ctx sync --force");
745
- console.log("\n If changes are ACCIDENTAL:");
746
- console.log(" - Review the blockers above and fix the issues");
747
- console.log(" - Then run: vibecheck ctx sync");
748
- } else {
749
- console.log("\n To accept these changes:");
750
- console.log(" $ vibecheck ctx sync");
751
- }
752
-
753
- console.log("\n" + "=".repeat(60) + "\n");
754
- }
755
-
756
- // =============================================================================
757
- // HELPERS
758
- // =============================================================================
759
-
760
- function computeHash(content) {
761
- return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
762
- }
763
-
764
- function countByField(items, field) {
765
- const counts = {};
766
- for (const item of items) {
767
- const value = item[field] || "unknown";
768
- counts[value] = (counts[value] || 0) + 1;
769
- }
770
- return counts;
771
- }
772
-
773
- // =============================================================================
774
- // EXPORTS
775
- // =============================================================================
776
-
777
- module.exports = {
778
- // Contract types
779
- CONTRACT_TYPES,
780
-
781
- // Generation
782
- generateContracts,
783
- generateRouteContract,
784
- generateEnvContract,
785
- generateAuthContract,
786
- generateExternalContract,
787
-
788
- // Drift detection
789
- detectDrift,
790
- detectRouteDrift,
791
- detectEnvDrift,
792
- detectAuthDrift,
793
- detectExternalDrift,
794
-
795
- // Drift explainability
796
- explainDrift,
797
- writeContractsDiff,
798
- printDriftReport,
799
-
800
- // Sync/Guard
801
- syncContracts,
802
- loadPreviousContracts,
803
- guardContracts,
804
- };
1
+ /**
2
+ * Contracts Generator + Drift Detector v2
3
+ *
4
+ * Generates contracts from truthpack and detects drift between runs.
5
+ * The hallucination stopper - AI can't invent endpoints, env vars, or auth.
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 } = require("./schemas/validator");
15
+
16
+ // =============================================================================
17
+ // CONTRACT TYPES
18
+ // =============================================================================
19
+
20
+ const CONTRACT_TYPES = {
21
+ ROUTES: "routes",
22
+ ENV: "env",
23
+ AUTH: "auth",
24
+ EXTERNAL: "external",
25
+ };
26
+
27
+ // =============================================================================
28
+ // CONTRACT GENERATION
29
+ // =============================================================================
30
+
31
+ /**
32
+ * Generate all contracts from truthpack
33
+ */
34
+ function generateContracts(truthpack, options = {}) {
35
+ const { includeInternal = false } = options;
36
+
37
+ const contracts = {
38
+ meta: {
39
+ version: "2.0.0",
40
+ generatedAt: new Date().toISOString(),
41
+ truthpackHash: computeHash(JSON.stringify(truthpack)),
42
+ },
43
+ routes: generateRouteContract(truthpack),
44
+ env: generateEnvContract(truthpack),
45
+ auth: generateAuthContract(truthpack),
46
+ external: generateExternalContract(truthpack),
47
+ };
48
+
49
+ // Compute contract hashes
50
+ contracts.hashes = {
51
+ routes: computeHash(JSON.stringify(contracts.routes)),
52
+ env: computeHash(JSON.stringify(contracts.env)),
53
+ auth: computeHash(JSON.stringify(contracts.auth)),
54
+ external: computeHash(JSON.stringify(contracts.external)),
55
+ };
56
+
57
+ return contracts;
58
+ }
59
+
60
+ /**
61
+ * Generate route contract
62
+ */
63
+ function generateRouteContract(truthpack) {
64
+ const routes = truthpack?.routes?.server || [];
65
+
66
+ return {
67
+ endpoints: routes.map(r => ({
68
+ method: r.method,
69
+ path: r.path,
70
+ file: r.file,
71
+ confidence: r.confidence,
72
+ authRequired: r.authRequired || false,
73
+ paidOnly: r.paidOnly || false,
74
+ })),
75
+ count: routes.length,
76
+ byMethod: countByField(routes, "method"),
77
+ byConfidence: countByField(routes, "confidence"),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Generate env contract
83
+ */
84
+ function generateEnvContract(truthpack) {
85
+ const env = truthpack?.env || {};
86
+ const vars = env.vars || [];
87
+ const declared = env.declared || [];
88
+
89
+ // Classify vars by prefix
90
+ const classified = {
91
+ public: vars.filter(v => v.name.startsWith("NEXT_PUBLIC_") || v.name.startsWith("VITE_")),
92
+ server: vars.filter(v => !v.name.startsWith("NEXT_PUBLIC_") && !v.name.startsWith("VITE_")),
93
+ };
94
+
95
+ // Required vs optional (heuristic: used in multiple files = required)
96
+ const required = vars.filter(v => v.usageCount > 1 || v.required);
97
+ const optional = vars.filter(v => v.usageCount <= 1 && !v.required);
98
+
99
+ return {
100
+ variables: vars.map(v => ({
101
+ name: v.name,
102
+ required: v.required || v.usageCount > 1,
103
+ public: v.name.startsWith("NEXT_PUBLIC_") || v.name.startsWith("VITE_"),
104
+ declaredIn: declared.includes(v.name) ? ".env.example" : null,
105
+ usageCount: v.usageCount || 1,
106
+ })),
107
+ count: vars.length,
108
+ requiredCount: required.length,
109
+ publicCount: classified.public.length,
110
+ declared: declared,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Generate auth contract
116
+ */
117
+ function generateAuthContract(truthpack) {
118
+ const auth = truthpack?.auth || {};
119
+
120
+ return {
121
+ providers: auth.providers || [],
122
+ protectedPatterns: auth.nextMatcherPatterns || [],
123
+ middleware: {
124
+ next: auth.nextMiddleware?.length > 0,
125
+ fastify: auth.fastify?.preHandlerHooks?.length > 0,
126
+ },
127
+ rbacDetected: auth.rbacPatterns?.length > 0,
128
+ signals: {
129
+ sessionChecks: auth.sessionChecks || [],
130
+ redirects: auth.redirects || [],
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Generate external services contract
137
+ */
138
+ function generateExternalContract(truthpack) {
139
+ const billing = truthpack?.billing || {};
140
+ const integrations = truthpack?.integrations || [];
141
+
142
+ return {
143
+ stripe: {
144
+ detected: billing.stripeDetected || false,
145
+ webhookVerified: billing.webhookSignatureVerification || false,
146
+ products: billing.products || [],
147
+ },
148
+ services: integrations.map(i => ({
149
+ name: i.name,
150
+ type: i.type,
151
+ envVars: i.envVars || [],
152
+ })),
153
+ };
154
+ }
155
+
156
+ // =============================================================================
157
+ // DRIFT DETECTION
158
+ // =============================================================================
159
+
160
+ /**
161
+ * Detect drift between two contract sets
162
+ */
163
+ function detectDrift(currentContracts, previousContracts, options = {}) {
164
+ const { strict = false } = options;
165
+
166
+ const findings = [];
167
+
168
+ // Route drift
169
+ const routeDrift = detectRouteDrift(
170
+ currentContracts.routes,
171
+ previousContracts.routes,
172
+ { strict }
173
+ );
174
+ findings.push(...routeDrift);
175
+
176
+ // Env drift
177
+ const envDrift = detectEnvDrift(
178
+ currentContracts.env,
179
+ previousContracts.env,
180
+ { strict }
181
+ );
182
+ findings.push(...envDrift);
183
+
184
+ // Auth drift
185
+ const authDrift = detectAuthDrift(
186
+ currentContracts.auth,
187
+ previousContracts.auth,
188
+ { strict }
189
+ );
190
+ findings.push(...authDrift);
191
+
192
+ // External drift
193
+ const externalDrift = detectExternalDrift(
194
+ currentContracts.external,
195
+ previousContracts.external,
196
+ { strict }
197
+ );
198
+ findings.push(...externalDrift);
199
+
200
+ return {
201
+ hasDrift: findings.length > 0,
202
+ findings,
203
+ summary: {
204
+ routes: routeDrift.length,
205
+ env: envDrift.length,
206
+ auth: authDrift.length,
207
+ external: externalDrift.length,
208
+ },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Detect route drift
214
+ */
215
+ function detectRouteDrift(current, previous, options = {}) {
216
+ const findings = [];
217
+ const { strict } = options;
218
+
219
+ const currentPaths = new Set(current.endpoints.map(e => `${e.method}:${e.path}`));
220
+ const previousPaths = new Set(previous.endpoints.map(e => `${e.method}:${e.path}`));
221
+
222
+ // New routes (not in previous)
223
+ for (const ep of current.endpoints) {
224
+ const key = `${ep.method}:${ep.path}`;
225
+ if (!previousPaths.has(key)) {
226
+ findings.push(createFinding({
227
+ detectorId: "D_DRIFT_ROUTE_NEW",
228
+ severity: "INFO",
229
+ category: "ContractDrift",
230
+ scope: "contracts",
231
+ title: `New route added: ${ep.method} ${ep.path}`,
232
+ why: "Route was added since last contract sync",
233
+ confidence: "high",
234
+ path: ep.path,
235
+ method: ep.method,
236
+ evidence: [{
237
+ kind: "file",
238
+ file: ep.file,
239
+ reason: "New route definition",
240
+ }],
241
+ }));
242
+ }
243
+ }
244
+
245
+ // Removed routes (in previous but not current)
246
+ for (const ep of previous.endpoints) {
247
+ const key = `${ep.method}:${ep.path}`;
248
+ if (!currentPaths.has(key)) {
249
+ findings.push(createFinding({
250
+ detectorId: "D_DRIFT_ROUTE_REMOVED",
251
+ severity: strict ? "BLOCK" : "WARN",
252
+ category: "ContractDrift",
253
+ scope: "contracts",
254
+ title: `Route removed: ${ep.method} ${ep.path}`,
255
+ why: "Route existed in previous contract but is now missing",
256
+ confidence: "high",
257
+ path: ep.path,
258
+ method: ep.method,
259
+ evidence: [{
260
+ kind: "file",
261
+ file: ep.file,
262
+ reason: "Route no longer exists",
263
+ }],
264
+ }));
265
+ }
266
+ }
267
+
268
+ // Auth requirement changes
269
+ for (const ep of current.endpoints) {
270
+ const key = `${ep.method}:${ep.path}`;
271
+ const prev = previous.endpoints.find(p => `${p.method}:${p.path}` === key);
272
+
273
+ if (prev && prev.authRequired && !ep.authRequired) {
274
+ findings.push(createFinding({
275
+ detectorId: "D_DRIFT_AUTH_REMOVED",
276
+ severity: "BLOCK",
277
+ category: "ContractDrift",
278
+ scope: "contracts",
279
+ title: `Auth removed from route: ${ep.method} ${ep.path}`,
280
+ why: "Route previously required auth but no longer does",
281
+ confidence: "high",
282
+ path: ep.path,
283
+ method: ep.method,
284
+ evidence: [{
285
+ kind: "file",
286
+ file: ep.file,
287
+ reason: "Auth requirement removed",
288
+ }],
289
+ }));
290
+ }
291
+ }
292
+
293
+ return findings;
294
+ }
295
+
296
+ /**
297
+ * Detect env drift
298
+ */
299
+ function detectEnvDrift(current, previous, options = {}) {
300
+ const findings = [];
301
+ const { strict } = options;
302
+
303
+ const currentVars = new Set(current.variables.map(v => v.name));
304
+ const previousVars = new Set(previous.variables.map(v => v.name));
305
+
306
+ // New required env vars
307
+ for (const v of current.variables) {
308
+ if (v.required && !previousVars.has(v.name)) {
309
+ findings.push(createFinding({
310
+ detectorId: "D_DRIFT_ENV_NEW_REQUIRED",
311
+ severity: strict ? "BLOCK" : "WARN",
312
+ category: "ContractDrift",
313
+ scope: "contracts",
314
+ title: `New required env var: ${v.name}`,
315
+ why: "New required environment variable added",
316
+ confidence: "high",
317
+ evidence: [{
318
+ kind: "file",
319
+ reason: `${v.name} is required but not in previous contract`,
320
+ }],
321
+ }));
322
+ }
323
+ }
324
+
325
+ // Required vars removed (may break existing deployments)
326
+ for (const v of previous.variables) {
327
+ if (v.required && !currentVars.has(v.name)) {
328
+ findings.push(createFinding({
329
+ detectorId: "D_DRIFT_ENV_REMOVED",
330
+ severity: "INFO",
331
+ category: "ContractDrift",
332
+ scope: "contracts",
333
+ title: `Env var removed: ${v.name}`,
334
+ why: "Environment variable no longer used",
335
+ confidence: "high",
336
+ evidence: [{
337
+ kind: "file",
338
+ reason: `${v.name} was in previous contract but not current`,
339
+ }],
340
+ }));
341
+ }
342
+ }
343
+
344
+ // Public → Server (security concern)
345
+ for (const v of current.variables) {
346
+ const prev = previous.variables.find(p => p.name === v.name);
347
+ if (prev && prev.public && !v.public) {
348
+ findings.push(createFinding({
349
+ detectorId: "D_DRIFT_ENV_PUBLIC_TO_SERVER",
350
+ severity: "INFO",
351
+ category: "ContractDrift",
352
+ scope: "contracts",
353
+ title: `Env var moved from public to server: ${v.name}`,
354
+ why: "Variable changed from public to server-only (good for security)",
355
+ confidence: "medium",
356
+ evidence: [{
357
+ kind: "file",
358
+ reason: `${v.name} prefix changed`,
359
+ }],
360
+ }));
361
+ }
362
+ }
363
+
364
+ return findings;
365
+ }
366
+
367
+ /**
368
+ * Detect auth drift
369
+ */
370
+ function detectAuthDrift(current, previous, options = {}) {
371
+ const findings = [];
372
+
373
+ // Protected patterns removed
374
+ const currentPatterns = new Set(current.protectedPatterns);
375
+ const previousPatterns = new Set(previous.protectedPatterns);
376
+
377
+ for (const pattern of previousPatterns) {
378
+ if (!currentPatterns.has(pattern)) {
379
+ findings.push(createFinding({
380
+ detectorId: "D_DRIFT_AUTH_PATTERN_REMOVED",
381
+ severity: "BLOCK",
382
+ category: "ContractDrift",
383
+ scope: "contracts",
384
+ title: `Protected pattern removed: ${pattern}`,
385
+ why: "Auth protection pattern was removed from middleware",
386
+ confidence: "high",
387
+ evidence: [{
388
+ kind: "file",
389
+ reason: `Pattern "${pattern}" no longer in matcher`,
390
+ }],
391
+ }));
392
+ }
393
+ }
394
+
395
+ // Middleware disabled
396
+ if (previous.middleware.next && !current.middleware.next) {
397
+ findings.push(createFinding({
398
+ detectorId: "D_DRIFT_AUTH_MIDDLEWARE_DISABLED",
399
+ severity: "BLOCK",
400
+ category: "ContractDrift",
401
+ scope: "contracts",
402
+ title: "Next.js auth middleware disabled",
403
+ why: "Auth middleware was active but is now disabled",
404
+ confidence: "high",
405
+ evidence: [{
406
+ kind: "file",
407
+ reason: "middleware.ts no longer exports auth matcher",
408
+ }],
409
+ }));
410
+ }
411
+
412
+ return findings;
413
+ }
414
+
415
+ /**
416
+ * Detect external service drift
417
+ */
418
+ function detectExternalDrift(current, previous, options = {}) {
419
+ const findings = [];
420
+
421
+ // Stripe webhook verification removed
422
+ if (previous.stripe.webhookVerified && !current.stripe.webhookVerified) {
423
+ findings.push(createFinding({
424
+ detectorId: "D_DRIFT_STRIPE_VERIFY_REMOVED",
425
+ severity: "BLOCK",
426
+ category: "ContractDrift",
427
+ scope: "contracts",
428
+ title: "Stripe webhook verification removed",
429
+ why: "Stripe webhook signature verification was present but removed",
430
+ confidence: "high",
431
+ evidence: [{
432
+ kind: "file",
433
+ reason: "constructEvent or verifyHeader no longer called",
434
+ }],
435
+ }));
436
+ }
437
+
438
+ return findings;
439
+ }
440
+
441
+ // =============================================================================
442
+ // CONTRACT SYNC (ctx sync)
443
+ // =============================================================================
444
+
445
+ /**
446
+ * Sync contracts - generate and save
447
+ */
448
+ function syncContracts(truthpack, repoRoot) {
449
+ const contracts = generateContracts(truthpack);
450
+
451
+ const contractsDir = path.join(repoRoot, ".vibecheck", "contracts");
452
+ fs.mkdirSync(contractsDir, { recursive: true });
453
+
454
+ // Write individual contract files
455
+ for (const type of Object.values(CONTRACT_TYPES)) {
456
+ if (contracts[type]) {
457
+ fs.writeFileSync(
458
+ path.join(contractsDir, `${type}.json`),
459
+ JSON.stringify(contracts[type], null, 2)
460
+ );
461
+ }
462
+ }
463
+
464
+ // Write combined contracts
465
+ fs.writeFileSync(
466
+ path.join(contractsDir, "contracts.json"),
467
+ JSON.stringify(contracts, null, 2)
468
+ );
469
+
470
+ // Write hashes for quick drift check
471
+ fs.writeFileSync(
472
+ path.join(contractsDir, "hashes.json"),
473
+ JSON.stringify(contracts.hashes, null, 2)
474
+ );
475
+
476
+ return contracts;
477
+ }
478
+
479
+ /**
480
+ * Load previous contracts for drift comparison
481
+ */
482
+ function loadPreviousContracts(repoRoot) {
483
+ const contractsPath = path.join(repoRoot, ".vibecheck", "contracts", "contracts.json");
484
+
485
+ if (!fs.existsSync(contractsPath)) {
486
+ return null;
487
+ }
488
+
489
+ try {
490
+ return JSON.parse(fs.readFileSync(contractsPath, "utf8"));
491
+ } catch {
492
+ return null;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Guard contracts - check for drift and generate findings
498
+ */
499
+ function guardContracts(truthpack, repoRoot, options = {}) {
500
+ const previous = loadPreviousContracts(repoRoot);
501
+
502
+ if (!previous) {
503
+ // No previous contracts, just sync
504
+ const contracts = syncContracts(truthpack, repoRoot);
505
+ return {
506
+ isFirstRun: true,
507
+ hasDrift: false,
508
+ findings: [],
509
+ contracts,
510
+ };
511
+ }
512
+
513
+ const current = generateContracts(truthpack);
514
+ const drift = detectDrift(current, previous, options);
515
+
516
+ // If no blockers, update contracts
517
+ const hasBlockers = drift.findings.some(f => f.severity === "BLOCK");
518
+ if (!hasBlockers) {
519
+ syncContracts(truthpack, repoRoot);
520
+ }
521
+
522
+ return {
523
+ isFirstRun: false,
524
+ hasDrift: drift.hasDrift,
525
+ hasBlockers,
526
+ findings: drift.findings,
527
+ contracts: current,
528
+ previous,
529
+ };
530
+ }
531
+
532
+ // =============================================================================
533
+ // DRIFT EXPLAINABILITY
534
+ // =============================================================================
535
+
536
+ /**
537
+ * Generate detailed drift explanation with remediation
538
+ */
539
+ function explainDrift(driftResult) {
540
+ const { findings, summary } = driftResult;
541
+
542
+ const explanation = {
543
+ summary: {
544
+ hasDrift: driftResult.hasDrift,
545
+ totalChanges: findings.length,
546
+ byCategory: summary,
547
+ verdict: findings.some(f => f.severity === "BLOCK") ? "BLOCK" :
548
+ findings.some(f => f.severity === "WARN") ? "WARN" : "OK",
549
+ },
550
+ changes: [],
551
+ remediation: [],
552
+ };
553
+
554
+ for (const finding of findings) {
555
+ const change = {
556
+ type: getChangeType(finding.detectorId),
557
+ severity: finding.severity,
558
+ title: finding.title,
559
+ why: finding.why || getWhyMessage(finding),
560
+ impact: getImpactMessage(finding),
561
+ remediation: getRemediationCommand(finding),
562
+ };
563
+ explanation.changes.push(change);
564
+
565
+ if (change.remediation) {
566
+ explanation.remediation.push(change.remediation);
567
+ }
568
+ }
569
+
570
+ return explanation;
571
+ }
572
+
573
+ /**
574
+ * Get change type from detector ID
575
+ */
576
+ function getChangeType(detectorId) {
577
+ const types = {
578
+ "D_DRIFT_ROUTE_NEW": "added",
579
+ "D_DRIFT_ROUTE_REMOVED": "removed",
580
+ "D_DRIFT_AUTH_REMOVED": "modified",
581
+ "D_DRIFT_ENV_NEW_REQUIRED": "added",
582
+ "D_DRIFT_ENV_REMOVED": "removed",
583
+ "D_DRIFT_ENV_PUBLIC_TO_SERVER": "modified",
584
+ "D_DRIFT_AUTH_PATTERN_REMOVED": "removed",
585
+ "D_DRIFT_AUTH_PATTERN_NEW": "added",
586
+ "D_DRIFT_EXTERNAL_NEW": "added",
587
+ "D_DRIFT_EXTERNAL_REMOVED": "removed",
588
+ };
589
+ return types[detectorId] || "unknown";
590
+ }
591
+
592
+ /**
593
+ * Get why message for finding
594
+ */
595
+ function getWhyMessage(finding) {
596
+ const messages = {
597
+ "D_DRIFT_ROUTE_NEW": "New endpoint referenced by client but not in previous contract",
598
+ "D_DRIFT_ROUTE_REMOVED": "Endpoint was in contract but no longer exists in codebase",
599
+ "D_DRIFT_AUTH_REMOVED": "Route lost auth protection - potential security regression",
600
+ "D_DRIFT_ENV_NEW_REQUIRED": "New required env var may break deployments without it",
601
+ "D_DRIFT_ENV_REMOVED": "Env var no longer used - can be cleaned up",
602
+ "D_DRIFT_AUTH_PATTERN_REMOVED": "Auth protection pattern removed from middleware",
603
+ "D_DRIFT_AUTH_PATTERN_NEW": "New route pattern added to auth middleware",
604
+ "D_DRIFT_EXTERNAL_NEW": "New external API dependency detected",
605
+ "D_DRIFT_EXTERNAL_REMOVED": "External API dependency removed",
606
+ };
607
+ return messages[finding.detectorId] || "Contract changed since last sync";
608
+ }
609
+
610
+ /**
611
+ * Get impact message for finding
612
+ */
613
+ function getImpactMessage(finding) {
614
+ if (finding.severity === "BLOCK") {
615
+ return "This change may cause runtime failures or security issues";
616
+ }
617
+ if (finding.severity === "WARN") {
618
+ return "This change should be reviewed before shipping";
619
+ }
620
+ return "This change is informational";
621
+ }
622
+
623
+ /**
624
+ * Get remediation command for finding
625
+ */
626
+ function getRemediationCommand(finding) {
627
+ const detectorId = finding.detectorId;
628
+
629
+ // If the change is intentional, sync contracts
630
+ if (detectorId.includes("NEW") || detectorId.includes("ADDED")) {
631
+ return {
632
+ ifIntentional: "vibecheck ctx sync",
633
+ description: "If this change is intentional, sync contracts",
634
+ };
635
+ }
636
+
637
+ if (detectorId.includes("REMOVED")) {
638
+ if (detectorId.includes("ROUTE")) {
639
+ return {
640
+ ifIntentional: "vibecheck ctx sync",
641
+ ifAccidental: `Add route back or remove client call to ${finding.evidence?.[0]?.path || "the endpoint"}`,
642
+ description: "Route was removed - verify this is intentional",
643
+ };
644
+ }
645
+ if (detectorId.includes("AUTH")) {
646
+ return {
647
+ ifIntentional: "vibecheck ctx sync --force",
648
+ ifAccidental: "Restore auth protection to the route",
649
+ description: "Auth was removed - this is usually NOT intentional",
650
+ };
651
+ }
652
+ }
653
+
654
+ return {
655
+ ifIntentional: "vibecheck ctx sync",
656
+ description: "Review the change and sync if intentional",
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Write contracts diff to disk
662
+ */
663
+ function writeContractsDiff(repoRoot, driftResult) {
664
+ const dir = path.join(repoRoot, ".vibecheck");
665
+ fs.mkdirSync(dir, { recursive: true });
666
+
667
+ const explanation = explainDrift(driftResult);
668
+
669
+ const diff = {
670
+ meta: {
671
+ generatedAt: new Date().toISOString(),
672
+ verdict: explanation.summary.verdict,
673
+ },
674
+ ...explanation,
675
+ };
676
+
677
+ const diffPath = path.join(dir, "contracts_diff.json");
678
+ fs.writeFileSync(diffPath, JSON.stringify(diff, null, 2));
679
+
680
+ return { path: diffPath, explanation };
681
+ }
682
+
683
+ /**
684
+ * Print drift report to console
685
+ */
686
+ function printDriftReport(driftResult) {
687
+ const explanation = explainDrift(driftResult);
688
+
689
+ console.log("\n" + "=".repeat(60));
690
+ console.log("šŸ“‹ CONTRACT DRIFT REPORT");
691
+ console.log("=".repeat(60));
692
+
693
+ if (!driftResult.hasDrift) {
694
+ console.log("\nāœ… No drift detected - contracts are in sync\n");
695
+ return;
696
+ }
697
+
698
+ const verdictEmoji = {
699
+ BLOCK: "🚫",
700
+ WARN: "āš ļø",
701
+ OK: "āœ…",
702
+ }[explanation.summary.verdict];
703
+
704
+ console.log(`\nVerdict: ${verdictEmoji} ${explanation.summary.verdict}`);
705
+ console.log(`Changes: ${explanation.summary.totalChanges}`);
706
+
707
+ // Group by severity
708
+ const blockers = explanation.changes.filter(c => c.severity === "BLOCK");
709
+ const warnings = explanation.changes.filter(c => c.severity === "WARN");
710
+ const infos = explanation.changes.filter(c => c.severity === "INFO");
711
+
712
+ if (blockers.length > 0) {
713
+ console.log("\n🚫 BLOCKERS:");
714
+ for (const change of blockers) {
715
+ console.log(` ${change.type.toUpperCase()}: ${change.title}`);
716
+ console.log(` └─ Why: ${change.why}`);
717
+ if (change.remediation?.ifAccidental) {
718
+ console.log(` └─ Fix: ${change.remediation.ifAccidental}`);
719
+ }
720
+ }
721
+ }
722
+
723
+ if (warnings.length > 0) {
724
+ console.log("\nāš ļø WARNINGS:");
725
+ for (const change of warnings) {
726
+ console.log(` ${change.type.toUpperCase()}: ${change.title}`);
727
+ console.log(` └─ Why: ${change.why}`);
728
+ }
729
+ }
730
+
731
+ if (infos.length > 0) {
732
+ console.log("\nā„¹ļø INFO:");
733
+ for (const change of infos) {
734
+ console.log(` ${change.type.toUpperCase()}: ${change.title}`);
735
+ }
736
+ }
737
+
738
+ // Remediation
739
+ console.log("\n" + "-".repeat(60));
740
+ console.log("REMEDIATION:");
741
+
742
+ if (explanation.summary.verdict === "BLOCK") {
743
+ console.log("\n If changes are INTENTIONAL:");
744
+ console.log(" $ vibecheck ctx sync --force");
745
+ console.log("\n If changes are ACCIDENTAL:");
746
+ console.log(" - Review the blockers above and fix the issues");
747
+ console.log(" - Then run: vibecheck ctx sync");
748
+ } else {
749
+ console.log("\n To accept these changes:");
750
+ console.log(" $ vibecheck ctx sync");
751
+ }
752
+
753
+ console.log("\n" + "=".repeat(60) + "\n");
754
+ }
755
+
756
+ // =============================================================================
757
+ // HELPERS
758
+ // =============================================================================
759
+
760
+ function computeHash(content) {
761
+ return crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
762
+ }
763
+
764
+ function countByField(items, field) {
765
+ const counts = {};
766
+ for (const item of items) {
767
+ const value = item[field] || "unknown";
768
+ counts[value] = (counts[value] || 0) + 1;
769
+ }
770
+ return counts;
771
+ }
772
+
773
+ // =============================================================================
774
+ // EXPORTS
775
+ // =============================================================================
776
+
777
+ module.exports = {
778
+ // Contract types
779
+ CONTRACT_TYPES,
780
+
781
+ // Generation
782
+ generateContracts,
783
+ generateRouteContract,
784
+ generateEnvContract,
785
+ generateAuthContract,
786
+ generateExternalContract,
787
+
788
+ // Drift detection
789
+ detectDrift,
790
+ detectRouteDrift,
791
+ detectEnvDrift,
792
+ detectAuthDrift,
793
+ detectExternalDrift,
794
+
795
+ // Drift explainability
796
+ explainDrift,
797
+ writeContractsDiff,
798
+ printDriftReport,
799
+
800
+ // Sync/Guard
801
+ syncContracts,
802
+ loadPreviousContracts,
803
+ guardContracts,
804
+ };