@vibecheckai/cli 3.2.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/runners/lib/agent-firewall/change-packet/builder.js +214 -0
  2. package/bin/runners/lib/agent-firewall/change-packet/schema.json +228 -0
  3. package/bin/runners/lib/agent-firewall/change-packet/store.js +200 -0
  4. package/bin/runners/lib/agent-firewall/claims/claim-types.js +21 -0
  5. package/bin/runners/lib/agent-firewall/claims/extractor.js +214 -0
  6. package/bin/runners/lib/agent-firewall/claims/patterns.js +24 -0
  7. package/bin/runners/lib/agent-firewall/evidence/auth-evidence.js +88 -0
  8. package/bin/runners/lib/agent-firewall/evidence/contract-evidence.js +75 -0
  9. package/bin/runners/lib/agent-firewall/evidence/env-evidence.js +118 -0
  10. package/bin/runners/lib/agent-firewall/evidence/resolver.js +102 -0
  11. package/bin/runners/lib/agent-firewall/evidence/route-evidence.js +142 -0
  12. package/bin/runners/lib/agent-firewall/evidence/side-effect-evidence.js +145 -0
  13. package/bin/runners/lib/agent-firewall/fs-hook/daemon.js +19 -0
  14. package/bin/runners/lib/agent-firewall/fs-hook/installer.js +87 -0
  15. package/bin/runners/lib/agent-firewall/fs-hook/watcher.js +184 -0
  16. package/bin/runners/lib/agent-firewall/git-hook/pre-commit.js +163 -0
  17. package/bin/runners/lib/agent-firewall/ide-extension/cursor.js +107 -0
  18. package/bin/runners/lib/agent-firewall/ide-extension/vscode.js +68 -0
  19. package/bin/runners/lib/agent-firewall/ide-extension/windsurf.js +66 -0
  20. package/bin/runners/lib/agent-firewall/interceptor/base.js +304 -0
  21. package/bin/runners/lib/agent-firewall/interceptor/cursor.js +35 -0
  22. package/bin/runners/lib/agent-firewall/interceptor/vscode.js +35 -0
  23. package/bin/runners/lib/agent-firewall/interceptor/windsurf.js +34 -0
  24. package/bin/runners/lib/agent-firewall/policy/default-policy.json +84 -0
  25. package/bin/runners/lib/agent-firewall/policy/engine.js +72 -0
  26. package/bin/runners/lib/agent-firewall/policy/loader.js +143 -0
  27. package/bin/runners/lib/agent-firewall/policy/rules/auth-drift.js +50 -0
  28. package/bin/runners/lib/agent-firewall/policy/rules/contract-drift.js +50 -0
  29. package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +61 -0
  30. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +50 -0
  31. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +50 -0
  32. package/bin/runners/lib/agent-firewall/policy/rules/scope.js +93 -0
  33. package/bin/runners/lib/agent-firewall/policy/rules/unsafe-side-effect.js +57 -0
  34. package/bin/runners/lib/agent-firewall/policy/schema.json +183 -0
  35. package/bin/runners/lib/agent-firewall/policy/verdict.js +54 -0
  36. package/bin/runners/lib/agent-firewall/truthpack/index.js +67 -0
  37. package/bin/runners/lib/agent-firewall/truthpack/loader.js +116 -0
  38. package/bin/runners/lib/agent-firewall/unblock/planner.js +337 -0
  39. package/bin/runners/lib/analysis-core.js +198 -180
  40. package/bin/runners/lib/analyzers.js +1119 -536
  41. package/bin/runners/lib/cli-output.js +236 -210
  42. package/bin/runners/lib/detectors-v2.js +547 -785
  43. package/bin/runners/lib/fingerprint.js +377 -0
  44. package/bin/runners/lib/route-truth.js +1167 -322
  45. package/bin/runners/lib/scan-output.js +144 -738
  46. package/bin/runners/lib/ship-output-enterprise.js +239 -0
  47. package/bin/runners/lib/terminal-ui.js +188 -770
  48. package/bin/runners/lib/truth.js +1004 -321
  49. package/bin/runners/lib/unified-output.js +162 -158
  50. package/bin/runners/runAgent.js +161 -0
  51. package/bin/runners/runFirewall.js +134 -0
  52. package/bin/runners/runFirewallHook.js +56 -0
  53. package/bin/runners/runScan.js +113 -10
  54. package/bin/runners/runShip.js +7 -8
  55. package/bin/runners/runTruth.js +89 -0
  56. package/mcp-server/agent-firewall-interceptor.js +164 -0
  57. package/mcp-server/index.js +347 -313
  58. package/mcp-server/truth-context.js +131 -90
  59. package/mcp-server/truth-firewall-tools.js +1412 -1045
  60. package/package.json +1 -1
@@ -1,25 +1,79 @@
1
1
  /**
2
- * Truth Firewall MCP Tools
2
+ * Truth Firewall MCP Tools (Enhanced)
3
+ *
4
+ * Goals:
5
+ * - Evidence-first outputs (file/line/snippet + stable hash)
6
+ * - Policy-aware enforcement (strict/balanced/permissive)
7
+ * - Project fingerprint invalidates stale claims
8
+ * - Safe filesystem access (no path traversal)
9
+ * - Patch verification with allowlist + timeouts
10
+ * - Faster evidence search (optional ripgrep)
11
+ */
12
+
13
+ import fs from "fs/promises";
14
+ import fssync from "fs";
15
+ import path from "path";
16
+ import crypto from "crypto";
17
+ import { execSync, spawnSync } from "child_process";
18
+ import { createRequire } from "module";
19
+
20
+ // Route Truth v1 integration - AST-based route extraction (Fastify + Next.js)
21
+ const require = createRequire(import.meta.url);
22
+ const { RouteIndex, validateRouteExists: routeTruthValidate, canonicalizePath: routeTruthCanonicalize } = require("../bin/runners/lib/route-truth.js");
23
+
24
+ // =============================================================================
25
+ // TYPES (JSDoc for IDE support)
26
+ // =============================================================================
27
+
28
+ /**
29
+ * @typedef {"strict" | "balanced" | "permissive"} PolicyName
30
+ * @typedef {"true" | "false" | "unknown"} ClaimResultValue
31
+ * @typedef {"high" | "medium" | "med" | "low" | number} ConfidenceLabel
3
32
  *
4
- * The "best-in-world" hallucination stopper toolkit.
5
- * Agents cannot work without these tools - they enforce truth.
33
+ * @typedef {Object} EvidenceItem
34
+ * @property {string} file
35
+ * @property {number} [line] - start line
36
+ * @property {string} [lines] - "12-18"
37
+ * @property {string} [snippet]
38
+ * @property {string} [hash]
39
+ * @property {number} [confidence]
6
40
  *
7
- * Core Tools:
8
- * get_truthpack() - Get the truth pack for this repo
9
- * compile_context(task) - Get task-targeted context
10
- * validate_claim(claim) - Verify a claim has evidence (CRITICAL)
11
- * search_evidence(query) - Find evidence for a claim
12
- * find_counterexamples() - Falsify a claim
13
- * propose_patch() - Create proof-carrying patch
14
- * verify_patch() - Verify a patch meets requirements
15
- * check_invariants() - Check all invariants
41
+ * @typedef {Object} NormalizedEvidence
42
+ * @property {string} file
43
+ * @property {number} line
44
+ * @property {string} [lines]
45
+ * @property {string} snippet
46
+ * @property {string} hash
47
+ * @property {number} confidence
48
+ *
49
+ * @typedef {Object} EnforcementDecisionAllowed
50
+ * @property {true} allowed
51
+ * @property {number} confidence
52
+ * @property {string} [reason]
53
+ *
54
+ * @typedef {Object} EnforcementDecisionBlocked
55
+ * @property {false} allowed
56
+ * @property {number} [confidence]
57
+ * @property {string} reason
58
+ * @property {string} [suggestion]
59
+ * @property {string[]} [blockedActions]
60
+ *
61
+ * @typedef {EnforcementDecisionAllowed | EnforcementDecisionBlocked} EnforcementDecision
62
+ *
63
+ * @typedef {Object} ProjectFingerprint
64
+ * @property {string} hash
65
+ * @property {string} commitHash
66
+ * @property {string[]} fileHashes
67
+ * @property {string} generatedAt
68
+ *
69
+ * @typedef {Object} ToolResponseMeta
70
+ * @property {true} ok
71
+ * @property {string} version
72
+ * @property {ProjectFingerprint} projectFingerprint
73
+ * @property {string} attribution
74
+ * @property {string} generatedAt
16
75
  */
17
76
 
18
- import fs from 'fs/promises';
19
- import path from 'path';
20
- import crypto from 'crypto';
21
- import { execSync } from 'child_process';
22
-
23
77
  // =============================================================================
24
78
  // TOOL DEFINITIONS
25
79
  // =============================================================================
@@ -27,42 +81,36 @@ import { execSync } from 'child_process';
27
81
  export const TRUTH_FIREWALL_TOOLS = [
28
82
  {
29
83
  name: "vibecheck.get_truthpack",
30
- description: `📦 Get the Truth Pack — the verified ground truth about this codebase.
84
+ description: `📦 Get the Truth Pack — verified ground truth about this codebase.
31
85
 
32
86
  Returns evidence-backed facts about routes, auth, billing, env vars, and schema.
33
- Every claim has file/line citations and confidence scores.
87
+ Every claim should point to files/lines with confidence scores.
34
88
 
35
- ⚠️ CRITICAL: Use this BEFORE making any assertions about the codebase.`,
89
+ Use this BEFORE making assertions about the repo.`,
36
90
  inputSchema: {
37
91
  type: "object",
38
92
  properties: {
39
93
  scope: {
40
94
  type: "string",
41
95
  enum: ["all", "routes", "auth", "billing", "env", "schema", "graph"],
42
- description: "What to include (default: all)",
43
96
  default: "all",
44
97
  },
45
- refresh: {
46
- type: "boolean",
47
- description: "Force recompile even if cached (default: false)",
48
- default: false,
49
- },
98
+ refresh: { type: "boolean", default: false },
50
99
  },
51
100
  },
52
101
  },
53
-
102
+
54
103
  {
55
104
  name: "vibecheck.validate_claim",
56
105
  description: `🔍 TRUTH FIREWALL — Validate a claim before acting on it.
57
106
 
58
- Returns: true | false | unknown
59
- - If 'unknown': you MUST NOT proceed with actions that depend on this claim
60
- - If 'false': the claim is disproven, do not proceed
61
- - If 'true': proceed with evidence citations
107
+ Returns: true | false | unknown + enforcement decision
108
+ Policy matters:
109
+ - strict/balanced: unknown blocks dependent actions
110
+ - permissive: unknown allowed (but flagged)
62
111
 
63
112
  Examples:
64
113
  { "claim": "route_exists", "subject": { "method": "POST", "path": "/api/login" } }
65
- { "claim": "auth_enforced", "subject": { "path": "/dashboard" } }
66
114
  { "claim": "env_var_exists", "subject": { "name": "STRIPE_SECRET_KEY" } }`,
67
115
  inputSchema: {
68
116
  type: "object",
@@ -71,7 +119,7 @@ Examples:
71
119
  type: "string",
72
120
  enum: [
73
121
  "route_exists",
74
- "route_guarded",
122
+ "route_guarded",
75
123
  "env_var_exists",
76
124
  "env_var_used",
77
125
  "middleware_applied",
@@ -82,405 +130,358 @@ Examples:
82
130
  "model_exists",
83
131
  "component_exists",
84
132
  ],
85
- description: "Type of claim to verify",
86
133
  },
87
134
  subject: {
88
135
  type: "object",
89
- description: "What the claim is about",
90
136
  properties: {
91
- method: { type: "string", description: "HTTP method (for routes)" },
92
- path: { type: "string", description: "Route path or file path" },
93
- name: { type: "string", description: "Name of function/component/env var" },
137
+ method: { type: "string" },
138
+ path: { type: "string" },
139
+ name: { type: "string" },
94
140
  },
95
141
  },
96
- expected: {
142
+ expected: { type: "boolean", default: true },
143
+ policy: {
144
+ type: "string",
145
+ enum: ["strict", "balanced", "permissive"],
146
+ default: "strict",
147
+ },
148
+ refresh: {
97
149
  type: "boolean",
98
- description: "Expected result (default: true)",
99
- default: true,
150
+ default: false,
151
+ description: "Force refresh of underlying truthpack/contracts before verifying",
100
152
  },
101
153
  },
102
154
  required: ["claim", "subject"],
103
155
  },
104
156
  },
105
-
157
+
106
158
  {
107
159
  name: "vibecheck.compile_context",
108
- description: `🎯 Get task-targeted context — minimal sufficient context for your task.
160
+ description: `🎯 Get minimal sufficient context for a task (not a token bomb).
109
161
 
110
- Big context = noise. Small context = missing facts.
111
- This compiles exactly what you need for the task.
112
-
113
- Returns relevant nodes, edges, evidence, and applicable invariants.`,
162
+ Returns relevant nodes, edges, evidence, and invariants.`,
114
163
  inputSchema: {
115
164
  type: "object",
116
165
  properties: {
117
- task: {
118
- type: "string",
119
- description: "What you're trying to do (e.g., 'fix dead login button', 'add Stripe checkout')",
120
- },
121
- files: {
122
- type: "array",
123
- items: { type: "string" },
124
- description: "Specific files to include",
125
- },
126
- policy: {
127
- type: "string",
128
- enum: ["strict", "balanced", "permissive"],
129
- description: "How strict to be about context (default: balanced)",
130
- default: "balanced",
131
- },
166
+ task: { type: "string" },
167
+ files: { type: "array", items: { type: "string" } },
168
+ policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "balanced" },
169
+ maxItems: { type: "number", default: 50 },
132
170
  },
133
171
  required: ["task"],
134
172
  },
135
173
  },
136
-
174
+
137
175
  {
138
176
  name: "vibecheck.search_evidence",
139
177
  description: `📎 Search for evidence in the codebase.
140
178
 
141
- Returns code snippets with file/line citations.
142
- Use this to find proof for claims.`,
179
+ Supports text mode or regex mode. Returns file/line/snippet + hashes.`,
143
180
  inputSchema: {
144
181
  type: "object",
145
182
  properties: {
146
- query: {
147
- type: "string",
148
- description: "What to search for (e.g., 'login handler', 'auth middleware', 'Stripe webhook')",
149
- },
150
- type: {
151
- type: "string",
152
- enum: ["route", "handler", "middleware", "component", "env_var", "model", "any"],
153
- description: "Type of evidence to find (default: any)",
154
- default: "any",
155
- },
156
- limit: {
157
- type: "number",
158
- description: "Max results (default: 10)",
159
- default: 10,
160
- },
183
+ query: { type: "string" },
184
+ mode: { type: "string", enum: ["text", "regex"], default: "text" },
185
+ type: { type: "string", enum: ["route", "handler", "middleware", "component", "env_var", "model", "any"], default: "any" },
186
+ limit: { type: "number", default: 10 },
187
+ caseSensitive: { type: "boolean", default: false },
188
+ includeTests: { type: "boolean", default: false },
161
189
  },
162
190
  required: ["query"],
163
191
  },
164
192
  },
165
-
193
+
166
194
  {
167
195
  name: "vibecheck.find_counterexamples",
168
- description: `🔴 Find counterexamples that would disprove a claim.
169
-
170
- This is the FALSIFICATION mechanism.
171
- If counterexamples exist, the claim becomes false or low confidence.
196
+ description: `🔴 FALSIFICATION — find counterexamples that disprove a claim.
172
197
 
173
- Use this for high-stakes claims about auth, billing, security.`,
198
+ Use for auth, billing, security.`,
174
199
  inputSchema: {
175
200
  type: "object",
176
201
  properties: {
177
- claim: {
178
- type: "string",
179
- enum: ["auth_enforced", "billing_gate_exists", "route_guarded", "no_bypass"],
180
- description: "Claim to falsify",
181
- },
202
+ claim: { type: "string", enum: ["auth_enforced", "billing_gate_exists", "route_guarded", "no_bypass"] },
182
203
  subject: {
183
204
  type: "object",
184
- description: "What the claim is about",
185
- properties: {
186
- path: { type: "string" },
187
- name: { type: "string" },
188
- },
205
+ properties: { path: { type: "string" }, name: { type: "string" } },
189
206
  },
207
+ policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
190
208
  },
191
209
  required: ["claim", "subject"],
192
210
  },
193
211
  },
194
-
212
+
195
213
  {
196
214
  name: "vibecheck.propose_patch",
197
215
  description: `📝 Propose a proof-carrying patch.
198
216
 
199
- When proposing changes, you MUST attach:
200
- - Which findings it fixes
201
- - Which claims it depends on (must be verified)
202
- - Evidence references
203
- - Verification commands
217
+ Patches must attach:
218
+ - findings fixed
219
+ - claim dependencies (validated)
220
+ - verification commands
204
221
 
205
222
  Patches without proof are NOT eligible for auto-apply.`,
206
223
  inputSchema: {
207
224
  type: "object",
208
225
  properties: {
209
- diff: {
210
- type: "string",
211
- description: "The diff content",
212
- },
213
- fixes: {
214
- type: "array",
215
- items: { type: "string" },
216
- description: "Finding IDs this patch fixes",
217
- },
218
- claims: {
219
- type: "array",
220
- items: { type: "string" },
221
- description: "Claim IDs this patch depends on (must all be verified)",
222
- },
223
- verification: {
224
- type: "array",
225
- items: { type: "string" },
226
- description: "Commands to verify the patch (e.g., 'vibecheck ship', 'pnpm test')",
227
- },
226
+ diff: { type: "string" },
227
+ fixes: { type: "array", items: { type: "string" } },
228
+ claims: { type: "array", items: { type: "string" } },
229
+ verification: { type: "array", items: { type: "string" } },
230
+ policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
231
+ save: { type: "boolean", default: true },
228
232
  },
229
233
  required: ["diff", "fixes"],
230
234
  },
231
235
  },
232
-
236
+
233
237
  {
234
- name: "vibecheck.check_invariants",
235
- description: `⚖️ Check all invariants (product religion rules).
238
+ name: "vibecheck.verify_patch",
239
+ description: `✅ Verify a patch meets requirements.
240
+
241
+ Runs verification commands with allowlist + timeouts.
242
+ Returns pass/fail and command output (truncated).`,
243
+ inputSchema: {
244
+ type: "object",
245
+ properties: {
246
+ patchId: { type: "string", description: "Patch ID from propose_patch (optional)" },
247
+ diff: { type: "string", description: "If no patchId, provide diff text (not auto-applied)" },
248
+ commands: { type: "array", items: { type: "string" }, description: "Commands to run" },
249
+ policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
250
+ timeoutMs: { type: "number", default: 120000 },
251
+ },
252
+ required: ["commands"],
253
+ },
254
+ },
236
255
 
237
- Returns:
238
- - Ship killers: BLOCK deployment
239
- - Warnings: Require acknowledgment
256
+ {
257
+ name: "vibecheck.check_invariants",
258
+ description: `⚖️ Check invariants (ship-killer rules).
240
259
 
241
- Invariants include:
242
- - No paid feature without server-side enforcement
260
+ Examples:
261
+ - No paid feature without server enforcement
243
262
  - No success UI without confirmed success
244
- - No route reference without matching route map entry
245
- - No silent catch in auth/billing flows`,
263
+ - No silent catch in auth/billing`,
246
264
  inputSchema: {
247
265
  type: "object",
248
266
  properties: {
249
- category: {
250
- type: "string",
251
- enum: ["all", "auth", "billing", "security", "ux", "api"],
252
- description: "Category to check (default: all)",
253
- default: "all",
254
- },
267
+ category: { type: "string", enum: ["all", "auth", "billing", "security", "ux", "api"], default: "all" },
268
+ policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
255
269
  },
256
270
  },
257
271
  },
258
-
272
+
259
273
  {
260
274
  name: "vibecheck.add_assumption",
261
- description: `⚠️ Log an assumption (with budget enforcement).
262
-
263
- Assumptions stack up and cause failures. Track them explicitly.
264
-
265
- Rules:
266
- - Max 2 assumptions per mission
267
- - Must provide verification steps
268
- - If budget exceeded, you MUST use proof tooling instead`,
275
+ description: `⚠️ Log an assumption (budget enforced).`,
269
276
  inputSchema: {
270
277
  type: "object",
271
278
  properties: {
272
- description: {
273
- type: "string",
274
- description: "What you're assuming",
275
- },
276
- reason: {
277
- type: "string",
278
- description: "Why you need this assumption",
279
- },
280
- verificationSteps: {
281
- type: "array",
282
- items: { type: "string" },
283
- description: "How to verify this assumption later",
284
- },
279
+ description: { type: "string" },
280
+ reason: { type: "string" },
281
+ verificationSteps: { type: "array", items: { type: "string" } },
285
282
  },
286
283
  required: ["description", "verificationSteps"],
287
284
  },
288
285
  },
289
-
286
+
290
287
  {
291
288
  name: "vibecheck.validate_plan",
292
- description: `🛡️ HALLUCINATION STOPPER — Validate an AI plan against contracts.
293
-
294
- Before executing a plan, you MUST validate it against contracts.
295
- Rejects plans that:
296
- - Reference routes not in routes.json
297
- - Use env vars not in env.json
298
- - Make auth assumptions that contradict auth.json
299
- - Reference external services not in external.json
300
-
301
- ⚠️ CRITICAL: If validation fails, you MUST NOT proceed with the plan.
302
-
303
- Example:
304
- { "plan": "Create POST /api/checkout endpoint using STRIPE_SECRET_KEY" }
289
+ description: `🛡️ Validate an AI plan against contracts (routes/env/auth/external).
305
290
 
306
- Returns: { valid: boolean, violations: [], warnings: [], suggestions: [] }`,
291
+ If validation fails: do NOT proceed.`,
307
292
  inputSchema: {
308
293
  type: "object",
309
294
  properties: {
310
- plan: {
311
- type: "string",
312
- description: "The plan text or JSON to validate",
313
- },
314
- strict: {
315
- type: "boolean",
316
- description: "If true, warnings also cause rejection (default: false)",
317
- default: false,
318
- },
295
+ plan: { type: "string" },
296
+ strict: { type: "boolean", default: false },
319
297
  },
320
298
  required: ["plan"],
321
299
  },
322
300
  },
323
-
301
+
324
302
  {
325
303
  name: "vibecheck.check_drift",
326
- description: `📊 Check for contract drift — detect when code has changed but contracts are stale.
327
-
328
- Returns drift findings for:
329
- - Routes added/removed but not in contract
330
- - Env vars used but not declared
331
- - Auth patterns changed
332
- - External services added
333
-
334
- Per spec: routes/env/auth drift → BLOCK (AI will lie about these)`,
304
+ description: `📊 Detect contract drift (routes/env/auth/external).`,
335
305
  inputSchema: {
336
306
  type: "object",
337
307
  properties: {
338
- category: {
339
- type: "string",
340
- enum: ["all", "routes", "env", "auth", "external"],
341
- description: "Category to check (default: all)",
342
- default: "all",
343
- },
308
+ category: { type: "string", enum: ["all", "routes", "env", "auth", "external"], default: "all" },
344
309
  },
345
310
  },
346
311
  },
347
-
312
+
348
313
  {
349
314
  name: "vibecheck.get_contracts",
350
- description: `📜 Get the current contracts (routes/env/auth/external).
351
-
352
- Contracts are the "you may not lie" rules for this repo.
353
- AI output must satisfy these contracts.
354
-
355
- Returns the contract files from .vibecheck/contracts/`,
315
+ description: `📜 Get contracts from .vibecheck/contracts/`,
356
316
  inputSchema: {
357
317
  type: "object",
358
318
  properties: {
359
- type: {
360
- type: "string",
361
- enum: ["all", "routes", "env", "auth", "external"],
362
- description: "Which contract to get (default: all)",
363
- default: "all",
364
- },
319
+ type: { type: "string", enum: ["all", "routes", "env", "auth", "external"], default: "all" },
365
320
  },
366
321
  },
367
322
  },
368
323
  ];
369
324
 
370
325
  // =============================================================================
371
- // TOOL HANDLERS
326
+ // TOOL HANDLER
372
327
  // =============================================================================
373
328
 
374
329
  export async function handleTruthFirewallTool(toolName, args, projectPath = process.cwd()) {
375
330
  switch (toolName) {
376
331
  case "vibecheck.get_truthpack":
377
- return await getTruthPack(projectPath, args);
378
-
332
+ return wrapMcpResponse(await getTruthPack(projectPath, args), projectPath);
333
+
379
334
  case "vibecheck.validate_claim":
380
- return await validateClaim(projectPath, args);
381
-
335
+ return wrapMcpResponse(await validateClaim(projectPath, args), projectPath);
336
+
382
337
  case "vibecheck.compile_context":
383
- return await compileContext(projectPath, args);
384
-
338
+ return wrapMcpResponse(await compileContext(projectPath, args), projectPath);
339
+
385
340
  case "vibecheck.search_evidence":
386
- return await searchEvidence(projectPath, args);
387
-
341
+ return wrapMcpResponse(await searchEvidence(projectPath, args), projectPath);
342
+
388
343
  case "vibecheck.find_counterexamples":
389
- return await findCounterexamples(projectPath, args);
390
-
344
+ return wrapMcpResponse(await findCounterexamples(projectPath, args), projectPath);
345
+
391
346
  case "vibecheck.propose_patch":
392
- return await proposePatch(projectPath, args);
393
-
347
+ return wrapMcpResponse(await proposePatch(projectPath, args), projectPath);
348
+
349
+ case "vibecheck.verify_patch":
350
+ return wrapMcpResponse(await verifyPatch(projectPath, args), projectPath);
351
+
394
352
  case "vibecheck.check_invariants":
395
- return await checkInvariants(projectPath, args);
396
-
353
+ return wrapMcpResponse(await checkInvariants(projectPath, args), projectPath);
354
+
397
355
  case "vibecheck.add_assumption":
398
- return await addAssumption(projectPath, args);
399
-
356
+ return wrapMcpResponse(await addAssumption(projectPath, args), projectPath);
357
+
400
358
  case "vibecheck.validate_plan":
401
- return await getPlanValidationResult(projectPath, args);
402
-
359
+ return wrapMcpResponse(await getPlanValidationResult(projectPath, args), projectPath);
360
+
403
361
  case "vibecheck.check_drift":
404
- return await checkDriftTool(projectPath, args);
405
-
362
+ return wrapMcpResponse(await checkDriftTool(projectPath, args), projectPath);
363
+
406
364
  case "vibecheck.get_contracts":
407
- return await getContractsTool(projectPath, args);
408
-
365
+ return wrapMcpResponse(await getContractsTool(projectPath, args), projectPath);
366
+
409
367
  default:
410
- return { error: `Unknown tool: ${toolName}` };
368
+ return wrapMcpResponse({ error: `Unknown tool: ${toolName}` }, projectPath);
411
369
  }
412
370
  }
413
371
 
414
372
  // =============================================================================
415
- // IMPLEMENTATION
373
+ // STATE (Per-project aware)
416
374
  // =============================================================================
417
375
 
418
- // In-memory state
376
+ /**
377
+ * @typedef {Object} CachedClaim
378
+ * @property {string} projectHash
379
+ * @property {number} timestamp
380
+ * @property {*} result
381
+ */
382
+
419
383
  const state = {
420
- truthPack: null,
421
- assumptions: [],
384
+ truthPackByProject: new Map(),
385
+ assumptionsByProject: new Map(),
422
386
  verifiedClaims: new Map(),
423
- maxAssumptions: 2,
424
387
  lastValidationByProject: new Map(),
388
+ routeIndexByProject: new Map(), // Route Truth v1 index cache
389
+ maxAssumptions: 2,
425
390
  };
426
391
 
427
- const MAX_EVIDENCE_SNIPPET = 200;
392
+ const MAX_EVIDENCE_SNIPPET = 240;
393
+ const MAX_CMD_OUTPUT = 12_000;
394
+
395
+ // =============================================================================
396
+ // POLICY CONFIG
397
+ // =============================================================================
428
398
 
429
- /**
430
- * Policy configuration - aligned with proof-context.js TRUTH_CONTRACT
431
- */
432
399
  const POLICY_CONFIG = {
433
400
  strict: {
434
401
  minConfidence: 0.8,
435
402
  allowUnknown: false,
436
403
  requireValidation: true,
437
404
  blockOnDrift: true,
438
- validationTTL: 5 * 60 * 1000, // 5 minutes
405
+ validationTTL: 5 * 60 * 1000,
439
406
  },
440
407
  balanced: {
441
408
  minConfidence: 0.6,
442
409
  allowUnknown: false,
443
410
  requireValidation: true,
444
411
  blockOnDrift: false,
445
- validationTTL: 10 * 60 * 1000, // 10 minutes
412
+ validationTTL: 10 * 60 * 1000,
446
413
  },
447
414
  permissive: {
448
415
  minConfidence: 0.4,
449
416
  allowUnknown: true,
450
417
  requireValidation: false,
451
418
  blockOnDrift: false,
452
- validationTTL: 30 * 60 * 1000, // 30 minutes
419
+ validationTTL: 30 * 60 * 1000,
453
420
  },
454
421
  };
455
422
 
456
- /**
457
- * Get policy configuration
458
- */
459
- export function getPolicyConfig(policy = 'strict') {
423
+ export function getPolicyConfig(policy = "strict") {
460
424
  return POLICY_CONFIG[policy] || POLICY_CONFIG.strict;
461
425
  }
462
426
 
463
427
  function confidenceToScore(confidence) {
464
428
  if (typeof confidence === "number") return confidence;
465
429
  switch (confidence) {
466
- case "high":
467
- return 0.9;
430
+ case "high": return 0.9;
468
431
  case "medium":
469
- return 0.7;
470
- case "low":
471
- return 0.5;
472
- default:
473
- return 0.6;
432
+ case "med": return 0.7;
433
+ case "low": return 0.5;
434
+ default: return 0.6;
435
+ }
436
+ }
437
+
438
+ // =============================================================================
439
+ // SAFETY: PROJECT-ROOT SANDBOX
440
+ // =============================================================================
441
+
442
+ function safeProjectJoin(projectPath, rel) {
443
+ const root = path.resolve(projectPath);
444
+ const abs = path.resolve(root, rel);
445
+ if (!abs.startsWith(root + path.sep) && abs !== root) {
446
+ throw new Error(`Refusing to access path outside project root: ${rel}`);
474
447
  }
448
+ return abs;
449
+ }
450
+
451
+ async function safeReadFile(projectPath, rel) {
452
+ const abs = safeProjectJoin(projectPath, rel);
453
+ return await fs.readFile(abs, "utf8");
454
+ }
455
+
456
+ // =============================================================================
457
+ // EVIDENCE NORMALIZATION
458
+ // =============================================================================
459
+
460
+ function sha16(s) {
461
+ return crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
462
+ }
463
+
464
+ function parseLineRange(lines) {
465
+ if (typeof lines === "number") return { start: Math.max(1, lines), end: Math.max(1, lines) };
466
+ if (!lines) return { start: 1, end: 1 };
467
+ const s = String(lines).trim();
468
+ const m = s.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
469
+ if (!m) return { start: 1, end: 1 };
470
+ const a = Number(m[1]);
471
+ const b = m[2] ? Number(m[2]) : a;
472
+ return { start: Math.max(1, a), end: Math.max(1, b) };
475
473
  }
476
474
 
477
- async function readSnippet(projectPath, file, line) {
475
+ async function readSnippet(projectPath, file, lines) {
478
476
  if (!file) return "";
479
477
  try {
480
- const content = await fs.readFile(path.join(projectPath, file), "utf8");
481
- const lines = content.split("\n");
482
- const idx = Math.max(0, Math.min(lines.length - 1, line - 1));
483
- return (lines[idx] || "").slice(0, MAX_EVIDENCE_SNIPPET);
478
+ const content = await safeReadFile(projectPath, file);
479
+ const arr = content.split(/\r?\n/);
480
+ const { start, end } = parseLineRange(lines);
481
+ const s = Math.max(1, Math.min(arr.length, start));
482
+ const e = Math.max(s, Math.min(arr.length, end));
483
+ const snippet = arr.slice(s - 1, e).join("\n");
484
+ return snippet.slice(0, MAX_EVIDENCE_SNIPPET);
484
485
  } catch {
485
486
  return "";
486
487
  }
@@ -488,91 +489,180 @@ async function readSnippet(projectPath, file, line) {
488
489
 
489
490
  async function normalizeEvidence(projectPath, evidence, fallback, confidence) {
490
491
  const raw = Array.isArray(evidence) ? evidence : evidence ? [evidence] : [];
491
- const normalized = [];
492
+ const out = [];
492
493
 
493
494
  for (const item of raw) {
494
495
  const file = item?.file || fallback?.file || "";
495
- const line = Number(item?.line || item?.lines || fallback?.line || 1);
496
- const snippet =
497
- item?.snippet ||
498
- item?.evidence ||
499
- (await readSnippet(projectPath, file, line));
496
+ const rangeStr = item?.lines ?? item?.line ?? fallback?.line ?? 1;
497
+ const { start, end } = parseLineRange(rangeStr);
498
+ const snippet = (item?.snippet || (await readSnippet(projectPath, file, `${start}-${end}`)) || "").slice(0, MAX_EVIDENCE_SNIPPET);
499
+ const hash = item?.hash || sha16(`${file}:${start}:${snippet}`);
500
500
 
501
- normalized.push({
501
+ out.push({
502
502
  file,
503
- line,
503
+ line: start,
504
+ lines: end !== start ? `${start}-${end}` : `${start}`,
504
505
  snippet,
506
+ hash,
505
507
  confidence: item?.confidence ?? confidenceToScore(confidence),
506
508
  });
507
509
  }
508
510
 
509
- if (normalized.length === 0 && fallback?.file) {
510
- normalized.push({
511
- file: fallback.file,
512
- line: fallback.line || 1,
513
- snippet: await readSnippet(projectPath, fallback.file, fallback.line || 1),
511
+ if (out.length === 0 && fallback?.file) {
512
+ const file = fallback.file;
513
+ const line = fallback.line || 1;
514
+ const snippet = await readSnippet(projectPath, file, line);
515
+ out.push({
516
+ file,
517
+ line,
518
+ lines: `${line}`,
519
+ snippet,
520
+ hash: sha16(`${file}:${line}:${snippet}`),
514
521
  confidence: confidenceToScore(confidence),
515
522
  });
516
523
  }
517
524
 
518
- return normalized;
525
+ return out;
519
526
  }
520
527
 
521
- /**
522
- * Check if there's a recent claim validation for the project.
523
- * The TTL depends on the policy mode.
524
- */
525
- export function hasRecentClaimValidation(projectPath, policy = 'strict') {
526
- const last = state.lastValidationByProject.get(projectPath);
527
- if (typeof last !== "number") return false;
528
-
529
- const config = getPolicyConfig(policy);
530
- const maxAgeMs = config.validationTTL;
531
- return Date.now() - last <= maxAgeMs;
532
- }
528
+ // =============================================================================
529
+ // POLICY ENFORCEMENT (FIXED)
530
+ // =============================================================================
533
531
 
534
532
  /**
535
- * Validate a claim result against policy thresholds.
536
- * Returns an enforcement decision.
533
+ * Correct confidence derivation (your original had precedence issues).
537
534
  */
538
- export function enforceClaimResult(result, policy = 'strict') {
535
+ export function enforceClaimResult(result, policy = "strict") {
539
536
  const config = getPolicyConfig(policy);
540
- const confidence = confidenceToScore(result.confidence || result.result === 'true' ? 0.9 : 0.3);
541
-
542
- // Unknown results
543
- if (result.result === 'unknown') {
544
- if (!config.allowUnknown) {
545
- return {
546
- allowed: false,
547
- reason: `Unknown claims are not allowed in ${policy} mode`,
548
- suggestion: 'Use search_evidence to find proof or get_truthpack to refresh context',
549
- };
550
- }
537
+
538
+ const derived =
539
+ result?.confidence !== undefined
540
+ ? confidenceToScore(result.confidence)
541
+ : (result?.result === "true" ? 0.9 : result?.result === "false" ? 0.9 : 0.3);
542
+
543
+ if (result.result === "unknown" && !config.allowUnknown) {
544
+ return {
545
+ allowed: false,
546
+ confidence: derived,
547
+ reason: `Unknown claims are not allowed in ${policy} mode`,
548
+ suggestion: "Use search_evidence / get_truthpack / refresh=true to gather proof.",
549
+ blockedActions: ["fix", "autopilot_apply", "propose_patch"],
550
+ };
551
+ }
552
+
553
+ if (derived < config.minConfidence) {
554
+ return {
555
+ allowed: false,
556
+ confidence: derived,
557
+ reason: `Confidence ${(derived * 100).toFixed(0)}% below ${policy} threshold ${(config.minConfidence * 100).toFixed(0)}%`,
558
+ suggestion: "Find more evidence or lower strictness (permissive policy).",
559
+ };
551
560
  }
552
-
553
- // Low confidence
554
- if (confidence < config.minConfidence) {
561
+
562
+ if (result.result === "false") {
555
563
  return {
556
564
  allowed: false,
557
- reason: `Confidence ${(confidence * 100).toFixed(0)}% below ${policy} threshold ${(config.minConfidence * 100).toFixed(0)}%`,
558
- suggestion: 'Find additional evidence or use permissive policy',
565
+ confidence: derived,
566
+ reason: "Claim is disproven. Do not proceed with dependent actions.",
559
567
  };
560
568
  }
561
-
562
- return { allowed: true };
569
+
570
+ return { allowed: true, confidence: derived };
571
+ }
572
+
573
+ /**
574
+ * Claim validation freshness per policy TTL.
575
+ */
576
+ export function hasRecentClaimValidation(projectPath, policy = "strict") {
577
+ const last = state.lastValidationByProject.get(projectPath);
578
+ if (typeof last !== "number") return false;
579
+ const ttl = getPolicyConfig(policy).validationTTL;
580
+ return Date.now() - last <= ttl;
581
+ }
582
+
583
+ // =============================================================================
584
+ // FINGERPRINT + WRAPPER
585
+ // =============================================================================
586
+
587
+ function getCommitHash(projectPath) {
588
+ try {
589
+ return execSync("git rev-parse HEAD", { cwd: projectPath, encoding: "utf8" }).trim();
590
+ } catch {
591
+ return "unknown";
592
+ }
593
+ }
594
+
595
+ export function getProjectFingerprint(projectPath) {
596
+ const commitHash = getCommitHash(projectPath);
597
+ const keyFiles = [
598
+ "package.json",
599
+ "pnpm-lock.yaml",
600
+ "package-lock.json",
601
+ "yarn.lock",
602
+ "prisma/schema.prisma",
603
+ "next.config.js",
604
+ "next.config.ts",
605
+ ".vibecheck/contracts/routes.json",
606
+ ".vibecheck/contracts/env.json",
607
+ ".vibecheck/contracts/auth.json",
608
+ ".vibecheck/contracts/external.json",
609
+ ];
610
+
611
+ const fileHashes = [];
612
+ for (const rel of keyFiles) {
613
+ try {
614
+ const abs = safeProjectJoin(projectPath, rel);
615
+ if (!fssync.existsSync(abs)) continue;
616
+ const content = fssync.readFileSync(abs, "utf8");
617
+ fileHashes.push(`${rel}:${sha16(content)}`);
618
+ } catch { /* ignore */ }
619
+ }
620
+
621
+ const material = [commitHash, ...fileHashes].join("|");
622
+ return {
623
+ hash: sha16(material),
624
+ commitHash,
625
+ fileHashes,
626
+ generatedAt: new Date().toISOString(),
627
+ };
628
+ }
629
+
630
+ const CONTEXT_ATTRIBUTION = "🧠 Context enhanced by vibecheck";
631
+
632
+ export function getContextAttribution() {
633
+ return CONTEXT_ATTRIBUTION;
634
+ }
635
+
636
+ export function wrapMcpResponse(data, projectPath) {
637
+ return {
638
+ ok: true,
639
+ version: "2.1.0",
640
+ projectFingerprint: getProjectFingerprint(projectPath),
641
+ attribution: CONTEXT_ATTRIBUTION,
642
+ generatedAt: new Date().toISOString(),
643
+ data,
644
+ };
563
645
  }
564
646
 
647
+ // =============================================================================
648
+ // IMPLEMENTATION: TRUTHPACK
649
+ // =============================================================================
650
+
565
651
  async function getTruthPack(projectPath, args) {
566
- const scope = args.scope || 'all';
567
- const refresh = args.refresh || false;
568
-
569
- if (state.truthPack && !refresh) {
570
- return filterTruthPack(state.truthPack, scope);
652
+ const scope = args?.scope || "all";
653
+ const refresh = Boolean(args?.refresh);
654
+
655
+ if (refresh) {
656
+ // Also clear route index on refresh
657
+ state.routeIndexByProject.delete(projectPath);
658
+ }
659
+
660
+ if (!refresh && state.truthPackByProject.has(projectPath)) {
661
+ return filterTruthPack(state.truthPackByProject.get(projectPath), scope);
571
662
  }
572
-
573
- // Build truth pack
663
+
574
664
  const truthPack = {
575
- version: '1.0.0',
665
+ version: "2.0.0", // v2 with Route Truth v1 integration
576
666
  generatedAt: new Date().toISOString(),
577
667
  projectPath,
578
668
  commitHash: getCommitHash(projectPath),
@@ -580,378 +670,603 @@ async function getTruthPack(projectPath, args) {
580
670
  confidence: 0,
581
671
  _attribution: CONTEXT_ATTRIBUTION,
582
672
  };
583
-
584
- if (scope === 'all' || scope === 'routes') {
585
- truthPack.sections.routes = await extractRoutes(projectPath);
586
- }
587
- if (scope === 'all' || scope === 'auth') {
588
- truthPack.sections.auth = await extractAuth(projectPath);
589
- }
590
- if (scope === 'all' || scope === 'billing') {
591
- truthPack.sections.billing = await extractBilling(projectPath);
592
- }
593
- if (scope === 'all' || scope === 'env') {
594
- truthPack.sections.env = await extractEnv(projectPath);
595
- }
596
- if (scope === 'all' || scope === 'schema') {
597
- truthPack.sections.schema = await extractSchema(projectPath);
598
- }
599
- if (scope === 'all' || scope === 'graph') {
600
- truthPack.sections.graph = await extractGraph(projectPath);
601
- }
602
-
603
- // Calculate confidence
673
+
674
+ if (scope === "all" || scope === "routes") truthPack.sections.routes = await extractRoutes(projectPath, refresh);
675
+ if (scope === "all" || scope === "auth") truthPack.sections.auth = await extractAuth(projectPath);
676
+ if (scope === "all" || scope === "billing") truthPack.sections.billing = await extractBilling(projectPath);
677
+ if (scope === "all" || scope === "env") truthPack.sections.env = await extractEnv(projectPath);
678
+ if (scope === "all" || scope === "schema") truthPack.sections.schema = await extractSchema(projectPath);
679
+ if (scope === "all" || scope === "graph") truthPack.sections.graph = await extractGraph(projectPath);
680
+
604
681
  const sections = Object.values(truthPack.sections);
605
- truthPack.confidence = sections.reduce((sum, s) => sum + (s.confidence || 0), 0) / sections.length;
606
-
607
- state.truthPack = truthPack;
682
+ truthPack.confidence = sections.length
683
+ ? sections.reduce((sum, s) => sum + (s?.confidence || 0), 0) / sections.length
684
+ : 0.4;
685
+
686
+ state.truthPackByProject.set(projectPath, truthPack);
608
687
  return truthPack;
609
688
  }
610
689
 
690
+ function filterTruthPack(pack, scope) {
691
+ if (scope === "all") return pack;
692
+ return { ...pack, sections: { [scope]: pack.sections?.[scope] } };
693
+ }
694
+
695
+ // =============================================================================
696
+ // IMPLEMENTATION: validate_claim (policy-aware + fingerprint cache)
697
+ // =============================================================================
698
+
611
699
  async function validateClaim(projectPath, args) {
612
- const { claim, subject, expected = true } = args;
613
- const claimId = `claim_${crypto.createHash('sha256').update(JSON.stringify({ claim, subject })).digest('hex').slice(0, 12)}`;
614
-
615
- // Check cache
616
- if (state.verifiedClaims.has(claimId)) {
617
- const cached = state.verifiedClaims.get(claimId);
618
- if (Date.now() - cached.timestamp < 5 * 60 * 1000) {
619
- return { ...cached.result, cached: true };
700
+ const { claim, subject, expected = true, policy = "strict", refresh = false } = args || {};
701
+ const pol = policy || "strict";
702
+
703
+ if (refresh) {
704
+ // force refresh truthpack + route index for this project
705
+ state.truthPackByProject.delete(projectPath);
706
+ state.routeIndexByProject.delete(projectPath);
707
+ }
708
+
709
+ const fingerprint = getProjectFingerprint(projectPath);
710
+ const claimKey = { claim, subject, expected };
711
+ const claimId = `claim_${sha16(JSON.stringify(claimKey))}`;
712
+
713
+ // policy TTL cache + fingerprint invalidation
714
+ const cached = state.verifiedClaims.get(claimId);
715
+ if (cached && cached.projectHash === fingerprint.hash) {
716
+ const ttl = getPolicyConfig(pol).validationTTL;
717
+ if (Date.now() - cached.timestamp <= ttl) {
718
+ return { claimId, ...cached.result, cached: true };
620
719
  }
621
720
  }
622
-
623
- let result = { result: 'unknown', confidence: 'low', evidence: [], nextSteps: [] };
624
-
721
+
722
+ let result = { result: "unknown", confidence: "low", evidence: [], nextSteps: [] };
723
+
625
724
  try {
626
725
  switch (claim) {
627
- case 'route_exists':
628
- result = await verifyRouteExists(projectPath, subject);
726
+ case "route_exists":
727
+ result = await verifyRouteExists(projectPath, subject, refresh);
629
728
  break;
630
- case 'file_exists':
729
+
730
+ case "file_exists":
631
731
  result = await verifyFileExists(projectPath, subject);
632
732
  break;
633
- case 'env_var_exists':
634
- case 'env_var_used':
733
+
734
+ case "env_var_exists":
735
+ case "env_var_used":
635
736
  result = await verifyEnvVar(projectPath, subject, claim);
636
737
  break;
637
- case 'auth_enforced':
638
- case 'route_guarded':
738
+
739
+ case "auth_enforced":
740
+ case "route_guarded":
639
741
  result = await verifyRouteGuarded(projectPath, subject);
640
742
  break;
641
- case 'function_exists':
642
- case 'component_exists':
643
- case 'model_exists':
743
+
744
+ case "function_exists":
745
+ case "component_exists":
746
+ case "model_exists":
644
747
  result = await verifyEntityExists(projectPath, subject, claim);
645
748
  break;
749
+
646
750
  default:
647
- result.nextSteps = [`Claim type "${claim}" not yet implemented. Use search_evidence instead.`];
751
+ result = {
752
+ result: "unknown",
753
+ confidence: "low",
754
+ evidence: [],
755
+ nextSteps: [`Claim type "${claim}" not implemented. Use search_evidence.`],
756
+ };
757
+ break;
648
758
  }
649
759
  } catch (error) {
650
- result.nextSteps = [`Verification error: ${error.message}`];
651
- }
652
-
653
- // ENFORCED: Unknown claims must return explicit "unknown" error
654
- // that blocks dependent actions in strict/balanced modes
655
- if (result.result === 'unknown') {
656
- result.nextSteps.push(
657
- 'call vibecheck.search_evidence to find related code',
658
- 'call vibecheck.get_truthpack to get full context',
659
- );
660
- result.warning = '⚠️ UNKNOWN claims BLOCK dependent actions. Verify before proceeding.';
661
- result.enforcement = {
662
- allowed: false,
663
- reason: 'Claim result is unknown - cannot proceed without evidence',
664
- blockedActions: ['fix', 'autopilot_apply', 'propose_patch'],
665
- };
666
- } else if (result.result === 'true') {
667
- result.enforcement = {
668
- allowed: true,
669
- confidence: confidenceToScore(result.confidence),
670
- };
671
- } else {
672
- // result is 'false'
673
- result.enforcement = {
674
- allowed: false,
675
- reason: 'Claim is disproven - do not proceed with dependent actions',
760
+ result = {
761
+ result: "unknown",
762
+ confidence: "low",
763
+ evidence: [],
764
+ nextSteps: [`Verification error: ${error?.message || String(error)}`],
676
765
  };
677
766
  }
678
-
679
- // Cache result
680
- state.verifiedClaims.set(claimId, { result, timestamp: Date.now(), projectPath });
681
- state.lastValidationByProject.set(projectPath, Date.now());
682
-
683
- return {
767
+
768
+ // normalize evidence consistently
769
+ const normalized = await normalizeEvidence(
770
+ projectPath,
771
+ result.evidence,
772
+ { file: subject?.path || subject?.name, line: 1 },
773
+ result.confidence
774
+ );
775
+
776
+ // enforcement (policy-driven)
777
+ const enforcement = enforceClaimResult({ result: result.result, confidence: result.confidence }, pol);
778
+
779
+ // guidance
780
+ const nextSteps = Array.isArray(result.nextSteps) ? result.nextSteps : [];
781
+ if (result.result === "unknown") {
782
+ nextSteps.push("call vibecheck.search_evidence for proof", "call vibecheck.get_truthpack refresh=true");
783
+ }
784
+ if (result.result === "false" && expected === true) {
785
+ nextSteps.push("Claim expected true but evaluated false: check path/method/name canonicalization.");
786
+ }
787
+
788
+ const finalResult = {
684
789
  claimId,
685
- ...result,
686
- evidence: await normalizeEvidence(projectPath, result.evidence, {
687
- file: subject?.path || subject?.name,
688
- line: 1,
689
- }, result.confidence),
790
+ claim,
791
+ subject,
792
+ expected,
793
+ result: result.result,
794
+ confidence: result.confidence,
795
+ evidence: normalized,
796
+ enforcement,
797
+ nextSteps,
690
798
  timestamp: new Date().toISOString(),
691
799
  _attribution: CONTEXT_ATTRIBUTION,
692
800
  };
801
+
802
+ state.verifiedClaims.set(claimId, { projectHash: fingerprint.hash, timestamp: Date.now(), result: finalResult });
803
+ state.lastValidationByProject.set(projectPath, Date.now());
804
+
805
+ return finalResult;
693
806
  }
694
807
 
808
+ // =============================================================================
809
+ // IMPLEMENTATION: compile_context
810
+ // =============================================================================
811
+
695
812
  async function compileContext(projectPath, args) {
696
- const { task, files = [], policy = 'balanced' } = args;
697
-
698
- // Analyze task to determine relevant domains
813
+ const { task, files = [], policy = "balanced", maxItems = 50 } = args || {};
814
+ const pol = policy || "balanced";
815
+
699
816
  const domains = detectDomains(task);
700
817
  const keywords = extractKeywords(task);
701
-
702
- // Get relevant parts of truth pack
703
- const truthPack = await getTruthPack(projectPath, { scope: 'all' });
704
-
705
- // Filter to relevant content
706
- const relevantRoutes = truthPack.sections.routes?.routes?.filter(r =>
707
- keywords.some(k => r.path.includes(k) || r.file.includes(k))
708
- ) || [];
709
-
710
- const relevantAuth = domains.includes('auth') ? truthPack.sections.auth : null;
711
- const relevantBilling = domains.includes('billing') ? truthPack.sections.billing : null;
712
-
713
- // Get applicable invariants
818
+ const truthPack = await getTruthPack(projectPath, { scope: "all", refresh: false });
819
+
820
+ const routesAll = truthPack.sections?.routes?.routes || [];
821
+ const relevantRoutes = routesAll.filter((r) => {
822
+ const hay = `${r.path || ""} ${r.file || ""}`.toLowerCase();
823
+ return keywords.some((k) => hay.includes(k));
824
+ });
825
+
826
+ const context = {
827
+ routes: relevantRoutes.slice(0, Math.max(0, maxItems)),
828
+ auth: domains.includes("auth") ? truthPack.sections?.auth : null,
829
+ billing: domains.includes("billing") ? truthPack.sections?.billing : null,
830
+ env: domains.includes("env") ? truthPack.sections?.env : null,
831
+ focusFiles: Array.isArray(files) ? files : [],
832
+ };
833
+
714
834
  const invariants = getInvariantsForDomains(domains);
715
-
716
- // Estimate token count
717
- const tokenCount = estimateTokens({ relevantRoutes, relevantAuth, relevantBilling });
718
-
835
+ const tokenCount = estimateTokens(context);
836
+
719
837
  return {
720
838
  task,
721
- policy,
839
+ policy: pol,
722
840
  domains,
723
- context: {
724
- routes: relevantRoutes.slice(0, policy === 'strict' ? 10 : 50),
725
- auth: relevantAuth,
726
- billing: relevantBilling,
727
- },
841
+ context,
728
842
  invariants,
729
843
  tokenCount,
730
- warnings: generateContextWarnings(domains, policy, relevantRoutes.length),
844
+ warnings: generateContextWarnings(domains, pol, relevantRoutes.length),
731
845
  _attribution: CONTEXT_ATTRIBUTION,
732
846
  };
733
847
  }
734
848
 
849
+ // =============================================================================
850
+ // IMPLEMENTATION: search_evidence (rg accel + safe scan)
851
+ // =============================================================================
852
+
853
+ function escapeRegex(s) {
854
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
855
+ }
856
+
857
+ function isTestFilePath(rel) {
858
+ return /(^|\/)(__tests__|test|tests|spec)\//i.test(rel) || /\.(test|spec)\.(ts|tsx|js|jsx)$/i.test(rel);
859
+ }
860
+
735
861
  async function searchEvidence(projectPath, args) {
736
- const { query, type = 'any', limit = 10 } = args;
862
+ const {
863
+ query,
864
+ mode = "text",
865
+ type = "any",
866
+ limit = 10,
867
+ caseSensitive = false,
868
+ includeTests = false,
869
+ } = args || {};
870
+
871
+ const q = String(query || "").trim();
872
+ if (!q) return { query: q, count: 0, results: [], _attribution: CONTEXT_ATTRIBUTION };
873
+
874
+ // Try ripgrep for speed (optional)
875
+ const rgResults = tryRipgrep(projectPath, q, { mode, caseSensitive, limit, includeTests });
876
+ if (rgResults) {
877
+ return { query: q, count: rgResults.length, results: rgResults, engine: "ripgrep", _attribution: CONTEXT_ATTRIBUTION };
878
+ }
879
+
880
+ const files = await findSourceFiles(projectPath, { includeTests });
881
+ const flags = caseSensitive ? "g" : "gi";
882
+ const re = new RegExp(mode === "regex" ? q : escapeRegex(q), flags);
883
+
737
884
  const results = [];
738
-
739
- const files = await findSourceFiles(projectPath);
740
- const pattern = new RegExp(query, 'gi');
741
-
742
- for (const file of files.slice(0, 100)) {
885
+ for (const fileAbs of files) {
886
+ const relPath = path.relative(projectPath, fileAbs).replace(/\\/g, "/");
887
+ if (!includeTests && isTestFilePath(relPath)) continue;
888
+
743
889
  try {
744
- const content = await fs.readFile(file, 'utf8');
745
- const lines = content.split('\n');
746
- const relPath = path.relative(projectPath, file);
747
-
890
+ const content = await fs.readFile(fileAbs, "utf8");
891
+ const lines = content.split(/\r?\n/);
892
+
748
893
  for (let i = 0; i < lines.length; i++) {
749
- if (pattern.test(lines[i])) {
750
- const snippet = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join('\n');
894
+ if (re.test(lines[i])) {
895
+ const snippet = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n").slice(0, 320);
751
896
  results.push({
752
897
  file: relPath,
753
898
  line: i + 1,
754
- snippet: snippet.slice(0, 300),
755
- hash: crypto.createHash('sha256').update(lines[i]).digest('hex').slice(0, 16),
899
+ lines: `${i + 1}`,
900
+ snippet,
901
+ hash: sha16(`${relPath}:${i + 1}:${lines[i]}`),
756
902
  confidence: 0.6,
757
903
  });
758
-
759
904
  if (results.length >= limit) break;
760
905
  }
761
- pattern.lastIndex = 0;
906
+ re.lastIndex = 0;
762
907
  }
763
-
908
+
764
909
  if (results.length >= limit) break;
765
- } catch {}
910
+ } catch { /* ignore */ }
766
911
  }
767
-
768
- return {
769
- query,
770
- count: results.length,
771
- results,
772
- _attribution: CONTEXT_ATTRIBUTION,
773
- };
912
+
913
+ return { query: q, count: results.length, results, engine: "scan", _attribution: CONTEXT_ATTRIBUTION };
774
914
  }
775
915
 
916
+ function tryRipgrep(projectPath, query, opts) {
917
+ try {
918
+ const rgArgs = ["-n", "--hidden", "--no-heading", "--color", "never"];
919
+ rgArgs.push("--glob", "!**/node_modules/**");
920
+ rgArgs.push("--glob", "!**/.next/**");
921
+ rgArgs.push("--glob", "!**/dist/**");
922
+ rgArgs.push("--glob", "!**/build/**");
923
+ rgArgs.push("--glob", "!**/coverage/**");
924
+ if (!opts.includeTests) {
925
+ rgArgs.push("--glob", "!**/__tests__/**");
926
+ rgArgs.push("--glob", "!**/tests/**");
927
+ rgArgs.push("--glob", "!**/*.test.*");
928
+ rgArgs.push("--glob", "!**/*.spec.*");
929
+ }
930
+ if (!opts.caseSensitive) rgArgs.push("-i");
931
+ if (opts.mode === "text") rgArgs.push("-F"); // fixed string
932
+ rgArgs.push("--max-count", String(Math.max(1, opts.limit)));
933
+ rgArgs.push(query);
934
+ rgArgs.push(".");
935
+
936
+ const out = spawnSync("rg", rgArgs, { cwd: projectPath, encoding: "utf8" });
937
+ if (out.error || out.status !== 0) return null;
938
+
939
+ const lines = String(out.stdout || "").split(/\r?\n/).filter(Boolean);
940
+ const results = lines.slice(0, opts.limit).map((l) => {
941
+ // format: file:line:match
942
+ const m = l.match(/^(.+?):(\d+):(.*)$/);
943
+ if (!m) return null;
944
+ const file = m[1].replace(/\\/g, "/");
945
+ const lineNum = Number(m[2]);
946
+ const text = m[3] || "";
947
+ return {
948
+ file,
949
+ line: lineNum,
950
+ lines: `${lineNum}`,
951
+ snippet: text.slice(0, 320),
952
+ hash: sha16(`${file}:${lineNum}:${text}`),
953
+ confidence: 0.65,
954
+ };
955
+ }).filter(Boolean);
956
+
957
+ return results;
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+
963
+ // =============================================================================
964
+ // IMPLEMENTATION: find_counterexamples
965
+ // =============================================================================
966
+
776
967
  async function findCounterexamples(projectPath, args) {
777
- const { claim, subject } = args;
968
+ const { claim, subject, policy = "strict" } = args || {};
969
+ const pol = policy || "strict";
970
+
778
971
  const counterexamples = [];
779
-
780
- switch (claim) {
781
- case 'auth_enforced':
782
- // Check for client-only guards
783
- const authResult = await verifyRouteGuarded(projectPath, subject);
784
- if (authResult.result === 'true' && authResult.evidence) {
785
- // Check if guard is client-only
786
- for (const ev of authResult.evidence) {
787
- const content = await fs.readFile(path.join(projectPath, ev.file), 'utf8');
788
- if (content.includes('client') && !content.includes('middleware')) {
789
- counterexamples.push({
790
- type: 'bypass_possible',
791
- description: 'Auth appears to be client-side only - can be bypassed',
792
- evidence: ev,
793
- severity: 'ship_killer',
794
- });
795
- }
796
- }
797
- }
798
- break;
799
-
800
- case 'billing_gate_exists':
801
- // Check for client-only tier checks
802
- const evidence = await searchEvidence(projectPath, { query: subject.name || 'tier', limit: 5 });
803
- for (const ev of evidence.results) {
804
- if (ev.snippet.includes('localStorage') || ev.snippet.includes('client')) {
805
- counterexamples.push({
806
- type: 'bypass_possible',
807
- description: 'Billing check appears client-side only',
808
- evidence: ev,
809
- severity: 'ship_killer',
810
- });
811
- }
972
+
973
+ if (claim === "auth_enforced" || claim === "route_guarded") {
974
+ // Look for client-only guards (classic bypass)
975
+ const guardEvidence = await searchEvidence(projectPath, { query: "useEffect|client|localStorage|sessionStorage", mode: "regex", limit: 20 });
976
+ const middlewareEvidence = await searchEvidence(projectPath, { query: "middleware", mode: "text", limit: 10 });
977
+
978
+ if (guardEvidence.count > 0 && middlewareEvidence.count === 0) {
979
+ counterexamples.push({
980
+ type: "bypass_possible",
981
+ severity: "ship_killer",
982
+ description: "Auth appears client-only (no middleware/server guard evidence found).",
983
+ evidence: guardEvidence.results.slice(0, 3),
984
+ });
985
+ }
986
+ }
987
+
988
+ if (claim === "billing_gate_exists") {
989
+ const billingEvidence = await searchEvidence(projectPath, { query: "isPro|tier|plan|subscription|stripe", mode: "regex", limit: 20 });
990
+ for (const ev of billingEvidence.results) {
991
+ if (/localStorage|sessionStorage|client/i.test(ev.snippet)) {
992
+ counterexamples.push({
993
+ type: "bypass_possible",
994
+ severity: "ship_killer",
995
+ description: "Billing gate likely client-side (bypassable).",
996
+ evidence: ev,
997
+ });
812
998
  }
813
- break;
999
+ }
814
1000
  }
815
-
1001
+
816
1002
  return {
817
1003
  claim,
818
1004
  subject,
1005
+ policy: pol,
819
1006
  counterexamples,
820
1007
  claimDemoted: counterexamples.length > 0,
1008
+ _attribution: CONTEXT_ATTRIBUTION,
821
1009
  };
822
1010
  }
823
1011
 
1012
+ // =============================================================================
1013
+ // IMPLEMENTATION: propose_patch + verify_patch
1014
+ // =============================================================================
1015
+
1016
+ function ensureDir(dirAbs) {
1017
+ if (!fssync.existsSync(dirAbs)) fssync.mkdirSync(dirAbs, { recursive: true });
1018
+ }
1019
+
824
1020
  async function proposePatch(projectPath, args) {
825
- const { diff, fixes, claims = [], verification = [] } = args;
826
-
827
- // Validate all dependent claims
1021
+ const { diff, fixes, claims = [], verification = [], policy = "strict", save = true } = args || {};
1022
+ const pol = policy || "strict";
1023
+
828
1024
  const claimValidation = [];
829
1025
  for (const claimId of claims) {
830
1026
  const cached = state.verifiedClaims.get(claimId);
831
1027
  if (!cached) {
832
- claimValidation.push({ claimId, valid: false, error: 'Claim not verified' });
833
- } else if (cached.result.result === 'unknown') {
834
- claimValidation.push({ claimId, valid: false, error: 'Claim is unknown - cannot proceed' });
835
- } else if (cached.result.result === 'false') {
836
- claimValidation.push({ claimId, valid: false, error: 'Claim is false - invalid dependency' });
837
- } else {
838
- claimValidation.push({ claimId, valid: true });
1028
+ claimValidation.push({ claimId, valid: false, error: "Claim not verified" });
1029
+ continue;
839
1030
  }
1031
+ const res = cached.result;
1032
+ if (res?.result === "unknown") claimValidation.push({ claimId, valid: false, error: "Claim is unknown" });
1033
+ else if (res?.result === "false") claimValidation.push({ claimId, valid: false, error: "Claim is false" });
1034
+ else claimValidation.push({ claimId, valid: true });
840
1035
  }
841
-
842
- const allClaimsValid = claimValidation.every(c => c.valid);
843
-
1036
+
1037
+ const allClaimsValid = claimValidation.every((c) => c.valid);
1038
+ const patchId = `patch_${crypto.randomUUID().slice(0, 12)}`;
1039
+
844
1040
  const patch = {
845
- patchId: `patch_${crypto.randomUUID().slice(0, 12)}`,
846
- diff: diff.slice(0, 5000), // Truncate for storage
847
- fixes,
848
- dependsOnClaims: claims,
849
- verification: verification.length > 0 ? verification : ['vibecheck ship', 'pnpm test'],
1041
+ patchId,
1042
+ diff: String(diff || "").slice(0, 50_000),
1043
+ fixes: Array.isArray(fixes) ? fixes : [],
1044
+ dependsOnClaims: Array.isArray(claims) ? claims : [],
1045
+ verification: (Array.isArray(verification) && verification.length > 0) ? verification : ["vibecheck ship", "pnpm test"],
850
1046
  createdAt: new Date().toISOString(),
851
- eligible: allClaimsValid && fixes.length > 0,
1047
+ eligible: allClaimsValid && (Array.isArray(fixes) && fixes.length > 0),
852
1048
  claimValidation,
1049
+ policy: pol,
853
1050
  };
854
-
1051
+
855
1052
  if (!patch.eligible) {
856
- patch.blockers = claimValidation.filter(c => !c.valid);
857
- patch.message = '⚠️ Patch NOT eligible for auto-apply. Fix blockers first.';
1053
+ patch.blockers = claimValidation.filter((c) => !c.valid);
1054
+ patch.message = "⚠️ Patch NOT eligible for auto-apply. Fix blockers first.";
1055
+ }
1056
+
1057
+ if (save) {
1058
+ try {
1059
+ const dir = safeProjectJoin(projectPath, ".vibecheck/patches");
1060
+ ensureDir(dir);
1061
+ const out = path.join(dir, `${patchId}.json`);
1062
+ await fs.writeFile(out, JSON.stringify(patch, null, 2), "utf8");
1063
+ patch.savedTo = path.relative(projectPath, out).replace(/\\/g, "/");
1064
+ } catch (e) {
1065
+ patch.saveError = e?.message || String(e);
1066
+ }
858
1067
  }
859
-
1068
+
860
1069
  return patch;
861
1070
  }
862
1071
 
1072
+ function commandAllowlisted(cmd) {
1073
+ // Keep this tight. Expand only if you mean it.
1074
+ const allow = [
1075
+ /^vibecheck(\s|$)/,
1076
+ /^pnpm(\s|$)/,
1077
+ /^npm(\s|$)/,
1078
+ /^yarn(\s|$)/,
1079
+ /^node(\s|$)/,
1080
+ /^bun(\s|$)/,
1081
+ /^vitest(\s|$)/,
1082
+ /^jest(\s|$)/,
1083
+ /^tsc(\s|$)/,
1084
+ /^eslint(\s|$)/,
1085
+ /^playwright(\s|$)/,
1086
+ ];
1087
+ return allow.some((re) => re.test(cmd.trim()));
1088
+ }
1089
+
1090
+ async function verifyPatch(projectPath, args) {
1091
+ const { patchId, diff, commands, policy = "strict", timeoutMs = 120000 } = args || {};
1092
+ const pol = policy || "strict";
1093
+
1094
+ let patch = null;
1095
+ if (patchId) {
1096
+ try {
1097
+ const abs = safeProjectJoin(projectPath, `.vibecheck/patches/${patchId}.json`);
1098
+ const content = await fs.readFile(abs, "utf8");
1099
+ patch = JSON.parse(content);
1100
+ } catch (error) {
1101
+ // Invalid JSON or file not found - treat as no patch
1102
+ patch = null;
1103
+ }
1104
+ }
1105
+
1106
+ // NOTE: This does NOT auto-apply diff. It only runs verification commands.
1107
+ // Auto-apply should be a separate tool with explicit guardrails.
1108
+ const cmds = Array.isArray(commands) ? commands : [];
1109
+ const results = [];
1110
+
1111
+ for (const cmd of cmds) {
1112
+ if (!commandAllowlisted(cmd)) {
1113
+ results.push({ cmd, ok: false, blocked: true, reason: "Command not allowlisted" });
1114
+ continue;
1115
+ }
1116
+ const started = Date.now();
1117
+ const out = spawnSync(cmd, {
1118
+ cwd: projectPath,
1119
+ shell: true,
1120
+ encoding: "utf8",
1121
+ timeout: Math.max(1000, Number(timeoutMs) || 120000),
1122
+ maxBuffer: 1024 * 1024 * 5,
1123
+ });
1124
+
1125
+ const stdout = String(out.stdout || "").slice(0, MAX_CMD_OUTPUT);
1126
+ const stderr = String(out.stderr || "").slice(0, MAX_CMD_OUTPUT);
1127
+
1128
+ results.push({
1129
+ cmd,
1130
+ ok: out.status === 0 && !out.error,
1131
+ status: out.status,
1132
+ durationMs: Date.now() - started,
1133
+ stdout,
1134
+ stderr,
1135
+ error: out.error ? String(out.error.message || out.error) : null,
1136
+ });
1137
+ }
1138
+
1139
+ const pass = results.every((r) => r.ok);
1140
+
1141
+ return {
1142
+ patchId: patch?.patchId || patchId || null,
1143
+ hasPatchRecord: !!patch,
1144
+ policy: pol,
1145
+ pass,
1146
+ results,
1147
+ note: "verify_patch runs commands only. Applying diffs should be explicit + guarded.",
1148
+ _attribution: CONTEXT_ATTRIBUTION,
1149
+ };
1150
+ }
1151
+
1152
+ // =============================================================================
1153
+ // IMPLEMENTATION: invariants
1154
+ // =============================================================================
1155
+
863
1156
  async function checkInvariants(projectPath, args) {
864
- const category = args.category || 'all';
1157
+ const { category = "all", policy = "strict" } = args || {};
1158
+ const pol = policy || "strict";
1159
+
865
1160
  const shipKillers = [];
866
1161
  const warnings = [];
867
-
868
- // Check for silent catches in auth/billing
869
- const silentCatches = await searchEvidence(projectPath, {
870
- query: 'catch.*\\{\\s*\\}|catch.*\\{\\s*//|catch.*console\\.log',
871
- limit: 20
1162
+
1163
+ // 1) Silent catches in auth/billing/middleware are ship killers
1164
+ const silentCatches = await searchEvidence(projectPath, {
1165
+ query: String.raw`catch\s*\(\s*\w*\s*\)\s*\{\s*(?:\}|\/\/|console\.log|return\s*;|return\s*null\s*;)`,
1166
+ mode: "regex",
1167
+ limit: 50,
872
1168
  });
873
-
1169
+
874
1170
  for (const ev of silentCatches.results) {
875
- if (ev.file.includes('auth') || ev.file.includes('billing') || ev.file.includes('middleware')) {
1171
+ if (/auth|billing|middleware|payment/i.test(ev.file)) {
876
1172
  shipKillers.push({
877
- invariant: 'security_no_silent_catch',
878
- rule: 'No silent catch in auth/billing flows',
1173
+ invariant: "security_no_silent_catch",
1174
+ rule: "No silent catch in auth/billing/middleware",
879
1175
  evidence: ev,
880
1176
  });
881
1177
  }
882
1178
  }
883
-
884
- // Check for hardcoded secrets
1179
+
1180
+ // 2) Hardcoded secrets (ship killer)
885
1181
  const secrets = await searchEvidence(projectPath, {
886
- query: 'sk_live_|sk_test_|apiKey.*=.*["\'][a-zA-Z0-9]{20,}',
887
- limit: 10,
1182
+ query: String.raw`(sk_live_|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{35}|xox[baprs]-[0-9A-Za-z\-]{10,})`,
1183
+ mode: "regex",
1184
+ limit: 20,
888
1185
  });
889
-
1186
+
890
1187
  for (const ev of secrets.results) {
891
- if (!ev.file.includes('.test.') && !ev.file.includes('.example')) {
1188
+ if (!/\.example|\.test\.|\.spec\./i.test(ev.file)) {
892
1189
  shipKillers.push({
893
- invariant: 'security_no_exposed_secrets',
894
- rule: 'No hardcoded secrets or API keys',
1190
+ invariant: "security_no_exposed_secrets",
1191
+ rule: "No hardcoded secrets or API keys",
895
1192
  evidence: ev,
896
1193
  });
897
1194
  }
898
1195
  }
899
-
1196
+
1197
+ // 3) “Success UI without confirmed success” (warning by default)
1198
+ const fakeSuccess = await searchEvidence(projectPath, {
1199
+ query: String.raw`toast\.(success|info)|setSuccess\s*\(|"success"|success:\s*true`,
1200
+ mode: "regex",
1201
+ limit: 30,
1202
+ });
1203
+
1204
+ for (const ev of fakeSuccess.results) {
1205
+ // This is heuristic: you’ll tighten it by correlating with network calls later.
1206
+ warnings.push({
1207
+ invariant: "ux_no_fake_success",
1208
+ rule: "Success UI should be tied to confirmed success (network/response)",
1209
+ evidence: ev,
1210
+ });
1211
+ }
1212
+
1213
+ const passed = shipKillers.length === 0;
900
1214
  return {
901
- passed: shipKillers.length === 0,
1215
+ policy: pol,
1216
+ category,
1217
+ passed,
902
1218
  shipKillers,
903
1219
  warnings,
904
- summary: shipKillers.length === 0
905
- ? '✅ All invariants pass'
906
- : `❌ ${shipKillers.length} ship killers found - deployment blocked`,
1220
+ summary: passed ? "✅ Invariants pass" : `❌ ${shipKillers.length} ship killers found`,
1221
+ _attribution: CONTEXT_ATTRIBUTION,
907
1222
  };
908
1223
  }
909
1224
 
1225
+ // =============================================================================
1226
+ // IMPLEMENTATION: assumptions
1227
+ // =============================================================================
1228
+
910
1229
  async function addAssumption(projectPath, args) {
911
- const { description, reason, verificationSteps } = args;
912
-
913
- if (state.assumptions.length >= state.maxAssumptions) {
1230
+ const { description, reason, verificationSteps } = args || {};
1231
+
1232
+ const list = state.assumptionsByProject.get(projectPath) || [];
1233
+ if (list.length >= state.maxAssumptions) {
914
1234
  return {
915
- error: `Assumption budget exceeded (${state.assumptions.length}/${state.maxAssumptions})`,
916
- message: '⚠️ You MUST verify existing assumptions or use proof tooling instead of assuming.',
917
- currentAssumptions: state.assumptions,
1235
+ error: `Assumption budget exceeded (${list.length}/${state.maxAssumptions})`,
1236
+ message: "⚠️ Verify or delete assumptions. Don’t stack guesses.",
1237
+ currentAssumptions: list,
1238
+ _attribution: CONTEXT_ATTRIBUTION,
918
1239
  };
919
1240
  }
920
-
1241
+
921
1242
  const assumption = {
922
- id: `assumption_${Date.now()}`,
923
- description,
924
- reason,
925
- verificationSteps,
1243
+ id: `assumption_${Date.now()}_${crypto.randomUUID().slice(0, 6)}`,
1244
+ description: String(description || ""),
1245
+ reason: reason ? String(reason) : "",
1246
+ verificationSteps: Array.isArray(verificationSteps) ? verificationSteps : [],
926
1247
  madeAt: new Date().toISOString(),
927
1248
  verified: false,
928
1249
  };
929
-
930
- state.assumptions.push(assumption);
931
-
1250
+
1251
+ list.push(assumption);
1252
+ state.assumptionsByProject.set(projectPath, list);
1253
+
932
1254
  return {
933
1255
  assumption,
934
- budget: {
935
- used: state.assumptions.length,
936
- max: state.maxAssumptions,
937
- remaining: state.maxAssumptions - state.assumptions.length,
938
- },
939
- warning: state.assumptions.length >= state.maxAssumptions - 1
940
- ? '⚠️ Approaching assumption limit. Consider verifying claims instead.'
941
- : null,
1256
+ budget: { used: list.length, max: state.maxAssumptions, remaining: state.maxAssumptions - list.length },
1257
+ warning: list.length >= state.maxAssumptions ? "⚠️ Assumption limit reached." : null,
1258
+ _attribution: CONTEXT_ATTRIBUTION,
942
1259
  };
943
1260
  }
944
1261
 
945
1262
  // =============================================================================
946
- // PLAN VALIDATION & DRIFT DETECTION (Spec 10.3)
1263
+ // PLAN VALIDATION & DRIFT
947
1264
  // =============================================================================
948
1265
 
949
1266
  async function getPlanValidationResult(projectPath, args) {
950
- const { plan, strict = false } = args;
951
-
952
- // Load contracts
1267
+ const { plan, strict = false } = args || {};
1268
+
953
1269
  const contracts = await loadContractsFromDisk(projectPath);
954
-
955
1270
  if (!contracts || Object.keys(contracts).length === 0) {
956
1271
  return {
957
1272
  valid: true,
@@ -959,87 +1274,72 @@ async function getPlanValidationResult(projectPath, args) {
959
1274
  violations: [],
960
1275
  warnings: [],
961
1276
  suggestions: ['Generate contracts with: vibecheck ctx sync'],
1277
+ _attribution: CONTEXT_ATTRIBUTION,
962
1278
  };
963
1279
  }
964
-
965
- // Parse plan to extract actions
1280
+
966
1281
  const actions = parsePlanActions(plan);
1282
+
967
1283
  const violations = [];
968
1284
  const warnings = [];
969
1285
  const suggestions = [];
970
-
971
- // Validate route references
1286
+
1287
+ // routes
972
1288
  if (contracts.routes && actions.routes.length > 0) {
973
- const contractRoutes = new Set(contracts.routes.routes?.map(r => r.path) || []);
974
-
975
- for (const route of actions.routes) {
976
- if (!contractRoutes.has(route.path)) {
977
- // Check parameterized match
978
- const match = contracts.routes.routes?.find(r => matchesParameterizedPath(r.path, route.path));
979
- if (!match) {
980
- violations.push({
981
- type: 'invented_route',
982
- severity: 'BLOCK',
983
- route: route.path,
984
- method: route.method,
985
- message: `Plan references route ${route.method} ${route.path} which does not exist in contract`,
986
- suggestion: `Available routes: ${contracts.routes.routes?.slice(0, 5).map(r => r.path).join(', ')}...`,
987
- });
988
- }
989
- }
990
- }
991
- }
992
-
993
- // Validate env var references
994
- if (contracts.env && actions.envVars.length > 0) {
995
- const contractVars = new Set(contracts.env.vars?.map(v => v.name) || []);
996
-
997
- for (const varName of actions.envVars) {
998
- if (!contractVars.has(varName)) {
999
- warnings.push({
1000
- type: 'undeclared_env',
1001
- severity: 'WARN',
1002
- name: varName,
1003
- message: `Plan uses env var ${varName} which is not in contract`,
1004
- suggestion: 'Add to .vibecheck/contracts/env.json or .env.example',
1289
+ const contractRoutes = (contracts.routes.routes || []).map((r) => ({
1290
+ method: String(r.method || "*").toUpperCase(),
1291
+ path: canonicalizePath(String(r.path || "")),
1292
+ }));
1293
+
1294
+ for (const r of actions.routes) {
1295
+ const wanted = { method: String(r.method || "GET").toUpperCase(), path: canonicalizePath(r.path) };
1296
+ const exists = contractRoutes.some((cr) => (cr.method === "*" || cr.method === wanted.method) && matchesParameterizedPath(cr.path, wanted.path));
1297
+ if (!exists) {
1298
+ violations.push({
1299
+ type: "invented_route",
1300
+ severity: "BLOCK",
1301
+ route: wanted.path,
1302
+ method: wanted.method,
1303
+ message: `Plan references route ${wanted.method} ${wanted.path} not in routes contract`,
1005
1304
  });
1006
1305
  }
1007
1306
  }
1008
1307
  }
1009
-
1010
- // Validate auth assumptions
1011
- if (contracts.auth && actions.authAssumptions.length > 0) {
1012
- for (const assumption of actions.authAssumptions) {
1013
- if (assumption.type === 'no_auth') {
1308
+
1309
+ // env vars
1310
+ if (contracts.env && actions.envVars.length > 0) {
1311
+ const contractVars = new Set((contracts.env.vars || []).map((v) => v.name));
1312
+ for (const v of actions.envVars) {
1313
+ if (!contractVars.has(v)) {
1014
1314
  warnings.push({
1015
- type: 'auth_assumption',
1016
- severity: 'WARN',
1017
- message: 'Plan assumes some routes are public - verify against auth contract',
1018
- suggestion: `Protected patterns: ${contracts.auth.protectedPatterns?.slice(0, 3).join(', ')}...`,
1315
+ type: "undeclared_env",
1316
+ severity: "WARN",
1317
+ name: v,
1318
+ message: `Plan uses env var ${v} not declared in env contract`,
1319
+ suggestion: "Add to .vibecheck/contracts/env.json and .env.example",
1019
1320
  });
1020
1321
  }
1021
1322
  }
1022
1323
  }
1023
-
1024
- // Validate external service usage
1324
+
1325
+ // external services
1025
1326
  if (contracts.external && actions.externalCalls.length > 0) {
1026
- const contractServices = new Set(contracts.external.services?.map(s => s.name) || []);
1027
-
1327
+ const contractServices = new Set((contracts.external.services || []).map((s) => s.name));
1028
1328
  for (const call of actions.externalCalls) {
1029
1329
  if (!contractServices.has(call.service)) {
1030
1330
  warnings.push({
1031
- type: 'undeclared_service',
1032
- severity: 'WARN',
1331
+ type: "undeclared_service",
1332
+ severity: "WARN",
1033
1333
  service: call.service,
1034
- message: `Plan uses ${call.service} which is not declared in external contract`,
1035
- suggestion: 'Add to .vibecheck/contracts/external.json',
1334
+ message: `Plan uses ${call.service} not declared in external contract`,
1335
+ suggestion: "Add to .vibecheck/contracts/external.json",
1036
1336
  });
1037
1337
  }
1038
1338
  }
1039
1339
  }
1040
-
1340
+
1041
1341
  const valid = violations.length === 0 && (!strict || warnings.length === 0);
1042
-
1342
+
1043
1343
  return {
1044
1344
  valid,
1045
1345
  violations,
@@ -1047,78 +1347,48 @@ async function getPlanValidationResult(projectPath, args) {
1047
1347
  suggestions,
1048
1348
  parsedActions: actions,
1049
1349
  contractsLoaded: Object.keys(contracts),
1050
- message: valid
1051
- ? '✅ Plan validated against contracts'
1052
- : `❌ Plan validation failed: ${violations.length} violations, ${warnings.length} warnings`,
1350
+ message: valid ? "✅ Plan validated" : `❌ Plan invalid: ${violations.length} violations, ${warnings.length} warnings`,
1351
+ _attribution: CONTEXT_ATTRIBUTION,
1053
1352
  };
1054
1353
  }
1055
1354
 
1056
1355
  async function checkDriftTool(projectPath, args) {
1057
- const category = args.category || 'all';
1058
-
1059
- // Load contracts
1356
+ const category = args?.category || "all";
1357
+
1060
1358
  const contracts = await loadContractsFromDisk(projectPath);
1061
-
1062
1359
  if (!contracts || Object.keys(contracts).length === 0) {
1063
- return {
1064
- hasDrift: false,
1065
- message: 'No contracts found. Run "vibecheck ctx sync" to generate contracts.',
1066
- findings: [],
1067
- };
1360
+ return { hasDrift: false, message: 'No contracts found. Run "vibecheck ctx sync".', findings: [], _attribution: CONTEXT_ATTRIBUTION };
1068
1361
  }
1069
-
1070
- // Build current truthpack
1362
+
1071
1363
  const truthpack = await buildCurrentTruthpack(projectPath);
1072
-
1073
- // Detect drift
1074
1364
  const findings = [];
1075
-
1076
- if (category === 'all' || category === 'routes') {
1077
- const routeDrift = detectRouteDrift(contracts.routes, truthpack);
1078
- findings.push(...routeDrift);
1079
- }
1080
-
1081
- if (category === 'all' || category === 'env') {
1082
- const envDrift = detectEnvDrift(contracts.env, truthpack);
1083
- findings.push(...envDrift);
1084
- }
1085
-
1086
- if (category === 'all' || category === 'auth') {
1087
- const authDrift = detectAuthDrift(contracts.auth, truthpack);
1088
- findings.push(...authDrift);
1089
- }
1090
-
1091
- const blocks = findings.filter(f => f.severity === 'BLOCK');
1092
- const warns = findings.filter(f => f.severity === 'WARN');
1093
-
1365
+
1366
+ if (category === "all" || category === "routes") findings.push(...detectRouteDrift(contracts.routes, truthpack));
1367
+ if (category === "all" || category === "env") findings.push(...detectEnvDrift(contracts.env, truthpack));
1368
+ if (category === "all" || category === "auth") findings.push(...detectAuthDrift(contracts.auth, truthpack));
1369
+
1370
+ const blocks = findings.filter((f) => f.severity === "BLOCK");
1371
+ const warns = findings.filter((f) => f.severity === "WARN");
1372
+
1094
1373
  return {
1095
1374
  hasDrift: findings.length > 0,
1096
- verdict: blocks.length > 0 ? 'BLOCK' : warns.length > 0 ? 'WARN' : 'PASS',
1097
- summary: {
1098
- blocks: blocks.length,
1099
- warns: warns.length,
1100
- total: findings.length,
1101
- },
1375
+ verdict: blocks.length > 0 ? "BLOCK" : warns.length > 0 ? "WARN" : "PASS",
1376
+ summary: { blocks: blocks.length, warns: warns.length, total: findings.length },
1102
1377
  findings,
1103
- message: findings.length === 0
1104
- ? '✅ No drift detected - contracts match codebase'
1105
- : `⚠️ Drift detected: ${blocks.length} blocks, ${warns.length} warnings. Run 'vibecheck ctx sync' to update.`,
1378
+ message: findings.length === 0 ? "✅ No drift detected" : `⚠️ Drift detected: ${blocks.length} blocks, ${warns.length} warnings`,
1379
+ _attribution: CONTEXT_ATTRIBUTION,
1106
1380
  };
1107
1381
  }
1108
1382
 
1109
1383
  async function getContractsTool(projectPath, args) {
1110
- const type = args.type || 'all';
1384
+ const type = args?.type || "all";
1111
1385
  const contracts = await loadContractsFromDisk(projectPath);
1112
-
1386
+
1113
1387
  if (!contracts || Object.keys(contracts).length === 0) {
1114
- return {
1115
- found: false,
1116
- message: 'No contracts found. Run "vibecheck ctx sync" to generate contracts.',
1117
- contracts: {},
1118
- };
1388
+ return { found: false, message: 'No contracts found. Run "vibecheck ctx sync".', contracts: {}, _attribution: CONTEXT_ATTRIBUTION };
1119
1389
  }
1120
-
1121
- if (type === 'all') {
1390
+
1391
+ if (type === "all") {
1122
1392
  return {
1123
1393
  found: true,
1124
1394
  contracts,
@@ -1128,371 +1398,461 @@ async function getContractsTool(projectPath, args) {
1128
1398
  authPatterns: contracts.auth?.protectedPatterns?.length || 0,
1129
1399
  services: contracts.external?.services?.length || 0,
1130
1400
  },
1401
+ _attribution: CONTEXT_ATTRIBUTION,
1131
1402
  };
1132
1403
  }
1133
-
1134
- return {
1135
- found: !!contracts[type],
1136
- contracts: { [type]: contracts[type] },
1137
- };
1404
+
1405
+ return { found: !!contracts[type], contracts: { [type]: contracts[type] }, _attribution: CONTEXT_ATTRIBUTION };
1138
1406
  }
1139
1407
 
1140
- // Helper: Load contracts from disk
1141
1408
  async function loadContractsFromDisk(projectPath) {
1142
- const contractDir = path.join(projectPath, '.vibecheck', 'contracts');
1409
+ const contractDir = safeProjectJoin(projectPath, ".vibecheck/contracts");
1143
1410
  const contracts = {};
1144
-
1411
+
1145
1412
  const files = {
1146
- routes: 'routes.json',
1147
- env: 'env.json',
1148
- auth: 'auth.json',
1149
- external: 'external.json',
1413
+ routes: "routes.json",
1414
+ env: "env.json",
1415
+ auth: "auth.json",
1416
+ external: "external.json",
1150
1417
  };
1151
-
1418
+
1152
1419
  for (const [key, file] of Object.entries(files)) {
1153
- const filePath = path.join(contractDir, file);
1154
1420
  try {
1155
- const content = await fs.readFile(filePath, 'utf8');
1421
+ const abs = path.join(contractDir, file);
1422
+ const content = await fs.readFile(abs, "utf8");
1156
1423
  contracts[key] = JSON.parse(content);
1157
- } catch {}
1424
+ } catch (error) {
1425
+ // Invalid JSON or file not found - skip this contract
1426
+ // ignore
1427
+ }
1158
1428
  }
1159
-
1429
+
1160
1430
  return contracts;
1161
1431
  }
1162
1432
 
1163
- // Helper: Parse plan to extract actions
1433
+ // =============================================================================
1434
+ // PARSING HELPERS
1435
+ // =============================================================================
1436
+
1437
+ function canonicalizePath(p) {
1438
+ let s = String(p || "").trim();
1439
+ if (!s.startsWith("/")) s = "/" + s;
1440
+ s = s.replace(/\/+/g, "/");
1441
+ if (s.length > 1) s = s.replace(/\/$/, "");
1442
+ return s;
1443
+ }
1444
+
1445
+ function dedupe(arr, keyFn) {
1446
+ const seen = new Set();
1447
+ const out = [];
1448
+ for (const item of arr) {
1449
+ const k = keyFn(item);
1450
+ if (seen.has(k)) continue;
1451
+ seen.add(k);
1452
+ out.push(item);
1453
+ }
1454
+ return out;
1455
+ }
1456
+
1164
1457
  function parsePlanActions(plan) {
1165
1458
  const actions = {
1166
1459
  routes: [],
1167
1460
  envVars: [],
1168
- files: [],
1169
1461
  authAssumptions: [],
1170
1462
  externalCalls: [],
1171
1463
  };
1172
-
1173
- const planText = typeof plan === 'string' ? plan : JSON.stringify(plan);
1174
-
1175
- // Extract route references
1464
+
1465
+ const planText = typeof plan === "string" ? plan : JSON.stringify(plan);
1466
+
1467
+ // routes
1176
1468
  const routePatterns = [
1177
- /(?:fetch|axios|api\.?)\s*\(\s*['"`]([/][^'"`]+)['"`]/gi,
1178
- /(?:GET|POST|PUT|PATCH|DELETE)\s+([/][^\s]+)/gi,
1469
+ /(?:GET|POST|PUT|PATCH|DELETE)\s+([/][^\s"'`]+)/gi,
1179
1470
  /\/api\/[a-z0-9/_-]+/gi,
1180
1471
  ];
1181
-
1472
+
1182
1473
  for (const pattern of routePatterns) {
1183
1474
  let match;
1184
1475
  while ((match = pattern.exec(planText)) !== null) {
1185
- const p = match[1] || match[0];
1186
- if (p.startsWith('/')) {
1187
- actions.routes.push({
1188
- path: p.replace(/['"`]/g, ''),
1189
- method: inferMethodFromText(match[0]),
1190
- });
1476
+ const p = (match[1] || match[0]).replace(/['"`]/g, "");
1477
+ if (p.startsWith("/")) {
1478
+ actions.routes.push({ path: canonicalizePath(p), method: inferMethodFromText(match[0]) });
1191
1479
  }
1192
1480
  }
1193
1481
  }
1194
-
1195
- // Extract env var references
1482
+
1483
+ actions.routes = dedupe(actions.routes, (r) => `${r.method}:${r.path}`);
1484
+
1485
+ // env vars
1196
1486
  const envPatterns = [
1197
- /process\.env\.([A-Z_][A-Z0-9_]*)/gi,
1198
- /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/gi,
1487
+ /process\.env\.([A-Z_][A-Z0-9_]*)/g,
1488
+ /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
1199
1489
  ];
1200
-
1201
1490
  for (const pattern of envPatterns) {
1202
1491
  let match;
1203
- while ((match = pattern.exec(planText)) !== null) {
1204
- if (match[1] && /^[A-Z]/.test(match[1])) {
1205
- actions.envVars.push(match[1]);
1206
- }
1207
- }
1208
- }
1209
-
1210
- // Extract auth assumptions
1211
- if (/(?:authenticated|logged in|auth required|protected)/i.test(planText)) {
1212
- actions.authAssumptions.push({ type: 'requires_auth' });
1492
+ while ((match = pattern.exec(planText)) !== null) actions.envVars.push(match[1]);
1213
1493
  }
1214
- if (/(?:public|no auth|unauthenticated)/i.test(planText)) {
1215
- actions.authAssumptions.push({ type: 'no_auth' });
1216
- }
1217
-
1218
- // Extract external service references
1219
- const servicePatterns = [
1220
- { pattern: /stripe\./gi, service: 'stripe' },
1221
- { pattern: /github\./gi, service: 'github' },
1222
- { pattern: /sendgrid\./gi, service: 'sendgrid' },
1223
- { pattern: /twilio\./gi, service: 'twilio' },
1224
- { pattern: /supabase\./gi, service: 'supabase' },
1494
+ actions.envVars = dedupe(actions.envVars, (v) => v);
1495
+
1496
+ // auth assumptions
1497
+ if (/(authenticated|logged in|auth required|protected)/i.test(planText)) actions.authAssumptions.push({ type: "requires_auth" });
1498
+ if (/(public|no auth|unauthenticated)/i.test(planText)) actions.authAssumptions.push({ type: "no_auth" });
1499
+
1500
+ // external services
1501
+ const services = [
1502
+ { re: /stripe\./i, service: "stripe" },
1503
+ { re: /github\./i, service: "github" },
1504
+ { re: /sendgrid\./i, service: "sendgrid" },
1505
+ { re: /twilio\./i, service: "twilio" },
1506
+ { re: /supabase\./i, service: "supabase" },
1225
1507
  ];
1226
-
1227
- for (const { pattern, service } of servicePatterns) {
1228
- if (pattern.test(planText)) {
1229
- actions.externalCalls.push({ service });
1230
- }
1508
+ for (const s of services) {
1509
+ if (s.re.test(planText)) actions.externalCalls.push({ service: s.service });
1231
1510
  }
1232
-
1511
+ actions.externalCalls = dedupe(actions.externalCalls, (c) => c.service);
1512
+
1233
1513
  return actions;
1234
1514
  }
1235
1515
 
1236
1516
  function inferMethodFromText(text) {
1237
- const upper = text.toUpperCase();
1238
- if (upper.includes('POST')) return 'POST';
1239
- if (upper.includes('PUT')) return 'PUT';
1240
- if (upper.includes('PATCH')) return 'PATCH';
1241
- if (upper.includes('DELETE')) return 'DELETE';
1242
- return 'GET';
1517
+ const upper = String(text || "").toUpperCase();
1518
+ if (upper.includes("POST")) return "POST";
1519
+ if (upper.includes("PUT")) return "PUT";
1520
+ if (upper.includes("PATCH")) return "PATCH";
1521
+ if (upper.includes("DELETE")) return "DELETE";
1522
+ return "GET";
1243
1523
  }
1244
1524
 
1245
1525
  function matchesParameterizedPath(pattern, actual) {
1246
- const patternParts = pattern.split('/').filter(Boolean);
1247
- const actualParts = actual.split('/').filter(Boolean);
1248
- if (patternParts.length !== actualParts.length) return false;
1249
- for (let i = 0; i < patternParts.length; i++) {
1250
- const p = patternParts[i];
1251
- if (p.startsWith(':') || p.startsWith('*') || p.startsWith('[')) continue;
1252
- if (p !== actualParts[i]) return false;
1526
+ const pParts = canonicalizePath(pattern).split("/").filter(Boolean);
1527
+ const aParts = canonicalizePath(actual).split("/").filter(Boolean);
1528
+ if (pParts.length !== aParts.length) return false;
1529
+
1530
+ for (let i = 0; i < pParts.length; i++) {
1531
+ const p = pParts[i];
1532
+ if (p.startsWith(":") || p.startsWith("*") || p.startsWith("[")) continue;
1533
+ if (p !== aParts[i]) return false;
1253
1534
  }
1254
1535
  return true;
1255
1536
  }
1256
1537
 
1257
- // Helper: Build current truthpack (lightweight version)
1258
- async function buildCurrentTruthpack(projectPath) {
1259
- const routes = await extractRoutes(projectPath);
1538
+ // =============================================================================
1539
+ // DRIFT DETECTORS (improved: added removals)
1540
+ // =============================================================================
1541
+
1542
+ async function buildCurrentTruthpack(projectPath, refresh = false) {
1543
+ const routes = await extractRoutes(projectPath, refresh);
1260
1544
  const env = await extractEnv(projectPath);
1261
1545
  const auth = await extractAuth(projectPath);
1262
-
1546
+
1263
1547
  return {
1264
1548
  routes: {
1265
- server: routes.routes,
1266
- clientRefs: [],
1267
- },
1268
- env: {
1269
- vars: env.used,
1270
- declared: env.declared.map(d => d.name),
1271
- },
1272
- auth: {
1273
- nextMatcherPatterns: [], // Would need middleware parsing
1549
+ server: routes.routes || [],
1550
+ gaps: routes.gaps || [],
1551
+ engine: routes.engine,
1274
1552
  },
1553
+ env: { vars: env.used || [], declared: (env.declared || []).map((d) => d.name) },
1554
+ auth: { nextMatcherPatterns: auth?.nextMatcherPatterns || [] },
1275
1555
  };
1276
1556
  }
1277
1557
 
1278
- // Helper: Detect route drift
1279
1558
  function detectRouteDrift(routeContract, truthpack) {
1280
1559
  const findings = [];
1281
1560
  if (!routeContract?.routes) return findings;
1282
-
1283
- const contractRoutes = new Map(routeContract.routes.map(r => [`${r.method}_${r.path}`, r]));
1284
- const serverRoutes = truthpack?.routes?.server || [];
1285
-
1286
- for (const route of serverRoutes) {
1287
- const key = `${route.method}_${route.path}`;
1288
- if (!contractRoutes.has(key)) {
1561
+
1562
+ // Use Route Truth v1 canonicalization for consistency
1563
+ const canonicalize = routeTruthCanonicalize || canonicalizePath;
1564
+
1565
+ const contractSet = new Set(routeContract.routes.map((r) => `${String(r.method || "*").toUpperCase()}_${canonicalize(r.path)}`));
1566
+ const server = truthpack?.routes?.server || [];
1567
+ const serverSet = new Set(server.map((r) => `${String(r.method || "*").toUpperCase()}_${canonicalize(r.path)}`));
1568
+ const gaps = truthpack?.routes?.gaps || [];
1569
+
1570
+ // new routes (in code, not in contract)
1571
+ for (const key of serverSet) {
1572
+ if (!contractSet.has(key)) {
1573
+ const [method, routePath] = key.split("_");
1574
+ const routeInfo = server.find((r) => canonicalize(r.path) === routePath && String(r.method || "*").toUpperCase() === method);
1575
+
1576
+ findings.push({
1577
+ type: "new_route_not_in_contract",
1578
+ severity: "BLOCK",
1579
+ title: `New route not in contract: ${method} ${routePath}`,
1580
+ message: "Route exists in code but not synced to routes contract.",
1581
+ file: routeInfo?.file,
1582
+ framework: routeInfo?.framework,
1583
+ });
1584
+ }
1585
+ }
1586
+
1587
+ // removed routes (in contract, not in code)
1588
+ for (const key of contractSet) {
1589
+ if (!serverSet.has(key)) {
1590
+ const hasGaps = gaps.length > 0;
1289
1591
  findings.push({
1290
- type: 'new_route_not_in_contract',
1291
- severity: 'BLOCK',
1292
- title: `New route ${route.method} ${route.path} not in contract`,
1293
- message: 'Route added to code but not synced to contracts.',
1592
+ type: "contract_route_missing_in_code",
1593
+ severity: hasGaps ? "WARN" : "WARN", // Demote if gaps exist
1594
+ title: `Contract route missing in code: ${key.replace("_", " ")}`,
1595
+ message: hasGaps
1596
+ ? "Contract lists a route not detected in code. Note: some plugins couldn't be resolved."
1597
+ : "Contract lists a route not detected in code (stale contract?).",
1598
+ mayBeExtractorGap: hasGaps,
1294
1599
  });
1295
1600
  }
1296
1601
  }
1297
-
1602
+
1603
+ // Report gaps as info
1604
+ if (gaps.length > 0) {
1605
+ findings.push({
1606
+ type: "extractor_gaps",
1607
+ severity: "INFO",
1608
+ title: `Route extractor has ${gaps.length} unresolved module(s)`,
1609
+ message: "Some Fastify plugins or imports couldn't be resolved - routes may be incomplete.",
1610
+ gaps: gaps.slice(0, 5), // Limit to first 5
1611
+ });
1612
+ }
1613
+
1298
1614
  return findings;
1299
1615
  }
1300
1616
 
1301
- // Helper: Detect env drift
1302
1617
  function detectEnvDrift(envContract, truthpack) {
1303
1618
  const findings = [];
1304
1619
  if (!envContract?.vars) return findings;
1305
-
1306
- const contractVars = new Set(envContract.vars.map(v => v.name));
1307
- const usedVars = truthpack?.env?.vars || [];
1308
-
1309
- for (const v of usedVars) {
1310
- if (!contractVars.has(v.name)) {
1620
+
1621
+ const contractVars = new Set(envContract.vars.map((v) => v.name));
1622
+ const usedVars = new Set((truthpack?.env?.vars || []).map((v) => v.name));
1623
+
1624
+ for (const name of usedVars) {
1625
+ if (!contractVars.has(name)) {
1311
1626
  findings.push({
1312
- type: 'new_env_not_in_contract',
1313
- severity: 'WARN',
1314
- title: `Env var ${v.name} used but not in contract`,
1315
- message: 'Env var used in code but not declared in contracts.',
1627
+ type: "new_env_not_in_contract",
1628
+ severity: "WARN",
1629
+ title: `Env var used but not in contract: ${name}`,
1630
+ message: "Env var used in code but not declared in contracts.",
1316
1631
  });
1317
1632
  }
1318
1633
  }
1319
-
1634
+
1635
+ for (const name of contractVars) {
1636
+ if (!usedVars.has(name)) {
1637
+ findings.push({
1638
+ type: "contract_env_unused",
1639
+ severity: "WARN",
1640
+ title: `Env var in contract appears unused: ${name}`,
1641
+ message: "Contract env var not detected in usage (stale contract or extractor gap).",
1642
+ });
1643
+ }
1644
+ }
1645
+
1320
1646
  return findings;
1321
1647
  }
1322
1648
 
1323
- // Helper: Detect auth drift
1324
1649
  function detectAuthDrift(authContract, truthpack) {
1325
1650
  const findings = [];
1326
1651
  if (!authContract?.protectedPatterns) return findings;
1327
-
1328
- const contractPatterns = new Set(authContract.protectedPatterns);
1329
- const currentPatterns = new Set(truthpack?.auth?.nextMatcherPatterns || []);
1330
-
1331
- for (const pattern of currentPatterns) {
1332
- if (!contractPatterns.has(pattern)) {
1652
+
1653
+ const contract = new Set(authContract.protectedPatterns);
1654
+ const current = new Set(truthpack?.auth?.nextMatcherPatterns || []);
1655
+
1656
+ for (const pattern of current) {
1657
+ if (!contract.has(pattern)) {
1333
1658
  findings.push({
1334
- type: 'new_auth_pattern',
1335
- severity: 'BLOCK',
1336
- title: `New auth pattern "${pattern}" not in contract`,
1337
- message: 'Auth pattern added but not in contracts.',
1659
+ type: "new_auth_pattern",
1660
+ severity: "BLOCK",
1661
+ title: `New auth pattern not in contract: ${pattern}`,
1662
+ message: "Auth matcher/pattern changed but contracts not updated.",
1338
1663
  });
1339
1664
  }
1340
1665
  }
1341
-
1666
+
1342
1667
  return findings;
1343
1668
  }
1344
1669
 
1345
1670
  // =============================================================================
1346
- // HELPERS
1671
+ // DOMAIN HELPERS
1347
1672
  // =============================================================================
1348
1673
 
1349
- function getCommitHash(projectPath) {
1350
- try {
1351
- return execSync('git rev-parse HEAD', { cwd: projectPath, encoding: 'utf8' }).trim();
1352
- } catch {
1353
- return 'unknown';
1354
- }
1674
+ function detectDomains(task) {
1675
+ const domains = [];
1676
+ const t = String(task || "");
1677
+ if (/auth|login|logout|session|password/i.test(t)) domains.push("auth");
1678
+ if (/billing|payment|stripe|subscription/i.test(t)) domains.push("billing");
1679
+ if (/env|secret|config/i.test(t)) domains.push("env");
1680
+ if (/route|api|endpoint/i.test(t)) domains.push("api");
1681
+ if (/component|button|ui|form/i.test(t)) domains.push("ui");
1682
+ return domains.length ? domains : ["general"];
1355
1683
  }
1356
1684
 
1357
- /**
1358
- * Generate a project fingerprint for stale assumption detection (Spec 10.2)
1359
- * Includes: commit hash, key file hashes, timestamp
1360
- */
1361
- export function getProjectFingerprint(projectPath) {
1362
- const commitHash = getCommitHash(projectPath);
1363
- const keyFiles = [
1364
- 'package.json',
1365
- 'prisma/schema.prisma',
1366
- 'next.config.js',
1367
- 'next.config.ts',
1368
- '.vibecheck/contracts/routes.json',
1369
- ];
1370
-
1371
- const fileHashes = [];
1372
- for (const file of keyFiles) {
1685
+ function extractKeywords(task) {
1686
+ return String(task || "")
1687
+ .toLowerCase()
1688
+ .replace(/[^a-z0-9\s/_-]/g, " ")
1689
+ .split(/\s+/)
1690
+ .filter((w) => w.length > 2)
1691
+ .slice(0, 40);
1692
+ }
1693
+
1694
+ function getInvariantsForDomains(domains) {
1695
+ const invariants = [];
1696
+ if (domains.includes("auth")) invariants.push("No protected route without server middleware");
1697
+ if (domains.includes("billing")) invariants.push("No paid feature without server-side enforcement");
1698
+ invariants.push("No success UI without confirmed success");
1699
+ invariants.push("No invented routes/env vars/functions in plans");
1700
+ return invariants;
1701
+ }
1702
+
1703
+ function estimateTokens(context) {
1704
+ let tokens = 0;
1705
+ if (context?.routes) tokens += context.routes.length * 50;
1706
+ if (context?.auth) tokens += 220;
1707
+ if (context?.billing) tokens += 220;
1708
+ if (context?.env) tokens += 180;
1709
+ return tokens;
1710
+ }
1711
+
1712
+ function generateContextWarnings(domains, policy, routeCount) {
1713
+ const warnings = [];
1714
+ if (domains.includes("auth") || domains.includes("billing")) warnings.push("High-stakes domain: verify claims before edits.");
1715
+ if (routeCount > 50 && policy === "strict") warnings.push("Large route surface: narrow task or specify files.");
1716
+ return warnings;
1717
+ }
1718
+
1719
+ // =============================================================================
1720
+ // SOURCE FILE DISCOVERY (safe + bounded)
1721
+ // =============================================================================
1722
+
1723
+ async function findSourceFiles(projectPath, opts) {
1724
+ const files = [];
1725
+ const ignoreDirs = new Set(["node_modules", "dist", "build", ".git", ".next", "coverage", "out"]);
1726
+
1727
+ async function walk(dirAbs) {
1728
+ let entries = [];
1373
1729
  try {
1374
- const content = require('fs').readFileSync(path.join(projectPath, file), 'utf8');
1375
- const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
1376
- fileHashes.push(`${file}:${hash}`);
1377
- } catch {}
1730
+ entries = await fs.readdir(dirAbs, { withFileTypes: true });
1731
+ } catch {
1732
+ return;
1733
+ }
1734
+
1735
+ for (const entry of entries) {
1736
+ const full = path.join(dirAbs, entry.name);
1737
+ if (entry.isDirectory()) {
1738
+ if (ignoreDirs.has(entry.name) || entry.name.startsWith(".")) continue;
1739
+ await walk(full);
1740
+ } else if (entry.isFile()) {
1741
+ if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) continue;
1742
+ const rel = path.relative(projectPath, full).replace(/\\/g, "/");
1743
+ if (!opts?.includeTests && isTestFilePath(rel)) continue;
1744
+ files.push(full);
1745
+ if (files.length > 2500) return; // hard cap
1746
+ }
1747
+ }
1378
1748
  }
1379
-
1380
- const fingerprintData = [
1381
- commitHash,
1382
- ...fileHashes,
1383
- ].join('|');
1384
-
1385
- return {
1386
- hash: crypto.createHash('sha256').update(fingerprintData).digest('hex').slice(0, 16),
1387
- commitHash,
1388
- fileHashes,
1389
- generatedAt: new Date().toISOString(),
1390
- };
1749
+
1750
+ await walk(path.resolve(projectPath));
1751
+ return files;
1391
1752
  }
1392
1753
 
1393
- /**
1394
- * Context attribution message shown when AI uses vibecheck data
1395
- */
1396
- const CONTEXT_ATTRIBUTION = "🧠 Context enhanced by vibecheck";
1754
+ // =============================================================================
1755
+ // ROUTE TRUTH V1 INTEGRATION (AST-based, follows Fastify register prefixes + Next.js app/pages)
1756
+ // =============================================================================
1397
1757
 
1398
1758
  /**
1399
- * Wrap MCP response with standard metadata including fingerprint (Spec 10.2)
1759
+ * Get or build the Route Truth v1 index for a project.
1760
+ * This is the SINGLE SOURCE OF TRUTH for route reality.
1400
1761
  */
1401
- export function wrapMcpResponse(data, projectPath) {
1402
- return {
1403
- ok: true,
1404
- version: '2.0.0',
1405
- projectFingerprint: getProjectFingerprint(projectPath),
1406
- data,
1407
- _attribution: CONTEXT_ATTRIBUTION,
1408
- };
1762
+ async function getRouteIndex(projectPath, refresh = false) {
1763
+ if (!refresh && state.routeIndexByProject.has(projectPath)) {
1764
+ return state.routeIndexByProject.get(projectPath);
1765
+ }
1766
+
1767
+ const index = new RouteIndex();
1768
+ await index.build(projectPath);
1769
+ state.routeIndexByProject.set(projectPath, index);
1770
+ return index;
1409
1771
  }
1410
1772
 
1411
1773
  /**
1412
- * Get the context attribution message
1774
+ * Extract routes using Route Truth v1 (AST-based).
1775
+ * - Fastify: follows register() prefixes, resolves relative plugin imports
1776
+ * - Next.js: App Router (route.ts exports) + Pages Router (/pages/api/**)
1777
+ * - Proper path canonicalization (dynamic segments, splats)
1413
1778
  */
1414
- export function getContextAttribution() {
1415
- return CONTEXT_ATTRIBUTION;
1416
- }
1779
+ async function extractRoutes(projectPath, refresh = false) {
1780
+ const index = await getRouteIndex(projectPath, refresh);
1781
+ const routeMap = index.getRouteMap();
1782
+
1783
+ // Transform to truthpack format
1784
+ const routes = (routeMap.server || []).map((r) => ({
1785
+ method: r.method,
1786
+ path: r.path,
1787
+ file: r.handler,
1788
+ line: r.evidence?.[0]?.lines?.split("-")[0] || 1,
1789
+ framework: r.framework,
1790
+ routerType: r.routerType,
1791
+ confidence: r.confidence === "high" ? 0.95 : r.confidence === "med" ? 0.8 : 0.6,
1792
+ evidence: r.evidence,
1793
+ }));
1794
+
1795
+ const gaps = routeMap.gaps || [];
1796
+ const hasGaps = gaps.length > 0;
1417
1797
 
1418
- async function extractRoutes(projectPath) {
1419
- const routes = [];
1420
- const files = await findSourceFiles(projectPath);
1421
- const routePatterns = [
1422
- /\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
1423
- /router\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
1424
- ];
1425
-
1426
- for (const file of files.slice(0, 50)) {
1427
- try {
1428
- const content = await fs.readFile(file, 'utf8');
1429
- const relPath = path.relative(projectPath, file);
1430
-
1431
- for (const pattern of routePatterns) {
1432
- let match;
1433
- pattern.lastIndex = 0;
1434
- while ((match = pattern.exec(content)) !== null) {
1435
- const line = content.substring(0, match.index).split('\n').length;
1436
- routes.push({
1437
- method: match[1].toUpperCase(),
1438
- path: match[2],
1439
- file: relPath,
1440
- line,
1441
- });
1442
- }
1443
- }
1444
- } catch {}
1445
- }
1446
-
1447
- return { count: routes.length, routes, confidence: routes.length > 0 ? 0.8 : 0.2 };
1798
+ return {
1799
+ count: routes.length,
1800
+ routes,
1801
+ gaps,
1802
+ confidence: hasGaps ? 0.7 : (routes.length > 0 ? 0.95 : 0.3),
1803
+ engine: "route-truth-v1",
1804
+ _note: hasGaps
1805
+ ? `⚠️ ${gaps.length} unresolved plugins/modules - some routes may be missing`
1806
+ : "AST-based extraction (Fastify register prefixes + Next.js app/pages)",
1807
+ };
1448
1808
  }
1449
1809
 
1450
1810
  async function extractAuth(projectPath) {
1451
- const evidence = await searchEvidence(projectPath, {
1452
- query: 'auth|authenticate|authorize|middleware|guard|jwt|session',
1453
- limit: 30
1454
- });
1455
- return { count: evidence.count, indicators: evidence.results, confidence: evidence.count > 5 ? 0.8 : 0.4 };
1811
+ // TODO: parse Next middleware matcher for real; this is evidence-based heuristics
1812
+ const evidence = await searchEvidence(projectPath, { query: "middleware|auth|authenticate|authorize|jwt|session", mode: "regex", limit: 30 });
1813
+ return {
1814
+ count: evidence.count,
1815
+ indicators: evidence.results,
1816
+ nextMatcherPatterns: [],
1817
+ confidence: evidence.count > 5 ? 0.8 : 0.4,
1818
+ };
1456
1819
  }
1457
1820
 
1458
1821
  async function extractBilling(projectPath) {
1459
- const evidence = await searchEvidence(projectPath, {
1460
- query: 'stripe|billing|payment|subscription|checkout|tier|isPro',
1461
- limit: 20
1462
- });
1463
- return { count: evidence.count, indicators: evidence.results, confidence: evidence.count > 3 ? 0.7 : 0.3 };
1822
+ const evidence = await searchEvidence(projectPath, { query: "stripe|billing|payment|subscription|checkout|tier|isPro", mode: "regex", limit: 25 });
1823
+ return { count: evidence.count, indicators: evidence.results, confidence: evidence.count > 3 ? 0.75 : 0.3 };
1464
1824
  }
1465
1825
 
1466
1826
  async function extractEnv(projectPath) {
1467
1827
  const declared = [];
1468
1828
  const used = [];
1469
-
1470
- // Check .env.example
1829
+
1830
+ // .env.example declarations
1471
1831
  try {
1472
- const content = await fs.readFile(path.join(projectPath, '.env.example'), 'utf8');
1473
- const lines = content.split('\n');
1832
+ const content = await safeReadFile(projectPath, ".env.example");
1833
+ const lines = content.split(/\r?\n/);
1474
1834
  for (let i = 0; i < lines.length; i++) {
1475
- const match = lines[i].match(/^([A-Z][A-Z0-9_]*)=/);
1476
- if (match) declared.push({ name: match[1], line: i + 1 });
1835
+ const m = lines[i].match(/^([A-Z][A-Z0-9_]*)=/);
1836
+ if (m) declared.push({ name: m[1], line: i + 1 });
1477
1837
  }
1478
- } catch {}
1479
-
1480
- // Check process.env usage
1481
- const evidence = await searchEvidence(projectPath, { query: 'process\\.env\\.([A-Z][A-Z0-9_]*)', limit: 50 });
1482
- for (const ev of evidence.results) {
1483
- const match = ev.snippet.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
1484
- if (match) used.push({ name: match[1], file: ev.file, line: ev.line });
1838
+ } catch { /* ignore */ }
1839
+
1840
+ // usage
1841
+ const usage = await searchEvidence(projectPath, { query: "process\\.env\\.([A-Z][A-Z0-9_]*)", mode: "regex", limit: 200, includeTests: false });
1842
+ for (const ev of usage.results) {
1843
+ const m = ev.snippet.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
1844
+ if (m) used.push({ name: m[1], file: ev.file, line: ev.line });
1485
1845
  }
1486
-
1487
- const declaredNames = new Set(declared.map(d => d.name));
1488
- const usedNames = new Set(used.map(u => u.name));
1489
-
1846
+
1847
+ const declaredNames = new Set(declared.map((d) => d.name));
1848
+ const usedNames = new Set(used.map((u) => u.name));
1849
+
1490
1850
  return {
1491
1851
  declared,
1492
1852
  used,
1493
1853
  mismatches: {
1494
- undeclared: Array.from(usedNames).filter(n => !declaredNames.has(n)),
1495
- unused: Array.from(declaredNames).filter(n => !usedNames.has(n)),
1854
+ undeclared: Array.from(usedNames).filter((n) => !declaredNames.has(n)),
1855
+ unused: Array.from(declaredNames).filter((n) => !usedNames.has(n)),
1496
1856
  },
1497
1857
  confidence: 0.8,
1498
1858
  };
@@ -1500,178 +1860,184 @@ async function extractEnv(projectPath) {
1500
1860
 
1501
1861
  async function extractSchema(projectPath) {
1502
1862
  const schemas = [];
1503
-
1504
- // Check Prisma
1505
1863
  try {
1506
- const content = await fs.readFile(path.join(projectPath, 'prisma', 'schema.prisma'), 'utf8');
1864
+ const content = await safeReadFile(projectPath, "prisma/schema.prisma");
1507
1865
  const models = content.matchAll(/model\s+(\w+)\s*\{/g);
1508
- for (const match of models) {
1509
- schemas.push({ type: 'prisma_model', name: match[1] });
1510
- }
1511
- } catch {}
1512
-
1866
+ for (const m of models) schemas.push({ type: "prisma_model", name: m[1] });
1867
+ } catch { /* ignore */ }
1513
1868
  return { count: schemas.length, schemas, confidence: schemas.length > 0 ? 0.9 : 0.3 };
1514
1869
  }
1515
1870
 
1516
- async function extractGraph(projectPath) {
1517
- return { nodes: [], edges: [], message: 'Graph extraction requires full build. Use get_truthpack with refresh=true.' };
1871
+ async function extractGraph(_projectPath) {
1872
+ return { nodes: [], edges: [], message: "Graph extraction not implemented in this module." };
1518
1873
  }
1519
1874
 
1520
- async function verifyRouteExists(projectPath, subject) {
1521
- const routes = await extractRoutes(projectPath);
1522
- const match = routes.routes.find(r =>
1523
- r.path === subject.path ||
1524
- (subject.method && r.method === subject.method.toUpperCase() && r.path === subject.path)
1525
- );
1526
-
1527
- if (match) {
1875
+ // =============================================================================
1876
+ // CLAIM VERIFIERS (tightened)
1877
+ // =============================================================================
1878
+
1879
+ /**
1880
+ * Verify route_exists claim using Route Truth v1 index.
1881
+ * This uses proper AST-based route detection with:
1882
+ * - Fastify register() prefix resolution
1883
+ * - Next.js App/Pages router support
1884
+ * - Parameterized path matching (:id, *slug)
1885
+ * - Closest route suggestions on miss
1886
+ */
1887
+ async function verifyRouteExists(projectPath, subject, refresh = false) {
1888
+ const index = await getRouteIndex(projectPath, refresh);
1889
+
1890
+ const claim = {
1891
+ method: subject?.method || "*",
1892
+ path: subject?.path,
1893
+ };
1894
+
1895
+ // Use Route Truth v1 validation (handles parameterized matching, gaps, etc.)
1896
+ const result = await routeTruthValidate(claim, projectPath, index);
1897
+
1898
+ if (result.result === "true") {
1899
+ const matched = result.matchedRoute;
1528
1900
  return {
1529
- result: 'true',
1530
- confidence: 'high',
1531
- evidence: [{ file: match.file, lines: `${match.line}`, hash: '' }],
1901
+ result: "true",
1902
+ confidence: result.confidence === "high" ? "high" : result.confidence === "med" ? "medium" : "low",
1903
+ evidence: (matched?.evidence || []).map((e) => ({
1904
+ file: e.file,
1905
+ lines: e.lines,
1906
+ snippet: "",
1907
+ hash: e.snippetHash || sha16(`${e.file}:${e.lines}`),
1908
+ })),
1909
+ matchedRoute: {
1910
+ method: matched?.method,
1911
+ path: matched?.path,
1912
+ file: matched?.handler,
1913
+ framework: matched?.framework,
1914
+ },
1532
1915
  };
1533
1916
  }
1534
-
1535
- return { result: 'false', confidence: 'high', evidence: [], nextSteps: ['Route not found in codebase'] };
1917
+
1918
+ if (result.result === "unknown") {
1919
+ // Has gaps (unresolved plugins) - can't be certain
1920
+ return {
1921
+ result: "unknown",
1922
+ confidence: "low",
1923
+ evidence: [],
1924
+ gaps: result.gaps,
1925
+ closestRoutes: (result.closestRoutes || []).map((r) => ({
1926
+ method: r.method,
1927
+ path: r.path,
1928
+ file: r.handler,
1929
+ })),
1930
+ nextSteps: result.nextSteps || [
1931
+ "Some plugins/modules could not be resolved",
1932
+ "Route may exist but extractor couldn't follow import chain",
1933
+ ],
1934
+ };
1935
+ }
1936
+
1937
+ // result === "false" - route definitely doesn't exist
1938
+ const closest = (result.closestRoutes || []).map((r) => ({
1939
+ method: r.method,
1940
+ path: r.path,
1941
+ file: r.handler,
1942
+ }));
1943
+
1944
+ return {
1945
+ result: "false",
1946
+ confidence: "high",
1947
+ evidence: [],
1948
+ closestRoutes: closest,
1949
+ nextSteps: result.nextSteps || (closest.length
1950
+ ? [`Route not found. Did you mean: ${closest.map((r) => `${r.method} ${r.path}`).join(", ")}?`]
1951
+ : ["Route not found. No similar routes detected."]),
1952
+ };
1536
1953
  }
1537
1954
 
1538
1955
  async function verifyFileExists(projectPath, subject) {
1539
- const filePath = path.join(projectPath, subject.path || subject.name);
1956
+ const rel = String(subject?.path || subject?.name || "");
1957
+ if (!rel) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.path"] };
1540
1958
  try {
1541
- await fs.access(filePath);
1542
- return { result: 'true', confidence: 'high', evidence: [{ file: subject.path || subject.name, lines: '1', hash: '' }] };
1959
+ const abs = safeProjectJoin(projectPath, rel);
1960
+ await fs.access(abs);
1961
+ return { result: "true", confidence: "high", evidence: [{ file: rel, lines: "1", hash: sha16(rel) }] };
1543
1962
  } catch {
1544
- return { result: 'false', confidence: 'high', evidence: [] };
1963
+ return { result: "false", confidence: "high", evidence: [] };
1545
1964
  }
1546
1965
  }
1547
1966
 
1548
1967
  async function verifyEnvVar(projectPath, subject, claim) {
1549
1968
  const env = await extractEnv(projectPath);
1550
- const name = subject.name;
1551
-
1552
- const isDeclared = env.declared.some(d => d.name === name);
1553
- const isUsed = env.used.some(u => u.name === name);
1554
-
1555
- if (claim === 'env_var_exists') {
1556
- return {
1557
- result: isDeclared ? 'true' : 'unknown',
1558
- confidence: isDeclared ? 'high' : 'low',
1559
- evidence: isDeclared ? [{ file: '.env.example', lines: '1', hash: '' }] : [],
1560
- };
1561
- } else {
1562
- return {
1563
- result: isUsed ? 'true' : 'false',
1564
- confidence: 'high',
1565
- evidence: env.used.filter(u => u.name === name).map(u => ({ file: u.file, lines: `${u.line}`, hash: '' })),
1566
- };
1969
+ const name = String(subject?.name || "");
1970
+ if (!name) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.name"] };
1971
+
1972
+ const declared = env.declared?.find((d) => d.name === name);
1973
+ const used = (env.used || []).filter((u) => u.name === name);
1974
+
1975
+ if (claim === "env_var_exists") {
1976
+ if (declared) {
1977
+ return { result: "true", confidence: "high", evidence: [{ file: ".env.example", lines: `${declared.line}`, hash: sha16(`${name}:${declared.line}`) }] };
1978
+ }
1979
+ // "exists" might be in real env, but contract says no -> unknown
1980
+ return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Not declared in .env.example / contract"] };
1567
1981
  }
1568
- }
1569
1982
 
1570
- async function verifyRouteGuarded(projectPath, subject) {
1571
- const routePath = subject.path;
1572
- const evidence = await searchEvidence(projectPath, { query: `${routePath}.*auth|middleware.*${routePath}`, limit: 5 });
1573
-
1574
- if (evidence.count > 0) {
1983
+ // env_var_used
1984
+ if (used.length > 0) {
1575
1985
  return {
1576
- result: 'true',
1577
- confidence: 'medium',
1578
- evidence: evidence.results.map(e => ({ file: e.file, lines: `${e.line}`, hash: e.hash })),
1986
+ result: "true",
1987
+ confidence: "high",
1988
+ evidence: used.map((u) => ({ file: u.file, lines: `${u.line}`, hash: sha16(`${u.file}:${u.line}:${name}`) })),
1579
1989
  };
1580
1990
  }
1581
-
1582
- return { result: 'unknown', confidence: 'low', evidence: [], nextSteps: ['Could not find auth guards for this route'] };
1991
+
1992
+ return { result: "false", confidence: "high", evidence: [] };
1583
1993
  }
1584
1994
 
1585
- async function verifyEntityExists(projectPath, subject, claim) {
1586
- const name = subject.name;
1587
- const evidence = await searchEvidence(projectPath, { query: name, limit: 5 });
1588
-
1995
+ async function verifyRouteGuarded(projectPath, subject) {
1996
+ const routePath = canonicalizePath(String(subject?.path || ""));
1997
+ if (!routePath) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.path"] };
1998
+
1999
+ // heuristic: route mentioned near middleware/auth patterns
2000
+ const evidence = await searchEvidence(projectPath, {
2001
+ query: String.raw`(middleware|auth|authorize|session).*(\Q${routePath}\E)|\Q${routePath}\E.*(middleware|auth|authorize|session)`.replace(/\Q|\E/g, ""),
2002
+ mode: "regex",
2003
+ limit: 10,
2004
+ });
2005
+
1589
2006
  if (evidence.count > 0) {
1590
2007
  return {
1591
- result: 'true',
1592
- confidence: 'medium',
1593
- evidence: evidence.results.map(e => ({ file: e.file, lines: `${e.line}`, hash: e.hash })),
2008
+ result: "true",
2009
+ confidence: "med",
2010
+ evidence: evidence.results.map((e) => ({ file: e.file, lines: `${e.line}`, hash: e.hash, snippet: e.snippet })),
1594
2011
  };
1595
2012
  }
1596
-
1597
- return { result: 'unknown', confidence: 'low', evidence: [] };
1598
- }
1599
-
1600
- function filterTruthPack(pack, scope) {
1601
- if (scope === 'all') return pack;
1602
- return {
1603
- ...pack,
1604
- sections: { [scope]: pack.sections[scope] },
1605
- };
1606
- }
1607
2013
 
1608
- function detectDomains(task) {
1609
- const domains = [];
1610
- if (/auth|login|logout|session|password/i.test(task)) domains.push('auth');
1611
- if (/billing|payment|stripe|subscription/i.test(task)) domains.push('billing');
1612
- if (/route|api|endpoint/i.test(task)) domains.push('api');
1613
- if (/component|button|ui|form/i.test(task)) domains.push('ui');
1614
- return domains.length > 0 ? domains : ['general'];
2014
+ return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["No guard evidence found (may still exist; extractor is heuristic)"] };
1615
2015
  }
1616
2016
 
1617
- function extractKeywords(task) {
1618
- return task.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter(w => w.length > 2);
1619
- }
2017
+ async function verifyEntityExists(projectPath, subject, _claim) {
2018
+ const name = String(subject?.name || "");
2019
+ if (!name) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.name"] };
1620
2020
 
1621
- function getInvariantsForDomains(domains) {
1622
- const invariants = [];
1623
- if (domains.includes('auth')) {
1624
- invariants.push('No protected route without server middleware');
1625
- }
1626
- if (domains.includes('billing')) {
1627
- invariants.push('No paid feature without server-side enforcement');
2021
+ const evidence = await searchEvidence(projectPath, { query: name, mode: "text", limit: 8 });
2022
+ if (evidence.count > 0) {
2023
+ return {
2024
+ result: "true",
2025
+ confidence: "med",
2026
+ evidence: evidence.results.map((e) => ({ file: e.file, lines: `${e.line}`, hash: e.hash, snippet: e.snippet })),
2027
+ };
1628
2028
  }
1629
- invariants.push('No success UI without confirmed success');
1630
- return invariants;
1631
- }
1632
2029
 
1633
- function estimateTokens(context) {
1634
- let tokens = 0;
1635
- if (context.relevantRoutes) tokens += context.relevantRoutes.length * 50;
1636
- if (context.relevantAuth) tokens += 200;
1637
- if (context.relevantBilling) tokens += 200;
1638
- return tokens;
2030
+ return { result: "unknown", confidence: "low", evidence: [] };
1639
2031
  }
1640
2032
 
1641
- function generateContextWarnings(domains, policy, routeCount) {
1642
- const warnings = [];
1643
- if (domains.includes('auth') || domains.includes('billing')) {
1644
- warnings.push('High-stakes domain - verify all claims before changes');
1645
- }
1646
- if (routeCount > 20 && policy === 'strict') {
1647
- warnings.push('Large context - consider narrowing scope');
1648
- }
1649
- return warnings;
1650
- }
2033
+ // =============================================================================
2034
+ // EXPORTS
2035
+ // =============================================================================
1651
2036
 
1652
- async function findSourceFiles(projectPath) {
1653
- const files = [];
1654
- const ignoreDirs = ['node_modules', 'dist', 'build', '.git', '.next', 'coverage'];
1655
-
1656
- async function walk(dir) {
1657
- try {
1658
- const entries = await fs.readdir(dir, { withFileTypes: true });
1659
- for (const entry of entries) {
1660
- const fullPath = path.join(dir, entry.name);
1661
- if (entry.isDirectory()) {
1662
- if (!ignoreDirs.includes(entry.name) && !entry.name.startsWith('.')) {
1663
- await walk(fullPath);
1664
- }
1665
- } else if (entry.isFile() && /\.(ts|tsx|js|jsx)$/.test(entry.name)) {
1666
- files.push(fullPath);
1667
- }
1668
- }
1669
- } catch {}
1670
- }
1671
-
1672
- await walk(projectPath);
1673
- return files;
1674
- }
2037
+ export {
2038
+ getRouteIndex, // Route Truth v1 index - single source of truth for routes
2039
+ extractRoutes, // Route extraction via Route Truth v1
2040
+ };
1675
2041
 
1676
2042
  export default {
1677
2043
  TRUTH_FIREWALL_TOOLS,
@@ -1682,5 +2048,6 @@ export default {
1682
2048
  getProjectFingerprint,
1683
2049
  wrapMcpResponse,
1684
2050
  getContextAttribution,
2051
+ getRouteIndex, // Also on default export
1685
2052
  CONTEXT_ATTRIBUTION,
1686
2053
  };