ucn 3.8.23 → 3.8.25

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 (44) hide show
  1. package/.claude/skills/ucn/SKILL.md +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
package/core/bridge.js ADDED
@@ -0,0 +1,1111 @@
1
+ /**
2
+ * core/bridge.js — Polyglot HTTP API endpoint bridging.
3
+ *
4
+ * Detects HTTP server routes (Express/Fastify/Koa/NestJS, Flask/FastAPI,
5
+ * net-http/gorilla/gin/echo/chi/fiber, Spring/JAX-RS, axum/actix-web) and
6
+ * client requests (fetch, axios, requests, http, restTemplate, reqwest, etc.),
7
+ * then matches them so a polyglot codebase shows which client call hits which
8
+ * server route — across language boundaries.
9
+ *
10
+ * REUSES the call cache (getCachedCalls) and AST-derived symbol metadata
11
+ * (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs). The only file
12
+ * I/O is index-driven; we never re-parse files. Extraction results are cached
13
+ * lazily on `index._endpointsCache` and invalidated on rebuild via the same
14
+ * mechanism as `_reachableSymbols`.
15
+ *
16
+ * Output shape:
17
+ * serverRoutes: [{ method, path, normalizedPath, handler, file, line, framework, raw }]
18
+ * clientRequests: [{ method, path, normalizedPath, file, line, callerName, callerStartLine,
19
+ * framework, interp }]
20
+ * bridges: [{ route, request, confidence, methodInferred, matchType }]
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const { getCachedCalls } = require('./callers');
28
+ const { langTraits } = require('../languages');
29
+
30
+ // ============================================================================
31
+ // HTTP METHOD CONSTANTS
32
+ // ============================================================================
33
+
34
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'ALL', 'USE']);
35
+
36
+ // ============================================================================
37
+ // PATH NORMALIZATION
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Canonicalize a route path: strip query string, trailing slash, normalize
42
+ * all parameter syntaxes to a single token (`*`).
43
+ *
44
+ * Examples:
45
+ * /users/:id → /users/*
46
+ * /users/{id} → /users/*
47
+ * /users/<int:user_id> → /users/*
48
+ * /users/<id>/ → /users/*
49
+ * /users?q=foo → /users
50
+ *
51
+ * @param {string} p - Raw path
52
+ * @returns {string} Canonical path
53
+ */
54
+ function normalizePath(p) {
55
+ if (typeof p !== 'string' || !p) return '';
56
+ let s = p;
57
+ // Strip query string and fragment
58
+ const q = s.indexOf('?');
59
+ if (q !== -1) s = s.slice(0, q);
60
+ const h = s.indexOf('#');
61
+ if (h !== -1) s = s.slice(0, h);
62
+ // Strip trailing slash (but keep "/")
63
+ if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
64
+ // Normalize all parameter forms to '*'
65
+ // :param → Express/Koa/Rails/fastify
66
+ // {param} → Spring/OpenAPI
67
+ // <param> → Flask
68
+ // <converter:param> → Flask typed
69
+ // Flask <converter:name> or <name> — replace BEFORE colon-prefix params so we don't
70
+ // see e.g. `<int:user>` as an unmatched colon-form first.
71
+ s = s.replace(/<[^>]+>/g, '*');
72
+ // Spring/OpenAPI {param}
73
+ s = s.replace(/\{[^}]+\}/g, '*');
74
+ // Express/Koa/Rails :param
75
+ s = s.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '*');
76
+ return s;
77
+ }
78
+
79
+ /** Join a class-level prefix with a method-level path. */
80
+ function joinRoutePath(prefix, sub) {
81
+ const p = (prefix || '').replace(/\/+$/, '');
82
+ const s = (sub || '').replace(/^\/+/, '');
83
+ if (!p && !s) return '/';
84
+ if (!s) return p || '/';
85
+ if (!p) return '/' + s;
86
+ return p + '/' + s;
87
+ }
88
+
89
+ /** True if `s` ends with the wildcard sentinel from a template literal. */
90
+ function endsWithWildcard(s) {
91
+ return typeof s === 'string' && s.endsWith('*');
92
+ }
93
+
94
+ // ============================================================================
95
+ // FRAMEWORK PATTERNS
96
+ // ============================================================================
97
+
98
+ // Server: receiver+method patterns (router-like calls).
99
+ // receiver matches case-insensitively; method matches exactly.
100
+ //
101
+ // Python is intentionally absent: Flask/FastAPI use decorators, which we capture
102
+ // via collectMethodRoutes(). Including a Python entry would double-count routes
103
+ // (the decorator application is also a call expression in the AST).
104
+ const SERVER_RECEIVER_PATTERNS = {
105
+ javascript: [
106
+ // Express, Fastify, Koa router, generic
107
+ { receiverPattern: /^(app|router|server|api|fastify|koaRouter|koa)$/i,
108
+ methodPattern: /^(get|post|put|delete|patch|options|head|all)$/,
109
+ framework: 'express' },
110
+ // app.use is more ambiguous but counts as a route mount
111
+ ],
112
+ typescript: [
113
+ { receiverPattern: /^(app|router|server|api|fastify|koaRouter|koa)$/i,
114
+ methodPattern: /^(get|post|put|delete|patch|options|head|all)$/,
115
+ framework: 'express' },
116
+ ],
117
+ python: [],
118
+ go: [
119
+ // gin, echo, chi, fiber: r.GET("/x", h), r.Group("/api"), e.GET(...)
120
+ { receiverPattern: /^(r|router|engine|app|e|api|v\d+|group|mux|serveMux|http)$/i,
121
+ methodPattern: /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any|Handle|HandleFunc)$/,
122
+ framework: 'go-http' },
123
+ ],
124
+ java: [
125
+ // Less common — Spring uses annotations. Capture WebFlux router builders if present.
126
+ ],
127
+ rust: [
128
+ // axum: matches both
129
+ // - Named variable form: let app = Router::new(); app.route("/p", get(h))
130
+ // → receiver = 'app' (matched by the alpha pattern)
131
+ // - Chained constructor: Router::new().route("/p", get(h)).route(...)
132
+ // → receiver = 'Router' (synthetic marker set by rust.js findCallsInCode
133
+ // when it walks the chain to its `Router::new()` root)
134
+ { receiverPattern: /^(router|app|api|r)$/i,
135
+ methodPattern: /^route$/,
136
+ framework: 'axum' },
137
+ // axum nested: .nest("/prefix", inner) — captured but treated as a
138
+ // route mount with method ALL. (Prefix concat with inner router routes
139
+ // is deferred — too complex to track inner Router argument.)
140
+ ],
141
+ };
142
+
143
+ // Client: receiver+method patterns and bare-call patterns.
144
+ const CLIENT_PATTERNS = {
145
+ javascript: {
146
+ // Bare calls: fetch('/x')
147
+ bareCalls: new Set(['fetch']),
148
+ // Receiver.method patterns
149
+ receivers: [
150
+ { receiverPattern: /^(axios|client|http|api|httpClient)$/i,
151
+ methodPattern: /^(get|post|put|delete|patch|options|head|request)$/,
152
+ framework: 'axios' },
153
+ ],
154
+ // axios('/path', {...}) or axios({method:..., url:'/path'})
155
+ callableReceivers: new Set(['axios']),
156
+ },
157
+ typescript: {
158
+ bareCalls: new Set(['fetch']),
159
+ receivers: [
160
+ { receiverPattern: /^(axios|client|http|api|httpClient)$/i,
161
+ methodPattern: /^(get|post|put|delete|patch|options|head|request)$/,
162
+ framework: 'axios' },
163
+ ],
164
+ callableReceivers: new Set(['axios']),
165
+ },
166
+ python: {
167
+ bareCalls: new Set(),
168
+ receivers: [
169
+ { receiverPattern: /^(requests|httpx|client|session|s)$/,
170
+ methodPattern: /^(get|post|put|delete|patch|options|head|request)$/,
171
+ framework: 'requests' },
172
+ ],
173
+ callableReceivers: new Set(),
174
+ },
175
+ go: {
176
+ bareCalls: new Set(),
177
+ receivers: [
178
+ { receiverPattern: /^(http|client|c)$/i,
179
+ methodPattern: /^(Get|Post|PostForm|Head|Do|NewRequest)$/,
180
+ framework: 'go-http' },
181
+ ],
182
+ callableReceivers: new Set(),
183
+ },
184
+ java: {
185
+ bareCalls: new Set(),
186
+ receivers: [
187
+ { receiverPattern: /^(restTemplate|client|webClient|http|httpClient)$/i,
188
+ methodPattern: /^(getForObject|postForObject|putForObject|exchange|getForEntity|postForEntity|uri|send)$/,
189
+ framework: 'spring-client' },
190
+ ],
191
+ callableReceivers: new Set(),
192
+ },
193
+ rust: {
194
+ bareCalls: new Set(),
195
+ receivers: [
196
+ { receiverPattern: /^(client|reqwest|c|http)$/i,
197
+ methodPattern: /^(get|post|put|delete|patch|head|request)$/,
198
+ framework: 'reqwest' },
199
+ ],
200
+ // reqwest::get("/path") is a path-call captured separately
201
+ callableReceivers: new Set(),
202
+ },
203
+ };
204
+
205
+ // HTTP-method decorator/annotation/attribute patterns.
206
+ // name → method (or 'ALL' if multi).
207
+ const METHOD_DECORATORS = {
208
+ // NestJS / TS decorators
209
+ 'Get': 'GET',
210
+ 'Post': 'POST',
211
+ 'Put': 'PUT',
212
+ 'Delete': 'DELETE',
213
+ 'Patch': 'PATCH',
214
+ 'Options': 'OPTIONS',
215
+ 'Head': 'HEAD',
216
+ 'All': 'ALL',
217
+ // Spring
218
+ 'GetMapping': 'GET',
219
+ 'PostMapping': 'POST',
220
+ 'PutMapping': 'PUT',
221
+ 'DeleteMapping': 'DELETE',
222
+ 'PatchMapping': 'PATCH',
223
+ // Spring catch-all (handled specially when 'method' attr present)
224
+ 'RequestMapping': null,
225
+ // JAX-RS
226
+ 'GET': 'GET',
227
+ 'POST': 'POST',
228
+ 'PUT': 'PUT',
229
+ 'DELETE': 'DELETE',
230
+ 'HEAD': 'HEAD',
231
+ 'OPTIONS': 'OPTIONS',
232
+ 'PATCH': 'PATCH',
233
+ 'Path': null, // JAX-RS @Path: only the prefix; HTTP method comes from @GET etc.
234
+ };
235
+
236
+ // Rust attribute names that map to HTTP methods (actix #[get("/x")] etc.)
237
+ const RUST_METHOD_ATTRS = {
238
+ 'get': 'GET',
239
+ 'post': 'POST',
240
+ 'put': 'PUT',
241
+ 'delete': 'DELETE',
242
+ 'patch': 'PATCH',
243
+ 'head': 'HEAD',
244
+ 'options':'OPTIONS',
245
+ };
246
+
247
+ // Class-level decorator names that contribute a path PREFIX (no HTTP method).
248
+ const PREFIX_DECORATORS = new Set([
249
+ 'Controller', // NestJS class decorator: @Controller('/users')
250
+ ]);
251
+ const PREFIX_ANNOTATIONS = new Set([
252
+ 'RequestMapping', // Spring class-level @RequestMapping("/api")
253
+ 'Path', // JAX-RS class-level @Path("/api")
254
+ ]);
255
+
256
+ // Python decorator name patterns (e.g., 'app.route' or 'app.get')
257
+ // Returns { method, isPrefix } when matched.
258
+ function parsePythonDecorator(name) {
259
+ if (typeof name !== 'string') return null;
260
+ const m = name.match(/^[A-Za-z_][A-Za-z0-9_]*\.(route|get|post|put|delete|patch|options|head)/i);
261
+ if (!m) return null;
262
+ const verb = m[1].toLowerCase();
263
+ if (verb === 'route') return { method: 'ALL', isRoute: true };
264
+ return { method: verb.toUpperCase() };
265
+ }
266
+
267
+ // ============================================================================
268
+ // EXTRACT SERVER ROUTES
269
+ // ============================================================================
270
+
271
+ /**
272
+ * Build map of all server routes detected in the index.
273
+ * Cached lazily on `index._endpointsCache.serverRoutes`.
274
+ *
275
+ * @param {object} index - ProjectIndex
276
+ * @returns {Array<{method, path, normalizedPath, handler, file, line, framework, raw, classPrefix}>}
277
+ */
278
+ function extractServerRoutes(index) {
279
+ if (index._endpointsCache && index._endpointsCache.serverRoutes) {
280
+ return index._endpointsCache.serverRoutes;
281
+ }
282
+
283
+ const routes = [];
284
+
285
+ // 1) Decorator/annotation/attribute-based routes (NestJS, Flask/FastAPI, Spring, JAX-RS, Actix).
286
+ // Iterate symbols once and look at their decoratorsWithArgs/annotationsWithArgs/attributesWithArgs.
287
+ // Class-level prefixes are captured first then applied to methods inside the same class.
288
+ const classPrefixByFileClass = new Map(); // `${file}:${className}` -> prefix string
289
+ for (const [, syms] of index.symbols) {
290
+ for (const sym of syms) {
291
+ const fileEntry = index.files.get(sym.file);
292
+ if (!fileEntry) continue;
293
+
294
+ // CLASS-LEVEL prefix capture
295
+ if (sym.type === 'class' || sym.type === 'interface') {
296
+ const prefixes = collectClassPrefixes(sym, fileEntry.language);
297
+ if (prefixes.length > 0) {
298
+ classPrefixByFileClass.set(`${sym.file}:${sym.name}`, prefixes[0]);
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ for (const [, syms] of index.symbols) {
305
+ for (const sym of syms) {
306
+ const fileEntry = index.files.get(sym.file);
307
+ if (!fileEntry) continue;
308
+ const lang = fileEntry.language;
309
+ // Only methods/functions are HTTP handlers; classes already produced prefixes above.
310
+ if (sym.type !== 'function' && sym.type !== 'method' && !sym.isMethod) continue;
311
+
312
+ // Resolve class prefix if this is a method on a controller class
313
+ let classPrefix = '';
314
+ if (sym.className) {
315
+ classPrefix = classPrefixByFileClass.get(`${sym.file}:${sym.className}`) || '';
316
+ }
317
+
318
+ const declRoutes = collectMethodRoutes(sym, lang, classPrefix);
319
+ for (const r of declRoutes) {
320
+ routes.push({
321
+ method: r.method,
322
+ path: r.path,
323
+ normalizedPath: normalizePath(r.path),
324
+ handler: sym.name,
325
+ file: sym.relativePath || sym.file,
326
+ absoluteFile: sym.file,
327
+ line: sym.startLine,
328
+ framework: r.framework,
329
+ classPrefix: classPrefix || undefined,
330
+ raw: r.raw || `${r.method} ${r.path}`,
331
+ });
332
+ }
333
+ }
334
+ }
335
+
336
+ // 2) Call-pattern routes (Express/Fastify/Koa/Gin/Echo/Chi/Fiber, axum, http).
337
+ // Iterate calls once per file via the call cache.
338
+ for (const [filePath, fileEntry] of index.files) {
339
+ const lang = fileEntry.language;
340
+ const calls = getCachedCalls(index, filePath);
341
+ if (!calls || calls.length === 0) continue;
342
+
343
+ for (const call of calls) {
344
+ const r = matchCallPatternRoute(call, lang);
345
+ if (!r) continue;
346
+
347
+ // Resolve handler name from arg position 1 if call.firstStringArg is set.
348
+ // The handler reference is captured as a separate `isPotentialCallback`
349
+ // call on the same line — we look for it.
350
+ const handlerName = findHandlerCallback(calls, call.line, call) || '<anonymous>';
351
+
352
+ routes.push({
353
+ method: r.method,
354
+ path: r.path,
355
+ normalizedPath: normalizePath(r.path),
356
+ handler: handlerName,
357
+ file: fileEntry.relativePath || filePath,
358
+ absoluteFile: filePath,
359
+ line: call.line,
360
+ framework: r.framework,
361
+ raw: `${r.method} ${r.path}`,
362
+ });
363
+ }
364
+ }
365
+
366
+ // 3) Next.js file-based routes — only scan if `pages/` or `app/` exists at root.
367
+ const nextRoutes = extractNextjsRoutes(index);
368
+ for (const r of nextRoutes) routes.push(r);
369
+
370
+ // Sort deterministically (file, line, method, path)
371
+ routes.sort((a, b) => {
372
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
373
+ if (a.line !== b.line) return a.line - b.line;
374
+ if (a.method !== b.method) return a.method.localeCompare(b.method);
375
+ return a.path.localeCompare(b.path);
376
+ });
377
+
378
+ // Cache it
379
+ if (!index._endpointsCache) index._endpointsCache = {};
380
+ index._endpointsCache.serverRoutes = routes;
381
+
382
+ return routes;
383
+ }
384
+
385
+ /** Return the list of class-level path prefixes for a class symbol. */
386
+ function collectClassPrefixes(sym, lang) {
387
+ const prefixes = [];
388
+ // JS/TS decorators
389
+ if ((lang === 'javascript' || lang === 'typescript' || lang === 'tsx') && sym.decoratorsWithArgs) {
390
+ for (const d of sym.decoratorsWithArgs) {
391
+ if (PREFIX_DECORATORS.has(d.name) && d.firstStringArg != null) {
392
+ prefixes.push(d.firstStringArg);
393
+ }
394
+ }
395
+ }
396
+ // Java annotations: @RequestMapping("/api"), @Path("/api")
397
+ if (lang === 'java' && sym.annotationsWithArgs) {
398
+ for (const a of sym.annotationsWithArgs) {
399
+ if (PREFIX_ANNOTATIONS.has(a.name) && a.firstStringArg != null) {
400
+ prefixes.push(a.firstStringArg);
401
+ }
402
+ }
403
+ }
404
+ return prefixes;
405
+ }
406
+
407
+ /**
408
+ * Return zero or more route objects {method, path, framework, raw} for a method/function symbol.
409
+ */
410
+ function collectMethodRoutes(sym, lang, classPrefix) {
411
+ const out = [];
412
+
413
+ // ── JS/TS decorators (NestJS) ────────────────────────────────────
414
+ if ((lang === 'javascript' || lang === 'typescript' || lang === 'tsx') && sym.decoratorsWithArgs) {
415
+ for (const d of sym.decoratorsWithArgs) {
416
+ const method = METHOD_DECORATORS[d.name];
417
+ if (method == null && d.name !== 'RequestMapping') continue;
418
+ // Allow no-arg form: @Get() — defaults to ''
419
+ const sub = d.firstStringArg || '';
420
+ const fullPath = joinRoutePath(classPrefix, sub);
421
+ out.push({
422
+ method: method || 'GET',
423
+ path: fullPath || '/',
424
+ framework: 'nestjs',
425
+ });
426
+ }
427
+ }
428
+
429
+ // ── Python decorators (Flask, FastAPI) ───────────────────────────
430
+ if (lang === 'python' && sym.decorators) {
431
+ for (const decRaw of sym.decorators) {
432
+ // Decorator text in Python is the full source: "app.route('/users', methods=['GET'])"
433
+ const r = parsePythonDecoratorFull(decRaw);
434
+ if (r) {
435
+ out.push({
436
+ method: r.method,
437
+ path: r.path,
438
+ framework: r.framework,
439
+ });
440
+ }
441
+ }
442
+ }
443
+
444
+ // ── Java annotations (Spring, JAX-RS) ────────────────────────────
445
+ if (lang === 'java' && sym.annotationsWithArgs) {
446
+ // Track JAX-RS @Path + @GET pattern: @Path supplies path, @GET supplies method.
447
+ let jaxrsPath = null;
448
+ const jaxrsMethods = [];
449
+ for (const a of sym.annotationsWithArgs) {
450
+ const meth = METHOD_DECORATORS[a.name];
451
+ if (a.name === 'Path' && a.firstStringArg != null) {
452
+ jaxrsPath = a.firstStringArg;
453
+ continue;
454
+ }
455
+ if (['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(a.name) && a.firstStringArg == null) {
456
+ jaxrsMethods.push(a.name);
457
+ continue;
458
+ }
459
+ if (a.name === 'RequestMapping') {
460
+ // Try to detect method= attribute in args
461
+ const detectedMethod = parseSpringRequestMappingMethod(a.args) || 'ALL';
462
+ const sub = a.firstStringArg || '';
463
+ out.push({
464
+ method: detectedMethod,
465
+ path: joinRoutePath(classPrefix, sub) || '/',
466
+ framework: 'spring',
467
+ });
468
+ continue;
469
+ }
470
+ if (meth) {
471
+ // Spring @GetMapping, @PostMapping, etc.
472
+ const sub = a.firstStringArg || '';
473
+ out.push({
474
+ method: meth,
475
+ path: joinRoutePath(classPrefix, sub) || '/',
476
+ framework: 'spring',
477
+ });
478
+ }
479
+ }
480
+ // JAX-RS finalization
481
+ if (jaxrsMethods.length > 0) {
482
+ const subPath = jaxrsPath || '';
483
+ for (const m of jaxrsMethods) {
484
+ out.push({
485
+ method: m,
486
+ path: joinRoutePath(classPrefix, subPath) || '/',
487
+ framework: 'jax-rs',
488
+ });
489
+ }
490
+ }
491
+ }
492
+
493
+ // ── Rust attributes (actix #[get("/users")]) ─────────────────────
494
+ if (lang === 'rust' && sym.attributesWithArgs) {
495
+ for (const a of sym.attributesWithArgs) {
496
+ const method = RUST_METHOD_ATTRS[a.name];
497
+ if (!method) continue;
498
+ // a.args = '"/users"' — strip quotes
499
+ const arg = (a.args || '').trim();
500
+ const m = arg.match(/^"([^"]*)"/);
501
+ if (m) {
502
+ out.push({
503
+ method,
504
+ path: m[1] || '/',
505
+ framework: 'actix',
506
+ });
507
+ }
508
+ }
509
+ }
510
+
511
+ return out;
512
+ }
513
+
514
+ /**
515
+ * Parse a Python decorator raw string like:
516
+ * "app.route('/users', methods=['GET'])"
517
+ * "app.get('/users/<int:user_id>')"
518
+ * "router.post('/items')"
519
+ * Returns { method, path, framework } or null.
520
+ */
521
+ function parsePythonDecoratorFull(raw) {
522
+ if (typeof raw !== 'string') return null;
523
+ // Match receiver.verb('path', ...)
524
+ const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([a-z]+)\s*\(\s*(['"])([^'"]*)\3/);
525
+ if (!m) return null;
526
+ const verb = m[2];
527
+ const pathStr = m[4];
528
+ if (verb === 'route') {
529
+ // Methods= attr
530
+ const methodsMatch = raw.match(/methods\s*=\s*\[(.*?)\]/);
531
+ if (methodsMatch) {
532
+ const methods = methodsMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '').toUpperCase()).filter(Boolean);
533
+ // Caller will receive ONE entry; we return GET if methods empty, else first.
534
+ if (methods.length > 0) {
535
+ return { method: methods[0], path: pathStr, framework: 'flask' };
536
+ }
537
+ }
538
+ return { method: 'GET', path: pathStr, framework: 'flask' };
539
+ }
540
+ if (['get','post','put','delete','patch','options','head'].includes(verb)) {
541
+ return { method: verb.toUpperCase(), path: pathStr, framework: 'fastapi' };
542
+ }
543
+ return null;
544
+ }
545
+
546
+ /**
547
+ * Spring @RequestMapping(method = RequestMethod.GET) — extract method.
548
+ * Returns 'GET' / 'POST' / etc. or null.
549
+ */
550
+ function parseSpringRequestMappingMethod(argsRaw) {
551
+ if (typeof argsRaw !== 'string') return null;
552
+ const m = argsRaw.match(/method\s*=\s*RequestMethod\.([A-Z]+)/);
553
+ return m ? m[1] : null;
554
+ }
555
+
556
+ /**
557
+ * Match a call against server route patterns. Returns {method, path, framework} or null.
558
+ */
559
+ function matchCallPatternRoute(call, lang) {
560
+ if (!call.firstStringArg) return null;
561
+
562
+ // Path-call patterns (Rust): handled outside (router.route, router.nest captured below)
563
+ const patterns = SERVER_RECEIVER_PATTERNS[lang];
564
+ if (!patterns || patterns.length === 0) return null;
565
+
566
+ // For Express/Gin/etc, the call is method-call: app.get('/path', handler)
567
+ for (const p of patterns) {
568
+ if (!call.receiver) continue;
569
+ if (!p.receiverPattern.test(call.receiver)) continue;
570
+ if (!p.methodPattern.test(call.name)) continue;
571
+
572
+ // BUG M5: Express has dual-purpose APIs where 1-arg .get/.set are config
573
+ // getters/setters, not route registrations. A real route registration has
574
+ // path + at least one handler (≥2 args).
575
+ // app.get('/users', handler) → 2+ args → route
576
+ // app.get('env') → 1 arg → config getter, skip
577
+ // Only apply when argCount is known (parser provided it).
578
+ if (p.framework === 'express' && typeof call.argCount === 'number' && call.argCount < 2) {
579
+ continue;
580
+ }
581
+
582
+ // axum router.route('/path', get(handler)) — method comes from the *second* arg's verb,
583
+ // which we don't have direct access to here. Fall back to ALL.
584
+ let method = call.name.toUpperCase();
585
+ if (method === 'ROUTE' || method === 'HANDLE' || method === 'HANDLEFUNC' || method === 'USE' || method === 'ANY') {
586
+ method = 'ALL';
587
+ }
588
+ // axum-style nest('/prefix', inner) is a prefix mount, not a route — skip when not handled
589
+ return { method, path: call.firstStringArg, framework: p.framework };
590
+ }
591
+ return null;
592
+ }
593
+
594
+ /**
595
+ * Find a handler-callback identifier on the same line as a route registration call.
596
+ * Looks for callback-marker calls (isPotentialCallback / isFunctionReference) on that line.
597
+ */
598
+ function findHandlerCallback(calls, line, exclude) {
599
+ for (const c of calls) {
600
+ if (c === exclude) continue;
601
+ if (c.line !== line) continue;
602
+ if (c.isPotentialCallback || c.isFunctionReference) {
603
+ return c.name;
604
+ }
605
+ }
606
+ // Fallback: any non-method call on the same line
607
+ for (const c of calls) {
608
+ if (c === exclude) continue;
609
+ if (c.line !== line) continue;
610
+ if (!c.isMethod) return c.name;
611
+ }
612
+ return null;
613
+ }
614
+
615
+ // ============================================================================
616
+ // NEXT.JS FILE-BASED ROUTES
617
+ // ============================================================================
618
+
619
+ /**
620
+ * Detect Next.js routes by scanning files under pages/ or app/.
621
+ * Each matching file becomes a route; method comes from exported function name.
622
+ * pages/users/[id].ts → GET /users/:id (default export)
623
+ * app/users/[id]/route.ts (export GET) → GET /users/:id
624
+ */
625
+ function extractNextjsRoutes(index) {
626
+ const root = index.root;
627
+ if (!root) return [];
628
+
629
+ // Cheap existence check before scanning
630
+ const hasPages = fs.existsSync(path.join(root, 'pages'));
631
+ const hasApp = fs.existsSync(path.join(root, 'app'));
632
+ if (!hasPages && !hasApp) return [];
633
+
634
+ const out = [];
635
+ for (const [filePath, fileEntry] of index.files) {
636
+ const rel = (fileEntry.relativePath || filePath).split(path.sep).join('/');
637
+ const isPages = /(^|\/)pages\/.*\.(js|ts|jsx|tsx|mjs|cjs)$/.test(rel);
638
+ const isApp = /(^|\/)app\/.*\/route\.(js|ts|jsx|tsx|mjs|cjs)$/.test(rel);
639
+ if (!isPages && !isApp) continue;
640
+
641
+ // Convert file path to route
642
+ let routePath = rel;
643
+ if (isPages) {
644
+ routePath = routePath.replace(/^.*?\/?pages\//, '/');
645
+ routePath = routePath.replace(/\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
646
+ // index → /
647
+ routePath = routePath.replace(/\/index$/, '');
648
+ if (!routePath) routePath = '/';
649
+ } else {
650
+ routePath = routePath.replace(/^.*?\/?app\//, '/');
651
+ routePath = routePath.replace(/\/route\.(js|ts|jsx|tsx|mjs|cjs)$/, '');
652
+ if (!routePath) routePath = '/';
653
+ }
654
+ // Convert [param] → :param
655
+ routePath = routePath.replace(/\[\.\.\.([^\]]+)\]/g, '*');
656
+ routePath = routePath.replace(/\[([^\]]+)\]/g, ':$1');
657
+
658
+ if (isPages) {
659
+ // Default export = GET (page render)
660
+ out.push({
661
+ method: 'GET',
662
+ path: routePath,
663
+ normalizedPath: normalizePath(routePath),
664
+ handler: 'default',
665
+ file: fileEntry.relativePath || filePath,
666
+ absoluteFile: filePath,
667
+ line: 1,
668
+ framework: 'nextjs',
669
+ raw: `GET ${routePath} (next page)`,
670
+ });
671
+ } else {
672
+ // App router: each named export GET/POST/etc. is a method handler
673
+ const exports = fileEntry.exports || [];
674
+ const methodsFound = new Set();
675
+ for (const e of exports) {
676
+ if (HTTP_METHODS.has(String(e.name).toUpperCase())) {
677
+ methodsFound.add(String(e.name).toUpperCase());
678
+ }
679
+ }
680
+ // If none detected (e.g., exports not parsed), default to GET
681
+ if (methodsFound.size === 0) methodsFound.add('GET');
682
+ for (const m of methodsFound) {
683
+ out.push({
684
+ method: m,
685
+ path: routePath,
686
+ normalizedPath: normalizePath(routePath),
687
+ handler: m,
688
+ file: fileEntry.relativePath || filePath,
689
+ absoluteFile: filePath,
690
+ line: 1,
691
+ framework: 'nextjs',
692
+ raw: `${m} ${routePath} (next route)`,
693
+ });
694
+ }
695
+ }
696
+ }
697
+ return out;
698
+ }
699
+
700
+ // ============================================================================
701
+ // EXTRACT CLIENT REQUESTS
702
+ // ============================================================================
703
+
704
+ /**
705
+ * Detect HTTP client requests across the project.
706
+ * Cached on `index._endpointsCache.clientRequests`.
707
+ */
708
+ function extractClientRequests(index) {
709
+ if (index._endpointsCache && index._endpointsCache.clientRequests) {
710
+ return index._endpointsCache.clientRequests;
711
+ }
712
+ const requests = [];
713
+
714
+ for (const [filePath, fileEntry] of index.files) {
715
+ const lang = fileEntry.language;
716
+ const calls = getCachedCalls(index, filePath);
717
+ if (!calls || calls.length === 0) continue;
718
+
719
+ for (const call of calls) {
720
+ if (!call.firstStringArg) continue;
721
+ const r = matchClientRequest(call, lang, calls);
722
+ if (!r) continue;
723
+
724
+ const callerName = call.enclosingFunction?.name || '<top-level>';
725
+ const callerStartLine = call.enclosingFunction?.startLine;
726
+
727
+ requests.push({
728
+ method: r.method,
729
+ path: call.firstStringArg,
730
+ normalizedPath: normalizePath(call.firstStringArg),
731
+ interp: !!call.firstStringArgInterp,
732
+ file: fileEntry.relativePath || filePath,
733
+ absoluteFile: filePath,
734
+ line: call.line,
735
+ callerName,
736
+ callerStartLine,
737
+ framework: r.framework,
738
+ methodInferred: r.methodInferred,
739
+ });
740
+ }
741
+ }
742
+
743
+ // Stable sort
744
+ requests.sort((a, b) => {
745
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
746
+ if (a.line !== b.line) return a.line - b.line;
747
+ if (a.method !== b.method) return a.method.localeCompare(b.method);
748
+ return a.path.localeCompare(b.path);
749
+ });
750
+
751
+ if (!index._endpointsCache) index._endpointsCache = {};
752
+ index._endpointsCache.clientRequests = requests;
753
+ return requests;
754
+ }
755
+
756
+ /**
757
+ * Match a call against client request patterns.
758
+ * Returns { method, framework, methodInferred } or null.
759
+ */
760
+ function matchClientRequest(call, lang, allCallsInFile) {
761
+ const conf = CLIENT_PATTERNS[lang];
762
+ if (!conf) return null;
763
+
764
+ // 1) Bare-call patterns: fetch('/path') or fetch('/path', { method: 'POST' })
765
+ if (!call.isMethod && conf.bareCalls.has(call.name)) {
766
+ // MEDIUM-5: parse-time captured `optionsMethod` from
767
+ // fetch(url, { method: 'POST' }) wins over default GET.
768
+ const explicitMethod = call.optionsMethod || inferMethodFromFetchOptions(call);
769
+ const inferredMethod = explicitMethod || 'GET';
770
+ // Method is "inferred" only when we fell through to the default GET;
771
+ // an explicit options.method is exact knowledge from the source.
772
+ const methodInferred = !explicitMethod;
773
+ return { method: inferredMethod, framework: 'fetch', methodInferred };
774
+ }
775
+
776
+ // 2) Receiver.method patterns. For Go, package-qualified calls have
777
+ // `isMethod: false` (e.g., `http.Get(...)`) when the receiver matches an
778
+ // import alias; treat those as method-like for routing purposes.
779
+ const isMethodLike = call.isMethod || (lang === 'go' && !!call.receiver && !call.isPathCall);
780
+ if (isMethodLike && call.receiver) {
781
+ for (const p of conf.receivers) {
782
+ if (!p.receiverPattern.test(call.receiver)) continue;
783
+ if (!p.methodPattern.test(call.name)) continue;
784
+
785
+ // Determine method
786
+ const methodName = call.name.toLowerCase();
787
+ // Java webClient.get().uri('/path') — `uri` is the actual path-bearing call,
788
+ // but the HTTP method must be inferred from the chained .get() — too complex,
789
+ // we tag as ALL.
790
+ let method;
791
+ let inferred = false;
792
+ if (methodName === 'uri') {
793
+ // Java pattern: rest of the chain — we can't easily extract method, use ALL
794
+ method = 'ALL';
795
+ inferred = true;
796
+ } else if (methodName === 'do' || methodName === 'newrequest' || methodName === 'send' || methodName === 'exchange' || methodName === 'request') {
797
+ // Generic — can't determine method
798
+ method = 'ALL';
799
+ inferred = true;
800
+ } else if (methodName === 'getforobject' || methodName === 'getforentity') {
801
+ method = 'GET';
802
+ } else if (methodName === 'postforobject' || methodName === 'postforentity' || methodName === 'postform') {
803
+ method = 'POST';
804
+ } else if (methodName === 'putforobject') {
805
+ method = 'PUT';
806
+ } else {
807
+ method = methodName.toUpperCase();
808
+ }
809
+ return { method, framework: p.framework, methodInferred: inferred };
810
+ }
811
+ }
812
+
813
+ // 3) Path-call (Rust): scoped_identifier reqwest::get('/path')
814
+ if (lang === 'rust' && call.isPathCall && call.receiver) {
815
+ // call.receiver = 'reqwest' or similar; call.name = 'get'/'post'/etc.
816
+ const verb = call.name.toLowerCase();
817
+ if (['get','post','put','delete','patch','head','options'].includes(verb)) {
818
+ return { method: verb.toUpperCase(), framework: 'reqwest', methodInferred: false };
819
+ }
820
+ }
821
+
822
+ return null;
823
+ }
824
+
825
+ /**
826
+ * Best-effort detection of fetch('/p', { method: 'POST' }) by looking at the
827
+ * surrounding raw call. Without full AST access here, we read the call line
828
+ * from the cached calls array (no I/O). Only returns explicit method or null.
829
+ */
830
+ function inferMethodFromFetchOptions(_call) {
831
+ // We don't have the args AST in the call cache; bail and let caller default to GET.
832
+ // A future enhancement could capture a `optionsMethod` field at parse time.
833
+ return null;
834
+ }
835
+
836
+ // ============================================================================
837
+ // PATH MATCHING
838
+ // ============================================================================
839
+
840
+ /**
841
+ * Match each client request against server routes.
842
+ * Returns array of { route, request, confidence, matchType, methodInferred }.
843
+ *
844
+ * Match types:
845
+ * exact — same canonical path, exact method match
846
+ * partial — server has wildcards, client supplies literal that the wildcard
847
+ * form matches; OR client has wildcards, server has literal/wildcard
848
+ * uncertain — interpolated client path partially overlaps server's literal prefix
849
+ */
850
+ function bridgeEndpoints(index) {
851
+ if (index._endpointsCache && index._endpointsCache.bridges) {
852
+ return index._endpointsCache.bridges;
853
+ }
854
+ const routes = extractServerRoutes(index);
855
+ const requests = extractClientRequests(index);
856
+
857
+ // Bucket routes by HTTP method for cheap pruning
858
+ const routesByMethod = new Map();
859
+ for (const r of routes) {
860
+ const list = routesByMethod.get(r.method) || [];
861
+ list.push(r);
862
+ routesByMethod.set(r.method, list);
863
+ // ALL routes match every method
864
+ }
865
+ const allRoutes = routesByMethod.get('ALL') || [];
866
+
867
+ const bridges = [];
868
+
869
+ for (const req of requests) {
870
+ const candidates = [];
871
+ // Pull buckets compatible with the request's method (or ALL when inferred)
872
+ const methodKey = req.method;
873
+ if (req.methodInferred) {
874
+ // Could match any method-bucket; but typical: try GET, then ALL
875
+ for (const list of routesByMethod.values()) {
876
+ for (const r of list) candidates.push(r);
877
+ }
878
+ } else {
879
+ const list = routesByMethod.get(methodKey) || [];
880
+ for (const r of list) candidates.push(r);
881
+ for (const r of allRoutes) candidates.push(r);
882
+ }
883
+
884
+ for (const route of candidates) {
885
+ const match = matchPath(route, req);
886
+ if (!match) continue;
887
+
888
+ // Method matching contributes to confidence
889
+ const methodMatches = methodMatch(route.method, req.method);
890
+ if (!methodMatches.ok) continue;
891
+
892
+ const confidence = scoreMatch(match.matchType, methodMatches);
893
+ bridges.push({
894
+ route,
895
+ request: req,
896
+ matchType: match.matchType,
897
+ methodInferred: methodMatches.inferred,
898
+ confidence,
899
+ });
900
+ }
901
+ }
902
+
903
+ // For each (request) keep all matches but sort with best first
904
+ bridges.sort((a, b) => {
905
+ // Group by request first
906
+ const reqCmpFile = a.request.file.localeCompare(b.request.file);
907
+ if (reqCmpFile !== 0) return reqCmpFile;
908
+ if (a.request.line !== b.request.line) return a.request.line - b.request.line;
909
+ // Then by confidence desc
910
+ if (a.confidence !== b.confidence) return b.confidence - a.confidence;
911
+ // Then by route file/line
912
+ if (a.route.file !== b.route.file) return a.route.file.localeCompare(b.route.file);
913
+ return a.route.line - b.route.line;
914
+ });
915
+
916
+ if (!index._endpointsCache) index._endpointsCache = {};
917
+ index._endpointsCache.bridges = bridges;
918
+ return bridges;
919
+ }
920
+
921
+ /** True iff route method and client method are compatible. */
922
+ function methodMatch(routeMethod, clientMethod) {
923
+ if (routeMethod === 'ALL' || clientMethod === 'ALL') {
924
+ return { ok: true, inferred: true };
925
+ }
926
+ // 'USE' covers all methods
927
+ if (routeMethod === 'USE') return { ok: true, inferred: true };
928
+ return { ok: routeMethod === clientMethod, inferred: false };
929
+ }
930
+
931
+ /**
932
+ * Determine match type between server route and client request.
933
+ * Returns {matchType: 'exact'|'partial'|'uncertain'} or null.
934
+ */
935
+ function matchPath(route, req) {
936
+ const sNorm = route.normalizedPath;
937
+ const cNorm = req.normalizedPath;
938
+ if (sNorm === '' || cNorm === '') return null;
939
+
940
+ // Exact: both canonical paths identical AND neither has wildcards.
941
+ if (sNorm === cNorm) {
942
+ const hasWild = sNorm.includes('*');
943
+ if (hasWild) {
944
+ return { matchType: 'partial' };
945
+ }
946
+ return { matchType: 'exact' };
947
+ }
948
+
949
+ // Wildcard match: server has wildcards; client has literal.
950
+ if (sNorm.includes('*') && wildcardMatches(sNorm, cNorm)) {
951
+ return { matchType: 'partial' };
952
+ }
953
+
954
+ // Reverse: client wildcard against server literal/wildcard.
955
+ if (cNorm.includes('*') && req.interp) {
956
+ // Treat the client wildcard like a single path segment (`*` ≡ `[^/]+`).
957
+ // The client's `/users/*` should match the server's `/users/:id`
958
+ // (also normalized to `/users/*`) but NOT `/users/create` because that's
959
+ // a fixed literal segment, not a parameter slot.
960
+ if (wildcardMatches(cNorm, sNorm)) {
961
+ return { matchType: 'uncertain' };
962
+ }
963
+ // Looser fallback: if both share a literal prefix and the server has
964
+ // a wildcard at the position the client truncated to, accept partial.
965
+ const cPrefix = cNorm.replace(/\*+$/g, '');
966
+ if (sNorm.startsWith(cPrefix) && sNorm.includes('*')) {
967
+ return { matchType: 'uncertain' };
968
+ }
969
+ }
970
+
971
+ return null;
972
+ }
973
+
974
+ /*
975
+ * Check if a wildcard-bearing pattern matches a literal path.
976
+ * Each '*' in the pattern matches a single non-empty path segment.
977
+ * /users/(*) vs /users/123 → true
978
+ * /users/(*)/posts/(*) vs /users/1/posts/2 → true
979
+ * /users/(*) vs /users/1/2 → false (single segment)
980
+ * /users/(*) vs /users → false
981
+ */
982
+ function wildcardMatches(pattern, literal) {
983
+ // Build a regex from the pattern: '*' → '[^/]+'
984
+ const escaped = pattern
985
+ .split('*')
986
+ .map(seg => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
987
+ .join('[^/]+');
988
+ const re = new RegExp('^' + escaped + '$');
989
+ return re.test(literal);
990
+ }
991
+
992
+ /** Numeric confidence based on match type and method certainty. */
993
+ function scoreMatch(matchType, methodCheck) {
994
+ let base;
995
+ if (matchType === 'exact') base = 1.0;
996
+ else if (matchType === 'partial') base = 0.85;
997
+ else base = 0.6; // uncertain
998
+ if (methodCheck.inferred) base -= 0.1;
999
+ return Math.max(0, Math.min(1, base));
1000
+ }
1001
+
1002
+ // ============================================================================
1003
+ // PUBLIC API
1004
+ // ============================================================================
1005
+
1006
+ /**
1007
+ * Reset the endpoints cache. Called by index rebuild paths.
1008
+ */
1009
+ function clearEndpointsCache(index) {
1010
+ index._endpointsCache = null;
1011
+ }
1012
+
1013
+ /**
1014
+ * Top-level entry: detect endpoints, optionally bridge clients to servers.
1015
+ *
1016
+ * @param {object} index - ProjectIndex
1017
+ * @param {object} [options]
1018
+ * @param {boolean} [options.bridge=false] - Compute server↔client bridges
1019
+ * @param {boolean} [options.serverOnly=false]
1020
+ * @param {boolean} [options.clientOnly=false]
1021
+ * @param {boolean} [options.unmatched=false] - Only return unmatched routes/requests
1022
+ * @param {string} [options.method] - Filter by HTTP method
1023
+ * @param {string} [options.prefix] - Filter by path prefix (literal)
1024
+ * @param {boolean} [options.showUncertain=true]
1025
+ * @returns {object} { routes, requests, bridges, unmatchedRoutes, unmatchedRequests, meta }
1026
+ */
1027
+ function endpoints(index, options = {}) {
1028
+ const opts = {
1029
+ bridge: !!options.bridge,
1030
+ serverOnly: !!options.serverOnly,
1031
+ clientOnly: !!options.clientOnly,
1032
+ unmatched: !!options.unmatched,
1033
+ method: options.method ? String(options.method).toUpperCase() : null,
1034
+ prefix: options.prefix || null,
1035
+ showUncertain: options.showUncertain !== false,
1036
+ };
1037
+
1038
+ let routes = opts.clientOnly ? [] : extractServerRoutes(index);
1039
+ let requests = (opts.serverOnly ? [] : extractClientRequests(index));
1040
+
1041
+ // Apply filters
1042
+ if (opts.method) {
1043
+ routes = routes.filter(r => r.method === opts.method || r.method === 'ALL' || r.method === 'USE');
1044
+ requests = requests.filter(r => r.method === opts.method || r.method === 'ALL');
1045
+ }
1046
+ if (opts.prefix) {
1047
+ routes = routes.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
1048
+ requests = requests.filter(r => r.path.startsWith(opts.prefix) || r.normalizedPath.startsWith(opts.prefix));
1049
+ }
1050
+
1051
+ let bridges = opts.bridge ? bridgeEndpoints(index) : [];
1052
+ if (!opts.showUncertain) {
1053
+ bridges = bridges.filter(b => b.matchType !== 'uncertain');
1054
+ }
1055
+ // If user filtered routes/requests, also constrain bridges
1056
+ if (opts.method || opts.prefix) {
1057
+ const routeKeys = new Set(routes.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1058
+ const reqKeys = new Set(requests.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1059
+ bridges = bridges.filter(b =>
1060
+ routeKeys.has(`${b.route.absoluteFile}:${b.route.line}:${b.route.method}:${b.route.path}`) &&
1061
+ reqKeys.has(`${b.request.absoluteFile}:${b.request.line}:${b.request.method}:${b.request.path}`)
1062
+ );
1063
+ }
1064
+
1065
+ // Compute unmatched
1066
+ let unmatchedRoutes = [];
1067
+ let unmatchedRequests = [];
1068
+ if (opts.bridge || opts.unmatched) {
1069
+ const matchedRouteKeys = new Set();
1070
+ const matchedRequestKeys = new Set();
1071
+ for (const b of bridges) {
1072
+ matchedRouteKeys.add(`${b.route.absoluteFile}:${b.route.line}:${b.route.method}:${b.route.path}`);
1073
+ matchedRequestKeys.add(`${b.request.absoluteFile}:${b.request.line}:${b.request.method}:${b.request.path}`);
1074
+ }
1075
+ unmatchedRoutes = routes.filter(r => !matchedRouteKeys.has(`${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1076
+ unmatchedRequests = requests.filter(r => !matchedRequestKeys.has(`${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
1077
+ }
1078
+
1079
+ // Group counts
1080
+ const byFramework = {};
1081
+ for (const r of routes) {
1082
+ byFramework[r.framework] = (byFramework[r.framework] || 0) + 1;
1083
+ }
1084
+
1085
+ return {
1086
+ routes,
1087
+ requests,
1088
+ bridges,
1089
+ unmatchedRoutes,
1090
+ unmatchedRequests,
1091
+ meta: {
1092
+ totalRoutes: routes.length,
1093
+ totalRequests: requests.length,
1094
+ totalBridges: bridges.length,
1095
+ unmatchedRoutes: unmatchedRoutes.length,
1096
+ unmatchedRequests: unmatchedRequests.length,
1097
+ byFramework,
1098
+ },
1099
+ };
1100
+ }
1101
+
1102
+ module.exports = {
1103
+ endpoints,
1104
+ extractServerRoutes,
1105
+ extractClientRequests,
1106
+ bridgeEndpoints,
1107
+ clearEndpointsCache,
1108
+ normalizePath,
1109
+ joinRoutePath,
1110
+ wildcardMatches,
1111
+ };