@vibecheckai/cli 3.2.4 → 3.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/bin/.generated +25 -25
  2. package/bin/dev/run-v2-torture.js +30 -30
  3. package/bin/runners/lib/__tests__/entitlements-v2.test.js +295 -295
  4. package/bin/runners/lib/api-client.js +269 -0
  5. package/bin/runners/lib/auth-truth.js +193 -193
  6. package/bin/runners/lib/backup.js +62 -62
  7. package/bin/runners/lib/billing.js +107 -107
  8. package/bin/runners/lib/claims.js +118 -118
  9. package/bin/runners/lib/cli-ui.js +540 -540
  10. package/bin/runners/lib/contracts/auth-contract.js +202 -202
  11. package/bin/runners/lib/contracts/env-contract.js +181 -181
  12. package/bin/runners/lib/contracts/external-contract.js +206 -206
  13. package/bin/runners/lib/contracts/guard.js +168 -168
  14. package/bin/runners/lib/contracts/index.js +89 -89
  15. package/bin/runners/lib/contracts/plan-validator.js +311 -311
  16. package/bin/runners/lib/contracts/route-contract.js +199 -199
  17. package/bin/runners/lib/contracts.js +804 -804
  18. package/bin/runners/lib/detect.js +89 -89
  19. package/bin/runners/lib/doctor/autofix.js +254 -254
  20. package/bin/runners/lib/doctor/index.js +37 -37
  21. package/bin/runners/lib/doctor/modules/dependencies.js +325 -325
  22. package/bin/runners/lib/doctor/modules/index.js +46 -46
  23. package/bin/runners/lib/doctor/modules/network.js +250 -250
  24. package/bin/runners/lib/doctor/modules/project.js +312 -312
  25. package/bin/runners/lib/doctor/modules/runtime.js +224 -224
  26. package/bin/runners/lib/doctor/modules/security.js +348 -348
  27. package/bin/runners/lib/doctor/modules/system.js +213 -213
  28. package/bin/runners/lib/doctor/modules/vibecheck.js +394 -394
  29. package/bin/runners/lib/doctor/reporter.js +262 -262
  30. package/bin/runners/lib/doctor/service.js +262 -262
  31. package/bin/runners/lib/doctor/types.js +113 -113
  32. package/bin/runners/lib/doctor/ui.js +263 -263
  33. package/bin/runners/lib/doctor-v2.js +608 -608
  34. package/bin/runners/lib/drift.js +425 -425
  35. package/bin/runners/lib/enforcement.js +72 -72
  36. package/bin/runners/lib/enterprise-detect.js +603 -603
  37. package/bin/runners/lib/enterprise-init.js +942 -942
  38. package/bin/runners/lib/env-resolver.js +417 -417
  39. package/bin/runners/lib/env-template.js +66 -66
  40. package/bin/runners/lib/env.js +189 -189
  41. package/bin/runners/lib/extractors/client-calls.js +990 -990
  42. package/bin/runners/lib/extractors/fastify-route-dump.js +573 -573
  43. package/bin/runners/lib/extractors/fastify-routes.js +426 -426
  44. package/bin/runners/lib/extractors/index.js +363 -363
  45. package/bin/runners/lib/extractors/next-routes.js +524 -524
  46. package/bin/runners/lib/extractors/proof-graph.js +431 -431
  47. package/bin/runners/lib/extractors/route-matcher.js +451 -451
  48. package/bin/runners/lib/extractors/truthpack-v2.js +377 -377
  49. package/bin/runners/lib/extractors/ui-bindings.js +547 -547
  50. package/bin/runners/lib/findings-schema.js +281 -281
  51. package/bin/runners/lib/firewall-prompt.js +50 -50
  52. package/bin/runners/lib/graph/graph-builder.js +265 -265
  53. package/bin/runners/lib/graph/html-renderer.js +413 -413
  54. package/bin/runners/lib/graph/index.js +32 -32
  55. package/bin/runners/lib/graph/runtime-collector.js +215 -215
  56. package/bin/runners/lib/graph/static-extractor.js +518 -518
  57. package/bin/runners/lib/html-report.js +650 -650
  58. package/bin/runners/lib/llm.js +75 -75
  59. package/bin/runners/lib/meter.js +61 -61
  60. package/bin/runners/lib/missions/evidence.js +126 -126
  61. package/bin/runners/lib/patch.js +40 -40
  62. package/bin/runners/lib/permissions/auth-model.js +213 -213
  63. package/bin/runners/lib/permissions/idor-prover.js +205 -205
  64. package/bin/runners/lib/permissions/index.js +45 -45
  65. package/bin/runners/lib/permissions/matrix-builder.js +198 -198
  66. package/bin/runners/lib/pkgjson.js +28 -28
  67. package/bin/runners/lib/policy.js +295 -295
  68. package/bin/runners/lib/preflight.js +142 -142
  69. package/bin/runners/lib/reality/correlation-detectors.js +359 -359
  70. package/bin/runners/lib/reality/index.js +318 -318
  71. package/bin/runners/lib/reality/request-hashing.js +416 -416
  72. package/bin/runners/lib/reality/request-mapper.js +453 -453
  73. package/bin/runners/lib/reality/safety-rails.js +463 -463
  74. package/bin/runners/lib/reality/semantic-snapshot.js +408 -408
  75. package/bin/runners/lib/reality/toast-detector.js +393 -393
  76. package/bin/runners/lib/reality-findings.js +84 -84
  77. package/bin/runners/lib/receipts.js +179 -179
  78. package/bin/runners/lib/redact.js +29 -29
  79. package/bin/runners/lib/replay/capsule-manager.js +154 -154
  80. package/bin/runners/lib/replay/index.js +263 -263
  81. package/bin/runners/lib/replay/player.js +348 -348
  82. package/bin/runners/lib/replay/recorder.js +331 -331
  83. package/bin/runners/lib/report.js +135 -135
  84. package/bin/runners/lib/route-detection.js +1140 -1140
  85. package/bin/runners/lib/sandbox/index.js +59 -59
  86. package/bin/runners/lib/sandbox/proof-chain.js +399 -399
  87. package/bin/runners/lib/sandbox/sandbox-runner.js +205 -205
  88. package/bin/runners/lib/sandbox/worktree.js +174 -174
  89. package/bin/runners/lib/schema-validator.js +350 -350
  90. package/bin/runners/lib/schemas/contracts.schema.json +160 -160
  91. package/bin/runners/lib/schemas/finding.schema.json +100 -100
  92. package/bin/runners/lib/schemas/mission-pack.schema.json +206 -206
  93. package/bin/runners/lib/schemas/proof-graph.schema.json +176 -176
  94. package/bin/runners/lib/schemas/reality-report.schema.json +162 -162
  95. package/bin/runners/lib/schemas/share-pack.schema.json +180 -180
  96. package/bin/runners/lib/schemas/ship-report.schema.json +117 -117
  97. package/bin/runners/lib/schemas/truthpack-v2.schema.json +303 -303
  98. package/bin/runners/lib/schemas/validator.js +438 -438
  99. package/bin/runners/lib/score-history.js +282 -282
  100. package/bin/runners/lib/share-pack.js +239 -239
  101. package/bin/runners/lib/snippets.js +67 -67
  102. package/bin/runners/lib/upsell.js +510 -510
  103. package/bin/runners/lib/usage.js +153 -153
  104. package/bin/runners/lib/validate-patch.js +156 -156
  105. package/bin/runners/lib/verdict-engine.js +628 -628
  106. package/bin/runners/reality/engine.js +917 -917
  107. package/bin/runners/reality/flows.js +122 -122
  108. package/bin/runners/reality/report.js +378 -378
  109. package/bin/runners/reality/session.js +193 -193
  110. package/bin/runners/runAgent.d.ts +5 -0
  111. package/bin/runners/runFirewall.d.ts +5 -0
  112. package/bin/runners/runFirewallHook.d.ts +5 -0
  113. package/bin/runners/runGuard.js +168 -168
  114. package/bin/runners/runScan.js +82 -0
  115. package/bin/runners/runTruth.d.ts +5 -0
  116. package/bin/vibecheck.js +45 -20
  117. package/mcp-server/index.js +85 -0
  118. package/mcp-server/lib/api-client.js +269 -0
  119. package/mcp-server/package.json +1 -1
  120. package/mcp-server/tier-auth.js +173 -113
  121. package/mcp-server/tools/index.js +72 -72
  122. package/mcp-server/vibecheck-mcp-server-3.2.0.tgz +0 -0
  123. package/package.json +1 -1
@@ -1,990 +1,990 @@
1
- /**
2
- * Client Call Extractor v2
3
- *
4
- * Extracts client-side API calls: fetch, axios, tRPC, GraphQL, Server Actions, SDK calls.
5
- * Links them to the Reality Proof Graph for dead UI detection.
6
- *
7
- * Output: ClientCall records with evidence, confidence, and canonical paths.
8
- */
9
-
10
- "use strict";
11
-
12
- const fs = require("fs");
13
- const path = require("path");
14
- const fg = require("fast-glob");
15
- const crypto = require("crypto");
16
- const parser = require("@babel/parser");
17
- const traverse = require("@babel/traverse").default;
18
- const t = require("@babel/types");
19
-
20
- // =============================================================================
21
- // TYPES AND CONSTANTS
22
- // =============================================================================
23
-
24
- const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
25
-
26
- const CALL_KINDS = {
27
- HTTP: "http",
28
- TRPC: "trpc",
29
- GRAPHQL: "graphql",
30
- SERVER_ACTION: "server_action",
31
- SDK: "sdk",
32
- };
33
-
34
- const RUNTIMES = {
35
- CLIENT: "client",
36
- SERVER: "server",
37
- SHARED: "shared",
38
- UNKNOWN: "unknown",
39
- };
40
-
41
- // =============================================================================
42
- // MAIN EXTRACTION
43
- // =============================================================================
44
-
45
- /**
46
- * Extract all client calls from a project
47
- */
48
- function extractClientCalls(projectRoot, options = {}) {
49
- const { include = [], exclude = [] } = options;
50
-
51
- // Find source files
52
- const defaultInclude = ["**/*.{ts,tsx,js,jsx,mts,cts}"];
53
- const defaultExclude = [
54
- "**/node_modules/**",
55
- "**/.next/**",
56
- "**/dist/**",
57
- "**/build/**",
58
- "**/coverage/**",
59
- "**/*.min.js",
60
- "**/*.d.ts",
61
- ];
62
-
63
- const patterns = include.length > 0 ? include : defaultInclude;
64
- const ignorePatterns = [...defaultExclude, ...exclude];
65
-
66
- const files = fg.sync(patterns, {
67
- cwd: projectRoot,
68
- absolute: true,
69
- ignore: ignorePatterns,
70
- onlyFiles: true,
71
- });
72
-
73
- const calls = [];
74
- const wrappers = [];
75
- const uiBindings = [];
76
- const errors = [];
77
-
78
- // Pass 1: Extract wrappers (axios instances, custom request helpers)
79
- for (const file of files) {
80
- try {
81
- const content = fs.readFileSync(file, "utf8");
82
- const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
83
- const fileWrappers = extractWrappers(content, relPath, projectRoot);
84
- wrappers.push(...fileWrappers);
85
- } catch (err) {
86
- errors.push({ file, phase: "wrappers", error: err.message });
87
- }
88
- }
89
-
90
- // Build wrapper lookup
91
- const wrapperMap = new Map();
92
- for (const w of wrappers) {
93
- wrapperMap.set(w.name, w);
94
- }
95
-
96
- // Pass 2: Extract calls
97
- for (const file of files) {
98
- try {
99
- const content = fs.readFileSync(file, "utf8");
100
- const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
101
- const runtime = inferRuntime(content, relPath);
102
-
103
- const fileCalls = extractCallsFromFile(content, relPath, runtime, wrapperMap, projectRoot);
104
- calls.push(...fileCalls);
105
-
106
- const bindings = extractUIBindings(content, relPath, fileCalls);
107
- uiBindings.push(...bindings);
108
- } catch (err) {
109
- errors.push({ file, phase: "calls", error: err.message });
110
- }
111
- }
112
-
113
- return {
114
- calls,
115
- wrappers,
116
- uiBindings,
117
- errors,
118
- stats: {
119
- filesScanned: files.length,
120
- callsFound: calls.length,
121
- wrappersFound: wrappers.length,
122
- bindingsFound: uiBindings.length,
123
- },
124
- };
125
- }
126
-
127
- /**
128
- * Extract calls from a single file
129
- */
130
- function extractCallsFromFile(content, relPath, runtime, wrapperMap, projectRoot) {
131
- const calls = [];
132
-
133
- let ast;
134
- try {
135
- ast = parser.parse(content, {
136
- sourceType: "module",
137
- plugins: ["typescript", "jsx", "decorators-legacy"],
138
- errorRecovery: true,
139
- });
140
- } catch {
141
- return calls;
142
- }
143
-
144
- traverse(ast, {
145
- CallExpression(path) {
146
- // fetch() calls
147
- const fetchCall = extractFetchCall(path, content, relPath, runtime);
148
- if (fetchCall) {
149
- calls.push(fetchCall);
150
- return;
151
- }
152
-
153
- // axios calls
154
- const axiosCall = extractAxiosCall(path, content, relPath, runtime, wrapperMap);
155
- if (axiosCall) {
156
- calls.push(axiosCall);
157
- return;
158
- }
159
-
160
- // tRPC calls
161
- const trpcCall = extractTRPCCall(path, content, relPath, runtime);
162
- if (trpcCall) {
163
- calls.push(trpcCall);
164
- return;
165
- }
166
-
167
- // GraphQL calls
168
- const gqlCall = extractGraphQLCall(path, content, relPath, runtime);
169
- if (gqlCall) {
170
- calls.push(gqlCall);
171
- return;
172
- }
173
-
174
- // Wrapper calls
175
- const wrapperCall = extractWrapperCall(path, content, relPath, runtime, wrapperMap);
176
- if (wrapperCall) {
177
- calls.push(wrapperCall);
178
- return;
179
- }
180
- },
181
-
182
- // Server Actions (form action={...})
183
- JSXAttribute(path) {
184
- if (path.node.name.name === "action" && t.isJSXExpressionContainer(path.node.value)) {
185
- const actionCall = extractServerAction(path, content, relPath);
186
- if (actionCall) {
187
- calls.push(actionCall);
188
- }
189
- }
190
- },
191
- });
192
-
193
- return calls;
194
- }
195
-
196
- // =============================================================================
197
- // FETCH EXTRACTION
198
- // =============================================================================
199
-
200
- /**
201
- * Extract fetch() call
202
- */
203
- function extractFetchCall(path, content, relPath, runtime) {
204
- const { node } = path;
205
-
206
- // Check if callee is 'fetch'
207
- if (!t.isIdentifier(node.callee, { name: "fetch" })) {
208
- // Also check for fetch(new Request(...))
209
- if (t.isNewExpression(node.callee) && t.isIdentifier(node.callee.callee, { name: "Request" })) {
210
- return extractRequestCall(path, content, relPath, runtime);
211
- }
212
- return null;
213
- }
214
-
215
- const urlArg = node.arguments[0];
216
- const initArg = node.arguments[1];
217
-
218
- if (!urlArg) return null;
219
-
220
- // Extract URL
221
- const urlInfo = extractUrlFromNode(urlArg, content);
222
- if (!urlInfo.value) return null;
223
-
224
- // Extract method
225
- const method = extractMethodFromInit(initArg) || "GET";
226
-
227
- // Extract other info
228
- const expectsJson = extractExpectsJson(initArg, path);
229
- const authHint = extractAuthHint(initArg);
230
-
231
- const lineNum = node.loc?.start?.line || 1;
232
-
233
- return createClientCall({
234
- kind: CALL_KINDS.HTTP,
235
- runtime,
236
- method,
237
- urlTemplate: urlInfo.value,
238
- canonicalPath: canonicalizeUrl(urlInfo.value),
239
- expectsJson,
240
- authHint,
241
- confidence: urlInfo.confidence,
242
- file: relPath,
243
- lines: `${lineNum}-${lineNum + 5}`,
244
- snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
245
- reason: `fetch('${urlInfo.value.slice(0, 50)}') call`,
246
- });
247
- }
248
-
249
- /**
250
- * Extract new Request() call inside fetch
251
- */
252
- function extractRequestCall(path, content, relPath, runtime) {
253
- const { node } = path;
254
- const requestNode = node.callee;
255
-
256
- if (!t.isNewExpression(requestNode)) return null;
257
-
258
- const urlArg = requestNode.arguments[0];
259
- const initArg = requestNode.arguments[1];
260
-
261
- if (!urlArg) return null;
262
-
263
- const urlInfo = extractUrlFromNode(urlArg, content);
264
- if (!urlInfo.value) return null;
265
-
266
- const method = extractMethodFromInit(initArg) || "GET";
267
- const lineNum = node.loc?.start?.line || 1;
268
-
269
- return createClientCall({
270
- kind: CALL_KINDS.HTTP,
271
- runtime,
272
- method,
273
- urlTemplate: urlInfo.value,
274
- canonicalPath: canonicalizeUrl(urlInfo.value),
275
- expectsJson: false,
276
- authHint: "unknown",
277
- confidence: urlInfo.confidence,
278
- file: relPath,
279
- lines: `${lineNum}-${lineNum + 3}`,
280
- snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
281
- reason: `fetch(new Request('${urlInfo.value.slice(0, 50)}')) call`,
282
- });
283
- }
284
-
285
- // =============================================================================
286
- // AXIOS EXTRACTION
287
- // =============================================================================
288
-
289
- /**
290
- * Extract axios call
291
- */
292
- function extractAxiosCall(path, content, relPath, runtime, wrapperMap) {
293
- const { node } = path;
294
-
295
- // Pattern A: axios.get/post/etc(url)
296
- if (t.isMemberExpression(node.callee)) {
297
- const obj = node.callee.object;
298
- const prop = node.callee.property;
299
-
300
- if (t.isIdentifier(obj, { name: "axios" }) && t.isIdentifier(prop)) {
301
- const methodName = prop.name.toUpperCase();
302
- if (HTTP_METHODS.includes(methodName) || methodName === "REQUEST") {
303
- const urlArg = node.arguments[0];
304
- if (!urlArg) return null;
305
-
306
- const urlInfo = extractUrlFromNode(urlArg, content);
307
- if (!urlInfo.value) return null;
308
-
309
- const lineNum = node.loc?.start?.line || 1;
310
-
311
- return createClientCall({
312
- kind: CALL_KINDS.HTTP,
313
- runtime,
314
- method: methodName === "REQUEST" ? "UNKNOWN" : methodName,
315
- urlTemplate: urlInfo.value,
316
- canonicalPath: canonicalizeUrl(urlInfo.value),
317
- expectsJson: true,
318
- authHint: "unknown",
319
- confidence: urlInfo.confidence,
320
- file: relPath,
321
- lines: `${lineNum}-${lineNum + 3}`,
322
- snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
323
- reason: `axios.${prop.name}('${urlInfo.value.slice(0, 50)}') call`,
324
- });
325
- }
326
- }
327
- }
328
-
329
- // Pattern B: axios({ method, url })
330
- if (t.isIdentifier(node.callee, { name: "axios" })) {
331
- const configArg = node.arguments[0];
332
- if (t.isObjectExpression(configArg)) {
333
- const config = extractObjectLiteralProps(configArg);
334
- if (config.url) {
335
- const lineNum = node.loc?.start?.line || 1;
336
-
337
- return createClientCall({
338
- kind: CALL_KINDS.HTTP,
339
- runtime,
340
- method: (config.method || "GET").toUpperCase(),
341
- urlTemplate: config.url,
342
- canonicalPath: canonicalizeUrl(combineBaseUrl(config.baseURL, config.url)),
343
- expectsJson: true,
344
- authHint: "unknown",
345
- confidence: "medium",
346
- file: relPath,
347
- lines: `${lineNum}-${lineNum + 5}`,
348
- snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
349
- reason: `axios({ url: '${config.url.slice(0, 50)}' }) call`,
350
- });
351
- }
352
- }
353
- }
354
-
355
- return null;
356
- }
357
-
358
- // =============================================================================
359
- // TRPC EXTRACTION
360
- // =============================================================================
361
-
362
- /**
363
- * Extract tRPC call
364
- */
365
- function extractTRPCCall(path, content, relPath, runtime) {
366
- const { node } = path;
367
-
368
- // Pattern: trpc.user.get.useQuery() or trpc.billing.portal.useMutation()
369
- if (t.isMemberExpression(node.callee)) {
370
- const calleeCode = extractMemberChain(node.callee);
371
-
372
- // Check for tRPC patterns
373
- const trpcMatch = calleeCode.match(/^(?:trpc|api)\.(.+?)\.(useQuery|useMutation|query|mutate)$/);
374
- if (trpcMatch) {
375
- const procedure = trpcMatch[1];
376
- const operationType = trpcMatch[2].includes("Mutation") || trpcMatch[2] === "mutate" ? "mutation" : "query";
377
- const lineNum = node.loc?.start?.line || 1;
378
-
379
- return createClientCall({
380
- kind: CALL_KINDS.TRPC,
381
- runtime,
382
- method: "POST",
383
- urlTemplate: `/api/trpc/${procedure}`,
384
- canonicalPath: `/api/trpc/${procedure}`,
385
- expectsJson: true,
386
- authHint: "unknown",
387
- confidence: "medium",
388
- file: relPath,
389
- lines: `${lineNum}-${lineNum + 3}`,
390
- snippet: content.substring(node.start, Math.min(node.end, node.start + 150)),
391
- reason: `tRPC ${operationType}: ${procedure}`,
392
- meta: {
393
- procedure,
394
- operationType,
395
- },
396
- });
397
- }
398
- }
399
-
400
- return null;
401
- }
402
-
403
- // =============================================================================
404
- // GRAPHQL EXTRACTION
405
- // =============================================================================
406
-
407
- /**
408
- * Extract GraphQL call
409
- */
410
- function extractGraphQLCall(path, content, relPath, runtime) {
411
- const { node } = path;
412
-
413
- // Pattern: useQuery(gql`...`) or useMutation(gql`...`)
414
- if (t.isIdentifier(node.callee)) {
415
- const calleeName = node.callee.name;
416
-
417
- if (calleeName === "useQuery" || calleeName === "useMutation" || calleeName === "request") {
418
- const queryArg = node.arguments[0];
419
- let operationName = null;
420
-
421
- // Try to extract operation name from gql template
422
- if (t.isTaggedTemplateExpression(queryArg)) {
423
- const template = queryArg.quasi.quasis.map(q => q.value.raw).join("");
424
- const opMatch = template.match(/(?:query|mutation|subscription)\s+(\w+)/);
425
- if (opMatch) {
426
- operationName = opMatch[1];
427
- }
428
- }
429
-
430
- const lineNum = node.loc?.start?.line || 1;
431
-
432
- return createClientCall({
433
- kind: CALL_KINDS.GRAPHQL,
434
- runtime,
435
- method: "POST",
436
- urlTemplate: "/graphql",
437
- canonicalPath: "/graphql",
438
- expectsJson: true,
439
- authHint: "unknown",
440
- confidence: operationName ? "medium" : "low",
441
- file: relPath,
442
- lines: `${lineNum}-${lineNum + 5}`,
443
- snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
444
- reason: `GraphQL ${calleeName}${operationName ? `: ${operationName}` : ""}`,
445
- meta: {
446
- operationName,
447
- operationType: calleeName.includes("Mutation") ? "mutation" : "query",
448
- },
449
- });
450
- }
451
- }
452
-
453
- return null;
454
- }
455
-
456
- // =============================================================================
457
- // SERVER ACTIONS EXTRACTION
458
- // =============================================================================
459
-
460
- /**
461
- * Extract Next.js Server Action
462
- */
463
- function extractServerAction(path, content, relPath) {
464
- const jsxAttr = path.node;
465
- const container = jsxAttr.value;
466
-
467
- if (!t.isJSXExpressionContainer(container)) return null;
468
-
469
- const expr = container.expression;
470
- let actionName = null;
471
-
472
- if (t.isIdentifier(expr)) {
473
- actionName = expr.name;
474
- } else if (t.isMemberExpression(expr)) {
475
- actionName = extractMemberChain(expr);
476
- }
477
-
478
- if (!actionName) return null;
479
-
480
- const lineNum = jsxAttr.loc?.start?.line || 1;
481
-
482
- return createClientCall({
483
- kind: CALL_KINDS.SERVER_ACTION,
484
- runtime: RUNTIMES.SERVER,
485
- method: "POST",
486
- urlTemplate: `action://${relPath}#${actionName}`,
487
- canonicalPath: `action://${relPath}#${actionName}`,
488
- expectsJson: false,
489
- authHint: "unknown",
490
- confidence: "high",
491
- file: relPath,
492
- lines: `${lineNum}-${lineNum + 1}`,
493
- snippet: `action={${actionName}}`,
494
- reason: `Server Action: ${actionName}`,
495
- meta: {
496
- actionExport: actionName,
497
- module: relPath,
498
- },
499
- });
500
- }
501
-
502
- // =============================================================================
503
- // WRAPPER EXTRACTION
504
- // =============================================================================
505
-
506
- /**
507
- * Extract wrapper definitions (axios instances, custom fetch wrappers)
508
- */
509
- function extractWrappers(content, relPath, projectRoot) {
510
- const wrappers = [];
511
-
512
- let ast;
513
- try {
514
- ast = parser.parse(content, {
515
- sourceType: "module",
516
- plugins: ["typescript", "jsx", "decorators-legacy"],
517
- errorRecovery: true,
518
- });
519
- } catch {
520
- return wrappers;
521
- }
522
-
523
- traverse(ast, {
524
- // axios.create()
525
- CallExpression(path) {
526
- const { node } = path;
527
-
528
- if (
529
- t.isMemberExpression(node.callee) &&
530
- t.isIdentifier(node.callee.object, { name: "axios" }) &&
531
- t.isIdentifier(node.callee.property, { name: "create" })
532
- ) {
533
- const configArg = node.arguments[0];
534
- const config = t.isObjectExpression(configArg) ? extractObjectLiteralProps(configArg) : {};
535
-
536
- // Find variable name
537
- let name = "axiosInstance";
538
- if (t.isVariableDeclarator(path.parent)) {
539
- if (t.isIdentifier(path.parent.id)) {
540
- name = path.parent.id.name;
541
- }
542
- }
543
-
544
- wrappers.push({
545
- id: `W_AXIOS_${hashShort(relPath + name)}`,
546
- name,
547
- kind: "axios",
548
- baseURL: config.baseURL || "",
549
- defaultHeaders: config.headers ? Object.keys(config.headers) : [],
550
- file: relPath,
551
- lines: `${node.loc?.start?.line || 1}-${node.loc?.end?.line || 1}`,
552
- });
553
- }
554
- },
555
-
556
- // Custom fetch wrappers: export const api = { get: ..., post: ... }
557
- VariableDeclarator(path) {
558
- const { node } = path;
559
-
560
- if (t.isIdentifier(node.id) && t.isObjectExpression(node.init)) {
561
- const props = node.init.properties;
562
- const methodProps = props.filter(
563
- p => t.isIdentifier(p.key) && HTTP_METHODS.map(m => m.toLowerCase()).includes(p.key.name)
564
- );
565
-
566
- if (methodProps.length >= 2) {
567
- // Looks like an API client object
568
- wrappers.push({
569
- id: `W_CUSTOM_${hashShort(relPath + node.id.name)}`,
570
- name: node.id.name,
571
- kind: "custom",
572
- baseURL: "",
573
- methods: methodProps.map(p => p.key.name),
574
- file: relPath,
575
- lines: `${node.loc?.start?.line || 1}-${node.loc?.end?.line || 1}`,
576
- });
577
- }
578
- }
579
- },
580
- });
581
-
582
- return wrappers;
583
- }
584
-
585
- /**
586
- * Extract wrapper call (api.get(), apiClient.post(), etc.)
587
- */
588
- function extractWrapperCall(path, content, relPath, runtime, wrapperMap) {
589
- const { node } = path;
590
-
591
- if (!t.isMemberExpression(node.callee)) return null;
592
-
593
- const obj = node.callee.object;
594
- const prop = node.callee.property;
595
-
596
- if (!t.isIdentifier(obj) || !t.isIdentifier(prop)) return null;
597
-
598
- const wrapper = wrapperMap.get(obj.name);
599
- if (!wrapper) return null;
600
-
601
- const methodName = prop.name.toUpperCase();
602
- if (!HTTP_METHODS.includes(methodName) && methodName !== "REQUEST") return null;
603
-
604
- const urlArg = node.arguments[0];
605
- if (!urlArg) return null;
606
-
607
- const urlInfo = extractUrlFromNode(urlArg, content);
608
- if (!urlInfo.value) return null;
609
-
610
- // Combine with wrapper baseURL
611
- const fullUrl = combineBaseUrl(wrapper.baseURL, urlInfo.value);
612
- const lineNum = node.loc?.start?.line || 1;
613
-
614
- return createClientCall({
615
- kind: CALL_KINDS.HTTP,
616
- runtime,
617
- method: methodName === "REQUEST" ? "UNKNOWN" : methodName,
618
- urlTemplate: urlInfo.value,
619
- canonicalPath: canonicalizeUrl(fullUrl),
620
- expectsJson: true,
621
- authHint: "unknown",
622
- confidence: urlInfo.confidence,
623
- file: relPath,
624
- lines: `${lineNum}-${lineNum + 3}`,
625
- snippet: content.substring(node.start, Math.min(node.end, node.start + 150)),
626
- reason: `${obj.name}.${prop.name}('${urlInfo.value.slice(0, 50)}') via wrapper`,
627
- meta: {
628
- wrapperId: wrapper.id,
629
- },
630
- });
631
- }
632
-
633
- // =============================================================================
634
- // UI BINDING EXTRACTION
635
- // =============================================================================
636
-
637
- /**
638
- * Extract UI bindings (onClick, onSubmit that call API)
639
- */
640
- function extractUIBindings(content, relPath, calls) {
641
- const bindings = [];
642
- const callIds = new Set(calls.map(c => c.id));
643
-
644
- let ast;
645
- try {
646
- ast = parser.parse(content, {
647
- sourceType: "module",
648
- plugins: ["typescript", "jsx", "decorators-legacy"],
649
- errorRecovery: true,
650
- });
651
- } catch {
652
- return bindings;
653
- }
654
-
655
- traverse(ast, {
656
- JSXAttribute(path) {
657
- const { node } = path;
658
- const attrName = node.name?.name;
659
-
660
- if (!["onClick", "onSubmit", "onBlur", "onChange", "action"].includes(attrName)) return;
661
-
662
- if (!t.isJSXExpressionContainer(node.value)) return;
663
-
664
- const expr = node.value.expression;
665
- const lineNum = node.loc?.start?.line || 1;
666
-
667
- // Find associated calls in this handler
668
- const handlerCalls = calls.filter(c => {
669
- const callLine = parseInt(c.evidence?.[0]?.lines?.split("-")[0] || "0");
670
- return Math.abs(callLine - lineNum) < 20; // Within 20 lines
671
- });
672
-
673
- if (handlerCalls.length > 0) {
674
- // Try to find label hint
675
- const labelHint = findLabelHint(path);
676
-
677
- bindings.push({
678
- bindingId: `UIB_${hashShort(relPath + lineNum)}`,
679
- file: relPath,
680
- lines: `${lineNum}-${lineNum + 5}`,
681
- event: attrName,
682
- labelHint,
683
- calls: handlerCalls.map(c => c.id),
684
- });
685
- }
686
- },
687
- });
688
-
689
- return bindings;
690
- }
691
-
692
- /**
693
- * Find label hint from JSX context
694
- */
695
- function findLabelHint(path) {
696
- // Look for button text, aria-label, etc.
697
- const parent = path.parentPath;
698
- if (!parent) return null;
699
-
700
- // Check for aria-label
701
- if (t.isJSXOpeningElement(parent.node)) {
702
- const ariaLabel = parent.node.attributes.find(
703
- a => t.isJSXAttribute(a) && a.name?.name === "aria-label"
704
- );
705
- if (ariaLabel && t.isStringLiteral(ariaLabel.value)) {
706
- return ariaLabel.value.value;
707
- }
708
- }
709
-
710
- // Check for children text
711
- const jsxElement = parent.parentPath;
712
- if (t.isJSXElement(jsxElement?.node)) {
713
- const textChild = jsxElement.node.children.find(
714
- c => t.isJSXText(c) && c.value.trim()
715
- );
716
- if (textChild) {
717
- return textChild.value.trim();
718
- }
719
- }
720
-
721
- return null;
722
- }
723
-
724
- // =============================================================================
725
- // HELPERS
726
- // =============================================================================
727
-
728
- /**
729
- * Extract URL value and confidence from AST node
730
- */
731
- function extractUrlFromNode(node, content) {
732
- // String literal: HIGH confidence
733
- if (t.isStringLiteral(node)) {
734
- return { value: node.value, confidence: "high" };
735
- }
736
-
737
- // Template literal
738
- if (t.isTemplateLiteral(node)) {
739
- if (node.expressions.length === 0) {
740
- // No expressions: HIGH
741
- return { value: node.quasis[0].value.raw, confidence: "high" };
742
- }
743
- // Has expressions: MEDIUM
744
- const template = node.quasis.map((q, i) => {
745
- const expr = node.expressions[i];
746
- return q.value.raw + (expr ? `{${i}}` : "");
747
- }).join("");
748
- return { value: template, confidence: "medium" };
749
- }
750
-
751
- // Binary expression (concatenation)
752
- if (t.isBinaryExpression(node, { operator: "+" })) {
753
- const left = extractUrlFromNode(node.left, content);
754
- const right = extractUrlFromNode(node.right, content);
755
- if (left.value && right.value) {
756
- return {
757
- value: left.value + right.value,
758
- confidence: "medium",
759
- };
760
- }
761
- }
762
-
763
- // Variable: LOW confidence
764
- if (t.isIdentifier(node)) {
765
- return { value: `\${${node.name}}`, confidence: "low" };
766
- }
767
-
768
- return { value: null, confidence: "low" };
769
- }
770
-
771
- /**
772
- * Extract method from fetch init argument
773
- */
774
- function extractMethodFromInit(initNode) {
775
- if (!initNode || !t.isObjectExpression(initNode)) return null;
776
-
777
- const methodProp = initNode.properties.find(
778
- p => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: "method" })
779
- );
780
-
781
- if (methodProp && t.isStringLiteral(methodProp.value)) {
782
- return methodProp.value.value.toUpperCase();
783
- }
784
-
785
- return null;
786
- }
787
-
788
- /**
789
- * Extract expectsJson from init
790
- */
791
- function extractExpectsJson(initNode, path) {
792
- if (!initNode) return false;
793
-
794
- if (t.isObjectExpression(initNode)) {
795
- const props = extractObjectLiteralProps(initNode);
796
- if (props.headers?.["content-type"]?.includes("application/json")) return true;
797
- if (props.body && typeof props.body === "string" && props.body.includes("JSON.stringify")) return true;
798
- }
799
-
800
- // Check if .json() is called on response
801
- // This would require more complex flow analysis
802
- return false;
803
- }
804
-
805
- /**
806
- * Extract auth hint from init
807
- */
808
- function extractAuthHint(initNode) {
809
- if (!initNode || !t.isObjectExpression(initNode)) return "unknown";
810
-
811
- const props = extractObjectLiteralProps(initNode);
812
-
813
- if (props.credentials === "include") return "cookie";
814
- if (props.headers?.authorization?.toLowerCase().startsWith("bearer")) return "bearer";
815
- if (props.headers?.Authorization?.toLowerCase().startsWith("bearer")) return "bearer";
816
-
817
- return "unknown";
818
- }
819
-
820
- /**
821
- * Extract object literal properties as plain object
822
- */
823
- function extractObjectLiteralProps(node) {
824
- const props = {};
825
-
826
- for (const prop of node.properties) {
827
- if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
828
- const key = prop.key.name;
829
-
830
- if (t.isStringLiteral(prop.value)) {
831
- props[key] = prop.value.value;
832
- } else if (t.isObjectExpression(prop.value)) {
833
- props[key] = extractObjectLiteralProps(prop.value);
834
- } else if (t.isIdentifier(prop.value)) {
835
- props[key] = `$${prop.value.name}`;
836
- }
837
- }
838
- }
839
-
840
- return props;
841
- }
842
-
843
- /**
844
- * Extract member expression chain as string
845
- */
846
- function extractMemberChain(node) {
847
- const parts = [];
848
-
849
- let current = node;
850
- while (t.isMemberExpression(current)) {
851
- if (t.isIdentifier(current.property)) {
852
- parts.unshift(current.property.name);
853
- }
854
- current = current.object;
855
- }
856
-
857
- if (t.isIdentifier(current)) {
858
- parts.unshift(current.name);
859
- }
860
-
861
- return parts.join(".");
862
- }
863
-
864
- /**
865
- * Infer runtime from file content
866
- */
867
- function inferRuntime(content, relPath) {
868
- if (content.includes('"use client"') || content.includes("'use client'")) {
869
- return RUNTIMES.CLIENT;
870
- }
871
- if (content.includes('"use server"') || content.includes("'use server'")) {
872
- return RUNTIMES.SERVER;
873
- }
874
- if (relPath.includes("/api/") || relPath.includes("server")) {
875
- return RUNTIMES.SERVER;
876
- }
877
- if (relPath.includes("/lib/") || relPath.includes("/utils/")) {
878
- return RUNTIMES.SHARED;
879
- }
880
- return RUNTIMES.UNKNOWN;
881
- }
882
-
883
- /**
884
- * Canonicalize URL path
885
- */
886
- function canonicalizeUrl(url) {
887
- if (!url) return null;
888
-
889
- let canonical = url;
890
-
891
- // Strip protocol and host
892
- canonical = canonical.replace(/^https?:\/\/[^/]+/, "");
893
-
894
- // Strip query string
895
- canonical = canonical.split("?")[0];
896
-
897
- // Strip hash
898
- canonical = canonical.split("#")[0];
899
-
900
- // Replace template expressions with params
901
- canonical = canonical.replace(/\$\{[^}]+\}/g, "{param}");
902
- canonical = canonical.replace(/\{[0-9]+\}/g, "{param}");
903
-
904
- // Normalize slashes
905
- canonical = canonical.replace(/\/+/g, "/");
906
-
907
- // Ensure leading slash
908
- if (!canonical.startsWith("/") && !canonical.startsWith("action://")) {
909
- canonical = "/" + canonical;
910
- }
911
-
912
- // Remove trailing slash (except root)
913
- if (canonical.length > 1) {
914
- canonical = canonical.replace(/\/$/, "");
915
- }
916
-
917
- return canonical;
918
- }
919
-
920
- /**
921
- * Combine base URL with path
922
- */
923
- function combineBaseUrl(base, path) {
924
- if (!base) return path;
925
- if (!path) return base;
926
-
927
- const baseTrimmed = base.replace(/\/$/, "");
928
- const pathTrimmed = path.startsWith("/") ? path : "/" + path;
929
-
930
- return baseTrimmed + pathTrimmed;
931
- }
932
-
933
- /**
934
- * Create a ClientCall record
935
- */
936
- function createClientCall(opts) {
937
- const id = `C_CALL_${hashShort(opts.file + opts.urlTemplate + opts.method)}`;
938
-
939
- return {
940
- id,
941
- kind: opts.kind,
942
- runtime: opts.runtime,
943
- method: opts.method,
944
- urlTemplate: opts.urlTemplate,
945
- canonicalPath: opts.canonicalPath,
946
- expectsJson: opts.expectsJson,
947
- authHint: opts.authHint,
948
- confidence: opts.confidence,
949
- evidence: [{
950
- id: `E_${hashShort(opts.file + opts.lines)}`,
951
- kind: "file",
952
- file: opts.file,
953
- lines: opts.lines,
954
- snippetHash: hashSnippet(opts.snippet || ""),
955
- reason: opts.reason,
956
- }],
957
- meta: opts.meta || {},
958
- };
959
- }
960
-
961
- function hashShort(text) {
962
- return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12).toUpperCase();
963
- }
964
-
965
- function hashSnippet(text) {
966
- return `sha256:${crypto.createHash("sha256").update(text).digest("hex")}`;
967
- }
968
-
969
- // =============================================================================
970
- // EXPORTS
971
- // =============================================================================
972
-
973
- module.exports = {
974
- extractClientCalls,
975
- extractCallsFromFile,
976
- extractFetchCall,
977
- extractAxiosCall,
978
- extractTRPCCall,
979
- extractGraphQLCall,
980
- extractServerAction,
981
- extractWrappers,
982
- extractWrapperCall,
983
- extractUIBindings,
984
- canonicalizeUrl,
985
- combineBaseUrl,
986
- inferRuntime,
987
- CALL_KINDS,
988
- RUNTIMES,
989
- HTTP_METHODS,
990
- };
1
+ /**
2
+ * Client Call Extractor v2
3
+ *
4
+ * Extracts client-side API calls: fetch, axios, tRPC, GraphQL, Server Actions, SDK calls.
5
+ * Links them to the Reality Proof Graph for dead UI detection.
6
+ *
7
+ * Output: ClientCall records with evidence, confidence, and canonical paths.
8
+ */
9
+
10
+ "use strict";
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const fg = require("fast-glob");
15
+ const crypto = require("crypto");
16
+ const parser = require("@babel/parser");
17
+ const traverse = require("@babel/traverse").default;
18
+ const t = require("@babel/types");
19
+
20
+ // =============================================================================
21
+ // TYPES AND CONSTANTS
22
+ // =============================================================================
23
+
24
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
25
+
26
+ const CALL_KINDS = {
27
+ HTTP: "http",
28
+ TRPC: "trpc",
29
+ GRAPHQL: "graphql",
30
+ SERVER_ACTION: "server_action",
31
+ SDK: "sdk",
32
+ };
33
+
34
+ const RUNTIMES = {
35
+ CLIENT: "client",
36
+ SERVER: "server",
37
+ SHARED: "shared",
38
+ UNKNOWN: "unknown",
39
+ };
40
+
41
+ // =============================================================================
42
+ // MAIN EXTRACTION
43
+ // =============================================================================
44
+
45
+ /**
46
+ * Extract all client calls from a project
47
+ */
48
+ function extractClientCalls(projectRoot, options = {}) {
49
+ const { include = [], exclude = [] } = options;
50
+
51
+ // Find source files
52
+ const defaultInclude = ["**/*.{ts,tsx,js,jsx,mts,cts}"];
53
+ const defaultExclude = [
54
+ "**/node_modules/**",
55
+ "**/.next/**",
56
+ "**/dist/**",
57
+ "**/build/**",
58
+ "**/coverage/**",
59
+ "**/*.min.js",
60
+ "**/*.d.ts",
61
+ ];
62
+
63
+ const patterns = include.length > 0 ? include : defaultInclude;
64
+ const ignorePatterns = [...defaultExclude, ...exclude];
65
+
66
+ const files = fg.sync(patterns, {
67
+ cwd: projectRoot,
68
+ absolute: true,
69
+ ignore: ignorePatterns,
70
+ onlyFiles: true,
71
+ });
72
+
73
+ const calls = [];
74
+ const wrappers = [];
75
+ const uiBindings = [];
76
+ const errors = [];
77
+
78
+ // Pass 1: Extract wrappers (axios instances, custom request helpers)
79
+ for (const file of files) {
80
+ try {
81
+ const content = fs.readFileSync(file, "utf8");
82
+ const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
83
+ const fileWrappers = extractWrappers(content, relPath, projectRoot);
84
+ wrappers.push(...fileWrappers);
85
+ } catch (err) {
86
+ errors.push({ file, phase: "wrappers", error: err.message });
87
+ }
88
+ }
89
+
90
+ // Build wrapper lookup
91
+ const wrapperMap = new Map();
92
+ for (const w of wrappers) {
93
+ wrapperMap.set(w.name, w);
94
+ }
95
+
96
+ // Pass 2: Extract calls
97
+ for (const file of files) {
98
+ try {
99
+ const content = fs.readFileSync(file, "utf8");
100
+ const relPath = path.relative(projectRoot, file).replace(/\\/g, "/");
101
+ const runtime = inferRuntime(content, relPath);
102
+
103
+ const fileCalls = extractCallsFromFile(content, relPath, runtime, wrapperMap, projectRoot);
104
+ calls.push(...fileCalls);
105
+
106
+ const bindings = extractUIBindings(content, relPath, fileCalls);
107
+ uiBindings.push(...bindings);
108
+ } catch (err) {
109
+ errors.push({ file, phase: "calls", error: err.message });
110
+ }
111
+ }
112
+
113
+ return {
114
+ calls,
115
+ wrappers,
116
+ uiBindings,
117
+ errors,
118
+ stats: {
119
+ filesScanned: files.length,
120
+ callsFound: calls.length,
121
+ wrappersFound: wrappers.length,
122
+ bindingsFound: uiBindings.length,
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Extract calls from a single file
129
+ */
130
+ function extractCallsFromFile(content, relPath, runtime, wrapperMap, projectRoot) {
131
+ const calls = [];
132
+
133
+ let ast;
134
+ try {
135
+ ast = parser.parse(content, {
136
+ sourceType: "module",
137
+ plugins: ["typescript", "jsx", "decorators-legacy"],
138
+ errorRecovery: true,
139
+ });
140
+ } catch {
141
+ return calls;
142
+ }
143
+
144
+ traverse(ast, {
145
+ CallExpression(path) {
146
+ // fetch() calls
147
+ const fetchCall = extractFetchCall(path, content, relPath, runtime);
148
+ if (fetchCall) {
149
+ calls.push(fetchCall);
150
+ return;
151
+ }
152
+
153
+ // axios calls
154
+ const axiosCall = extractAxiosCall(path, content, relPath, runtime, wrapperMap);
155
+ if (axiosCall) {
156
+ calls.push(axiosCall);
157
+ return;
158
+ }
159
+
160
+ // tRPC calls
161
+ const trpcCall = extractTRPCCall(path, content, relPath, runtime);
162
+ if (trpcCall) {
163
+ calls.push(trpcCall);
164
+ return;
165
+ }
166
+
167
+ // GraphQL calls
168
+ const gqlCall = extractGraphQLCall(path, content, relPath, runtime);
169
+ if (gqlCall) {
170
+ calls.push(gqlCall);
171
+ return;
172
+ }
173
+
174
+ // Wrapper calls
175
+ const wrapperCall = extractWrapperCall(path, content, relPath, runtime, wrapperMap);
176
+ if (wrapperCall) {
177
+ calls.push(wrapperCall);
178
+ return;
179
+ }
180
+ },
181
+
182
+ // Server Actions (form action={...})
183
+ JSXAttribute(path) {
184
+ if (path.node.name.name === "action" && t.isJSXExpressionContainer(path.node.value)) {
185
+ const actionCall = extractServerAction(path, content, relPath);
186
+ if (actionCall) {
187
+ calls.push(actionCall);
188
+ }
189
+ }
190
+ },
191
+ });
192
+
193
+ return calls;
194
+ }
195
+
196
+ // =============================================================================
197
+ // FETCH EXTRACTION
198
+ // =============================================================================
199
+
200
+ /**
201
+ * Extract fetch() call
202
+ */
203
+ function extractFetchCall(path, content, relPath, runtime) {
204
+ const { node } = path;
205
+
206
+ // Check if callee is 'fetch'
207
+ if (!t.isIdentifier(node.callee, { name: "fetch" })) {
208
+ // Also check for fetch(new Request(...))
209
+ if (t.isNewExpression(node.callee) && t.isIdentifier(node.callee.callee, { name: "Request" })) {
210
+ return extractRequestCall(path, content, relPath, runtime);
211
+ }
212
+ return null;
213
+ }
214
+
215
+ const urlArg = node.arguments[0];
216
+ const initArg = node.arguments[1];
217
+
218
+ if (!urlArg) return null;
219
+
220
+ // Extract URL
221
+ const urlInfo = extractUrlFromNode(urlArg, content);
222
+ if (!urlInfo.value) return null;
223
+
224
+ // Extract method
225
+ const method = extractMethodFromInit(initArg) || "GET";
226
+
227
+ // Extract other info
228
+ const expectsJson = extractExpectsJson(initArg, path);
229
+ const authHint = extractAuthHint(initArg);
230
+
231
+ const lineNum = node.loc?.start?.line || 1;
232
+
233
+ return createClientCall({
234
+ kind: CALL_KINDS.HTTP,
235
+ runtime,
236
+ method,
237
+ urlTemplate: urlInfo.value,
238
+ canonicalPath: canonicalizeUrl(urlInfo.value),
239
+ expectsJson,
240
+ authHint,
241
+ confidence: urlInfo.confidence,
242
+ file: relPath,
243
+ lines: `${lineNum}-${lineNum + 5}`,
244
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
245
+ reason: `fetch('${urlInfo.value.slice(0, 50)}') call`,
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Extract new Request() call inside fetch
251
+ */
252
+ function extractRequestCall(path, content, relPath, runtime) {
253
+ const { node } = path;
254
+ const requestNode = node.callee;
255
+
256
+ if (!t.isNewExpression(requestNode)) return null;
257
+
258
+ const urlArg = requestNode.arguments[0];
259
+ const initArg = requestNode.arguments[1];
260
+
261
+ if (!urlArg) return null;
262
+
263
+ const urlInfo = extractUrlFromNode(urlArg, content);
264
+ if (!urlInfo.value) return null;
265
+
266
+ const method = extractMethodFromInit(initArg) || "GET";
267
+ const lineNum = node.loc?.start?.line || 1;
268
+
269
+ return createClientCall({
270
+ kind: CALL_KINDS.HTTP,
271
+ runtime,
272
+ method,
273
+ urlTemplate: urlInfo.value,
274
+ canonicalPath: canonicalizeUrl(urlInfo.value),
275
+ expectsJson: false,
276
+ authHint: "unknown",
277
+ confidence: urlInfo.confidence,
278
+ file: relPath,
279
+ lines: `${lineNum}-${lineNum + 3}`,
280
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
281
+ reason: `fetch(new Request('${urlInfo.value.slice(0, 50)}')) call`,
282
+ });
283
+ }
284
+
285
+ // =============================================================================
286
+ // AXIOS EXTRACTION
287
+ // =============================================================================
288
+
289
+ /**
290
+ * Extract axios call
291
+ */
292
+ function extractAxiosCall(path, content, relPath, runtime, wrapperMap) {
293
+ const { node } = path;
294
+
295
+ // Pattern A: axios.get/post/etc(url)
296
+ if (t.isMemberExpression(node.callee)) {
297
+ const obj = node.callee.object;
298
+ const prop = node.callee.property;
299
+
300
+ if (t.isIdentifier(obj, { name: "axios" }) && t.isIdentifier(prop)) {
301
+ const methodName = prop.name.toUpperCase();
302
+ if (HTTP_METHODS.includes(methodName) || methodName === "REQUEST") {
303
+ const urlArg = node.arguments[0];
304
+ if (!urlArg) return null;
305
+
306
+ const urlInfo = extractUrlFromNode(urlArg, content);
307
+ if (!urlInfo.value) return null;
308
+
309
+ const lineNum = node.loc?.start?.line || 1;
310
+
311
+ return createClientCall({
312
+ kind: CALL_KINDS.HTTP,
313
+ runtime,
314
+ method: methodName === "REQUEST" ? "UNKNOWN" : methodName,
315
+ urlTemplate: urlInfo.value,
316
+ canonicalPath: canonicalizeUrl(urlInfo.value),
317
+ expectsJson: true,
318
+ authHint: "unknown",
319
+ confidence: urlInfo.confidence,
320
+ file: relPath,
321
+ lines: `${lineNum}-${lineNum + 3}`,
322
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
323
+ reason: `axios.${prop.name}('${urlInfo.value.slice(0, 50)}') call`,
324
+ });
325
+ }
326
+ }
327
+ }
328
+
329
+ // Pattern B: axios({ method, url })
330
+ if (t.isIdentifier(node.callee, { name: "axios" })) {
331
+ const configArg = node.arguments[0];
332
+ if (t.isObjectExpression(configArg)) {
333
+ const config = extractObjectLiteralProps(configArg);
334
+ if (config.url) {
335
+ const lineNum = node.loc?.start?.line || 1;
336
+
337
+ return createClientCall({
338
+ kind: CALL_KINDS.HTTP,
339
+ runtime,
340
+ method: (config.method || "GET").toUpperCase(),
341
+ urlTemplate: config.url,
342
+ canonicalPath: canonicalizeUrl(combineBaseUrl(config.baseURL, config.url)),
343
+ expectsJson: true,
344
+ authHint: "unknown",
345
+ confidence: "medium",
346
+ file: relPath,
347
+ lines: `${lineNum}-${lineNum + 5}`,
348
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
349
+ reason: `axios({ url: '${config.url.slice(0, 50)}' }) call`,
350
+ });
351
+ }
352
+ }
353
+ }
354
+
355
+ return null;
356
+ }
357
+
358
+ // =============================================================================
359
+ // TRPC EXTRACTION
360
+ // =============================================================================
361
+
362
+ /**
363
+ * Extract tRPC call
364
+ */
365
+ function extractTRPCCall(path, content, relPath, runtime) {
366
+ const { node } = path;
367
+
368
+ // Pattern: trpc.user.get.useQuery() or trpc.billing.portal.useMutation()
369
+ if (t.isMemberExpression(node.callee)) {
370
+ const calleeCode = extractMemberChain(node.callee);
371
+
372
+ // Check for tRPC patterns
373
+ const trpcMatch = calleeCode.match(/^(?:trpc|api)\.(.+?)\.(useQuery|useMutation|query|mutate)$/);
374
+ if (trpcMatch) {
375
+ const procedure = trpcMatch[1];
376
+ const operationType = trpcMatch[2].includes("Mutation") || trpcMatch[2] === "mutate" ? "mutation" : "query";
377
+ const lineNum = node.loc?.start?.line || 1;
378
+
379
+ return createClientCall({
380
+ kind: CALL_KINDS.TRPC,
381
+ runtime,
382
+ method: "POST",
383
+ urlTemplate: `/api/trpc/${procedure}`,
384
+ canonicalPath: `/api/trpc/${procedure}`,
385
+ expectsJson: true,
386
+ authHint: "unknown",
387
+ confidence: "medium",
388
+ file: relPath,
389
+ lines: `${lineNum}-${lineNum + 3}`,
390
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 150)),
391
+ reason: `tRPC ${operationType}: ${procedure}`,
392
+ meta: {
393
+ procedure,
394
+ operationType,
395
+ },
396
+ });
397
+ }
398
+ }
399
+
400
+ return null;
401
+ }
402
+
403
+ // =============================================================================
404
+ // GRAPHQL EXTRACTION
405
+ // =============================================================================
406
+
407
+ /**
408
+ * Extract GraphQL call
409
+ */
410
+ function extractGraphQLCall(path, content, relPath, runtime) {
411
+ const { node } = path;
412
+
413
+ // Pattern: useQuery(gql`...`) or useMutation(gql`...`)
414
+ if (t.isIdentifier(node.callee)) {
415
+ const calleeName = node.callee.name;
416
+
417
+ if (calleeName === "useQuery" || calleeName === "useMutation" || calleeName === "request") {
418
+ const queryArg = node.arguments[0];
419
+ let operationName = null;
420
+
421
+ // Try to extract operation name from gql template
422
+ if (t.isTaggedTemplateExpression(queryArg)) {
423
+ const template = queryArg.quasi.quasis.map(q => q.value.raw).join("");
424
+ const opMatch = template.match(/(?:query|mutation|subscription)\s+(\w+)/);
425
+ if (opMatch) {
426
+ operationName = opMatch[1];
427
+ }
428
+ }
429
+
430
+ const lineNum = node.loc?.start?.line || 1;
431
+
432
+ return createClientCall({
433
+ kind: CALL_KINDS.GRAPHQL,
434
+ runtime,
435
+ method: "POST",
436
+ urlTemplate: "/graphql",
437
+ canonicalPath: "/graphql",
438
+ expectsJson: true,
439
+ authHint: "unknown",
440
+ confidence: operationName ? "medium" : "low",
441
+ file: relPath,
442
+ lines: `${lineNum}-${lineNum + 5}`,
443
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 200)),
444
+ reason: `GraphQL ${calleeName}${operationName ? `: ${operationName}` : ""}`,
445
+ meta: {
446
+ operationName,
447
+ operationType: calleeName.includes("Mutation") ? "mutation" : "query",
448
+ },
449
+ });
450
+ }
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ // =============================================================================
457
+ // SERVER ACTIONS EXTRACTION
458
+ // =============================================================================
459
+
460
+ /**
461
+ * Extract Next.js Server Action
462
+ */
463
+ function extractServerAction(path, content, relPath) {
464
+ const jsxAttr = path.node;
465
+ const container = jsxAttr.value;
466
+
467
+ if (!t.isJSXExpressionContainer(container)) return null;
468
+
469
+ const expr = container.expression;
470
+ let actionName = null;
471
+
472
+ if (t.isIdentifier(expr)) {
473
+ actionName = expr.name;
474
+ } else if (t.isMemberExpression(expr)) {
475
+ actionName = extractMemberChain(expr);
476
+ }
477
+
478
+ if (!actionName) return null;
479
+
480
+ const lineNum = jsxAttr.loc?.start?.line || 1;
481
+
482
+ return createClientCall({
483
+ kind: CALL_KINDS.SERVER_ACTION,
484
+ runtime: RUNTIMES.SERVER,
485
+ method: "POST",
486
+ urlTemplate: `action://${relPath}#${actionName}`,
487
+ canonicalPath: `action://${relPath}#${actionName}`,
488
+ expectsJson: false,
489
+ authHint: "unknown",
490
+ confidence: "high",
491
+ file: relPath,
492
+ lines: `${lineNum}-${lineNum + 1}`,
493
+ snippet: `action={${actionName}}`,
494
+ reason: `Server Action: ${actionName}`,
495
+ meta: {
496
+ actionExport: actionName,
497
+ module: relPath,
498
+ },
499
+ });
500
+ }
501
+
502
+ // =============================================================================
503
+ // WRAPPER EXTRACTION
504
+ // =============================================================================
505
+
506
+ /**
507
+ * Extract wrapper definitions (axios instances, custom fetch wrappers)
508
+ */
509
+ function extractWrappers(content, relPath, projectRoot) {
510
+ const wrappers = [];
511
+
512
+ let ast;
513
+ try {
514
+ ast = parser.parse(content, {
515
+ sourceType: "module",
516
+ plugins: ["typescript", "jsx", "decorators-legacy"],
517
+ errorRecovery: true,
518
+ });
519
+ } catch {
520
+ return wrappers;
521
+ }
522
+
523
+ traverse(ast, {
524
+ // axios.create()
525
+ CallExpression(path) {
526
+ const { node } = path;
527
+
528
+ if (
529
+ t.isMemberExpression(node.callee) &&
530
+ t.isIdentifier(node.callee.object, { name: "axios" }) &&
531
+ t.isIdentifier(node.callee.property, { name: "create" })
532
+ ) {
533
+ const configArg = node.arguments[0];
534
+ const config = t.isObjectExpression(configArg) ? extractObjectLiteralProps(configArg) : {};
535
+
536
+ // Find variable name
537
+ let name = "axiosInstance";
538
+ if (t.isVariableDeclarator(path.parent)) {
539
+ if (t.isIdentifier(path.parent.id)) {
540
+ name = path.parent.id.name;
541
+ }
542
+ }
543
+
544
+ wrappers.push({
545
+ id: `W_AXIOS_${hashShort(relPath + name)}`,
546
+ name,
547
+ kind: "axios",
548
+ baseURL: config.baseURL || "",
549
+ defaultHeaders: config.headers ? Object.keys(config.headers) : [],
550
+ file: relPath,
551
+ lines: `${node.loc?.start?.line || 1}-${node.loc?.end?.line || 1}`,
552
+ });
553
+ }
554
+ },
555
+
556
+ // Custom fetch wrappers: export const api = { get: ..., post: ... }
557
+ VariableDeclarator(path) {
558
+ const { node } = path;
559
+
560
+ if (t.isIdentifier(node.id) && t.isObjectExpression(node.init)) {
561
+ const props = node.init.properties;
562
+ const methodProps = props.filter(
563
+ p => t.isIdentifier(p.key) && HTTP_METHODS.map(m => m.toLowerCase()).includes(p.key.name)
564
+ );
565
+
566
+ if (methodProps.length >= 2) {
567
+ // Looks like an API client object
568
+ wrappers.push({
569
+ id: `W_CUSTOM_${hashShort(relPath + node.id.name)}`,
570
+ name: node.id.name,
571
+ kind: "custom",
572
+ baseURL: "",
573
+ methods: methodProps.map(p => p.key.name),
574
+ file: relPath,
575
+ lines: `${node.loc?.start?.line || 1}-${node.loc?.end?.line || 1}`,
576
+ });
577
+ }
578
+ }
579
+ },
580
+ });
581
+
582
+ return wrappers;
583
+ }
584
+
585
+ /**
586
+ * Extract wrapper call (api.get(), apiClient.post(), etc.)
587
+ */
588
+ function extractWrapperCall(path, content, relPath, runtime, wrapperMap) {
589
+ const { node } = path;
590
+
591
+ if (!t.isMemberExpression(node.callee)) return null;
592
+
593
+ const obj = node.callee.object;
594
+ const prop = node.callee.property;
595
+
596
+ if (!t.isIdentifier(obj) || !t.isIdentifier(prop)) return null;
597
+
598
+ const wrapper = wrapperMap.get(obj.name);
599
+ if (!wrapper) return null;
600
+
601
+ const methodName = prop.name.toUpperCase();
602
+ if (!HTTP_METHODS.includes(methodName) && methodName !== "REQUEST") return null;
603
+
604
+ const urlArg = node.arguments[0];
605
+ if (!urlArg) return null;
606
+
607
+ const urlInfo = extractUrlFromNode(urlArg, content);
608
+ if (!urlInfo.value) return null;
609
+
610
+ // Combine with wrapper baseURL
611
+ const fullUrl = combineBaseUrl(wrapper.baseURL, urlInfo.value);
612
+ const lineNum = node.loc?.start?.line || 1;
613
+
614
+ return createClientCall({
615
+ kind: CALL_KINDS.HTTP,
616
+ runtime,
617
+ method: methodName === "REQUEST" ? "UNKNOWN" : methodName,
618
+ urlTemplate: urlInfo.value,
619
+ canonicalPath: canonicalizeUrl(fullUrl),
620
+ expectsJson: true,
621
+ authHint: "unknown",
622
+ confidence: urlInfo.confidence,
623
+ file: relPath,
624
+ lines: `${lineNum}-${lineNum + 3}`,
625
+ snippet: content.substring(node.start, Math.min(node.end, node.start + 150)),
626
+ reason: `${obj.name}.${prop.name}('${urlInfo.value.slice(0, 50)}') via wrapper`,
627
+ meta: {
628
+ wrapperId: wrapper.id,
629
+ },
630
+ });
631
+ }
632
+
633
+ // =============================================================================
634
+ // UI BINDING EXTRACTION
635
+ // =============================================================================
636
+
637
+ /**
638
+ * Extract UI bindings (onClick, onSubmit that call API)
639
+ */
640
+ function extractUIBindings(content, relPath, calls) {
641
+ const bindings = [];
642
+ const callIds = new Set(calls.map(c => c.id));
643
+
644
+ let ast;
645
+ try {
646
+ ast = parser.parse(content, {
647
+ sourceType: "module",
648
+ plugins: ["typescript", "jsx", "decorators-legacy"],
649
+ errorRecovery: true,
650
+ });
651
+ } catch {
652
+ return bindings;
653
+ }
654
+
655
+ traverse(ast, {
656
+ JSXAttribute(path) {
657
+ const { node } = path;
658
+ const attrName = node.name?.name;
659
+
660
+ if (!["onClick", "onSubmit", "onBlur", "onChange", "action"].includes(attrName)) return;
661
+
662
+ if (!t.isJSXExpressionContainer(node.value)) return;
663
+
664
+ const expr = node.value.expression;
665
+ const lineNum = node.loc?.start?.line || 1;
666
+
667
+ // Find associated calls in this handler
668
+ const handlerCalls = calls.filter(c => {
669
+ const callLine = parseInt(c.evidence?.[0]?.lines?.split("-")[0] || "0");
670
+ return Math.abs(callLine - lineNum) < 20; // Within 20 lines
671
+ });
672
+
673
+ if (handlerCalls.length > 0) {
674
+ // Try to find label hint
675
+ const labelHint = findLabelHint(path);
676
+
677
+ bindings.push({
678
+ bindingId: `UIB_${hashShort(relPath + lineNum)}`,
679
+ file: relPath,
680
+ lines: `${lineNum}-${lineNum + 5}`,
681
+ event: attrName,
682
+ labelHint,
683
+ calls: handlerCalls.map(c => c.id),
684
+ });
685
+ }
686
+ },
687
+ });
688
+
689
+ return bindings;
690
+ }
691
+
692
+ /**
693
+ * Find label hint from JSX context
694
+ */
695
+ function findLabelHint(path) {
696
+ // Look for button text, aria-label, etc.
697
+ const parent = path.parentPath;
698
+ if (!parent) return null;
699
+
700
+ // Check for aria-label
701
+ if (t.isJSXOpeningElement(parent.node)) {
702
+ const ariaLabel = parent.node.attributes.find(
703
+ a => t.isJSXAttribute(a) && a.name?.name === "aria-label"
704
+ );
705
+ if (ariaLabel && t.isStringLiteral(ariaLabel.value)) {
706
+ return ariaLabel.value.value;
707
+ }
708
+ }
709
+
710
+ // Check for children text
711
+ const jsxElement = parent.parentPath;
712
+ if (t.isJSXElement(jsxElement?.node)) {
713
+ const textChild = jsxElement.node.children.find(
714
+ c => t.isJSXText(c) && c.value.trim()
715
+ );
716
+ if (textChild) {
717
+ return textChild.value.trim();
718
+ }
719
+ }
720
+
721
+ return null;
722
+ }
723
+
724
+ // =============================================================================
725
+ // HELPERS
726
+ // =============================================================================
727
+
728
+ /**
729
+ * Extract URL value and confidence from AST node
730
+ */
731
+ function extractUrlFromNode(node, content) {
732
+ // String literal: HIGH confidence
733
+ if (t.isStringLiteral(node)) {
734
+ return { value: node.value, confidence: "high" };
735
+ }
736
+
737
+ // Template literal
738
+ if (t.isTemplateLiteral(node)) {
739
+ if (node.expressions.length === 0) {
740
+ // No expressions: HIGH
741
+ return { value: node.quasis[0].value.raw, confidence: "high" };
742
+ }
743
+ // Has expressions: MEDIUM
744
+ const template = node.quasis.map((q, i) => {
745
+ const expr = node.expressions[i];
746
+ return q.value.raw + (expr ? `{${i}}` : "");
747
+ }).join("");
748
+ return { value: template, confidence: "medium" };
749
+ }
750
+
751
+ // Binary expression (concatenation)
752
+ if (t.isBinaryExpression(node, { operator: "+" })) {
753
+ const left = extractUrlFromNode(node.left, content);
754
+ const right = extractUrlFromNode(node.right, content);
755
+ if (left.value && right.value) {
756
+ return {
757
+ value: left.value + right.value,
758
+ confidence: "medium",
759
+ };
760
+ }
761
+ }
762
+
763
+ // Variable: LOW confidence
764
+ if (t.isIdentifier(node)) {
765
+ return { value: `\${${node.name}}`, confidence: "low" };
766
+ }
767
+
768
+ return { value: null, confidence: "low" };
769
+ }
770
+
771
+ /**
772
+ * Extract method from fetch init argument
773
+ */
774
+ function extractMethodFromInit(initNode) {
775
+ if (!initNode || !t.isObjectExpression(initNode)) return null;
776
+
777
+ const methodProp = initNode.properties.find(
778
+ p => t.isObjectProperty(p) && t.isIdentifier(p.key, { name: "method" })
779
+ );
780
+
781
+ if (methodProp && t.isStringLiteral(methodProp.value)) {
782
+ return methodProp.value.value.toUpperCase();
783
+ }
784
+
785
+ return null;
786
+ }
787
+
788
+ /**
789
+ * Extract expectsJson from init
790
+ */
791
+ function extractExpectsJson(initNode, path) {
792
+ if (!initNode) return false;
793
+
794
+ if (t.isObjectExpression(initNode)) {
795
+ const props = extractObjectLiteralProps(initNode);
796
+ if (props.headers?.["content-type"]?.includes("application/json")) return true;
797
+ if (props.body && typeof props.body === "string" && props.body.includes("JSON.stringify")) return true;
798
+ }
799
+
800
+ // Check if .json() is called on response
801
+ // This would require more complex flow analysis
802
+ return false;
803
+ }
804
+
805
+ /**
806
+ * Extract auth hint from init
807
+ */
808
+ function extractAuthHint(initNode) {
809
+ if (!initNode || !t.isObjectExpression(initNode)) return "unknown";
810
+
811
+ const props = extractObjectLiteralProps(initNode);
812
+
813
+ if (props.credentials === "include") return "cookie";
814
+ if (props.headers?.authorization?.toLowerCase().startsWith("bearer")) return "bearer";
815
+ if (props.headers?.Authorization?.toLowerCase().startsWith("bearer")) return "bearer";
816
+
817
+ return "unknown";
818
+ }
819
+
820
+ /**
821
+ * Extract object literal properties as plain object
822
+ */
823
+ function extractObjectLiteralProps(node) {
824
+ const props = {};
825
+
826
+ for (const prop of node.properties) {
827
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
828
+ const key = prop.key.name;
829
+
830
+ if (t.isStringLiteral(prop.value)) {
831
+ props[key] = prop.value.value;
832
+ } else if (t.isObjectExpression(prop.value)) {
833
+ props[key] = extractObjectLiteralProps(prop.value);
834
+ } else if (t.isIdentifier(prop.value)) {
835
+ props[key] = `$${prop.value.name}`;
836
+ }
837
+ }
838
+ }
839
+
840
+ return props;
841
+ }
842
+
843
+ /**
844
+ * Extract member expression chain as string
845
+ */
846
+ function extractMemberChain(node) {
847
+ const parts = [];
848
+
849
+ let current = node;
850
+ while (t.isMemberExpression(current)) {
851
+ if (t.isIdentifier(current.property)) {
852
+ parts.unshift(current.property.name);
853
+ }
854
+ current = current.object;
855
+ }
856
+
857
+ if (t.isIdentifier(current)) {
858
+ parts.unshift(current.name);
859
+ }
860
+
861
+ return parts.join(".");
862
+ }
863
+
864
+ /**
865
+ * Infer runtime from file content
866
+ */
867
+ function inferRuntime(content, relPath) {
868
+ if (content.includes('"use client"') || content.includes("'use client'")) {
869
+ return RUNTIMES.CLIENT;
870
+ }
871
+ if (content.includes('"use server"') || content.includes("'use server'")) {
872
+ return RUNTIMES.SERVER;
873
+ }
874
+ if (relPath.includes("/api/") || relPath.includes("server")) {
875
+ return RUNTIMES.SERVER;
876
+ }
877
+ if (relPath.includes("/lib/") || relPath.includes("/utils/")) {
878
+ return RUNTIMES.SHARED;
879
+ }
880
+ return RUNTIMES.UNKNOWN;
881
+ }
882
+
883
+ /**
884
+ * Canonicalize URL path
885
+ */
886
+ function canonicalizeUrl(url) {
887
+ if (!url) return null;
888
+
889
+ let canonical = url;
890
+
891
+ // Strip protocol and host
892
+ canonical = canonical.replace(/^https?:\/\/[^/]+/, "");
893
+
894
+ // Strip query string
895
+ canonical = canonical.split("?")[0];
896
+
897
+ // Strip hash
898
+ canonical = canonical.split("#")[0];
899
+
900
+ // Replace template expressions with params
901
+ canonical = canonical.replace(/\$\{[^}]+\}/g, "{param}");
902
+ canonical = canonical.replace(/\{[0-9]+\}/g, "{param}");
903
+
904
+ // Normalize slashes
905
+ canonical = canonical.replace(/\/+/g, "/");
906
+
907
+ // Ensure leading slash
908
+ if (!canonical.startsWith("/") && !canonical.startsWith("action://")) {
909
+ canonical = "/" + canonical;
910
+ }
911
+
912
+ // Remove trailing slash (except root)
913
+ if (canonical.length > 1) {
914
+ canonical = canonical.replace(/\/$/, "");
915
+ }
916
+
917
+ return canonical;
918
+ }
919
+
920
+ /**
921
+ * Combine base URL with path
922
+ */
923
+ function combineBaseUrl(base, path) {
924
+ if (!base) return path;
925
+ if (!path) return base;
926
+
927
+ const baseTrimmed = base.replace(/\/$/, "");
928
+ const pathTrimmed = path.startsWith("/") ? path : "/" + path;
929
+
930
+ return baseTrimmed + pathTrimmed;
931
+ }
932
+
933
+ /**
934
+ * Create a ClientCall record
935
+ */
936
+ function createClientCall(opts) {
937
+ const id = `C_CALL_${hashShort(opts.file + opts.urlTemplate + opts.method)}`;
938
+
939
+ return {
940
+ id,
941
+ kind: opts.kind,
942
+ runtime: opts.runtime,
943
+ method: opts.method,
944
+ urlTemplate: opts.urlTemplate,
945
+ canonicalPath: opts.canonicalPath,
946
+ expectsJson: opts.expectsJson,
947
+ authHint: opts.authHint,
948
+ confidence: opts.confidence,
949
+ evidence: [{
950
+ id: `E_${hashShort(opts.file + opts.lines)}`,
951
+ kind: "file",
952
+ file: opts.file,
953
+ lines: opts.lines,
954
+ snippetHash: hashSnippet(opts.snippet || ""),
955
+ reason: opts.reason,
956
+ }],
957
+ meta: opts.meta || {},
958
+ };
959
+ }
960
+
961
+ function hashShort(text) {
962
+ return crypto.createHash("sha256").update(text).digest("hex").slice(0, 12).toUpperCase();
963
+ }
964
+
965
+ function hashSnippet(text) {
966
+ return `sha256:${crypto.createHash("sha256").update(text).digest("hex")}`;
967
+ }
968
+
969
+ // =============================================================================
970
+ // EXPORTS
971
+ // =============================================================================
972
+
973
+ module.exports = {
974
+ extractClientCalls,
975
+ extractCallsFromFile,
976
+ extractFetchCall,
977
+ extractAxiosCall,
978
+ extractTRPCCall,
979
+ extractGraphQLCall,
980
+ extractServerAction,
981
+ extractWrappers,
982
+ extractWrapperCall,
983
+ extractUIBindings,
984
+ canonicalizeUrl,
985
+ combineBaseUrl,
986
+ inferRuntime,
987
+ CALL_KINDS,
988
+ RUNTIMES,
989
+ HTTP_METHODS,
990
+ };