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.
- package/.claude/skills/ucn/SKILL.md +114 -11
- package/README.md +152 -156
- package/cli/index.js +363 -37
- package/core/analysis.js +936 -32
- package/core/bridge.js +1111 -0
- package/core/brief.js +408 -0
- package/core/cache.js +105 -5
- package/core/callers.js +72 -18
- package/core/check.js +200 -0
- package/core/discovery.js +57 -34
- package/core/entrypoints.js +638 -4
- package/core/execute.js +304 -5
- package/core/git-enrich.js +130 -0
- package/core/graph.js +24 -2
- package/core/output/analysis.js +157 -25
- package/core/output/brief.js +100 -0
- package/core/output/check.js +79 -0
- package/core/output/doctor.js +85 -0
- package/core/output/endpoints.js +239 -0
- package/core/output/extraction.js +2 -0
- package/core/output/find.js +126 -39
- package/core/output/graph.js +48 -15
- package/core/output/refactoring.js +103 -5
- package/core/output/reporting.js +63 -23
- package/core/output/search.js +110 -17
- package/core/output/shared.js +56 -2
- package/core/output.js +4 -0
- package/core/parser.js +8 -2
- package/core/project.js +39 -3
- package/core/registry.js +30 -14
- package/core/reporting.js +465 -2
- package/core/search.js +130 -10
- package/core/shared.js +101 -5
- package/core/tracing.js +16 -6
- package/core/verify.js +982 -95
- package/languages/go.js +91 -6
- package/languages/html.js +10 -0
- package/languages/java.js +151 -35
- package/languages/javascript.js +290 -33
- package/languages/python.js +78 -11
- package/languages/rust.js +267 -12
- package/languages/utils.js +315 -3
- package/mcp/server.js +91 -16
- 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
|
+
};
|