ucn 5.4.2 → 5.5.0
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 +58 -236
- package/.claude/skills/ucn/references/commands.md +144 -68
- package/.claude/skills/ucn/references/resolution-rules.md +171 -0
- package/.claude/skills/ucn/references/trust-contract.md +27 -33
- package/README.md +18 -12
- package/cli/index.js +40 -14
- package/core/accessors.js +118 -3
- package/core/account.js +30 -0
- package/core/analysis.js +508 -264
- package/core/ast-analysis.js +76 -117
- package/core/async-producers.js +1729 -0
- package/core/bridge.js +1877 -268
- package/core/brief.js +6 -0
- package/core/build-worker.js +78 -14
- package/core/cache.js +522 -31
- package/core/callers.js +12053 -1047
- package/core/check.js +30 -38
- package/core/class-identity.js +1028 -0
- package/core/command-contracts.js +6 -6
- package/core/configuration-items.js +86 -0
- package/core/contract-membership.js +2262 -0
- package/core/cpp-scope.js +532 -0
- package/core/deadcode.js +171 -49
- package/core/decorator-binding.js +260 -0
- package/core/discovery.js +281 -164
- package/core/errors.js +52 -0
- package/core/execute.js +21 -9
- package/core/external-macros.js +326 -0
- package/core/graph-build.js +277 -8
- package/core/graph.js +35 -2
- package/core/ignore-rules.js +472 -0
- package/core/imports.js +504 -42
- package/core/index-ir.js +94 -11
- package/core/inprocess-clients.js +636 -0
- package/core/ir.js +87 -8
- package/core/macro-expansion.js +1416 -0
- package/core/output/analysis.js +18 -2
- package/core/output/brief.js +3 -0
- package/core/output/doctor.js +4 -0
- package/core/output/endpoints.js +15 -6
- package/core/output/find.js +7 -2
- package/core/output/refactoring.js +121 -14
- package/core/output/reporting.js +31 -3
- package/core/output/shared.js +14 -0
- package/core/output-budget.js +305 -122
- package/core/parallel-build.js +124 -47
- package/core/parser.js +3 -2
- package/core/project.js +457 -60
- package/core/provenance-facts.js +156 -12
- package/core/provenance-overload.js +30 -3
- package/core/provenance.js +104 -30
- package/core/pytest-fixtures.js +434 -0
- package/core/python-fixture-flow.js +17 -3
- package/core/python-modules.js +173 -0
- package/core/reflection.js +877 -0
- package/core/registry.js +1 -1
- package/core/reporting.js +234 -52
- package/core/request-helpers.js +561 -0
- package/core/route-graph.js +1922 -0
- package/core/route-lists.js +2414 -0
- package/core/rust-macro-expansion.js +2113 -0
- package/core/rust-modules.js +185 -0
- package/core/search.js +121 -29
- package/core/shared.js +19 -1
- package/core/test-name-bindings.js +256 -0
- package/core/type-denotation.js +1161 -0
- package/core/type-references.js +3229 -0
- package/core/verify.js +3360 -672
- package/languages/adapter.js +31 -5
- package/languages/c-family.js +4061 -212
- package/languages/c-macro-invocations.js +380 -0
- package/languages/c-preprocessor.js +254 -0
- package/languages/csharp.js +1406 -49
- package/languages/go.js +1243 -70
- package/languages/index.js +728 -6
- package/languages/java.js +473 -61
- package/languages/javascript.js +1270 -197
- package/languages/lexical-scope.js +1280 -0
- package/languages/python.js +1557 -72
- package/languages/rust-macro-rules.js +1020 -0
- package/languages/rust-value-flow.js +416 -0
- package/languages/rust.js +2237 -115
- package/languages/utils.js +298 -4
- package/mcp/server.js +22 -8
- package/package.json +1 -1
package/core/bridge.js
CHANGED
|
@@ -8,13 +8,20 @@
|
|
|
8
8
|
* server route — across language boundaries.
|
|
9
9
|
*
|
|
10
10
|
* REUSES the call cache (getCachedCalls) and AST-derived symbol metadata
|
|
11
|
-
* (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* (decoratorsWithArgs/annotationsWithArgs/attributesWithArgs). Router mount
|
|
12
|
+
* composition (fix #282, #366) re-parses only the files whose mounts the
|
|
13
|
+
* call records cannot settle (Python include/mount calls, non-literal
|
|
14
|
+
* prefixes, mounted call results, closures, routers passed to router-typed
|
|
15
|
+
* parameters, redeclared router names) through one AST session
|
|
16
|
+
* (core/route-graph.js), which also folds prefix expressions by constant
|
|
17
|
+
* propagation. Request-configuration clients are proven HTTP from the
|
|
18
|
+
* callee's definition (core/request-helpers.js). Declarative Python route
|
|
19
|
+
* tables (Django URLconfs, DRF routers, Starlette route lists, aiohttp
|
|
20
|
+
* tables) and registrations on proven apps come from core/route-lists.js,
|
|
21
|
+
* whose include/mount edges join the Python mount composition (fix #373).
|
|
22
|
+
* Extraction results are
|
|
23
|
+
* cached lazily on `index._endpointsCache` and invalidated on rebuild via the
|
|
24
|
+
* same mechanism as `_reachableSymbols`.
|
|
18
25
|
*
|
|
19
26
|
* Output shape:
|
|
20
27
|
* serverRoutes: [{ method, path, normalizedPath, handler, file, line, framework, raw }]
|
|
@@ -29,7 +36,9 @@ const fs = require('fs');
|
|
|
29
36
|
const { codeUnitCompare, isTestPath } = require('./shared');
|
|
30
37
|
const path = require('path');
|
|
31
38
|
const { getCachedCalls } = require('./callers');
|
|
32
|
-
const
|
|
39
|
+
const routeGraph = require('./route-graph');
|
|
40
|
+
const { RequestHelperProof } = require('./request-helpers');
|
|
41
|
+
const { collectRouteLists, regexCanMatchSlash, ANY_TEXT, pythonContainerKey } = require('./route-lists');
|
|
33
42
|
|
|
34
43
|
// ============================================================================
|
|
35
44
|
// HTTP METHOD CONSTANTS
|
|
@@ -42,46 +51,201 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'
|
|
|
42
51
|
// ============================================================================
|
|
43
52
|
|
|
44
53
|
/**
|
|
45
|
-
* Canonicalize a route path: strip
|
|
46
|
-
*
|
|
54
|
+
* Canonicalize a route path for matching: strip the trailing slash (and,
|
|
55
|
+
* for client URLs, the query string and fragment) and turn every parameter
|
|
56
|
+
* into a wildcard token: `*` for a parameter within one segment, `**` for
|
|
57
|
+
* one that can span segments (a catch-all, a path converter, a regex that
|
|
58
|
+
* matches '/', or an unresolved `{?expr}` prefix).
|
|
59
|
+
*
|
|
60
|
+
* Parameter spellings differ by framework (fix #383): a ':' in a Starlette
|
|
61
|
+
* route (`/users/{name}:disable`) or a client URL is literal text, and a
|
|
62
|
+
* bare `*` is a catch-all in Express or echo but one segment in Spring.
|
|
63
|
+
* `syntax` selects the spellings in force:
|
|
64
|
+
* router — Express/Koa/Fastify/Hono/NestJS, gin/echo/chi/gorilla/fiber/
|
|
65
|
+
* net/http, axum/actix: `:name` (with path-to-regexp `(re)`,
|
|
66
|
+
* `*`, `+`, `?`), `*name`, `*`, `{name}`, `{name:re}`, `{*name}`
|
|
67
|
+
* python — Flask/Django `<conv:name>` (`<path:x>` spans segments),
|
|
68
|
+
* Starlette/FastAPI/aiohttp `{name}`, `{name:conv|re}`
|
|
69
|
+
* jvm — Spring/JAX-RS/ASP.NET `{name}`, `{name:re}`, `{*name}`, `**`
|
|
70
|
+
* client — request URLs: SDK templates `{name}`; interpolated values
|
|
71
|
+
* arrive as `*`
|
|
47
72
|
*
|
|
48
|
-
* Examples:
|
|
49
73
|
* /users/:id → /users/*
|
|
50
74
|
* /users/{id} → /users/*
|
|
51
75
|
* /users/<int:user_id> → /users/*
|
|
52
|
-
* /
|
|
53
|
-
* /
|
|
76
|
+
* /files/<path:rest> → /files/**
|
|
77
|
+
* /static/*filepath → /static/**
|
|
78
|
+
* /users?q=foo → /users (client)
|
|
54
79
|
*
|
|
55
80
|
* @param {string} p - Raw path
|
|
81
|
+
* @param {string} [syntax] - router | python | jvm | client (default: all)
|
|
56
82
|
* @returns {string} Canonical path
|
|
57
83
|
*/
|
|
58
|
-
|
|
84
|
+
const MULTI_SEGMENT = '\u0000';
|
|
85
|
+
const ONE_SEGMENT = '\u0001';
|
|
86
|
+
const PARAM_SYNTAX = {
|
|
87
|
+
router: { colon: true, brace: true, starMulti: true },
|
|
88
|
+
python: { angle: true, brace: true },
|
|
89
|
+
jvm: { brace: true, doubleStarMulti: true },
|
|
90
|
+
client: { brace: true, query: true },
|
|
91
|
+
};
|
|
92
|
+
const ALL_SYNTAX = { colon: true, brace: true, angle: true };
|
|
93
|
+
|
|
94
|
+
/** Index of the brace/paren closing the one at `open` (nesting-aware). */
|
|
95
|
+
function closingIndex(s, open) {
|
|
96
|
+
const openCh = s[open];
|
|
97
|
+
const closeCh = openCh === '{' ? '}' : openCh === '(' ? ')' : '>';
|
|
98
|
+
let depth = 0;
|
|
99
|
+
for (let j = open; j < s.length; j++) {
|
|
100
|
+
const c = s[j];
|
|
101
|
+
if (c === '\\') { j++; continue; }
|
|
102
|
+
if (c === openCh) depth++;
|
|
103
|
+
else if (c === closeCh) { depth--; if (depth === 0) return j; }
|
|
104
|
+
}
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizePath(p, syntax = null) {
|
|
59
109
|
if (typeof p !== 'string' || !p) return '';
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
110
|
+
const fam = (syntax && PARAM_SYNTAX[syntax]) || ALL_SYNTAX;
|
|
111
|
+
// fix #366: an unresolved mount-prefix segment `{?expr}` may stand for
|
|
112
|
+
// several path segments; it normalizes to the multi-segment wildcard.
|
|
113
|
+
let s = p.replace(/\{\?[^}]*\}/g, MULTI_SEGMENT);
|
|
114
|
+
// Query strings and fragments belong to request URLs; in a route pattern
|
|
115
|
+
// '?' is an optional marker or regex syntax.
|
|
116
|
+
if (fam.query || !syntax) {
|
|
117
|
+
const q = s.indexOf('?');
|
|
118
|
+
if (q !== -1) s = s.slice(0, q);
|
|
119
|
+
const h = s.indexOf('#');
|
|
120
|
+
if (h !== -1) s = s.slice(0, h);
|
|
121
|
+
}
|
|
66
122
|
// Strip trailing slash (but keep "/")
|
|
67
123
|
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
124
|
+
// No parameter spelling at all: the path is its own canonical form.
|
|
125
|
+
if (!s.includes(MULTI_SEGMENT) && !/[<{:*]/.test(s)) return s;
|
|
126
|
+
let out = '';
|
|
127
|
+
for (let i = 0; i < s.length; i++) {
|
|
128
|
+
const c = s[i];
|
|
129
|
+
const segmentStart = i === 0 || s[i - 1] === '/';
|
|
130
|
+
if (fam.angle && c === '<') {
|
|
131
|
+
const close = s.indexOf('>', i);
|
|
132
|
+
if (close > i) {
|
|
133
|
+
const inner = s.slice(i + 1, close);
|
|
134
|
+
out += /^path:/.test(inner) ? MULTI_SEGMENT : ONE_SEGMENT;
|
|
135
|
+
i = close;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (fam.brace && c === '{') {
|
|
140
|
+
const close = closingIndex(s, i);
|
|
141
|
+
if (close > i) {
|
|
142
|
+
const inner = s.slice(i + 1, close).trim();
|
|
143
|
+
let token = ONE_SEGMENT;
|
|
144
|
+
if (inner === '$') token = ''; // Go 1.22 exact end
|
|
145
|
+
else if (/^\*/.test(inner) || /\.\.\.$/.test(inner)) token = MULTI_SEGMENT; // {*rest} {**slug} {rest...}
|
|
146
|
+
else {
|
|
147
|
+
const colon = inner.indexOf(':');
|
|
148
|
+
const spec = colon >= 0 ? inner.slice(colon + 1).trim() : '';
|
|
149
|
+
if (spec === 'path' || (spec && regexCanMatchSlash(spec))) token = MULTI_SEGMENT;
|
|
150
|
+
}
|
|
151
|
+
out += token;
|
|
152
|
+
i = close;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (fam.colon && c === ':' && /[A-Za-z_]/.test(s[i + 1] || '')) {
|
|
157
|
+
let j = i + 1;
|
|
158
|
+
while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;
|
|
159
|
+
let multi = false;
|
|
160
|
+
if (s[j] === '(') {
|
|
161
|
+
const close = closingIndex(s, j);
|
|
162
|
+
if (close > j) {
|
|
163
|
+
multi = regexCanMatchSlash(s.slice(j + 1, close));
|
|
164
|
+
j = close + 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (s[j] === '*' || s[j] === '+') { multi = true; j++; } else if (s[j] === '?') j++;
|
|
168
|
+
out += multi ? MULTI_SEGMENT : ONE_SEGMENT;
|
|
169
|
+
i = j - 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (c === '*') {
|
|
173
|
+
let j = i;
|
|
174
|
+
while (s[j] === '*') j++;
|
|
175
|
+
const stars = j - i;
|
|
176
|
+
if (fam.colon && segmentStart && /[A-Za-z_]/.test(s[j] || '')) {
|
|
177
|
+
// `*name`: httprouter/gin, axum, Express 5 catch-all.
|
|
178
|
+
while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;
|
|
179
|
+
out += MULTI_SEGMENT;
|
|
180
|
+
} else if (fam.starMulti || (fam.doubleStarMulti && stars >= 2) || (!syntax && stars >= 2)) {
|
|
181
|
+
out += MULTI_SEGMENT;
|
|
182
|
+
} else {
|
|
183
|
+
out += ONE_SEGMENT;
|
|
184
|
+
}
|
|
185
|
+
i = j - 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
out += c;
|
|
189
|
+
}
|
|
190
|
+
// Adjacent wildcards within one run form one token (a multi-segment one
|
|
191
|
+
// when any part spans segments).
|
|
192
|
+
let rendered = '';
|
|
193
|
+
for (let i = 0; i < out.length;) {
|
|
194
|
+
if (out[i] !== MULTI_SEGMENT && out[i] !== ONE_SEGMENT) { rendered += out[i++]; continue; }
|
|
195
|
+
let multi = false;
|
|
196
|
+
while (i < out.length && (out[i] === MULTI_SEGMENT || out[i] === ONE_SEGMENT)) {
|
|
197
|
+
if (out[i] === MULTI_SEGMENT) multi = true;
|
|
198
|
+
i++;
|
|
199
|
+
}
|
|
200
|
+
rendered += multi ? '**' : '*';
|
|
201
|
+
}
|
|
202
|
+
return rendered;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Parameter spelling family of a server route, by its source language. */
|
|
206
|
+
function routeSyntax(lang) {
|
|
207
|
+
if (lang === 'python') return 'python';
|
|
208
|
+
if (lang === 'java' || lang === 'csharp' || lang === 'kotlin') return 'jvm';
|
|
209
|
+
return 'router';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* How a framework joins a mounted fragment to the prefix it is mounted
|
|
214
|
+
* under (fix #383):
|
|
215
|
+
* path — one '/' between the parts whatever each side spells: Flask
|
|
216
|
+
* url_prefix, FastAPI include_router, Starlette Mount, aiohttp
|
|
217
|
+
* subapps, Express, Hono, NestJS, gin, chi, gorilla, fiber,
|
|
218
|
+
* net/http StripPrefix, axum nest, actix scope, Spring, JAX-RS,
|
|
219
|
+
* ASP.NET.
|
|
220
|
+
* concat — the strings are concatenated as written: Django's resolver
|
|
221
|
+
* matches an include pattern and hands the remainder to the
|
|
222
|
+
* included patterns (`path("api", include(...))` + "items/"
|
|
223
|
+
* serves /apiitems/); Echo groups prepend their prefix.
|
|
224
|
+
* koa — koa-router: concatenated; a '/' route under a prefix serves
|
|
225
|
+
* at the prefix itself.
|
|
226
|
+
* fastify — Fastify: route URLs are concatenated to the prefix, sharing
|
|
227
|
+
* one '/' when both spell it, and a '/' route serves at the
|
|
228
|
+
* prefix; plugin prefixes join with exactly one '/'.
|
|
229
|
+
*/
|
|
230
|
+
const JOIN_RULE_BY_FRAMEWORK = { django: 'concat', echo: 'concat', koa: 'koa', fastify: 'fastify' };
|
|
231
|
+
|
|
232
|
+
function joinRuleOf(framework) {
|
|
233
|
+
return JOIN_RULE_BY_FRAMEWORK[framework] || 'path';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Join a mount prefix with a route path. */
|
|
237
|
+
function joinRoutePath(prefix, sub, rule = 'path') {
|
|
238
|
+
if (rule !== 'path') {
|
|
239
|
+
const p = prefix || '';
|
|
240
|
+
let r = sub || '';
|
|
241
|
+
if ((rule === 'fastify' || rule === 'koa') && r === '/' && p) r = '';
|
|
242
|
+
else if (rule === 'fastify' && p.endsWith('/') && r.startsWith('/')) r = r.slice(1);
|
|
243
|
+
const out = p + r;
|
|
244
|
+
// The router matches from the root: a pattern that does not spell
|
|
245
|
+
// the leading '/' is rooted by the resolver (Django strips it from
|
|
246
|
+
// the request path, echo prepends it).
|
|
247
|
+
return out.startsWith('/') ? out : '/' + out;
|
|
248
|
+
}
|
|
85
249
|
const p = (prefix || '').replace(/\/+$/, '');
|
|
86
250
|
const s = (sub || '').replace(/^\/+/, '');
|
|
87
251
|
if (!p && !s) return '/';
|
|
@@ -93,7 +257,10 @@ function joinRoutePath(prefix, sub) {
|
|
|
93
257
|
/** Join two PREFIX fragments (fix #282). Unlike joinRoutePath, two empty
|
|
94
258
|
* fragments compose to '' — a router with no mount prefix anywhere must not
|
|
95
259
|
* gain a spurious '/'. */
|
|
96
|
-
function joinPrefixes(a, b) {
|
|
260
|
+
function joinPrefixes(a, b, rule = 'path') {
|
|
261
|
+
// Fastify's plugin prefixes join with exactly one '/' (buildRoutePrefix):
|
|
262
|
+
// the path rule. Only its route URLs concatenate.
|
|
263
|
+
if (rule === 'concat' || rule === 'koa') return (a || '') + (b || '');
|
|
97
264
|
if (!a) return b || '';
|
|
98
265
|
if (!b) return a;
|
|
99
266
|
return a.replace(/\/+$/, '') + '/' + b.replace(/^\/+/, '');
|
|
@@ -105,15 +272,17 @@ function joinPrefixes(a, b) {
|
|
|
105
272
|
* constructor prefix — FastAPI/Flask include semantics; Express has no
|
|
106
273
|
* constructor prefix so `ctorPrefixes` is simply empty there.
|
|
107
274
|
*
|
|
108
|
-
* @param {Map<string, Array<{mounterKey: string|null, prefix: string, overridesCtor?: boolean}>>} edges
|
|
275
|
+
* @param {Map<string, Array<{mounterKey: string|null, prefix: string, overridesCtor?: boolean, join?: string}>>} edges
|
|
109
276
|
* targetKey -> incoming mounts (mounterKey null = unresolvable mounter,
|
|
110
277
|
* treated as a root). `overridesCtor` models Flask's register_blueprint
|
|
111
278
|
* semantics: an explicit url_prefix REPLACES the blueprint's own prefix
|
|
112
|
-
* (FastAPI's include_router prefix composes with it instead).
|
|
279
|
+
* (FastAPI's include_router prefix composes with it instead). `join`
|
|
280
|
+
* is the mounting framework's join rule (fix #383, default 'path').
|
|
113
281
|
* @param {Map<string, string>} ctorPrefixes - key -> constructor prefix
|
|
282
|
+
* @param {Map<string, string>} [ctorJoins] - key -> join rule of its constructor prefix
|
|
114
283
|
* @returns {Map<string, string[]>} key -> composed full prefixes (sorted)
|
|
115
284
|
*/
|
|
116
|
-
function composeMountPrefixes(edges, ctorPrefixes) {
|
|
285
|
+
function composeMountPrefixes(edges, ctorPrefixes, ctorJoins = null) {
|
|
117
286
|
const memo = new Map();
|
|
118
287
|
const resolve = (key, depth, stack) => {
|
|
119
288
|
if (key == null) return [''];
|
|
@@ -121,11 +290,12 @@ function composeMountPrefixes(edges, ctorPrefixes) {
|
|
|
121
290
|
if (depth > 8 || stack.has(key)) return [''];
|
|
122
291
|
stack.add(key);
|
|
123
292
|
const own = ctorPrefixes.get(key) || '';
|
|
293
|
+
const ownJoin = (ctorJoins && ctorJoins.get(key)) || 'path';
|
|
124
294
|
const incoming = edges.get(key) || [];
|
|
125
295
|
const full = incoming.length === 0 ? [own]
|
|
126
296
|
: incoming.flatMap(e => resolve(e.mounterKey, depth + 1, stack)
|
|
127
|
-
.map(parentFull => joinPrefixes(joinPrefixes(parentFull, e.prefix),
|
|
128
|
-
e.overridesCtor ? '' : own)));
|
|
297
|
+
.map(parentFull => joinPrefixes(joinPrefixes(parentFull, e.prefix, e.join || 'path'),
|
|
298
|
+
e.overridesCtor ? '' : own, ownJoin)));
|
|
129
299
|
const out = [...new Set(full.length ? full : [own])].sort(codeUnitCompare);
|
|
130
300
|
stack.delete(key);
|
|
131
301
|
memo.set(key, out);
|
|
@@ -231,7 +401,7 @@ const CLIENT_PATTERNS = {
|
|
|
231
401
|
bareCalls: new Set(),
|
|
232
402
|
receivers: [
|
|
233
403
|
{ receiverPattern: /^(http|client|c)$/i,
|
|
234
|
-
methodPattern: /^(Get|Post|PostForm|Head|Do|NewRequest)$/,
|
|
404
|
+
methodPattern: /^(Get|Post|PostForm|Head|Do|NewRequest|NewRequestWithContext)$/,
|
|
235
405
|
framework: 'go-http' },
|
|
236
406
|
],
|
|
237
407
|
callableReceivers: new Set(),
|
|
@@ -266,6 +436,17 @@ const CLIENT_PATTERNS = {
|
|
|
266
436
|
},
|
|
267
437
|
};
|
|
268
438
|
|
|
439
|
+
// TSX files use the TypeScript patterns (fix #366: `.tsx` route and client
|
|
440
|
+
// calls were invisible).
|
|
441
|
+
SERVER_RECEIVER_PATTERNS.tsx = SERVER_RECEIVER_PATTERNS.typescript;
|
|
442
|
+
CLIENT_PATTERNS.tsx = CLIENT_PATTERNS.typescript;
|
|
443
|
+
|
|
444
|
+
// Languages whose routes can be registered by a call on a router value
|
|
445
|
+
// (the call-pattern loop of extractServerRoutes).
|
|
446
|
+
const CALL_REGISTERED_LANGS = new Set(Object.keys(SERVER_RECEIVER_PATTERNS)
|
|
447
|
+
.filter(lang => SERVER_RECEIVER_PATTERNS[lang].length > 0 ||
|
|
448
|
+
lang === 'javascript' || lang === 'typescript' || lang === 'tsx' || lang === 'go'));
|
|
449
|
+
|
|
269
450
|
// HTTP-method decorator/annotation/attribute patterns.
|
|
270
451
|
// name → method (or 'ALL' if multi).
|
|
271
452
|
const METHOD_DECORATORS = {
|
|
@@ -322,6 +503,104 @@ const CSHARP_PREFIX_ATTRIBUTES = new Set(['Route']);
|
|
|
322
503
|
// EXTRACT SERVER ROUTES
|
|
323
504
|
// ============================================================================
|
|
324
505
|
|
|
506
|
+
// Server frameworks by the module that provides the router (fix #383). A
|
|
507
|
+
// file's call-registered routes carry the framework its imports prove (or
|
|
508
|
+
// the project's own package, when the project IS that framework); with no
|
|
509
|
+
// single framework in evidence they keep the family label.
|
|
510
|
+
const JS_ROUTER_MODULES = [
|
|
511
|
+
[/^express(?:\/|$)/, 'express'],
|
|
512
|
+
[/^(?:@koa\/router|koa-router)(?:\/|$)/, 'koa'],
|
|
513
|
+
[/^fastify(?:\/|$)/, 'fastify'],
|
|
514
|
+
[/^hono(?:\/|$)/, 'hono'],
|
|
515
|
+
];
|
|
516
|
+
const GO_ROUTER_MODULES = [
|
|
517
|
+
[/^github\.com\/gin-gonic\/gin$/, 'gin'],
|
|
518
|
+
[/^github\.com\/labstack\/echo(?:\/v\d+)?$/, 'echo'],
|
|
519
|
+
[/^github\.com\/go-chi\/chi(?:\/v\d+)?$/, 'chi'],
|
|
520
|
+
[/^github\.com\/gorilla\/mux$/, 'gorilla'],
|
|
521
|
+
[/^github\.com\/gofiber\/fiber(?:\/v\d+)?$/, 'fiber'],
|
|
522
|
+
[/^github\.com\/julienschmidt\/httprouter$/, 'httprouter'],
|
|
523
|
+
];
|
|
524
|
+
|
|
525
|
+
function frameworkOfModule(table, spec) {
|
|
526
|
+
for (const [re, framework] of table) if (re.test(spec)) return framework;
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The project's own package, when its name is a router framework's. */
|
|
531
|
+
function projectJsFramework(index) {
|
|
532
|
+
const cache = index._endpointsCache || (index._endpointsCache = {});
|
|
533
|
+
if (cache.projectJsFramework !== undefined) return cache.projectJsFramework;
|
|
534
|
+
let result = null;
|
|
535
|
+
try {
|
|
536
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(index.root, 'package.json'), 'utf-8'));
|
|
537
|
+
const framework = typeof pkg.name === 'string' ? frameworkOfModule(JS_ROUTER_MODULES, pkg.name) : null;
|
|
538
|
+
const main = typeof pkg.main === 'string' ? pkg.main : 'index.js';
|
|
539
|
+
const entry = path.resolve(index.root, main);
|
|
540
|
+
const candidates = [entry, `${entry}.js`, path.join(entry, 'index.js')];
|
|
541
|
+
const entryFile = candidates.find(file => index.files.has(file));
|
|
542
|
+
if (framework && entryFile) result = { framework, entryFile };
|
|
543
|
+
} catch {
|
|
544
|
+
result = null;
|
|
545
|
+
}
|
|
546
|
+
cache.projectJsFramework = result;
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Whether a relative specifier names the project's own package entry
|
|
551
|
+
* (`require('..')` from test/, `require('../fastify')`). */
|
|
552
|
+
function ownPackageSpec(index, filePath, spec, own) {
|
|
553
|
+
const target = path.resolve(path.dirname(filePath), spec);
|
|
554
|
+
if (target === index.root) return true;
|
|
555
|
+
return [target, `${target}.js`, `${target}.cjs`, `${target}.mjs`, path.join(target, 'index.js')]
|
|
556
|
+
.includes(own.entryFile);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The router framework a file's routes and mounts belong to, or null.
|
|
561
|
+
* JS/TS: the framework modules it imports, or an import of the project's
|
|
562
|
+
* own entry when the project is a framework. Go: the framework packages it
|
|
563
|
+
* imports, or its own package's import path (go.mod module + directory).
|
|
564
|
+
*/
|
|
565
|
+
function routeFrameworkOfFile(index, filePath) {
|
|
566
|
+
const cache = index._endpointsCache || (index._endpointsCache = {});
|
|
567
|
+
if (!cache.frameworkByFile) cache.frameworkByFile = new Map();
|
|
568
|
+
if (cache.frameworkByFile.has(filePath)) return cache.frameworkByFile.get(filePath);
|
|
569
|
+
const entry = index.files.get(filePath);
|
|
570
|
+
const lang = entry?.language;
|
|
571
|
+
const found = new Set();
|
|
572
|
+
if (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') {
|
|
573
|
+
const specs = new Set([...(entry.imports || []), ...(entry.importBindings || []).map(b => b.module)]);
|
|
574
|
+
const own = projectJsFramework(index);
|
|
575
|
+
for (const spec of specs) {
|
|
576
|
+
if (typeof spec !== 'string') continue;
|
|
577
|
+
const framework = frameworkOfModule(JS_ROUTER_MODULES, spec);
|
|
578
|
+
if (framework) { found.add(framework); continue; }
|
|
579
|
+
if (!own) continue;
|
|
580
|
+
const rel = entry.moduleResolved?.[spec];
|
|
581
|
+
if (rel ? path.resolve(index.root, rel) === own.entryFile
|
|
582
|
+
: (spec.startsWith('.') && ownPackageSpec(index, filePath, spec, own))) found.add(own.framework);
|
|
583
|
+
}
|
|
584
|
+
} else if (lang === 'go') {
|
|
585
|
+
for (const b of entry.importBindings || []) {
|
|
586
|
+
const framework = b.module ? frameworkOfModule(GO_ROUTER_MODULES, b.module) : null;
|
|
587
|
+
if (framework) found.add(framework);
|
|
588
|
+
}
|
|
589
|
+
if (found.size === 0) {
|
|
590
|
+
const { findGoModule } = require('./imports');
|
|
591
|
+
const mod = findGoModule(path.dirname(filePath));
|
|
592
|
+
if (mod?.modulePath) {
|
|
593
|
+
const rel = path.relative(mod.root, path.dirname(filePath)).split(path.sep).join('/');
|
|
594
|
+
const own = frameworkOfModule(GO_ROUTER_MODULES, rel ? `${mod.modulePath}/${rel}` : mod.modulePath);
|
|
595
|
+
if (own) found.add(own);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const framework = found.size === 1 ? [...found][0] : null;
|
|
600
|
+
cache.frameworkByFile.set(filePath, framework);
|
|
601
|
+
return framework;
|
|
602
|
+
}
|
|
603
|
+
|
|
325
604
|
/**
|
|
326
605
|
* Build map of all server routes detected in the index.
|
|
327
606
|
* Cached lazily on `index._endpointsCache.serverRoutes`.
|
|
@@ -335,126 +614,338 @@ function extractServerRoutes(index) {
|
|
|
335
614
|
}
|
|
336
615
|
|
|
337
616
|
const routes = [];
|
|
617
|
+
const sess = endpointsSession(index);
|
|
338
618
|
const mountedPrefixes = collectProjectRouterMounts(index);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
619
|
+
// fix #373: declarative route tables (Django URLconfs, Starlette route
|
|
620
|
+
// lists, DRF routers, aiohttp tables) and imperative registrations on
|
|
621
|
+
// proven apps; their include/mount edges compose with the Python
|
|
622
|
+
// router mounts.
|
|
623
|
+
const mountFiles = pythonMountFiles(index);
|
|
624
|
+
const routeLists = collectRouteLists(index, sess, new Set(mountFiles.map(([file]) => file)));
|
|
625
|
+
if (!index._endpointsCache) index._endpointsCache = {};
|
|
626
|
+
index._endpointsCache.routeContainers = routeLists.containers;
|
|
627
|
+
const pythonMounts = collectPythonRouterMounts(index, sess, routeLists, mountFiles);
|
|
628
|
+
const graph = buildCallRouterGraph(index, sess);
|
|
629
|
+
index._endpointsCache.jsRouteGraph = graph;
|
|
630
|
+
const rustNests = collectRustNestMounts(index);
|
|
631
|
+
const actixScopes = collectActixScopes(index);
|
|
632
|
+
// Python decorator receivers' frameworks, per file on first use (only
|
|
633
|
+
// files with decorated symbols need them).
|
|
634
|
+
const pythonReceiverFrameworksMemo = new Map();
|
|
635
|
+
const pythonReceiverFrameworks = {
|
|
636
|
+
get(filePath) {
|
|
637
|
+
let receiverMap = pythonReceiverFrameworksMemo.get(filePath);
|
|
638
|
+
if (receiverMap) return receiverMap;
|
|
639
|
+
receiverMap = new Map();
|
|
640
|
+
if (index.files.get(filePath)?.language === 'python') {
|
|
641
|
+
for (const call of getCachedCalls(index, filePath) || []) {
|
|
642
|
+
if (!call.assignedTo) continue;
|
|
643
|
+
if (call.name === 'Flask' || call.name === 'Blueprint') {
|
|
644
|
+
receiverMap.set(call.assignedTo, 'flask');
|
|
645
|
+
} else if (call.name === 'FastAPI' || call.name === 'APIRouter') {
|
|
646
|
+
receiverMap.set(call.assignedTo, 'fastapi');
|
|
647
|
+
} else if (call.name === 'RouteTableDef' && isAiohttpWebRef(index.files.get(filePath), call)) {
|
|
648
|
+
// fix #373: `routes = web.RouteTableDef()` + `@routes.get(...)`
|
|
649
|
+
receiverMap.set(call.assignedTo, 'aiohttp');
|
|
650
|
+
}
|
|
651
|
+
}
|
|
350
652
|
}
|
|
653
|
+
pythonReceiverFrameworksMemo.set(filePath, receiverMap);
|
|
654
|
+
return receiverMap;
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
// fix #373: routes declared in route lists / registered on apps, under
|
|
659
|
+
// the composed prefixes of the container they flow into.
|
|
660
|
+
const appKeyMemo = new Map();
|
|
661
|
+
for (const r of routeLists.routes) {
|
|
662
|
+
const entry = index.files.get(r.file);
|
|
663
|
+
const prefixes = (r.containerKey && pythonMounts.get(r.containerKey)) || [''];
|
|
664
|
+
const appKeys = servingAppKeys(pythonMounts.edges, r.containerKey, appKeyMemo);
|
|
665
|
+
for (const prefix of prefixes) {
|
|
666
|
+
// An empty route serves at its prefix exactly (Django `path("", v)`
|
|
667
|
+
// under `docs/` is `/docs/`).
|
|
668
|
+
const fullPath = r.path === '' ? (prefix || '/') : joinRoutePath(prefix, r.path, joinRuleOf(r.framework));
|
|
669
|
+
routes.push(withAppKeys({
|
|
670
|
+
method: r.method,
|
|
671
|
+
path: fullPath,
|
|
672
|
+
normalizedPath: normalizePath(fullPath, 'python'),
|
|
673
|
+
handler: r.handler,
|
|
674
|
+
file: entry?.relativePath || r.file,
|
|
675
|
+
absoluteFile: r.file,
|
|
676
|
+
line: r.line,
|
|
677
|
+
framework: r.framework,
|
|
678
|
+
...(prefix && { mountPrefix: prefix }),
|
|
679
|
+
...(r.derived && { derived: r.derived }),
|
|
680
|
+
raw: `${r.method} ${fullPath}`,
|
|
681
|
+
}, appKeys));
|
|
351
682
|
}
|
|
352
|
-
pythonReceiverFrameworks.set(filePath, receiverMap);
|
|
353
683
|
}
|
|
354
684
|
|
|
355
685
|
// 1) Decorator/annotation/attribute-based routes (NestJS, Flask/FastAPI, Spring, JAX-RS, Actix).
|
|
356
686
|
// Iterate symbols once and look at their decoratorsWithArgs/annotationsWithArgs/attributesWithArgs.
|
|
357
687
|
// Class-level prefixes are captured first then applied to methods inside the same class.
|
|
358
|
-
|
|
688
|
+
// A class may declare several prefixes (`@RequestMapping({"/a", "/b"})`):
|
|
689
|
+
// every method then serves under each of them.
|
|
690
|
+
const classPrefixByFileClass = new Map(); // `${file}:${className}` -> prefix strings
|
|
691
|
+
// Only decorated/annotated/attributed symbols can declare routes or
|
|
692
|
+
// prefixes; the rest are skipped before any per-symbol work.
|
|
693
|
+
const declares = sym => sym.decorators || sym.decoratorsWithArgs || sym.annotationsWithArgs ||
|
|
694
|
+
sym.attributesWithArgs;
|
|
359
695
|
for (const [, syms] of index.symbols) {
|
|
360
696
|
for (const sym of syms) {
|
|
697
|
+
if (!declares(sym)) continue;
|
|
361
698
|
const fileEntry = index.files.get(sym.file);
|
|
362
699
|
if (!fileEntry) continue;
|
|
363
700
|
|
|
364
701
|
// CLASS-LEVEL prefix capture
|
|
365
702
|
if (sym.type === 'class' || sym.type === 'interface') {
|
|
366
|
-
const prefixes = collectClassPrefixes(sym, fileEntry.language);
|
|
703
|
+
const prefixes = collectClassPrefixes(sess, sym, fileEntry.language);
|
|
367
704
|
if (prefixes.length > 0) {
|
|
368
|
-
classPrefixByFileClass.set(`${sym.file}:${sym.name}`, prefixes
|
|
705
|
+
classPrefixByFileClass.set(`${sym.file}:${sym.name}`, prefixes);
|
|
369
706
|
}
|
|
370
707
|
}
|
|
371
708
|
}
|
|
372
709
|
}
|
|
710
|
+
// NestJS `app.setGlobalPrefix('api')` applies to every controller route.
|
|
711
|
+
const nestGlobalPrefixes = [...new Set([...graph.globalPrefixes].filter(Boolean)
|
|
712
|
+
.map(p => (p.startsWith('/') ? p : '/' + p)))].sort(codeUnitCompare);
|
|
373
713
|
|
|
374
714
|
for (const [, syms] of index.symbols) {
|
|
375
715
|
for (const sym of syms) {
|
|
716
|
+
if (!declares(sym)) continue;
|
|
376
717
|
const fileEntry = index.files.get(sym.file);
|
|
377
718
|
if (!fileEntry) continue;
|
|
378
719
|
const lang = fileEntry.language;
|
|
379
720
|
// Only methods/functions are HTTP handlers; classes already produced prefixes above.
|
|
380
721
|
if (sym.type !== 'function' && sym.type !== 'method' && !sym.isMethod) continue;
|
|
381
722
|
|
|
382
|
-
// Resolve class
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
723
|
+
// Resolve class prefixes if this is a method on a controller class
|
|
724
|
+
const classPrefixes = (sym.className &&
|
|
725
|
+
classPrefixByFileClass.get(`${sym.file}:${sym.className}`)) || [''];
|
|
726
|
+
|
|
727
|
+
for (const classPrefix of classPrefixes) {
|
|
728
|
+
const declRoutes = collectMethodRoutes(sess, sym, lang, classPrefix, fileEntry,
|
|
729
|
+
pythonReceiverFrameworks.get(sym.file));
|
|
730
|
+
for (const r of declRoutes) {
|
|
731
|
+
// fix #282: FastAPI/Flask routers serve under their composed
|
|
732
|
+
// mount prefixes (APIRouter(prefix=) + include_router(prefix=)).
|
|
733
|
+
let prefixes = (lang === 'python' && r.receiver &&
|
|
734
|
+
pythonMounts.get(`${sym.file}:${r.receiver}`)) || [''];
|
|
735
|
+
const appKeys = lang === 'python' && r.receiver
|
|
736
|
+
? servingAppKeys(pythonMounts.edges,
|
|
737
|
+
pythonDecoratorReceiverKey(index, sess, sym, r.receiver) || `${sym.file}:${r.receiver}`,
|
|
738
|
+
appKeyMemo)
|
|
739
|
+
: null;
|
|
740
|
+
if (r.framework === 'nestjs' && nestGlobalPrefixes.length > 0) {
|
|
741
|
+
prefixes = nestGlobalPrefixes;
|
|
742
|
+
}
|
|
743
|
+
if (r.framework === 'actix' && actixScopes && actixScopes.has(sym.name)) {
|
|
744
|
+
prefixes = actixScopes.get(sym.name);
|
|
745
|
+
}
|
|
746
|
+
for (const prefix of prefixes) {
|
|
747
|
+
const fullPath = prefix ? joinRoutePath(prefix, r.path) : r.path;
|
|
748
|
+
routes.push(withAppKeys({
|
|
749
|
+
method: r.method,
|
|
750
|
+
path: fullPath,
|
|
751
|
+
normalizedPath: normalizePath(fullPath, routeSyntax(lang)),
|
|
752
|
+
handler: sym.name,
|
|
753
|
+
file: sym.relativePath || sym.file,
|
|
754
|
+
absoluteFile: sym.file,
|
|
755
|
+
line: sym.startLine,
|
|
756
|
+
framework: r.framework,
|
|
757
|
+
classPrefix: classPrefix || undefined,
|
|
758
|
+
...(prefix && { mountPrefix: prefix }),
|
|
759
|
+
...(r.caseInsensitive && { caseInsensitive: true }),
|
|
760
|
+
raw: r.raw || `${r.method} ${fullPath}`,
|
|
761
|
+
}, appKeys));
|
|
762
|
+
}
|
|
410
763
|
}
|
|
411
764
|
}
|
|
412
765
|
}
|
|
413
766
|
}
|
|
414
767
|
|
|
415
|
-
// 2) Call-pattern routes (Express/Fastify/Koa/Gin/Echo/Chi/Fiber, axum, http).
|
|
768
|
+
// 2) Call-pattern routes (Express/Fastify/Koa/Hono/Gin/Echo/Chi/Fiber, axum, http).
|
|
416
769
|
// Iterate calls once per file via the call cache.
|
|
417
770
|
for (const [filePath, fileEntry] of index.files) {
|
|
418
771
|
const lang = fileEntry.language;
|
|
772
|
+
// Languages without call-registered route forms (Python, Java, C,
|
|
773
|
+
// C++: decorators/annotations and route tables are handled above)
|
|
774
|
+
// produce nothing in this loop; skip their records.
|
|
775
|
+
if (!CALL_REGISTERED_LANGS.has(lang)) continue;
|
|
419
776
|
const calls = getCachedCalls(index, filePath);
|
|
420
777
|
if (!calls || calls.length === 0) continue;
|
|
421
778
|
const routerReceivers = collectRouterReceivers(calls, lang);
|
|
779
|
+
// The framework the file's imports prove (fix #383): its label, its
|
|
780
|
+
// join rule, and its parameter syntax. Read on the file's first
|
|
781
|
+
// route or mount.
|
|
782
|
+
let fileFrameworkMemo;
|
|
783
|
+
const fileFramework = () => (fileFrameworkMemo === undefined
|
|
784
|
+
? (fileFrameworkMemo = routeFrameworkOfFile(index, filePath)) : fileFrameworkMemo);
|
|
785
|
+
const fileRule = () => joinRuleOf(fileFramework());
|
|
786
|
+
const syntax = routeSyntax(lang);
|
|
422
787
|
|
|
423
788
|
const groupPrefixes = new Map();
|
|
424
789
|
for (const call of calls) {
|
|
425
|
-
if (!/^(Group|group|
|
|
790
|
+
if (!/^(Group|group|MapGroup)$/.test(call.name) ||
|
|
426
791
|
!call.assignedTo || !call.firstStringArg ||
|
|
427
792
|
!routerReceivers.has(call.assignedTo)) continue;
|
|
428
793
|
const parent = groupPrefixes.get(call.receiver) || '';
|
|
429
|
-
groupPrefixes.set(call.assignedTo, joinRoutePath(parent, call.firstStringArg));
|
|
794
|
+
groupPrefixes.set(call.assignedTo, joinRoutePath(parent, call.firstStringArg, fileRule()));
|
|
430
795
|
}
|
|
431
796
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
797
|
+
// fix #366: chained registrations (`app.get(a, h).post(b, h)`)
|
|
798
|
+
// register on the router the chain started from. Records carry the
|
|
799
|
+
// inner call's span; inner calls end first.
|
|
800
|
+
let byLine = null; // line -> records (handler lookup), built on first route
|
|
801
|
+
const routeSpans = new Map(); // chained-call span -> router prefixes
|
|
802
|
+
const walked = graph.walked.has(filePath);
|
|
803
|
+
const callRegistered = lang === 'javascript' || lang === 'typescript' || lang === 'tsx' || lang === 'go';
|
|
804
|
+
const plugins = callRegistered && lang !== 'go' ? inlinePluginMounts(calls, fileEntry) : null;
|
|
805
|
+
const ordered = callRegistered && calls.some(c => c.receiverCallStart != null)
|
|
806
|
+
? [...calls].sort((a, b) => (a.callEnd ?? Infinity) - (b.callEnd ?? Infinity)) : calls;
|
|
807
|
+
// Mount prefixes of the router a route call registers on: the
|
|
808
|
+
// scope-aware graph when it composed a prefix, else inline plugin
|
|
809
|
+
// records, else the chain root's, else the name-keyed mounts (a
|
|
810
|
+
// mount the graph could not model is kept rather than dropped).
|
|
811
|
+
const routerPrefixesOf = (call) => {
|
|
812
|
+
const site = walked && call.callStart != null
|
|
813
|
+
? graph.sites.get(`${filePath}:${call.callStart}:${call.callEnd}`) : null;
|
|
814
|
+
const graphPrefixes = site ? graph.prefixes.get(site.key) : null;
|
|
815
|
+
if (graphPrefixes && graphPrefixes.some(Boolean)) return graphPrefixes;
|
|
816
|
+
if (plugins) {
|
|
817
|
+
const list = registerPrefixesFor(plugins, call.receiver, call.callStart, call.line, 0, fileRule());
|
|
818
|
+
if (list.some(Boolean)) return [...new Set(list.map(p => (p && !p.startsWith('/') ? '/' + p : p)))];
|
|
819
|
+
}
|
|
820
|
+
const chained = chainedPrefixes(call, routeSpans);
|
|
821
|
+
if (chained) return chained;
|
|
822
|
+
if (rustNests && lang === 'rust' && call.enclosingFunction) {
|
|
823
|
+
const nested = rustNests.get(`${filePath}:${call.enclosingFunction.startLine}`);
|
|
824
|
+
if (nested && nested.some(Boolean)) return nested;
|
|
825
|
+
}
|
|
826
|
+
return mountedPrefixes.get(`${filePath}:${call.receiver || ''}`) ||
|
|
443
827
|
(groupPrefixes.has(call.receiver) ? [groupPrefixes.get(call.receiver)] : ['']);
|
|
828
|
+
};
|
|
829
|
+
for (const call of ordered) {
|
|
830
|
+
// fix #366: `server.route({ method, url|path, handler })` - the
|
|
831
|
+
// full-declaration form (Fastify, hapi) on a router receiver.
|
|
832
|
+
if (call.requestConfig && call.name === 'route' && call.isMethod &&
|
|
833
|
+
(call.receiver ? routerReceivers.has(call.receiver)
|
|
834
|
+
: JS_SERVER_FACTORY_CALL.test(call.receiverCall || '')) &&
|
|
835
|
+
isPathShaped(call.requestConfig.url)) {
|
|
836
|
+
const cfg = call.requestConfig;
|
|
837
|
+
const receiverKey = `${filePath}:${call.receiver}`;
|
|
838
|
+
for (const prefix of mountedPrefixes.get(receiverKey) || ['']) {
|
|
839
|
+
const fullPath = prefix ? joinRoutePath(prefix, cfg.url, fileRule()) : cfg.url;
|
|
840
|
+
routes.push({
|
|
841
|
+
method: cfg.method || 'ALL',
|
|
842
|
+
path: fullPath,
|
|
843
|
+
normalizedPath: normalizePath(fullPath, syntax),
|
|
844
|
+
handler: cfg.handler || '<anonymous>',
|
|
845
|
+
file: fileEntry.relativePath || filePath,
|
|
846
|
+
absoluteFile: filePath,
|
|
847
|
+
line: call.line,
|
|
848
|
+
framework: fileFramework() || 'route-config',
|
|
849
|
+
...(prefix && { mountPrefix: prefix }),
|
|
850
|
+
raw: `${cfg.method || 'ALL'} ${fullPath}`,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
// fix #366: the router graph resolves the site's receiver to a
|
|
856
|
+
// router VALUE (scope-aware), proves it is a router, and folds a
|
|
857
|
+
// non-literal route path.
|
|
858
|
+
if (!call.isMethod && !call.receiver) continue;
|
|
859
|
+
const site = walked && call.callStart != null
|
|
860
|
+
? graph.sites.get(`${filePath}:${call.callStart}:${call.callEnd}`) : null;
|
|
861
|
+
// fix #383: `Handle(method, path, h)` / `Add` / `Match` take the
|
|
862
|
+
// method first; the parser records the path it names.
|
|
863
|
+
const routeCall = call.methodFirstRoute
|
|
864
|
+
? { ...call, firstStringArg: call.methodFirstRoute.path, firstStringArgInterp: !!call.methodFirstRoute.interp }
|
|
865
|
+
: (!call.firstStringArg && site?.path != null ? { ...call, firstStringArg: site.path } : call);
|
|
866
|
+
let r = matchCallPatternRoute(routeCall, lang, routerReceivers);
|
|
867
|
+
// A method-first registration (`e.Add(http.MethodGet, "/x", h)`)
|
|
868
|
+
// on a router receiver: the method and path settle the route.
|
|
869
|
+
if (!r && call.methodFirstRoute && call.receiver && (routerReceivers.has(call.receiver) ||
|
|
870
|
+
(SERVER_RECEIVER_PATTERNS[lang] || []).some(p => p.receiverPattern.test(call.receiver)) ||
|
|
871
|
+
(site && graph.evidence.has(site.key)) || routerTypedReceiver(call, lang))) {
|
|
872
|
+
r = { method: 'ALL', path: routeCall.firstStringArg, framework: 'go-http' };
|
|
873
|
+
}
|
|
874
|
+
if (!r && routeCall.firstStringArg && PROVEN_ROUTE_NAME.test(call.name) &&
|
|
875
|
+
((site && graph.evidence.has(site.key)) || routerTypedReceiver(call, lang) ||
|
|
876
|
+
chainedOnRoute(call, routeSpans) || (plugins && pluginFor(plugins, call.receiver, call.callStart, call.line)))) {
|
|
877
|
+
r = matchProvenRouterRoute(routeCall, lang);
|
|
878
|
+
}
|
|
879
|
+
if (!r) {
|
|
880
|
+
// `new Hono().use(mw).get(...)`: middleware registration on
|
|
881
|
+
// a router returns the router for further chaining.
|
|
882
|
+
if (call.name === 'use' && call.callStart != null && (routerTypedReceiver(call, lang) ||
|
|
883
|
+
(call.receiver && routerReceivers.has(call.receiver)) || chainedOnRoute(call, routeSpans))) {
|
|
884
|
+
routeSpans.set(`${call.callStart}:${call.callEnd}`, chainedPrefixes(call, routeSpans) ||
|
|
885
|
+
routerPrefixesOf(call));
|
|
886
|
+
}
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// The handler: the parser's reading of the registration's
|
|
891
|
+
// handler argument (fix #383), else a callback reference on the
|
|
892
|
+
// same line in the same scope (a reference inside an inline
|
|
893
|
+
// handler's body is not the handler).
|
|
894
|
+
let handlerName = null;
|
|
895
|
+
if (Array.isArray(call.handlerArgs) && call.handlerArgs.length > 0) {
|
|
896
|
+
// Echo takes the handler first and middleware after it;
|
|
897
|
+
// the other routers take middleware first, handler last.
|
|
898
|
+
const pick = fileFramework() === 'echo' ? call.handlerArgs[0]
|
|
899
|
+
: call.handlerArgs[call.handlerArgs.length - 1];
|
|
900
|
+
handlerName = pick || '<anonymous>';
|
|
901
|
+
}
|
|
902
|
+
if (!handlerName && call.methodFirstRoute) handlerName = '<anonymous>';
|
|
903
|
+
if (!handlerName) {
|
|
904
|
+
if (!byLine) {
|
|
905
|
+
byLine = new Map();
|
|
906
|
+
for (const c of calls) {
|
|
907
|
+
const list = byLine.get(c.line);
|
|
908
|
+
if (list) list.push(c); else byLine.set(c.line, [c]);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
handlerName = findHandlerCallback(byLine.get(call.line) || [], call.line, call) || '<anonymous>';
|
|
912
|
+
}
|
|
913
|
+
// axum `.route(path, get(h).post(h2))`: one route per method the
|
|
914
|
+
// method router serves (fix #383).
|
|
915
|
+
const variants = Array.isArray(call.methodRouter) && call.methodRouter.length > 0
|
|
916
|
+
? call.methodRouter.map(m => ({ method: m.method, handler: m.handler || '<anonymous>' }))
|
|
917
|
+
: call.methodFirstRoute
|
|
918
|
+
? call.methodFirstRoute.methods.map(method => ({ method, handler: handlerName }))
|
|
919
|
+
: [{ method: r.method, handler: handlerName }];
|
|
920
|
+
|
|
921
|
+
const prefixes = routerPrefixesOf(call);
|
|
922
|
+
if (call.callStart != null && call.isMethod) routeSpans.set(`${call.callStart}:${call.callEnd}`, prefixes);
|
|
444
923
|
for (const prefix of prefixes) {
|
|
445
|
-
const fullPath = prefix ? joinRoutePath(prefix, r.path) : r.path;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
924
|
+
const fullPath = prefix ? joinRoutePath(prefix, r.path, fileRule()) : r.path;
|
|
925
|
+
for (const variant of variants) {
|
|
926
|
+
const route = {
|
|
927
|
+
method: variant.method,
|
|
928
|
+
path: fullPath,
|
|
929
|
+
normalizedPath: normalizePath(fullPath, syntax),
|
|
930
|
+
handler: variant.handler,
|
|
931
|
+
file: fileEntry.relativePath || filePath,
|
|
932
|
+
absoluteFile: filePath,
|
|
933
|
+
line: call.line,
|
|
934
|
+
framework: fileFramework() || r.framework,
|
|
935
|
+
...(prefix && { mountPrefix: prefix }),
|
|
936
|
+
raw: `${variant.method} ${fullPath}`,
|
|
937
|
+
};
|
|
938
|
+
// The router value it registers on (fix #397), for
|
|
939
|
+
// in-process clients built from an app: the graph's key
|
|
940
|
+
// when the file was walked, else computed on demand.
|
|
941
|
+
if (JS_LANGS.has(lang) && call.callStart != null) {
|
|
942
|
+
Object.defineProperty(route, 'jsRouterSite', {
|
|
943
|
+
value: { key: site?.key || null, start: call.callStart, end: call.callEnd },
|
|
944
|
+
enumerable: false,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
routes.push(route);
|
|
948
|
+
}
|
|
458
949
|
}
|
|
459
950
|
}
|
|
460
951
|
}
|
|
@@ -478,8 +969,408 @@ function extractServerRoutes(index) {
|
|
|
478
969
|
return routes;
|
|
479
970
|
}
|
|
480
971
|
|
|
972
|
+
/** A call record whose callee is aiohttp.web's (`web.X()` on the imported
|
|
973
|
+
* module, or a bare name imported from it). */
|
|
974
|
+
function isAiohttpWebRef(entry, call) {
|
|
975
|
+
const bindings = entry?.importBindings || [];
|
|
976
|
+
if (call.isMethod) {
|
|
977
|
+
return !!call.receiver && bindings.some(b => (b.alias || b.name) === call.receiver &&
|
|
978
|
+
((b.module === 'aiohttp' && b.name === 'web') || (b.kind === 'import' && b.module === 'aiohttp.web')));
|
|
979
|
+
}
|
|
980
|
+
return bindings.some(b => (b.alias || b.name) === call.name && b.module === 'aiohttp.web');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** One AST session per endpoints extraction (trees parsed once). */
|
|
984
|
+
function endpointsSession(index) {
|
|
985
|
+
if (!index._endpointsCache) index._endpointsCache = {};
|
|
986
|
+
if (!index._endpointsCache.astSession) {
|
|
987
|
+
index._endpointsCache.astSession = new routeGraph.AstSession(index);
|
|
988
|
+
}
|
|
989
|
+
return index._endpointsCache.astSession;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Mount/group calls that make a JS/Go file worth walking for the router graph.
|
|
993
|
+
const JS_MOUNT_CALLS = new Set(['use', 'route', 'register', 'basePath', 'setGlobalPrefix']);
|
|
994
|
+
const GO_MOUNT_CALLS = new Set(['Group', 'Route', 'Mount', 'Subrouter', 'PathPrefix', 'StripPrefix']);
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Router composition graph for call-registered frameworks (fix #366).
|
|
998
|
+
* Seeds are the JS/TS/Go files whose call cache shows a mount/group call;
|
|
999
|
+
* files reached through mount targets, router-returning functions and
|
|
1000
|
+
* router parameters are walked on demand.
|
|
1001
|
+
*/
|
|
1002
|
+
function buildCallRouterGraph(index, sess) {
|
|
1003
|
+
const seeds = [];
|
|
1004
|
+
let routerParamNames;
|
|
1005
|
+
for (const [filePath, entry] of index.files) {
|
|
1006
|
+
const lang = entry.language;
|
|
1007
|
+
const isJs = lang === 'javascript' || lang === 'typescript' || lang === 'tsx';
|
|
1008
|
+
if (!isJs && lang !== 'go') continue;
|
|
1009
|
+
if (lang === 'go' && routerParamNames === undefined) routerParamNames = goRouterParamFunctionNames(index);
|
|
1010
|
+
const calls = getCachedCalls(index, filePath) || [];
|
|
1011
|
+
if (needsRouterGraph(entry, calls, lang, isJs, routerParamNames)) seeds.push(filePath);
|
|
1012
|
+
}
|
|
1013
|
+
const graph = routeGraph.buildRouteGraph(index, sess, seeds, file => getCachedCalls(index, file),
|
|
1014
|
+
routerParamNames || new Set(), file => joinRuleOf(routeFrameworkOfFile(index, file)));
|
|
1015
|
+
graph.prefixes = rootPrefixes(composeMountPrefixes(graph.edges, graph.ctor, graph.ctorJoin));
|
|
1016
|
+
return graph;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Whether a file's mounts need the AST router graph. Literal-prefix mounts
|
|
1021
|
+
* of unambiguous router names compose from the call records (name-keyed
|
|
1022
|
+
* mounts, Go literal groups, inline plugin registers); the graph is for
|
|
1023
|
+
* non-literal prefixes, mounted call results (`require('./r')`,
|
|
1024
|
+
* `adminRouter()`), closures (`Route("/x", func(r) ...)`), routers passed to
|
|
1025
|
+
* router-typed parameters, and names declared more than once.
|
|
1026
|
+
*/
|
|
1027
|
+
function needsRouterGraph(entry, calls, lang, isJs, routerParamNames) {
|
|
1028
|
+
let ambiguous = null;
|
|
1029
|
+
let requireLines = null;
|
|
1030
|
+
for (const c of calls) {
|
|
1031
|
+
if (isJs) {
|
|
1032
|
+
if (c.isConstructor && c.prefixOption) return true; // new Router({ prefix })
|
|
1033
|
+
if (!c.isMethod || !JS_MOUNT_CALLS.has(c.name)) continue;
|
|
1034
|
+
if (c.name === 'register') {
|
|
1035
|
+
const m = c.registerMount;
|
|
1036
|
+
// A prefix-less register composes nothing new.
|
|
1037
|
+
if (m && (m.prefixDynamic || (m.prefix != null && (!!m.pluginRef ||
|
|
1038
|
+
(!!m.pluginName && !sameFilePlugin(entry, m.pluginName, c)))))) return true;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (c.name === 'basePath' || c.name === 'setGlobalPrefix') return true;
|
|
1042
|
+
const args = c.mountArgs;
|
|
1043
|
+
if (!args || args.length < 2) continue;
|
|
1044
|
+
const targets = args.slice(1);
|
|
1045
|
+
if (!targets.some(a => a !== 'fn' && a !== '""')) continue; // inline handlers only
|
|
1046
|
+
if (!c.firstStringArg && args[0] !== 'fn') return true; // non-literal prefix
|
|
1047
|
+
if (!requireLines) {
|
|
1048
|
+
requireLines = new Set(calls.filter(r => r.name === 'require').map(r => r.line));
|
|
1049
|
+
}
|
|
1050
|
+
if (c.name === 'route' && targets.includes('()')) return true;
|
|
1051
|
+
if (requireLines.has(c.line)) return true;
|
|
1052
|
+
if (!ambiguous) ambiguous = ambiguousRouterNames(calls, entry.language);
|
|
1053
|
+
if (targets.some(a => ambiguous.has(a))) return true;
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
// Go
|
|
1057
|
+
if (c.name === 'Group' && c.receiver) {
|
|
1058
|
+
if (!c.firstStringArg || !c.assignedTo) return true;
|
|
1059
|
+
if (!ambiguous) ambiguous = ambiguousRouterNames(calls, 'go');
|
|
1060
|
+
if (ambiguous.has(c.assignedTo)) return true;
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (c.receiver && GO_MOUNT_CALLS.has(c.name)) return true;
|
|
1064
|
+
if (routerParamNames && routerParamNames.has(c.name) && (c.argCount == null || c.argCount > 0)) return true;
|
|
1065
|
+
}
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
/** Go function/method names with a router-typed parameter (index
|
|
1071
|
+
* structured parameters; no parse). Null when the project has no Go. */
|
|
1072
|
+
function goRouterParamFunctionNames(index) {
|
|
1073
|
+
// Candidates: functions with a router-typed parameter. Only those whose
|
|
1074
|
+
// body registers on that parameter (route, group, mount), or hands it
|
|
1075
|
+
// to another such function, receive routers worth composing - a helper
|
|
1076
|
+
// taking `*Engine` to build a test context does not.
|
|
1077
|
+
const candidates = [];
|
|
1078
|
+
for (const [name, defs] of index.symbols) {
|
|
1079
|
+
for (const d of defs) {
|
|
1080
|
+
if (!d.paramsStructured || !d.file.endsWith('.go')) continue;
|
|
1081
|
+
const params = d.paramsStructured.filter(p => routeGraph.isGoRouterTypeText(p.type)).map(p => p.name);
|
|
1082
|
+
if (params.length > 0) candidates.push({ name, def: d, params, body: null });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (candidates.length === 0) return null;
|
|
1086
|
+
const names = new Set();
|
|
1087
|
+
let changed = true;
|
|
1088
|
+
while (changed) {
|
|
1089
|
+
changed = false;
|
|
1090
|
+
for (const cand of candidates) {
|
|
1091
|
+
if (names.has(cand.name)) continue;
|
|
1092
|
+
if (!cand.body) {
|
|
1093
|
+
const calls = getCachedCalls(index, cand.def.file) || [];
|
|
1094
|
+
cand.body = calls.filter(c => c.line >= cand.def.startLine && c.line <= cand.def.endLine);
|
|
1095
|
+
}
|
|
1096
|
+
if (cand.body.some(c => (c.receiver && cand.params.includes(c.receiver) &&
|
|
1097
|
+
GO_REGISTRATION_CALL.test(c.name)) || names.has(c.name))) {
|
|
1098
|
+
names.add(cand.name);
|
|
1099
|
+
changed = true;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
return names.size > 0 ? names : null;
|
|
1104
|
+
}
|
|
1105
|
+
const GO_REGISTRATION_CALL = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any|Handle|HandleFunc|Get|Post|Put|Delete|Patch|Head|Options|Connect|Trace|All|Method|MethodFunc|Group|Route|Mount|With|Use|PathPrefix|Subrouter|Static|StaticFS|StaticFile)$/;
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* axum `.nest("/api", api::routes())` (fix #366): the routes a function
|
|
1109
|
+
* builds serve under the prefixes of every nest that mounts its result.
|
|
1110
|
+
* Composed over functions from the call records: a nest call's argument
|
|
1111
|
+
* calls name the mounted functions, its enclosing function is the mounter.
|
|
1112
|
+
* Returns Map `${file}:${fnStartLine}` -> composed prefixes, or null.
|
|
1113
|
+
*/
|
|
1114
|
+
function collectRustNestMounts(index) {
|
|
1115
|
+
const edges = new Map();
|
|
1116
|
+
let fnByName = null;
|
|
1117
|
+
for (const [filePath, entry] of index.files) {
|
|
1118
|
+
if (entry.language !== 'rust') continue;
|
|
1119
|
+
const calls = getCachedCalls(index, filePath) || [];
|
|
1120
|
+
for (const nest of calls) {
|
|
1121
|
+
if (nest.name !== 'nest' || !nest.isMethod || !nest.firstStringArg || nest.callStart == null ||
|
|
1122
|
+
!nest.enclosingFunction) continue;
|
|
1123
|
+
const argsFrom = nest.receiverCallEnd != null ? nest.receiverCallEnd : nest.callStart;
|
|
1124
|
+
const mounter = `${filePath}:${nest.enclosingFunction.startLine}`;
|
|
1125
|
+
for (const arg of calls) {
|
|
1126
|
+
if (arg === nest || arg.callStart == null || arg.callStart < argsFrom ||
|
|
1127
|
+
arg.callEnd > nest.callEnd || arg.name === 'nest') continue;
|
|
1128
|
+
if (!fnByName) fnByName = rustFunctionsByName(index);
|
|
1129
|
+
const target = resolveRustFunction(fnByName, arg, filePath);
|
|
1130
|
+
if (!target) continue;
|
|
1131
|
+
const key = `${target.file}:${target.startLine}`;
|
|
1132
|
+
const list = edges.get(key) || [];
|
|
1133
|
+
list.push({ mounterKey: mounter, prefix: nest.firstStringArg });
|
|
1134
|
+
edges.set(key, list);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
return edges.size > 0 ? rootPrefixes(composeMountPrefixes(edges, new Map())) : null;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* actix-web `web::scope("/api").service(handler)` (fix #366): an attribute
|
|
1143
|
+
* route handler registered on a scope serves under the scope's prefix, and
|
|
1144
|
+
* a scope registered inside another scope's `.service(...)` composes. From
|
|
1145
|
+
* the call records: `serviceArg` names the handler, receiver-call spans walk
|
|
1146
|
+
* the builder chain to its `scope(...)` root. Returns Map handler name ->
|
|
1147
|
+
* prefixes (names registered with conflicting chains in different files
|
|
1148
|
+
* keep every prefix), or null.
|
|
1149
|
+
*/
|
|
1150
|
+
function collectActixScopes(index) {
|
|
1151
|
+
let out = null;
|
|
1152
|
+
for (const [filePath, entry] of index.files) {
|
|
1153
|
+
if (entry.language !== 'rust') continue;
|
|
1154
|
+
const calls = getCachedCalls(index, filePath) || [];
|
|
1155
|
+
const services = calls.filter(c => c.name === 'service' && c.isMethod && c.callStart != null);
|
|
1156
|
+
if (!services.some(c => c.serviceArg)) continue;
|
|
1157
|
+
const bySpan = new Map();
|
|
1158
|
+
for (const c of calls) if (c.callStart != null) bySpan.set(`${c.callStart}:${c.callEnd}`, c);
|
|
1159
|
+
const chainScope = (rec) => {
|
|
1160
|
+
let r = rec;
|
|
1161
|
+
let guard = 0;
|
|
1162
|
+
while (r && guard++ < 64) {
|
|
1163
|
+
if (r.name === 'scope' && r.firstStringArg != null) return { scope: r.firstStringArg, root: r };
|
|
1164
|
+
if (r.receiverCallStart == null) return { scope: '', root: r };
|
|
1165
|
+
r = bySpan.get(`${r.receiverCallStart}:${r.receiverCallEnd}`);
|
|
1166
|
+
}
|
|
1167
|
+
return { scope: '', root: rec };
|
|
1168
|
+
};
|
|
1169
|
+
const enclosingService = (rec) => {
|
|
1170
|
+
let best = null;
|
|
1171
|
+
for (const s2 of services) {
|
|
1172
|
+
const argsFrom = s2.receiverCallEnd != null ? s2.receiverCallEnd : s2.callStart;
|
|
1173
|
+
if (s2 === rec || rec.callStart < argsFrom || rec.callEnd > s2.callEnd) continue;
|
|
1174
|
+
if (!best || s2.callStart > best.callStart || (s2.callStart === best.callStart && s2.callEnd < best.callEnd)) best = s2;
|
|
1175
|
+
}
|
|
1176
|
+
return best;
|
|
1177
|
+
};
|
|
1178
|
+
const prefixOf = (rec, depth) => {
|
|
1179
|
+
const { scope, root } = chainScope(rec);
|
|
1180
|
+
const outer = depth < 16 ? enclosingService(root) : null;
|
|
1181
|
+
return joinPrefixes(outer ? prefixOf(outer, depth + 1) : '', scope);
|
|
1182
|
+
};
|
|
1183
|
+
for (const svc of services) {
|
|
1184
|
+
if (!svc.serviceArg) continue;
|
|
1185
|
+
const prefix = prefixOf(svc, 0);
|
|
1186
|
+
if (!prefix) continue;
|
|
1187
|
+
if (!out) out = new Map();
|
|
1188
|
+
const list = out.get(svc.serviceArg) || [];
|
|
1189
|
+
if (!list.includes(prefix)) list.push(prefix);
|
|
1190
|
+
out.set(svc.serviceArg, list);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (out) for (const [k, list] of out) out.set(k, [...new Set(list.map(p => (p.startsWith('/') ? p : '/' + p)))].sort(codeUnitCompare));
|
|
1194
|
+
return out;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function rustFunctionsByName(index) {
|
|
1198
|
+
const map = new Map();
|
|
1199
|
+
for (const [name, defs] of index.symbols) {
|
|
1200
|
+
const fns = defs.filter(d => d.type === 'function' && !d.className && d.file.endsWith('.rs'));
|
|
1201
|
+
if (fns.length > 0) map.set(name, fns);
|
|
1202
|
+
}
|
|
1203
|
+
return map;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** The function a Rust call names: same file first, then the module a path
|
|
1207
|
+
* call's qualifier names (`api::routes` -> api.rs / api/mod.rs), else a
|
|
1208
|
+
* unique definition. */
|
|
1209
|
+
function resolveRustFunction(fnByName, call, filePath) {
|
|
1210
|
+
const defs = fnByName.get(call.name);
|
|
1211
|
+
if (!defs) return null;
|
|
1212
|
+
if (!call.isPathCall && !call.isMethod) {
|
|
1213
|
+
const local = defs.filter(d => d.file === filePath);
|
|
1214
|
+
if (local.length === 1) return local[0];
|
|
1215
|
+
}
|
|
1216
|
+
if (call.isPathCall && call.receiver) {
|
|
1217
|
+
const mod = String(call.receiver).split('::').pop();
|
|
1218
|
+
const inMod = defs.filter(d => path.basename(d.file, '.rs') === mod ||
|
|
1219
|
+
(path.basename(d.file) === 'mod.rs' && path.basename(path.dirname(d.file)) === mod));
|
|
1220
|
+
if (inMod.length === 1) return inMod[0];
|
|
1221
|
+
}
|
|
1222
|
+
return defs.length === 1 ? defs[0] : null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/** Router names assigned more than once in a file (`const router =
|
|
1226
|
+
* express.Router()` in several test blocks): name-keyed composition would
|
|
1227
|
+
* conflate them, so they need the scope-aware graph. */
|
|
1228
|
+
function ambiguousRouterNames(calls, lang) {
|
|
1229
|
+
const seen = new Set();
|
|
1230
|
+
const out = new Set();
|
|
1231
|
+
// A receiver name whose type evidence points at different declarations
|
|
1232
|
+
// (`var router = new Router()` in several blocks) is declared twice.
|
|
1233
|
+
const declOf = new Map();
|
|
1234
|
+
for (const c of calls) {
|
|
1235
|
+
const ev = c.receiver && c.receiverTypeEvidence;
|
|
1236
|
+
if (ev && ev.start != null) {
|
|
1237
|
+
const prev = declOf.get(c.receiver);
|
|
1238
|
+
if (prev == null) declOf.set(c.receiver, ev.start);
|
|
1239
|
+
else if (prev !== ev.start) out.add(c.receiver);
|
|
1240
|
+
}
|
|
1241
|
+
if (!c.assignedTo) continue;
|
|
1242
|
+
const producer = lang === 'go'
|
|
1243
|
+
? (c.name === 'Group' || c.name === 'NewRouter' || c.name === 'New' || c.name === 'Default' ||
|
|
1244
|
+
c.name === 'NewServeMux' || c.name === 'MapGroup')
|
|
1245
|
+
: (/^(Router|express|fastify|Fastify|Koa|Hono)$/.test(c.name) || c.name === 'basePath');
|
|
1246
|
+
if (!producer) continue;
|
|
1247
|
+
if (seen.has(c.assignedTo)) out.add(c.assignedTo);
|
|
1248
|
+
seen.add(c.assignedTo);
|
|
1249
|
+
}
|
|
1250
|
+
return out;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Inline plugin mounts of a file (`x.register((instance) => {...},
|
|
1254
|
+
* { prefix })`), from the parse-time `registerMount` records. */
|
|
1255
|
+
function inlinePluginMounts(calls, fileEntry) {
|
|
1256
|
+
let list = null;
|
|
1257
|
+
for (const c of calls) {
|
|
1258
|
+
const m = c.registerMount;
|
|
1259
|
+
if (!m || m.prefixDynamic) continue;
|
|
1260
|
+
let span = null;
|
|
1261
|
+
if (m.plugin) {
|
|
1262
|
+
span = { start: m.plugin.start, end: m.plugin.end, param: m.plugin.param };
|
|
1263
|
+
} else if (m.pluginName) {
|
|
1264
|
+
const fn = sameFilePlugin(fileEntry, m.pluginName, c);
|
|
1265
|
+
const param = fn?.paramsStructured?.[0]?.name;
|
|
1266
|
+
if (fn && param) span = { startLine: fn.startLine, endLine: fn.endLine, param };
|
|
1267
|
+
}
|
|
1268
|
+
if (!span) continue;
|
|
1269
|
+
(list || (list = [])).push({ ...span, prefix: m.prefix || '', receiver: c.receiver || null,
|
|
1270
|
+
at: c.callStart, atLine: c.line });
|
|
1271
|
+
}
|
|
1272
|
+
return list;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** The same-file function a named plugin denotes: the single candidate
|
|
1276
|
+
* visible from the register call (defined inside the call's enclosing
|
|
1277
|
+
* function, else at top level). */
|
|
1278
|
+
function sameFilePlugin(fileEntry, name, call = null) {
|
|
1279
|
+
const fns = (fileEntry?.symbols || []).filter(sym => sym.name === name && sym.type === 'function' &&
|
|
1280
|
+
!sym.className);
|
|
1281
|
+
if (fns.length <= 1 || !call) return fns.length === 1 ? fns[0] : null;
|
|
1282
|
+
const enc = call.enclosingFunction;
|
|
1283
|
+
const inside = enc?.endLine
|
|
1284
|
+
? fns.filter(fn => fn.startLine >= enc.startLine && fn.endLine <= enc.endLine) : [];
|
|
1285
|
+
return inside.length === 1 ? inside[0] : null;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Mounts of the innermost plugin function whose router parameter is
|
|
1289
|
+
* `receiver` at a call site (one function registered twice mounts twice). */
|
|
1290
|
+
function pluginFor(plugins, receiver, pos, line) {
|
|
1291
|
+
if (!receiver) return null;
|
|
1292
|
+
let best = null;
|
|
1293
|
+
let bestRank = -1;
|
|
1294
|
+
for (const p of plugins) {
|
|
1295
|
+
if (p.param !== receiver) continue;
|
|
1296
|
+
const inside = p.start != null
|
|
1297
|
+
? (pos != null && pos >= p.start && pos < p.end)
|
|
1298
|
+
: (line != null && line >= p.startLine && line <= p.endLine);
|
|
1299
|
+
if (!inside) continue;
|
|
1300
|
+
const rank = p.start != null ? p.start : p.startLine;
|
|
1301
|
+
if (rank > bestRank) { best = [p]; bestRank = rank; } else if (rank === bestRank) best.push(p);
|
|
1302
|
+
}
|
|
1303
|
+
return best;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** Composed plugin prefixes for a router receiver at a call site. */
|
|
1307
|
+
function registerPrefixesFor(plugins, receiver, pos, line, depth, rule = 'path') {
|
|
1308
|
+
const mounts = depth < 16 ? pluginFor(plugins, receiver, pos, line) : null;
|
|
1309
|
+
if (!mounts) return [''];
|
|
1310
|
+
const out = new Set();
|
|
1311
|
+
for (const p of mounts) {
|
|
1312
|
+
for (const parent of registerPrefixesFor(plugins, p.receiver, p.at, p.atLine, depth + 1, rule)) {
|
|
1313
|
+
out.add(joinPrefixes(parent, p.prefix, rule));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return [...out].sort(codeUnitCompare);
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** A call chained on a detected route registration (`.get(...).post(...)`):
|
|
1320
|
+
* JS route methods return the router. Records are visited by end offset,
|
|
1321
|
+
* so a chain's inner call is classified before the outer one. */
|
|
1322
|
+
function chainedOnRoute(call, routeSpans) {
|
|
1323
|
+
if (!call.receiverCallIsMethod || call.receiverCallStart == null || call.receiver) return false;
|
|
1324
|
+
return routeSpans.has(`${call.receiverCallStart}:${call.receiverCallEnd}`);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Prefixes of the router a chained call registers on (its chain root's). */
|
|
1328
|
+
function chainedPrefixes(call, routeSpans) {
|
|
1329
|
+
if (!call.receiverCallIsMethod || call.receiverCallStart == null || call.receiver) return null;
|
|
1330
|
+
return routeSpans.get(`${call.receiverCallStart}:${call.receiverCallEnd}`) || null;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Router receiver types by package (Go call records carry the parser's
|
|
1334
|
+
// receiverType + receiverTypeQualifier evidence).
|
|
1335
|
+
function routerTypedReceiver(call, lang) {
|
|
1336
|
+
if (!call.receiverType) return false;
|
|
1337
|
+
if (lang === 'go') {
|
|
1338
|
+
return !!call.receiverTypeQualifier &&
|
|
1339
|
+
routeGraph.GO_ROUTER_TYPES.has(`${call.receiverTypeQualifier}.${call.receiverType}`);
|
|
1340
|
+
}
|
|
1341
|
+
// JS/TS: a receiver constructed from / annotated with a router class.
|
|
1342
|
+
return (lang === 'javascript' || lang === 'typescript' || lang === 'tsx') &&
|
|
1343
|
+
call.receiverTypeSource !== 'guess' && JS_ROUTER_TYPE_NAMES.has(call.receiverType);
|
|
1344
|
+
}
|
|
1345
|
+
const JS_ROUTER_TYPE_NAMES = new Set(['Hono', 'Router', 'Koa', 'FastifyInstance', 'Express', 'OpenAPIHono']);
|
|
1346
|
+
|
|
1347
|
+
const PROVEN_ROUTE_NAME = /^(get|post|put|delete|patch|options|head|all|GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any|Handle|HandleFunc|Get|Post|Put|Delete|Patch|Head|Options|Connect|Trace|All)$/;
|
|
1348
|
+
const PROVEN_JS_VERBS = /^(get|post|put|delete|patch|options|head|all)$/;
|
|
1349
|
+
const PROVEN_GO_VERBS = /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Any|Handle|HandleFunc|Get|Post|Put|Delete|Patch|Head|Options|Connect|Trace|All)$/;
|
|
1350
|
+
|
|
1351
|
+
/** Route registration on a receiver PROVEN to be a router (factory,
|
|
1352
|
+
* group, typed parameter, mounted value), whatever its variable name. */
|
|
1353
|
+
function matchProvenRouterRoute(call, lang) {
|
|
1354
|
+
if (!call.firstStringArg || typeof call.argCount !== 'number' || call.argCount < 2) return null;
|
|
1355
|
+
const isJs = lang === 'javascript' || lang === 'typescript' || lang === 'tsx';
|
|
1356
|
+
if (isJs && PROVEN_JS_VERBS.test(call.name)) {
|
|
1357
|
+
return { method: call.name === 'all' ? 'ALL' : call.name.toUpperCase(),
|
|
1358
|
+
path: call.firstStringArg, framework: 'express' };
|
|
1359
|
+
}
|
|
1360
|
+
if (lang === 'go' && PROVEN_GO_VERBS.test(call.name)) {
|
|
1361
|
+
// Go route patterns are rooted; a non-path first argument is a method
|
|
1362
|
+
// (`Handle("GET", ...)`) or something else entirely.
|
|
1363
|
+
if (!call.firstStringArg.startsWith('/')) return null;
|
|
1364
|
+
const upper = call.name.toUpperCase();
|
|
1365
|
+
const method = (upper === 'ANY' || upper === 'ALL' || upper === 'HANDLE' || upper === 'HANDLEFUNC')
|
|
1366
|
+
? 'ALL' : upper;
|
|
1367
|
+
return { method, path: call.firstStringArg, framework: 'go-http' };
|
|
1368
|
+
}
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
481
1372
|
/** Return the list of class-level path prefixes for a class symbol. */
|
|
482
|
-
function collectClassPrefixes(sym, lang) {
|
|
1373
|
+
function collectClassPrefixes(sess, sym, lang) {
|
|
483
1374
|
const prefixes = [];
|
|
484
1375
|
// JS/TS decorators
|
|
485
1376
|
if ((lang === 'javascript' || lang === 'typescript' || lang === 'tsx') && sym.decoratorsWithArgs) {
|
|
@@ -489,28 +1380,108 @@ function collectClassPrefixes(sym, lang) {
|
|
|
489
1380
|
}
|
|
490
1381
|
}
|
|
491
1382
|
}
|
|
492
|
-
// Java annotations: @RequestMapping("/api"), @Path("/api")
|
|
1383
|
+
// Java annotations: @RequestMapping("/api"), @Path("/api"), constants,
|
|
1384
|
+
// arrays (fix #366)
|
|
493
1385
|
if (lang === 'java' && sym.annotationsWithArgs) {
|
|
494
1386
|
for (const a of sym.annotationsWithArgs) {
|
|
495
|
-
if (PREFIX_ANNOTATIONS.has(a.name)
|
|
496
|
-
|
|
497
|
-
}
|
|
1387
|
+
if (!PREFIX_ANNOTATIONS.has(a.name)) continue;
|
|
1388
|
+
for (const p of jvmAnnotationPaths(sess, sym, a, 'java')) if (p) prefixes.push(p);
|
|
498
1389
|
}
|
|
499
1390
|
}
|
|
500
1391
|
if (lang === 'csharp' && sym.attributesWithArgs) {
|
|
501
1392
|
for (const attribute of sym.attributesWithArgs) {
|
|
502
|
-
if (CSHARP_PREFIX_ATTRIBUTES.has(attribute.name)
|
|
503
|
-
|
|
504
|
-
}
|
|
1393
|
+
if (!CSHARP_PREFIX_ATTRIBUTES.has(attribute.name)) continue;
|
|
1394
|
+
for (const p of jvmAnnotationPaths(sess, sym, attribute, 'csharp')) if (p) prefixes.push(p);
|
|
505
1395
|
}
|
|
506
1396
|
}
|
|
507
|
-
return prefixes;
|
|
1397
|
+
return [...new Set(prefixes)];
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/** Enclosing class declaration node of a Java/C# symbol. */
|
|
1401
|
+
function jvmClassNode(sess, sym) {
|
|
1402
|
+
const name = sym.className || ((sym.type === 'class' || sym.type === 'interface') ? sym.name : null);
|
|
1403
|
+
if (!name) return null;
|
|
1404
|
+
const root = sess.root(sym.file);
|
|
1405
|
+
if (!root) return null;
|
|
1406
|
+
const nodes = routeGraph.findClassDecls(root, name);
|
|
1407
|
+
return nodes.find(n => n.startPosition.row + 1 <= sym.startLine &&
|
|
1408
|
+
n.endPosition.row + 1 >= sym.startLine) || (nodes.length === 1 ? nodes[0] : null);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Path arguments of a Java annotation / C# attribute (fix #366). Literal
|
|
1413
|
+
* arguments come straight from the parser record; constants, concatenation
|
|
1414
|
+
* and arrays are folded from a parse of the argument source, resolving
|
|
1415
|
+
* identifiers against the declaring class and project classes. Returns []
|
|
1416
|
+
* when the annotation carries no path.
|
|
1417
|
+
*/
|
|
1418
|
+
function jvmAnnotationPaths(sess, sym, a, lang) {
|
|
1419
|
+
if (lang === 'csharp') {
|
|
1420
|
+
if (a.arg != null) return [a.arg];
|
|
1421
|
+
if (!a.args) return [];
|
|
1422
|
+
const root = sess.snippet('csharp', `[__A(${a.args})] class __X {}`);
|
|
1423
|
+
const list = root && findFirst(root, n => n.type === 'attribute_argument_list');
|
|
1424
|
+
if (!list) return [];
|
|
1425
|
+
for (const arg of routeGraph.namedChildren(list)) {
|
|
1426
|
+
if (arg.type !== 'attribute_argument') continue;
|
|
1427
|
+
// Named properties (`Name = "x"`, `Order = 1`) are not the template.
|
|
1428
|
+
const kids = routeGraph.namedChildren(arg);
|
|
1429
|
+
if (kids.length !== 1 || /^\s*\w+\s*[=:]/.test(arg.text)) continue;
|
|
1430
|
+
const ctx = { lang: 'csharp', classNode: jvmClassNode(sess, sym) };
|
|
1431
|
+
return [routeGraph.evalString(sess, sym.file, kids[0], 0, ctx)];
|
|
1432
|
+
}
|
|
1433
|
+
return [];
|
|
1434
|
+
}
|
|
1435
|
+
const args = typeof a.args === 'string' ? a.args.trim() : '';
|
|
1436
|
+
if (!args) return a.firstStringArg != null ? [a.firstStringArg] : [];
|
|
1437
|
+
if (/^"(?:[^"\\]|\\.)*"$/.test(args)) return [a.firstStringArg ?? args.slice(1, -1)];
|
|
1438
|
+
const root = sess.snippet('java', `@__A(${args}) class __X {}`);
|
|
1439
|
+
const list = root && findFirst(root, n => n.type === 'annotation_argument_list');
|
|
1440
|
+
if (!list) return a.firstStringArg != null ? [a.firstStringArg] : [];
|
|
1441
|
+
let valueNode = null;
|
|
1442
|
+
for (const child of routeGraph.namedChildren(list)) {
|
|
1443
|
+
if (child.type.includes('comment')) continue;
|
|
1444
|
+
if (child.type === 'element_value_pair') {
|
|
1445
|
+
const key = child.childForFieldName('key')?.text;
|
|
1446
|
+
if (key === 'value' || key === 'path') { valueNode = child.childForFieldName('value'); break; }
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
valueNode = child;
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
if (!valueNode) return [];
|
|
1453
|
+
const items = valueNode.type === 'element_value_array_initializer'
|
|
1454
|
+
? routeGraph.namedChildren(valueNode).filter(c => !c.type.includes('comment'))
|
|
1455
|
+
: [valueNode];
|
|
1456
|
+
const ctx = { lang: 'java', classNode: jvmClassNode(sess, sym) };
|
|
1457
|
+
return items.map(item => routeGraph.evalString(sess, sym.file, item, 0, ctx));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function findFirst(root, pred) {
|
|
1461
|
+
const stack = [root];
|
|
1462
|
+
while (stack.length > 0) {
|
|
1463
|
+
const node = stack.pop();
|
|
1464
|
+
if (pred(node)) return node;
|
|
1465
|
+
for (let i = node.namedChildCount - 1; i >= 0; i--) stack.push(node.namedChild(i));
|
|
1466
|
+
}
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/** ASP.NET route template: `[controller]`/`[action]` tokens, `/` and `~/`
|
|
1471
|
+
* templates override the controller prefix, rooted at '/'. */
|
|
1472
|
+
function aspnetRoutePath(classPrefix, template, sym) {
|
|
1473
|
+
const t = template || '';
|
|
1474
|
+
let p = /^~?\//.test(t) ? t.replace(/^~/, '') : joinRoutePath(classPrefix, t);
|
|
1475
|
+
const controller = String(sym.className || '').replace(/Controller$/, '');
|
|
1476
|
+
p = p.replace(/\[controller\]/gi, controller).replace(/\[action\]/gi, sym.name);
|
|
1477
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
1478
|
+
return p;
|
|
508
1479
|
}
|
|
509
1480
|
|
|
510
1481
|
/**
|
|
511
1482
|
* Return zero or more route objects {method, path, framework, raw} for a method/function symbol.
|
|
512
1483
|
*/
|
|
513
|
-
function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
1484
|
+
function collectMethodRoutes(sess, sym, lang, classPrefix, fileEntry = null,
|
|
514
1485
|
pythonReceiverFramework = null) {
|
|
515
1486
|
const out = [];
|
|
516
1487
|
|
|
@@ -534,8 +1505,7 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
534
1505
|
if (lang === 'python' && sym.decorators) {
|
|
535
1506
|
for (const decRaw of sym.decorators) {
|
|
536
1507
|
// Decorator text in Python is the full source: "app.route('/users', methods=['GET'])"
|
|
537
|
-
const r
|
|
538
|
-
if (r) {
|
|
1508
|
+
for (const r of parsePythonDecoratorFull(decRaw, fileEntry, pythonReceiverFramework, sess)) {
|
|
539
1509
|
out.push({
|
|
540
1510
|
method: r.method,
|
|
541
1511
|
path: r.path,
|
|
@@ -553,10 +1523,14 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
553
1523
|
// Track JAX-RS @Path + @GET pattern: @Path supplies path, @GET supplies method.
|
|
554
1524
|
let jaxrsPath = null;
|
|
555
1525
|
const jaxrsMethods = [];
|
|
1526
|
+
const subPaths = a => {
|
|
1527
|
+
const paths = jvmAnnotationPaths(sess, sym, a, 'java');
|
|
1528
|
+
return paths.length > 0 ? paths : [''];
|
|
1529
|
+
};
|
|
556
1530
|
for (const a of sym.annotationsWithArgs) {
|
|
557
1531
|
const meth = METHOD_DECORATORS[a.name];
|
|
558
|
-
if (a.name === 'Path' && a.
|
|
559
|
-
jaxrsPath = a
|
|
1532
|
+
if (a.name === 'Path' && a.args != null) {
|
|
1533
|
+
jaxrsPath = subPaths(a)[0];
|
|
560
1534
|
continue;
|
|
561
1535
|
}
|
|
562
1536
|
if (['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'].includes(a.name) && a.firstStringArg == null) {
|
|
@@ -566,22 +1540,24 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
566
1540
|
if (a.name === 'RequestMapping') {
|
|
567
1541
|
// Try to detect method= attribute in args
|
|
568
1542
|
const detectedMethod = parseSpringRequestMappingMethod(a.args) || 'ALL';
|
|
569
|
-
const sub
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1543
|
+
for (const sub of subPaths(a)) {
|
|
1544
|
+
out.push({
|
|
1545
|
+
method: detectedMethod,
|
|
1546
|
+
path: joinRoutePath(classPrefix, sub) || '/',
|
|
1547
|
+
framework: 'spring',
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
575
1550
|
continue;
|
|
576
1551
|
}
|
|
577
1552
|
if (meth) {
|
|
578
1553
|
// Spring @GetMapping, @PostMapping, etc.
|
|
579
|
-
const sub
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1554
|
+
for (const sub of subPaths(a)) {
|
|
1555
|
+
out.push({
|
|
1556
|
+
method: meth,
|
|
1557
|
+
path: joinRoutePath(classPrefix, sub) || '/',
|
|
1558
|
+
framework: 'spring',
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
585
1561
|
}
|
|
586
1562
|
}
|
|
587
1563
|
// JAX-RS finalization
|
|
@@ -616,18 +1592,34 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
616
1592
|
}
|
|
617
1593
|
|
|
618
1594
|
// ── C# attributes (ASP.NET Core) ─────────────────────────────────
|
|
1595
|
+
// [HttpGet("{id}")] / [HttpPost] + [Route("create")] / [Route] alone
|
|
1596
|
+
// (any method). Templates resolve `[controller]`/`[action]` tokens and
|
|
1597
|
+
// constants; routing is case-insensitive.
|
|
619
1598
|
if (lang === 'csharp' && sym.attributesWithArgs) {
|
|
1599
|
+
const verbs = [];
|
|
1600
|
+
const routeTemplates = [];
|
|
620
1601
|
for (const attribute of sym.attributesWithArgs) {
|
|
621
1602
|
const match = attribute.name.match(
|
|
622
1603
|
/^Http(Get|Post|Put|Delete|Patch|Head|Options)$/);
|
|
623
|
-
if (
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1604
|
+
if (match) {
|
|
1605
|
+
verbs.push({ method: match[1].toUpperCase(),
|
|
1606
|
+
templates: jvmAnnotationPaths(sess, sym, attribute, 'csharp') });
|
|
1607
|
+
} else if (attribute.name === 'Route') {
|
|
1608
|
+
routeTemplates.push(...jvmAnnotationPaths(sess, sym, attribute, 'csharp'));
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
const emit = (method, template) => out.push({
|
|
1612
|
+
method,
|
|
1613
|
+
path: aspnetRoutePath(classPrefix, template, sym),
|
|
1614
|
+
framework: 'aspnet',
|
|
1615
|
+
caseInsensitive: true,
|
|
1616
|
+
});
|
|
1617
|
+
for (const verb of verbs) {
|
|
1618
|
+
const templates = verb.templates.length > 0 ? verb.templates
|
|
1619
|
+
: routeTemplates.length > 0 ? routeTemplates : [''];
|
|
1620
|
+
for (const t of templates) emit(verb.method, t);
|
|
630
1621
|
}
|
|
1622
|
+
if (verbs.length === 0) for (const t of routeTemplates) emit('ALL', t);
|
|
631
1623
|
}
|
|
632
1624
|
|
|
633
1625
|
return out;
|
|
@@ -640,24 +1632,26 @@ function collectMethodRoutes(sym, lang, classPrefix, fileEntry = null,
|
|
|
640
1632
|
* "router.post('/items')"
|
|
641
1633
|
* Returns { method, path, framework } or null.
|
|
642
1634
|
*/
|
|
643
|
-
function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = null) {
|
|
644
|
-
if (typeof raw !== 'string') return
|
|
1635
|
+
function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = null, sess = null) {
|
|
1636
|
+
if (typeof raw !== 'string') return [];
|
|
645
1637
|
// Match receiver.verb('path', ...)
|
|
646
1638
|
const m = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)\.([a-z]+)\s*\(\s*(['"])([^'"]*)\3/);
|
|
647
|
-
if (!m) return
|
|
1639
|
+
if (!m) return [];
|
|
648
1640
|
const verb = m[2];
|
|
649
1641
|
const pathStr = m[4];
|
|
650
1642
|
if (verb === 'route') {
|
|
651
|
-
//
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
}
|
|
1643
|
+
// fix #373: every method of `methods=[...]` / `(...)` / `{...}`
|
|
1644
|
+
// (read from the decorator's AST) is a route.
|
|
1645
|
+
if (receiverFramework?.get(m[1]) === 'aiohttp') {
|
|
1646
|
+
// aiohttp `@routes.route(method, path)`.
|
|
1647
|
+
const args = decoratorLiteralArgs(sess, raw);
|
|
1648
|
+
if (!args || args.length < 2 || !args[1].startsWith('/')) return [];
|
|
1649
|
+
const method = args[0] === '*' ? 'ALL' : args[0].toUpperCase();
|
|
1650
|
+
return [{ method, path: args[1], framework: 'aiohttp', receiver: m[1] }];
|
|
659
1651
|
}
|
|
660
|
-
|
|
1652
|
+
const methods = decoratorMethodsKwarg(sess, raw);
|
|
1653
|
+
return (methods && methods.length > 0 ? methods : ['GET'])
|
|
1654
|
+
.map(method => ({ method, path: pathStr, framework: 'flask', receiver: m[1] }));
|
|
661
1655
|
}
|
|
662
1656
|
if (['get','post','put','delete','patch','options','head'].includes(verb)) {
|
|
663
1657
|
const modules = (fileEntry?.imports || []).map(value =>
|
|
@@ -668,7 +1662,60 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
|
|
|
668
1662
|
? 'flask' : modules.some(module => /^fastapi\b/.test(module)) &&
|
|
669
1663
|
!modules.some(module => /^flask\b/.test(module))
|
|
670
1664
|
? 'fastapi' : 'unknown-python');
|
|
671
|
-
|
|
1665
|
+
// fix #373: with no proven framework, only a rooted path is a route
|
|
1666
|
+
// (Flask, Starlette and aiohttp reject unrooted route paths);
|
|
1667
|
+
// `@mock.patch("pkg.mod.attr")` is not a PATCH route.
|
|
1668
|
+
if (framework === 'unknown-python' && pathStr !== '' && !pathStr.startsWith('/')) return [];
|
|
1669
|
+
return [{ method: verb.toUpperCase(), path: pathStr, framework, receiver: m[1] }];
|
|
1670
|
+
}
|
|
1671
|
+
return [];
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/** Leading positional string-literal arguments of a decorator call. */
|
|
1675
|
+
function decoratorLiteralArgs(sess, raw) {
|
|
1676
|
+
const call = decoratorCall(sess, raw);
|
|
1677
|
+
if (!call) return null;
|
|
1678
|
+
const args = call.childForFieldName('arguments');
|
|
1679
|
+
const out = [];
|
|
1680
|
+
for (let i = 0; i < (args ? args.namedChildCount : 0); i++) {
|
|
1681
|
+
const arg = args.namedChild(i);
|
|
1682
|
+
if (!routeGraph.isStringLiteral(arg)) break;
|
|
1683
|
+
const v = routeGraph.readLiteral(arg, () => null);
|
|
1684
|
+
if (v == null) break;
|
|
1685
|
+
out.push(v);
|
|
1686
|
+
}
|
|
1687
|
+
return out;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function decoratorCall(sess, raw) {
|
|
1691
|
+
if (!sess) return null;
|
|
1692
|
+
const root = sess.snippet('python', raw);
|
|
1693
|
+
const stmt = root && root.namedChild(0);
|
|
1694
|
+
const call = stmt && (stmt.type === 'expression_statement' ? stmt.namedChild(0) : stmt);
|
|
1695
|
+
return call && call.type === 'call' ? call : null;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** String values of a decorator call's `methods=` keyword (AST of the
|
|
1699
|
+
* decorator text), or null. */
|
|
1700
|
+
function decoratorMethodsKwarg(sess, raw) {
|
|
1701
|
+
const call = decoratorCall(sess, raw);
|
|
1702
|
+
if (!call) return null;
|
|
1703
|
+
const args = call.childForFieldName('arguments');
|
|
1704
|
+
for (let i = 0; i < (args ? args.namedChildCount : 0); i++) {
|
|
1705
|
+
const arg = args.namedChild(i);
|
|
1706
|
+
if (arg.type !== 'keyword_argument' || arg.childForFieldName('name')?.text !== 'methods') continue;
|
|
1707
|
+
const value = arg.childForFieldName('value');
|
|
1708
|
+
if (!value || !['list', 'tuple', 'set'].includes(value.type)) return null;
|
|
1709
|
+
const out = [];
|
|
1710
|
+
for (let j = 0; j < value.namedChildCount; j++) {
|
|
1711
|
+
const el = value.namedChild(j);
|
|
1712
|
+
if (el.type.includes('comment')) continue;
|
|
1713
|
+
if (!routeGraph.isStringLiteral(el)) return null;
|
|
1714
|
+
const v = routeGraph.readLiteral(el, () => null);
|
|
1715
|
+
if (v == null) return null;
|
|
1716
|
+
out.push(v.toUpperCase());
|
|
1717
|
+
}
|
|
1718
|
+
return [...new Set(out)];
|
|
672
1719
|
}
|
|
673
1720
|
return null;
|
|
674
1721
|
}
|
|
@@ -681,18 +1728,15 @@ function parsePythonDecoratorFull(raw, fileEntry = null, receiverFramework = nul
|
|
|
681
1728
|
// with its bare decorator path — /list for the real /api/things/list — and
|
|
682
1729
|
// --bridge matched nothing on a prefixed application.
|
|
683
1730
|
const PY_ROUTER_FACTORIES = new Set(['APIRouter', 'Blueprint']);
|
|
1731
|
+
// Router/app objects that can be mounted (fix #366: `app.mount("/v2", sub)`).
|
|
1732
|
+
const PY_APP_FACTORIES = new Set(['APIRouter', 'Blueprint', 'FastAPI', 'Flask', 'Starlette']);
|
|
684
1733
|
const PY_INCLUDE_METHODS = new Set(['include_router', 'register_blueprint']);
|
|
685
1734
|
const PY_PREFIX_KWARGS = new Set(['prefix', 'url_prefix']);
|
|
686
1735
|
|
|
687
|
-
/**
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return m ? m[2] : null;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/** First `prefix=`/`url_prefix=` string keyword argument of a call node. */
|
|
695
|
-
function pyPrefixKwarg(argsNode) {
|
|
1736
|
+
/** First `prefix=`/`url_prefix=` keyword argument of a call node, folded by
|
|
1737
|
+
* constant propagation (fix #366); an unprovable value is a `{?expr}`
|
|
1738
|
+
* segment, never silently dropped. */
|
|
1739
|
+
function pyPrefixKwarg(sess, filePath, argsNode) {
|
|
696
1740
|
if (!argsNode) return null;
|
|
697
1741
|
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
698
1742
|
const arg = argsNode.namedChild(i);
|
|
@@ -700,7 +1744,8 @@ function pyPrefixKwarg(argsNode) {
|
|
|
700
1744
|
const nameNode = arg.childForFieldName('name') || arg.namedChild(0);
|
|
701
1745
|
if (!nameNode || !PY_PREFIX_KWARGS.has(nameNode.text)) continue;
|
|
702
1746
|
const valueNode = arg.childForFieldName('value') || arg.namedChild(1);
|
|
703
|
-
|
|
1747
|
+
if (!valueNode || valueNode.type === 'none') return null;
|
|
1748
|
+
return routeGraph.evalString(sess, filePath, valueNode);
|
|
704
1749
|
}
|
|
705
1750
|
return null;
|
|
706
1751
|
}
|
|
@@ -708,61 +1753,205 @@ function pyPrefixKwarg(argsNode) {
|
|
|
708
1753
|
/**
|
|
709
1754
|
* Map Python router variables to their composed mount prefixes.
|
|
710
1755
|
* Returns Map `${absFile}:${routerVar}` -> [full prefix strings].
|
|
711
|
-
* Only files whose call cache mentions a router factory
|
|
712
|
-
* AST-parsed; unresolvable mount
|
|
713
|
-
* keeps its
|
|
1756
|
+
* Only files whose call cache mentions a router factory, include or mount
|
|
1757
|
+
* call are AST-parsed; an unresolvable mount target contributes no edge
|
|
1758
|
+
* (the route keeps its own prefix - conservative under the advisory
|
|
1759
|
+
* contract).
|
|
714
1760
|
*/
|
|
715
|
-
|
|
1761
|
+
/** Python files whose router mounts the AST pass must read. */
|
|
1762
|
+
function pythonMountFiles(index) {
|
|
716
1763
|
const relevant = [];
|
|
717
1764
|
for (const [filePath, entry] of index.files) {
|
|
718
1765
|
if (entry.language !== 'python') continue;
|
|
719
1766
|
const calls = getCachedCalls(index, filePath) || [];
|
|
720
|
-
if (calls.some(c => (
|
|
721
|
-
PY_INCLUDE_METHODS.has(c.name))) {
|
|
1767
|
+
if (calls.some(c => (PY_APP_FACTORIES.has(c.name) && c.assignedTo) ||
|
|
1768
|
+
PY_INCLUDE_METHODS.has(c.name) || (c.name === 'mount' && c.isMethod))) {
|
|
722
1769
|
relevant.push([filePath, entry]);
|
|
723
1770
|
}
|
|
724
1771
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
try { parser = getParser('python'); } catch (e) { return new Map(); }
|
|
728
|
-
if (!parser) return new Map();
|
|
1772
|
+
return relevant;
|
|
1773
|
+
}
|
|
729
1774
|
|
|
730
|
-
|
|
731
|
-
const
|
|
1775
|
+
function collectPythonRouterMounts(index, sess, routeLists = null, relevant = pythonMountFiles(index)) {
|
|
1776
|
+
const listEdges = routeLists ? routeLists.edges : [];
|
|
1777
|
+
if (relevant.length === 0 && listEdges.length === 0) return new Map();
|
|
1778
|
+
|
|
1779
|
+
const state = { ctorPrefixes: new Map(), edges: new Map(), routerVars: new Set(), mounts: [] };
|
|
732
1780
|
for (const [filePath, entry] of relevant) {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1781
|
+
const root = sess.root(filePath);
|
|
1782
|
+
if (!root) continue;
|
|
1783
|
+
const stack = [root];
|
|
1784
|
+
while (stack.length > 0) {
|
|
1785
|
+
const node = stack.pop();
|
|
1786
|
+
if (node.type === 'call') visitPyRouterCall(sess, node, filePath, entry, index, state);
|
|
1787
|
+
for (let i = node.namedChildCount - 1; i >= 0; i--) stack.push(node.namedChild(i));
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
// `app.mount(prefix, sub)` mounts arbitrary ASGI apps; only a mounted
|
|
1791
|
+
// project router/app carries routes worth prefixing.
|
|
1792
|
+
for (const m of state.mounts) {
|
|
1793
|
+
if (!state.routerVars.has(m.targetKey)) continue;
|
|
1794
|
+
const list = state.edges.get(m.targetKey) || [];
|
|
1795
|
+
list.push({ mounterKey: m.mounterKey, prefix: m.prefix });
|
|
1796
|
+
state.edges.set(m.targetKey, list);
|
|
1797
|
+
}
|
|
1798
|
+
// fix #373: include()/Mount/Host/add_subapp edges of route lists; a
|
|
1799
|
+
// ROOT_URLCONF module is served at the root even when also included.
|
|
1800
|
+
for (const e of listEdges) {
|
|
1801
|
+
const list = state.edges.get(e.targetKey) || [];
|
|
1802
|
+
list.push({ mounterKey: e.mounterKey, prefix: e.prefix, ...(e.join && e.join !== 'path' && { join: e.join }) });
|
|
1803
|
+
state.edges.set(e.targetKey, list);
|
|
1804
|
+
}
|
|
1805
|
+
if (routeLists && listEdges.length > 0) {
|
|
1806
|
+
for (const key of [...routeLists.roots].sort(codeUnitCompare)) {
|
|
1807
|
+
const list = state.edges.get(key);
|
|
1808
|
+
if (list) list.push({ mounterKey: null, prefix: '' });
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
const prefixes = rootPrefixes(composeMountPrefixes(state.edges, state.ctorPrefixes));
|
|
1812
|
+
// The mount edges also name the apps each router is served from (fix
|
|
1813
|
+
// #392: an in-process test client reaches only its own app's routes).
|
|
1814
|
+
Object.defineProperty(prefixes, 'edges', { value: state.edges, enumerable: false });
|
|
1815
|
+
return prefixes;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* The container keys a route of container `key` is served from (fix #392):
|
|
1820
|
+
* the container itself and every app/router that mounts it, transitively.
|
|
1821
|
+
*/
|
|
1822
|
+
function servingAppKeys(edges, key, memo) {
|
|
1823
|
+
if (!key) return null;
|
|
1824
|
+
if (memo.has(key)) return memo.get(key);
|
|
1825
|
+
const keys = new Set([key]);
|
|
1826
|
+
const queue = [key];
|
|
1827
|
+
while (queue.length > 0 && keys.size < 256) {
|
|
1828
|
+
const current = queue.shift();
|
|
1829
|
+
for (const edge of (edges ? edges.get(current) : null) || []) {
|
|
1830
|
+
if (edge.mounterKey && !keys.has(edge.mounterKey)) {
|
|
1831
|
+
keys.add(edge.mounterKey);
|
|
1832
|
+
queue.push(edge.mounterKey);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
memo.set(key, keys);
|
|
1837
|
+
return keys;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/**
|
|
1841
|
+
* Container key of a Python route decorator's receiver (`@app.get(...)` on
|
|
1842
|
+
* `sym`), resolved where the decorator is written (fix #392): a function-local
|
|
1843
|
+
* app is its own container, not the module's namesake.
|
|
1844
|
+
*/
|
|
1845
|
+
function pythonDecoratorReceiverKey(index, sess, sym, receiver) {
|
|
1846
|
+
const root = sess.root(sym.file);
|
|
1847
|
+
if (!root) return null;
|
|
1848
|
+
const first = (sym.startLine || 1) - 1;
|
|
1849
|
+
const last = (sym.nameLine || sym.startLine || 1) - 1;
|
|
1850
|
+
for (const ident of root.descendantsOfType('identifier', { row: first, column: 0 }, { row: last, column: 1 << 20 })) {
|
|
1851
|
+
if (ident.text !== receiver || ident.parent?.type !== 'attribute') continue;
|
|
1852
|
+
let inDecorator = false;
|
|
1853
|
+
for (let n = ident.parent; n && !inDecorator; n = n.parent) {
|
|
1854
|
+
if (n.type === 'decorator') inDecorator = true;
|
|
1855
|
+
if (n.type === 'function_definition' || n.type === 'module') break;
|
|
1856
|
+
}
|
|
1857
|
+
if (inDecorator) return pythonContainerKey(index, sess, sym.file, ident);
|
|
1858
|
+
}
|
|
1859
|
+
return null;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
/** Attach the serving app keys to a route record (internal, not output). */
|
|
1863
|
+
function withAppKeys(route, keys) {
|
|
1864
|
+
if (keys) Object.defineProperty(route, 'appKeys', { value: keys, enumerable: false });
|
|
1865
|
+
return route;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/** Mount prefixes are rooted paths (gin `Group("v1")` serves `/v1`). */
|
|
1869
|
+
function rootPrefixes(map) {
|
|
1870
|
+
for (const [key, list] of map) {
|
|
1871
|
+
map.set(key, [...new Set(list.map(p => (p && !p.startsWith('/') ? '/' + p : p)))].sort(codeUnitCompare));
|
|
1872
|
+
}
|
|
1873
|
+
return map;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function pyCalleeName(fn) {
|
|
1877
|
+
if (!fn) return null;
|
|
1878
|
+
if (fn.type === 'identifier') return fn.text;
|
|
1879
|
+
if (fn.type === 'attribute') return fn.childForFieldName('attribute')?.text || null;
|
|
1880
|
+
return null;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/** Name-keyed router reference in `file`: a module-level router variable, or
|
|
1884
|
+
* one re-exported through import bindings (chased across modules). */
|
|
1885
|
+
function pyChaseRouterKey(sess, index, file, name, depth = 0) {
|
|
1886
|
+
if (depth > 6) return `${file}:${name}`;
|
|
1887
|
+
const root = sess.root(file);
|
|
1888
|
+
const decl = root && routeGraph.scopeDecls(sess, file, 'python', root).get(name);
|
|
1889
|
+
if (decl) return `${file}:${name}`;
|
|
1890
|
+
const entry = index.files.get(file);
|
|
1891
|
+
const bindings = entry?.importBindings || [];
|
|
1892
|
+
const binding = bindings.find(b => (b.alias || b.name) === name) ||
|
|
1893
|
+
(() => {
|
|
1894
|
+
const alias = (entry?.importAliases || []).find(a => a.local === name);
|
|
1895
|
+
return alias ? bindings.find(b => b.name === alias.original) : null;
|
|
1896
|
+
})();
|
|
1897
|
+
if (binding && binding.kind === 'from') {
|
|
1898
|
+
const mod = String(binding.module || '');
|
|
1899
|
+
const subSpec = mod.endsWith('.') ? mod + binding.name : `${mod}.${binding.name}`;
|
|
1900
|
+
if (!entry.moduleResolved?.[subSpec]) {
|
|
1901
|
+
const rel = entry.moduleResolved?.[mod];
|
|
1902
|
+
if (rel) return pyChaseRouterKey(sess, index, path.join(index.root, rel), binding.name, depth + 1);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return `${file}:${name}`;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function pyRouterRefKey(sess, index, filePath, entry, refNode) {
|
|
1909
|
+
if (!refNode) return null;
|
|
1910
|
+
if (refNode.type === 'attribute') {
|
|
1911
|
+
// `app.include_router(mod.router)` - module attribute (plain or
|
|
1912
|
+
// from-imported submodule).
|
|
1913
|
+
const obj = refNode.childForFieldName('object');
|
|
1914
|
+
const attr = refNode.childForFieldName('attribute');
|
|
1915
|
+
if (!obj || !attr) return null;
|
|
1916
|
+
const v = routeGraph.lookupValue(sess, filePath, obj, 0);
|
|
1917
|
+
if (v && v.kind === 'module') return pyChaseRouterKey(sess, index, v.file, attr.text);
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
if (refNode.type === 'identifier') {
|
|
1921
|
+
// Bare name: a local router variable, else an imported one.
|
|
1922
|
+
const decl = routeGraph.findDecl(sess, filePath, refNode.text, refNode);
|
|
1923
|
+
if (decl) return `${filePath}:${refNode.text}`;
|
|
1924
|
+
return pyChaseRouterKey(sess, index, filePath, refNode.text);
|
|
743
1925
|
}
|
|
744
|
-
return
|
|
1926
|
+
return null;
|
|
745
1927
|
}
|
|
746
1928
|
|
|
747
|
-
function visitPyRouterCall(callNode, filePath, entry, index,
|
|
1929
|
+
function visitPyRouterCall(sess, callNode, filePath, entry, index, state) {
|
|
748
1930
|
const fn = callNode.childForFieldName('function');
|
|
749
1931
|
if (!fn) return;
|
|
1932
|
+
const calleeName = pyCalleeName(fn);
|
|
750
1933
|
|
|
751
|
-
// `<var> = APIRouter(prefix=
|
|
752
|
-
|
|
1934
|
+
// `<var> = APIRouter(prefix=...)` / `<var> = FastAPI()` - router value,
|
|
1935
|
+
// with its constructor prefix.
|
|
1936
|
+
if (calleeName && PY_APP_FACTORIES.has(calleeName)) {
|
|
753
1937
|
const parent = callNode.parent;
|
|
754
1938
|
if (!parent || parent.type !== 'assignment') return;
|
|
755
1939
|
const left = parent.childForFieldName('left');
|
|
756
1940
|
if (!left || left.type !== 'identifier') return;
|
|
757
|
-
const
|
|
758
|
-
|
|
1941
|
+
const key = `${filePath}:${left.text}`;
|
|
1942
|
+
state.routerVars.add(key);
|
|
1943
|
+
if (PY_ROUTER_FACTORIES.has(calleeName)) {
|
|
1944
|
+
const prefix = pyPrefixKwarg(sess, filePath, callNode.childForFieldName('arguments'));
|
|
1945
|
+
if (prefix) state.ctorPrefixes.set(key, prefix);
|
|
1946
|
+
}
|
|
759
1947
|
return;
|
|
760
1948
|
}
|
|
761
1949
|
|
|
762
|
-
// `<recv>.include_router(<ref>, prefix="/v2")` — mount edge.
|
|
763
1950
|
if (fn.type !== 'attribute') return;
|
|
764
1951
|
const attrNode = fn.childForFieldName('attribute');
|
|
765
|
-
if (!attrNode
|
|
1952
|
+
if (!attrNode) return;
|
|
1953
|
+
const method = attrNode.text;
|
|
1954
|
+
if (!PY_INCLUDE_METHODS.has(method) && method !== 'mount') return;
|
|
766
1955
|
const recvNode = fn.childForFieldName('object');
|
|
767
1956
|
// Non-identifier receivers (self.app, factories) can't be keyed — treat
|
|
768
1957
|
// as a root mounter so the edge prefix still applies.
|
|
@@ -770,44 +1959,36 @@ function visitPyRouterCall(callNode, filePath, entry, index, ctorPrefixes, edges
|
|
|
770
1959
|
? `${filePath}:${recvNode.text}` : null;
|
|
771
1960
|
const argsNode = callNode.childForFieldName('arguments');
|
|
772
1961
|
if (!argsNode) return;
|
|
773
|
-
|
|
1962
|
+
const positional = [];
|
|
774
1963
|
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
775
1964
|
const arg = argsNode.namedChild(i);
|
|
776
1965
|
if (arg.type === 'keyword_argument' || arg.type.includes('comment')) continue;
|
|
777
|
-
|
|
778
|
-
break;
|
|
1966
|
+
positional.push(arg);
|
|
779
1967
|
}
|
|
1968
|
+
|
|
1969
|
+
if (method === 'mount') {
|
|
1970
|
+
// `app.mount("/v2", sub_app)` - Starlette/FastAPI sub-application.
|
|
1971
|
+
if (positional.length < 2) return;
|
|
1972
|
+
const targetKey = pyRouterRefKey(sess, index, filePath, entry, positional[1]);
|
|
1973
|
+
if (!targetKey) return;
|
|
1974
|
+
state.mounts.push({ targetKey, mounterKey,
|
|
1975
|
+
prefix: routeGraph.evalString(sess, filePath, positional[0]) });
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// `<recv>.include_router(<ref>, prefix=...)` - mount edge.
|
|
1980
|
+
const refNode = positional[0];
|
|
780
1981
|
if (!refNode) return;
|
|
781
|
-
const explicitPrefix = pyPrefixKwarg(argsNode);
|
|
1982
|
+
const explicitPrefix = pyPrefixKwarg(sess, filePath, argsNode);
|
|
782
1983
|
const prefix = explicitPrefix || '';
|
|
783
1984
|
// Flask: register_blueprint's url_prefix REPLACES the blueprint's own
|
|
784
1985
|
// url_prefix; FastAPI's include_router prefix composes with it.
|
|
785
|
-
const overridesCtor = explicitPrefix != null &&
|
|
786
|
-
|
|
787
|
-
let targetKey = null;
|
|
788
|
-
if (refNode.type === 'attribute') {
|
|
789
|
-
// `app.include_router(mod.router)` — module attribute.
|
|
790
|
-
const obj = refNode.childForFieldName('object');
|
|
791
|
-
const attr = refNode.childForFieldName('attribute');
|
|
792
|
-
if (obj && obj.type === 'identifier' && attr) {
|
|
793
|
-
const binding = (entry.importBindings || []).find(b => b.name === obj.text);
|
|
794
|
-
const rel = binding && entry.moduleResolved?.[binding.module];
|
|
795
|
-
if (rel) targetKey = `${path.join(index.root, rel)}:${attr.text}`;
|
|
796
|
-
}
|
|
797
|
-
} else if (refNode.type === 'identifier') {
|
|
798
|
-
// Bare name: alias-aware from-import first, then same-file variable.
|
|
799
|
-
const aliasEntry = (entry.importAliases || []).find(a => a.local === refNode.text);
|
|
800
|
-
const original = aliasEntry ? aliasEntry.original : refNode.text;
|
|
801
|
-
const binding = (entry.importBindings || []).find(b =>
|
|
802
|
-
b.name === original && entry.moduleResolved?.[b.module]);
|
|
803
|
-
targetKey = binding
|
|
804
|
-
? `${path.join(index.root, entry.moduleResolved[binding.module])}:${original}`
|
|
805
|
-
: `${filePath}:${refNode.text}`;
|
|
806
|
-
}
|
|
1986
|
+
const overridesCtor = explicitPrefix != null && method === 'register_blueprint';
|
|
1987
|
+
const targetKey = pyRouterRefKey(sess, index, filePath, entry, refNode);
|
|
807
1988
|
if (!targetKey) return;
|
|
808
|
-
const list = edges.get(targetKey) || [];
|
|
1989
|
+
const list = state.edges.get(targetKey) || [];
|
|
809
1990
|
list.push({ mounterKey, prefix, ...(overridesCtor && { overridesCtor }) });
|
|
810
|
-
edges.set(targetKey, list);
|
|
1991
|
+
state.edges.set(targetKey, list);
|
|
811
1992
|
}
|
|
812
1993
|
|
|
813
1994
|
/** Map exported router receiver variables to their literal project mounts.
|
|
@@ -820,12 +2001,19 @@ function collectProjectRouterMounts(index) {
|
|
|
820
2001
|
if (!['javascript', 'typescript', 'tsx'].includes(fileEntry.language)) continue;
|
|
821
2002
|
const calls = getCachedCalls(index, filePath) || [];
|
|
822
2003
|
const localRouters = collectRouterReceivers(calls, fileEntry.language);
|
|
2004
|
+
const ambiguous = ambiguousRouterNames(calls, fileEntry.language);
|
|
823
2005
|
for (const call of calls) {
|
|
824
|
-
if (call.name !== 'use' || !call.receiver || !call.firstStringArg ||
|
|
2006
|
+
if ((call.name !== 'use' && call.name !== 'route') || !call.receiver || !call.firstStringArg ||
|
|
825
2007
|
(call.argCount != null && call.argCount < 2)) continue;
|
|
826
2008
|
const mounterKey = `${filePath}:${call.receiver}`;
|
|
827
|
-
|
|
828
|
-
|
|
2009
|
+
// fix #366: the mount's identifier arguments (parse-time
|
|
2010
|
+
// `mountArgs`); names declared more than once in the file are
|
|
2011
|
+
// left to the scope-aware router graph.
|
|
2012
|
+
const refs = call.mountArgs
|
|
2013
|
+
? call.mountArgs.slice(1).filter(n => /^[A-Za-z_$][\w$]*$/.test(n) && !ambiguous.has(n))
|
|
2014
|
+
.map(name => ({ name }))
|
|
2015
|
+
: calls.filter(candidate => candidate.line === call.line &&
|
|
2016
|
+
candidate !== call && (candidate.isFunctionReference || candidate.isPotentialCallback));
|
|
829
2017
|
for (const ref of refs) {
|
|
830
2018
|
const targetKeys = [];
|
|
831
2019
|
const binding = (fileEntry.importBindings || []).find(item => item.name === ref.name);
|
|
@@ -847,9 +2035,10 @@ function collectProjectRouterMounts(index) {
|
|
|
847
2035
|
} else if (localRouters.has(ref.name)) {
|
|
848
2036
|
targetKeys.push(`${filePath}:${ref.name}`);
|
|
849
2037
|
}
|
|
2038
|
+
const join = joinRuleOf(routeFrameworkOfFile(index, filePath));
|
|
850
2039
|
for (const key of targetKeys) {
|
|
851
2040
|
const list = edges.get(key) || [];
|
|
852
|
-
list.push({ mounterKey, prefix: call.firstStringArg });
|
|
2041
|
+
list.push({ mounterKey, prefix: call.firstStringArg, ...(join !== 'path' && { join }) });
|
|
853
2042
|
edges.set(key, list);
|
|
854
2043
|
}
|
|
855
2044
|
}
|
|
@@ -871,19 +2060,37 @@ function parseSpringRequestMappingMethod(argsRaw) {
|
|
|
871
2060
|
/** Infer router values from AST call/assignment evidence, independent of the
|
|
872
2061
|
* variable's spelling. Legacy receiver-name patterns remain seeds for code
|
|
873
2062
|
* where the factory assignment is outside the indexed file. */
|
|
2063
|
+
// Per-call-array memo: route extraction, mount collection and client
|
|
2064
|
+
// classification all ask for the same file's router receivers.
|
|
2065
|
+
const routerReceiverMemo = new WeakMap();
|
|
2066
|
+
|
|
874
2067
|
function collectRouterReceivers(calls, lang) {
|
|
2068
|
+
let byLang = routerReceiverMemo.get(calls);
|
|
2069
|
+
if (!byLang) {
|
|
2070
|
+
byLang = new Map();
|
|
2071
|
+
routerReceiverMemo.set(calls, byLang);
|
|
2072
|
+
}
|
|
2073
|
+
let receivers = byLang.get(lang);
|
|
2074
|
+
if (!receivers) {
|
|
2075
|
+
receivers = computeRouterReceivers(calls, lang);
|
|
2076
|
+
byLang.set(lang, receivers);
|
|
2077
|
+
}
|
|
2078
|
+
return receivers;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function computeRouterReceivers(calls, lang) {
|
|
875
2082
|
const receivers = new Set();
|
|
876
2083
|
const patterns = SERVER_RECEIVER_PATTERNS[lang] || [];
|
|
877
2084
|
for (const call of calls) {
|
|
878
|
-
if (call.receiver && patterns.some(pattern =>
|
|
879
|
-
pattern.receiverPattern.test(call.receiver))) {
|
|
2085
|
+
if (call.receiver && (patterns.some(pattern =>
|
|
2086
|
+
pattern.receiverPattern.test(call.receiver)) || routerTypedReceiver(call, lang))) {
|
|
880
2087
|
receivers.add(call.receiver);
|
|
881
2088
|
}
|
|
882
2089
|
if (!call.assignedTo) continue;
|
|
883
2090
|
const receiver = String(call.receiver || '');
|
|
884
2091
|
const factory = (
|
|
885
|
-
(['javascript', 'typescript'].includes(lang) &&
|
|
886
|
-
/^(Router|express|fastify|Koa)$/.test(call.name)) ||
|
|
2092
|
+
(['javascript', 'typescript', 'tsx'].includes(lang) &&
|
|
2093
|
+
/^(Router|express|fastify|Fastify|Koa|Hono)$/.test(call.name)) ||
|
|
887
2094
|
(lang === 'go' && (
|
|
888
2095
|
/^(NewRouter|NewServeMux|Default)$/.test(call.name) ||
|
|
889
2096
|
(call.name === 'New' && /^(gin|echo|chi|fiber|mux|http)$/i.test(receiver)))) ||
|
|
@@ -900,7 +2107,7 @@ function collectRouterReceivers(calls, lang) {
|
|
|
900
2107
|
changed = false;
|
|
901
2108
|
for (const call of calls) {
|
|
902
2109
|
if (!call.assignedTo || !call.receiver ||
|
|
903
|
-
!/^(Group|group|nest|NewGroup)$/.test(call.name) ||
|
|
2110
|
+
!/^(Group|group|nest|NewGroup|MapGroup)$/.test(call.name) ||
|
|
904
2111
|
!receivers.has(call.receiver) || receivers.has(call.assignedTo)) continue;
|
|
905
2112
|
receivers.add(call.assignedTo);
|
|
906
2113
|
changed = true;
|
|
@@ -957,17 +2164,23 @@ function matchCallPatternRoute(call, lang, routerReceivers = null) {
|
|
|
957
2164
|
* Looks for callback-marker calls (isPotentialCallback / isFunctionReference) on that line.
|
|
958
2165
|
*/
|
|
959
2166
|
function findHandlerCallback(calls, line, exclude) {
|
|
2167
|
+
// A record inside an inline handler (a function literal argument) has
|
|
2168
|
+
// that function as its enclosing scope; only records in the
|
|
2169
|
+
// registration's own scope can name its handler (fix #383).
|
|
2170
|
+
const scopeOf = c => (c.enclosingFunction ? `${c.enclosingFunction.name}:${c.enclosingFunction.startLine}` : '');
|
|
2171
|
+
const scope = exclude ? scopeOf(exclude) : null;
|
|
2172
|
+
const inScope = c => c !== exclude && c.line === line && (scope === null || scopeOf(c) === scope) &&
|
|
2173
|
+
(exclude?.callStart == null || c.callStart == null ||
|
|
2174
|
+
(c.callStart >= exclude.callStart && c.callEnd <= exclude.callEnd));
|
|
960
2175
|
for (const c of calls) {
|
|
961
|
-
if (c
|
|
962
|
-
if (c.line !== line) continue;
|
|
2176
|
+
if (!inScope(c)) continue;
|
|
963
2177
|
if (c.isPotentialCallback || c.isFunctionReference) {
|
|
964
2178
|
return c.name;
|
|
965
2179
|
}
|
|
966
2180
|
}
|
|
967
2181
|
// Fallback: any non-method call on the same line
|
|
968
2182
|
for (const c of calls) {
|
|
969
|
-
if (c
|
|
970
|
-
if (c.line !== line) continue;
|
|
2183
|
+
if (!inScope(c)) continue;
|
|
971
2184
|
if (!c.isMethod) return c.name;
|
|
972
2185
|
}
|
|
973
2186
|
return null;
|
|
@@ -1021,7 +2234,7 @@ function extractNextjsRoutes(index) {
|
|
|
1021
2234
|
out.push({
|
|
1022
2235
|
method: 'GET',
|
|
1023
2236
|
path: routePath,
|
|
1024
|
-
normalizedPath: normalizePath(routePath),
|
|
2237
|
+
normalizedPath: normalizePath(routePath, 'router'),
|
|
1025
2238
|
handler: 'default',
|
|
1026
2239
|
file: fileEntry.relativePath || filePath,
|
|
1027
2240
|
absoluteFile: filePath,
|
|
@@ -1044,7 +2257,7 @@ function extractNextjsRoutes(index) {
|
|
|
1044
2257
|
out.push({
|
|
1045
2258
|
method: m,
|
|
1046
2259
|
path: routePath,
|
|
1047
|
-
normalizedPath: normalizePath(routePath),
|
|
2260
|
+
normalizedPath: normalizePath(routePath, 'router'),
|
|
1048
2261
|
handler: m,
|
|
1049
2262
|
file: fileEntry.relativePath || filePath,
|
|
1050
2263
|
absoluteFile: filePath,
|
|
@@ -1113,6 +2326,21 @@ function _pythonClientReceiver(index, fileEntry, call, fixtures) {
|
|
|
1113
2326
|
return params.includes(call.receiver) ? 'pytest-client-fixture' : null;
|
|
1114
2327
|
}
|
|
1115
2328
|
|
|
2329
|
+
// `fastify().route({...})` - a route declared on a freshly built server.
|
|
2330
|
+
const JS_SERVER_FACTORY_CALL = /^(fastify|Fastify|express|Hono|Koa)$/;
|
|
2331
|
+
|
|
2332
|
+
// Call names that read as issuing a request (uncertain band for unproven
|
|
2333
|
+
// request-configuration calls; configuration objects are common elsewhere).
|
|
2334
|
+
const REQUEST_SHAPED_NAME = /^(get|post|put|delete|patch|options|head|fetch|send|inject)$|request/i;
|
|
2335
|
+
|
|
2336
|
+
function isPathShaped(p) {
|
|
2337
|
+
return typeof p === 'string' && (p.startsWith('/') || p.includes('://'));
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function isAbsoluteUrl(p) {
|
|
2341
|
+
return typeof p === 'string' && p.includes('://');
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1116
2344
|
function extractClientRequests(index) {
|
|
1117
2345
|
if (index._endpointsCache && index._endpointsCache.clientRequests) {
|
|
1118
2346
|
return index._endpointsCache.clientRequests;
|
|
@@ -1120,13 +2348,153 @@ function extractClientRequests(index) {
|
|
|
1120
2348
|
const requests = [];
|
|
1121
2349
|
const uncertain = [];
|
|
1122
2350
|
const pyFixtures = _pythonClientFixtures(index);
|
|
2351
|
+
let helperProof = null;
|
|
1123
2352
|
|
|
1124
2353
|
for (const [filePath, fileEntry] of index.files) {
|
|
1125
2354
|
const lang = fileEntry.language;
|
|
1126
2355
|
const calls = getCachedCalls(index, filePath);
|
|
1127
2356
|
if (!calls || calls.length === 0) continue;
|
|
1128
2357
|
|
|
2358
|
+
let serverReceivers = null;
|
|
2359
|
+
// In-process supertest clients (fix #397): the names this file binds
|
|
2360
|
+
// to the `supertest` module (by module identity, whatever the local
|
|
2361
|
+
// name), and the call records that build a client from one.
|
|
2362
|
+
const supertestLocals = JS_LANGS.has(lang) ? supertestLocalNames(fileEntry) : null;
|
|
2363
|
+
let supertestProducers = null;
|
|
1129
2364
|
for (const call of calls) {
|
|
2365
|
+
if (supertestLocals && call.isMethod && SUPERTEST_VERBS.has(call.name) &&
|
|
2366
|
+
call.firstStringArg && isPathShaped(call.firstStringArg) &&
|
|
2367
|
+
!isAbsoluteUrl(call.firstStringArg)) {
|
|
2368
|
+
if (!supertestProducers) supertestProducers = supertestClientProducers(calls, supertestLocals);
|
|
2369
|
+
const producer = supertestProducerOf(call, calls, supertestProducers, supertestLocals);
|
|
2370
|
+
if (producer) {
|
|
2371
|
+
const request = {
|
|
2372
|
+
method: call.name === 'del' ? 'DELETE' : call.name.toUpperCase(),
|
|
2373
|
+
path: call.firstStringArg,
|
|
2374
|
+
normalizedPath: normalizePath(call.firstStringArg, 'client'),
|
|
2375
|
+
interp: !!call.firstStringArgInterp,
|
|
2376
|
+
file: fileEntry.relativePath || filePath,
|
|
2377
|
+
absoluteFile: filePath,
|
|
2378
|
+
line: call.line,
|
|
2379
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2380
|
+
callerStartLine: call.enclosingFunction?.startLine,
|
|
2381
|
+
framework: 'supertest',
|
|
2382
|
+
methodInferred: false,
|
|
2383
|
+
};
|
|
2384
|
+
// The app the client was built from serves the request
|
|
2385
|
+
// in process; bridging resolves it.
|
|
2386
|
+
Object.defineProperty(request, 'jsClientApp', {
|
|
2387
|
+
value: { start: producer.callStart, end: producer.callEnd }, enumerable: false,
|
|
2388
|
+
});
|
|
2389
|
+
requests.push(request);
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
// fix #366: request configuration objects/keywords
|
|
2394
|
+
// (`__request(OpenAPI, { method, url })`, `client.post({ url })`,
|
|
2395
|
+
// `session.request(method=..., url=...)`). A path-shaped first
|
|
2396
|
+
// string argument keeps the positional client path below.
|
|
2397
|
+
// `fetch(input, init)`: the URL is always `input`; a `url` key in
|
|
2398
|
+
// the init object is not the request target.
|
|
2399
|
+
const cfg = call.requestConfig && !(call.name === 'fetch' && !call.isMethod)
|
|
2400
|
+
? call.requestConfig : null;
|
|
2401
|
+
if (cfg && !(call.firstStringArg && isPathShaped(call.firstStringArg))) {
|
|
2402
|
+
// `server.route({ method, url })` declares a route (server
|
|
2403
|
+
// side); `server.inject({ method, url })` is an in-process
|
|
2404
|
+
// request against the server (Fastify/light-my-request).
|
|
2405
|
+
const onServer = call.isMethod && (call.name === 'route' || call.name === 'inject') &&
|
|
2406
|
+
(call.receiver
|
|
2407
|
+
? (serverReceivers || (serverReceivers = collectRouterReceivers(calls, lang))).has(call.receiver)
|
|
2408
|
+
: JS_SERVER_FACTORY_CALL.test(call.receiverCall || ''));
|
|
2409
|
+
if (onServer && call.name === 'route') continue;
|
|
2410
|
+
if (onServer && call.name === 'inject') {
|
|
2411
|
+
requests.push({
|
|
2412
|
+
method: cfg.method || 'ALL',
|
|
2413
|
+
path: cfg.url,
|
|
2414
|
+
normalizedPath: normalizePath(cfg.url, 'client'),
|
|
2415
|
+
interp: !!cfg.interp,
|
|
2416
|
+
file: fileEntry.relativePath || filePath,
|
|
2417
|
+
absoluteFile: filePath,
|
|
2418
|
+
line: call.line,
|
|
2419
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2420
|
+
callerStartLine: call.enclosingFunction?.startLine,
|
|
2421
|
+
framework: 'inject',
|
|
2422
|
+
methodInferred: !cfg.method,
|
|
2423
|
+
// An in-process injection targets the instance its
|
|
2424
|
+
// own test built: routes registered inside the same
|
|
2425
|
+
// enclosing function (or file, at top level).
|
|
2426
|
+
sameFileOnly: true,
|
|
2427
|
+
...(call.enclosingFunction?.endLine && {
|
|
2428
|
+
scopeLines: [call.enclosingFunction.startLine, call.enclosingFunction.endLine],
|
|
2429
|
+
}),
|
|
2430
|
+
});
|
|
2431
|
+
continue;
|
|
2432
|
+
}
|
|
2433
|
+
if (!helperProof) helperProof = new RequestHelperProof(index, endpointsSession(index));
|
|
2434
|
+
const proof = helperProof.classify(filePath, call);
|
|
2435
|
+
const verb = call.isMethod && /^(get|post|put|delete|patch|options|head)$/i.test(call.name)
|
|
2436
|
+
? call.name.toUpperCase() : null;
|
|
2437
|
+
const method = cfg.method || verb || 'ALL';
|
|
2438
|
+
if (proof.proven) {
|
|
2439
|
+
requests.push({
|
|
2440
|
+
method,
|
|
2441
|
+
path: cfg.url,
|
|
2442
|
+
normalizedPath: normalizePath(cfg.url, 'client'),
|
|
2443
|
+
interp: !!cfg.interp,
|
|
2444
|
+
file: fileEntry.relativePath || filePath,
|
|
2445
|
+
absoluteFile: filePath,
|
|
2446
|
+
line: call.line,
|
|
2447
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2448
|
+
callerStartLine: call.enclosingFunction?.startLine,
|
|
2449
|
+
framework: 'request-helper',
|
|
2450
|
+
methodInferred: !cfg.method && !verb,
|
|
2451
|
+
});
|
|
2452
|
+
} else if (isPathShaped(cfg.url) && REQUEST_SHAPED_NAME.test(call.name)) {
|
|
2453
|
+
uncertain.push({
|
|
2454
|
+
receiver: call.receiver || '', method: call.name, path: cfg.url,
|
|
2455
|
+
file: fileEntry.relativePath || filePath, absoluteFile: filePath, line: call.line,
|
|
2456
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2457
|
+
reason: `request-helper-unproven:${proof.reason}`,
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
continue;
|
|
2461
|
+
}
|
|
2462
|
+
// fix #383: Go request constructors read their method and URL
|
|
2463
|
+
// from their own argument positions.
|
|
2464
|
+
if (call.requestTarget && lang === 'go' && call.receiver && !call.isPathCall &&
|
|
2465
|
+
matchClientRequest({ ...call, firstStringArg: call.requestTarget.url }, lang, calls)) {
|
|
2466
|
+
const target = call.requestTarget;
|
|
2467
|
+
if (isPathShaped(target.url)) {
|
|
2468
|
+
// A request with a path but no scheme and host cannot be
|
|
2469
|
+
// sent by a client: it is served in process
|
|
2470
|
+
// (`handler.ServeHTTP(w, req)`), by the routes its own
|
|
2471
|
+
// test registers, like an injected request.
|
|
2472
|
+
const inProcess = !target.url.includes('://');
|
|
2473
|
+
requests.push({
|
|
2474
|
+
method: target.method || 'ALL',
|
|
2475
|
+
path: target.url,
|
|
2476
|
+
normalizedPath: normalizePath(target.url, 'client'),
|
|
2477
|
+
interp: !!target.interp,
|
|
2478
|
+
file: fileEntry.relativePath || filePath,
|
|
2479
|
+
absoluteFile: filePath,
|
|
2480
|
+
line: call.line,
|
|
2481
|
+
callerName: call.enclosingFunction?.name || '<top-level>',
|
|
2482
|
+
callerStartLine: call.enclosingFunction?.startLine,
|
|
2483
|
+
framework: inProcess ? 'go-inprocess' : 'go-http',
|
|
2484
|
+
methodInferred: !target.method,
|
|
2485
|
+
// Only the routes its own function registers can
|
|
2486
|
+
// serve it (a request built to feed a context in a
|
|
2487
|
+
// test with no router bridges nowhere).
|
|
2488
|
+
...(inProcess && {
|
|
2489
|
+
sameFileOnly: true,
|
|
2490
|
+
scopeOnly: true,
|
|
2491
|
+
scopeLines: call.enclosingFunction?.endLine
|
|
2492
|
+
? [call.enclosingFunction.startLine, call.enclosingFunction.endLine] : [0, 0],
|
|
2493
|
+
}),
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
1130
2498
|
if (!call.firstStringArg) continue;
|
|
1131
2499
|
let r = matchClientRequest(call, lang, calls);
|
|
1132
2500
|
if (!r && lang === 'python') {
|
|
@@ -1159,19 +2527,22 @@ function extractClientRequests(index) {
|
|
|
1159
2527
|
// shape before claiming an outbound request. This deliberately
|
|
1160
2528
|
// keeps absolute URLs and normal relative API paths while
|
|
1161
2529
|
// rejecting cache/session keys (UCN5-162).
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
2530
|
+
// Go (fix #383): `c.Get("key")` on a request context is a
|
|
2531
|
+
// key lookup; net/http clients take absolute URLs. A request
|
|
2532
|
+
// constructor's first argument is its method, never its URL.
|
|
2533
|
+
if ((lang === 'python' || lang === 'go') && !isPathShaped(call.firstStringArg)) {
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (lang === 'go' && (call.name === 'NewRequest' || call.name === 'NewRequestWithContext')) {
|
|
1165
2537
|
continue;
|
|
1166
2538
|
}
|
|
1167
2539
|
|
|
1168
2540
|
const callerName = call.enclosingFunction?.name || '<top-level>';
|
|
1169
2541
|
const callerStartLine = call.enclosingFunction?.startLine;
|
|
1170
|
-
|
|
1171
|
-
requests.push({
|
|
2542
|
+
const request = {
|
|
1172
2543
|
method: r.method,
|
|
1173
2544
|
path: call.firstStringArg,
|
|
1174
|
-
normalizedPath: normalizePath(call.firstStringArg),
|
|
2545
|
+
normalizedPath: normalizePath(call.firstStringArg, 'client'),
|
|
1175
2546
|
interp: !!call.firstStringArgInterp,
|
|
1176
2547
|
file: fileEntry.relativePath || filePath,
|
|
1177
2548
|
absoluteFile: filePath,
|
|
@@ -1180,7 +2551,15 @@ function extractClientRequests(index) {
|
|
|
1180
2551
|
callerStartLine,
|
|
1181
2552
|
framework: r.framework,
|
|
1182
2553
|
methodInferred: r.methodInferred,
|
|
1183
|
-
}
|
|
2554
|
+
};
|
|
2555
|
+
// A request through a client built from an app is served by that
|
|
2556
|
+
// app in process (fix #392); bridging resolves the client's app.
|
|
2557
|
+
if (lang === 'python' && call.isMethod && call.receiver && !isAbsoluteUrl(call.firstStringArg)) {
|
|
2558
|
+
Object.defineProperty(request, 'clientCall', {
|
|
2559
|
+
value: { receiver: call.receiver, name: call.name }, enumerable: false,
|
|
2560
|
+
});
|
|
2561
|
+
}
|
|
2562
|
+
requests.push(request);
|
|
1184
2563
|
}
|
|
1185
2564
|
}
|
|
1186
2565
|
|
|
@@ -1200,6 +2579,63 @@ function extractClientRequests(index) {
|
|
|
1200
2579
|
return requests;
|
|
1201
2580
|
}
|
|
1202
2581
|
|
|
2582
|
+
// supertest (fix #397): `request(app).get('/x')`, `request.agent(app)`,
|
|
2583
|
+
// and a client bound to a local (`const agent = request(app)`).
|
|
2584
|
+
const JS_LANGS = new Set(['javascript', 'typescript', 'tsx']);
|
|
2585
|
+
const SUPERTEST_VERBS = new Set(['get', 'post', 'put', 'delete', 'del', 'patch', 'head', 'options']);
|
|
2586
|
+
|
|
2587
|
+
/** Local names a file binds to the `supertest` module's export. */
|
|
2588
|
+
function supertestLocalNames(fileEntry) {
|
|
2589
|
+
let names = null;
|
|
2590
|
+
for (const binding of fileEntry.importBindings || []) {
|
|
2591
|
+
if (binding?.module !== 'supertest') continue;
|
|
2592
|
+
const local = binding.alias || binding.name;
|
|
2593
|
+
if (!local || local === '*') continue;
|
|
2594
|
+
(names || (names = new Set())).add(local);
|
|
2595
|
+
}
|
|
2596
|
+
return names;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
/** A call record that builds a supertest client: `request(app)` or `request.agent(app)`. */
|
|
2600
|
+
function isSupertestProducer(record, locals) {
|
|
2601
|
+
if (!record || record.localShadow) return false;
|
|
2602
|
+
if (!record.isMethod && !record.receiver) return locals.has(record.name);
|
|
2603
|
+
return record.isMethod && record.name === 'agent' && locals.has(record.receiver) &&
|
|
2604
|
+
!record.receiverLocalBinding;
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
/** Client producers of a file by call start, and locals bound to one. */
|
|
2608
|
+
function supertestClientProducers(calls, locals) {
|
|
2609
|
+
const byStart = new Map();
|
|
2610
|
+
const byLocal = new Map();
|
|
2611
|
+
for (const record of calls) {
|
|
2612
|
+
if (!isSupertestProducer(record, locals) || record.callStart == null) continue;
|
|
2613
|
+
byStart.set(record.callStart, record);
|
|
2614
|
+
if (record.assignedTo) {
|
|
2615
|
+
const list = byLocal.get(record.assignedTo) || [];
|
|
2616
|
+
list.push(record);
|
|
2617
|
+
byLocal.set(record.assignedTo, list);
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
return { byStart, byLocal };
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* The client-building call a request call is made through: its chained
|
|
2625
|
+
* producer (`request(app).get(..)`), or the one producer assigned to its
|
|
2626
|
+
* receiver in the same function (`const agent = request.agent(app)`).
|
|
2627
|
+
*/
|
|
2628
|
+
function supertestProducerOf(call, calls, producers, locals) {
|
|
2629
|
+
if (call.receiverCall && call.receiverCallStart != null && !call.receiver) {
|
|
2630
|
+
return producers.byStart.get(call.receiverCallStart) || null;
|
|
2631
|
+
}
|
|
2632
|
+
if (!call.receiver || locals.has(call.receiver)) return null;
|
|
2633
|
+
const scoped = (producers.byLocal.get(call.receiver) || []).filter(record =>
|
|
2634
|
+
(record.enclosingFunction?.startLine || 0) === (call.enclosingFunction?.startLine || 0) &&
|
|
2635
|
+
record.line <= call.line);
|
|
2636
|
+
return scoped.length === 1 ? scoped[0] : null;
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1203
2639
|
/**
|
|
1204
2640
|
* Match a call against client request patterns.
|
|
1205
2641
|
* Returns { method, framework, methodInferred } or null.
|
|
@@ -1318,14 +2754,66 @@ function bridgeEndpoints(index) {
|
|
|
1318
2754
|
// ALL routes match every method
|
|
1319
2755
|
}
|
|
1320
2756
|
const allRoutes = routesByMethod.get('ALL') || [];
|
|
2757
|
+
// Same-file-only requests (in-process injection) consult only their own
|
|
2758
|
+
// file's routes: the same bucket order, pre-split by file.
|
|
2759
|
+
let routesByMethodFile = null;
|
|
2760
|
+
const fileBucket = (method, file) => {
|
|
2761
|
+
if (!routesByMethodFile) {
|
|
2762
|
+
routesByMethodFile = new Map();
|
|
2763
|
+
for (const [method, list] of routesByMethod) {
|
|
2764
|
+
const byFile = new Map();
|
|
2765
|
+
for (const r of list) {
|
|
2766
|
+
const bucket = byFile.get(r.absoluteFile);
|
|
2767
|
+
if (bucket) bucket.push(r);
|
|
2768
|
+
else byFile.set(r.absoluteFile, [r]);
|
|
2769
|
+
}
|
|
2770
|
+
routesByMethodFile.set(method, byFile);
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
return routesByMethodFile.get(method)?.get(file) || [];
|
|
2774
|
+
};
|
|
1321
2775
|
|
|
1322
2776
|
const bridges = [];
|
|
2777
|
+
// Every container key some route is served from (fix #392).
|
|
2778
|
+
let servedAppKeys = null;
|
|
2779
|
+
const servedApps = () => {
|
|
2780
|
+
if (!servedAppKeys) {
|
|
2781
|
+
servedAppKeys = new Set();
|
|
2782
|
+
for (const r of routes) for (const key of r.appKeys || []) servedAppKeys.add(key);
|
|
2783
|
+
for (const key of index._endpointsCache?.routeContainers || []) servedAppKeys.add(key);
|
|
2784
|
+
}
|
|
2785
|
+
return servedAppKeys;
|
|
2786
|
+
};
|
|
2787
|
+
// The app an in-process client request is served by (fix #392).
|
|
2788
|
+
let pyClients = null;
|
|
2789
|
+
let jsClients = null;
|
|
2790
|
+
const servedBy = (req) => {
|
|
2791
|
+
if (req.jsClientApp) {
|
|
2792
|
+
if (!jsClients) {
|
|
2793
|
+
jsClients = new (require('./inprocess-clients').JsClientResolver)(
|
|
2794
|
+
index, endpointsSession(index), index._endpointsCache?.jsRouteGraph || null);
|
|
2795
|
+
}
|
|
2796
|
+
return jsClients.resolve(req.absoluteFile, req.jsClientApp.start, req.jsClientApp.end);
|
|
2797
|
+
}
|
|
2798
|
+
if (!req.clientCall) return null;
|
|
2799
|
+
if (!pyClients) {
|
|
2800
|
+
pyClients = new (require('./inprocess-clients').PythonClientResolver)(
|
|
2801
|
+
index, endpointsSession(index), servedApps);
|
|
2802
|
+
}
|
|
2803
|
+
return pyClients.resolve(req.absoluteFile, req.line, req.clientCall.receiver, req.clientCall.name);
|
|
2804
|
+
};
|
|
1323
2805
|
|
|
1324
2806
|
for (const req of requests) {
|
|
1325
2807
|
const candidates = [];
|
|
1326
2808
|
// Pull buckets compatible with the request's method (or ALL when inferred)
|
|
1327
2809
|
const methodKey = req.method;
|
|
1328
|
-
if (req.
|
|
2810
|
+
if (req.sameFileOnly) {
|
|
2811
|
+
const methods = req.methodInferred ? [...routesByMethod.keys()]
|
|
2812
|
+
: methodKey === 'HEAD' ? [methodKey, 'ALL', 'GET'] : [methodKey, 'ALL'];
|
|
2813
|
+
for (const method of methods) {
|
|
2814
|
+
for (const r of fileBucket(method, req.absoluteFile)) candidates.push(r);
|
|
2815
|
+
}
|
|
2816
|
+
} else if (req.methodInferred) {
|
|
1329
2817
|
// Could match any method-bucket; but typical: try GET, then ALL
|
|
1330
2818
|
for (const list of routesByMethod.values()) {
|
|
1331
2819
|
for (const r of list) candidates.push(r);
|
|
@@ -1334,18 +2822,25 @@ function bridgeEndpoints(index) {
|
|
|
1334
2822
|
const list = routesByMethod.get(methodKey) || [];
|
|
1335
2823
|
for (const r of list) candidates.push(r);
|
|
1336
2824
|
for (const r of allRoutes) candidates.push(r);
|
|
2825
|
+
if (methodKey === 'HEAD') {
|
|
2826
|
+
for (const r of routesByMethod.get('GET') || []) {
|
|
2827
|
+
if (HEAD_SERVED_BY_GET.has(r.framework)) candidates.push(r);
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
1337
2830
|
}
|
|
1338
2831
|
|
|
2832
|
+
const found = [];
|
|
1339
2833
|
for (const route of candidates) {
|
|
2834
|
+
if (req.sameFileOnly && route.absoluteFile !== req.absoluteFile) continue;
|
|
1340
2835
|
const match = matchPath(route, req);
|
|
1341
2836
|
if (!match) continue;
|
|
1342
2837
|
|
|
1343
2838
|
// Method matching contributes to confidence
|
|
1344
|
-
const methodMatches = methodMatch(route.method, req.method);
|
|
2839
|
+
const methodMatches = methodMatch(route.method, req.method, route.framework);
|
|
1345
2840
|
if (!methodMatches.ok) continue;
|
|
1346
2841
|
|
|
1347
2842
|
const confidence = scoreMatch(match.matchType, methodMatches);
|
|
1348
|
-
|
|
2843
|
+
found.push({
|
|
1349
2844
|
route,
|
|
1350
2845
|
request: req,
|
|
1351
2846
|
matchType: match.matchType,
|
|
@@ -1353,6 +2848,44 @@ function bridgeEndpoints(index) {
|
|
|
1353
2848
|
confidence,
|
|
1354
2849
|
});
|
|
1355
2850
|
}
|
|
2851
|
+
// An in-process injection hits the instance its own test built:
|
|
2852
|
+
// prefer routes registered inside the request's enclosing function,
|
|
2853
|
+
// else any route of the file.
|
|
2854
|
+
const inScope = req.scopeLines
|
|
2855
|
+
? found.filter(b => b.route.line >= req.scopeLines[0] && b.route.line <= req.scopeLines[1])
|
|
2856
|
+
: [];
|
|
2857
|
+
// A client built from an app reaches only that app's routes (fix
|
|
2858
|
+
// #392); an app whose routes the model does not hold, or a client
|
|
2859
|
+
// whose app did not resolve, keeps every path match, marked unscoped.
|
|
2860
|
+
const served = found.length > 0 ? servedBy(req) : null;
|
|
2861
|
+
// An app declared as a function or class serves the request itself.
|
|
2862
|
+
if (served?.noRoutes) continue;
|
|
2863
|
+
// A JS client's app (fix #397): the routes whose router is the app
|
|
2864
|
+
// or is mounted under it; a route whose router the graph did not
|
|
2865
|
+
// key stays a possible match, marked unscoped.
|
|
2866
|
+
if (served?.jsAppKey) {
|
|
2867
|
+
// The app's keyed routes are its routes when the app is a proven
|
|
2868
|
+
// router value (an `express()` / `new Koa()` binding) or some
|
|
2869
|
+
// matching route is keyed to it; an app serving only middleware
|
|
2870
|
+
// then bridges nowhere. A route the graph cannot key stays a
|
|
2871
|
+
// possible match, marked unscoped.
|
|
2872
|
+
const verdicts = found.map(b => jsClients.routeServedBy(b.route, served.jsAppKey));
|
|
2873
|
+
const servesApp = jsClients.isRouterValue(served.jsAppKey) || verdicts.includes(true);
|
|
2874
|
+
found.forEach((b, i) => {
|
|
2875
|
+
if (verdicts[i] === null || !servesApp) bridges.push({ ...b, unscoped: true });
|
|
2876
|
+
else if (verdicts[i]) bridges.push(b);
|
|
2877
|
+
});
|
|
2878
|
+
continue;
|
|
2879
|
+
}
|
|
2880
|
+
if (served?.appKey && servedApps().has(served.appKey)) {
|
|
2881
|
+
for (const b of found) if (b.route.appKeys?.has(served.appKey)) bridges.push(b);
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2884
|
+
if (served?.appKey || served?.inProcess) {
|
|
2885
|
+
for (const b of found) bridges.push({ ...b, unscoped: true });
|
|
2886
|
+
continue;
|
|
2887
|
+
}
|
|
2888
|
+
for (const b of (inScope.length > 0 || req.scopeOnly ? inScope : found)) bridges.push(b);
|
|
1356
2889
|
}
|
|
1357
2890
|
|
|
1358
2891
|
// For each (request) keep all matches but sort with best first
|
|
@@ -1374,35 +2907,74 @@ function bridgeEndpoints(index) {
|
|
|
1374
2907
|
}
|
|
1375
2908
|
|
|
1376
2909
|
/** True iff route method and client method are compatible. */
|
|
1377
|
-
|
|
2910
|
+
// Frameworks that answer HEAD with a GET route's handler (Starlette `Route`
|
|
2911
|
+
// adds HEAD to GET, Flask/werkzeug and Express route HEAD to GET).
|
|
2912
|
+
const HEAD_SERVED_BY_GET = new Set(['starlette', 'flask', 'express']);
|
|
2913
|
+
|
|
2914
|
+
function methodMatch(routeMethod, clientMethod, framework = null) {
|
|
1378
2915
|
if (routeMethod === 'ALL' || clientMethod === 'ALL') {
|
|
1379
2916
|
return { ok: true, inferred: true };
|
|
1380
2917
|
}
|
|
1381
2918
|
// 'USE' covers all methods
|
|
1382
2919
|
if (routeMethod === 'USE') return { ok: true, inferred: true };
|
|
2920
|
+
if (clientMethod === 'HEAD' && routeMethod === 'GET' && HEAD_SERVED_BY_GET.has(framework)) {
|
|
2921
|
+
return { ok: true, inferred: false };
|
|
2922
|
+
}
|
|
1383
2923
|
return { ok: routeMethod === clientMethod, inferred: false };
|
|
1384
2924
|
}
|
|
1385
2925
|
|
|
2926
|
+
// Per-route facts of its raw path, computed once per route object: an
|
|
2927
|
+
// unresolved part (`{?expr}`, not the proven any-text `{?*}`) and a trailing
|
|
2928
|
+
// any-text part.
|
|
2929
|
+
const routeShapeMemo = new WeakMap();
|
|
2930
|
+
function routeShape(route) {
|
|
2931
|
+
let shape = routeShapeMemo.get(route);
|
|
2932
|
+
if (!shape) {
|
|
2933
|
+
const raw = typeof route.path === 'string' ? route.path : '';
|
|
2934
|
+
shape = {
|
|
2935
|
+
unresolved: raw.includes('{?') && raw.split(ANY_TEXT).join('').includes('{?'),
|
|
2936
|
+
anyTail: raw.endsWith(ANY_TEXT),
|
|
2937
|
+
};
|
|
2938
|
+
routeShapeMemo.set(route, shape);
|
|
2939
|
+
}
|
|
2940
|
+
return shape;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
1386
2943
|
/**
|
|
1387
2944
|
* Determine match type between server route and client request.
|
|
1388
2945
|
* Returns {matchType: 'exact'|'partial'|'uncertain'} or null.
|
|
1389
2946
|
*/
|
|
1390
2947
|
function matchPath(route, req) {
|
|
1391
|
-
|
|
1392
|
-
const
|
|
2948
|
+
// ASP.NET routing is case-insensitive (fix #366).
|
|
2949
|
+
const sNorm = route.caseInsensitive ? route.normalizedPath.toLowerCase() : route.normalizedPath;
|
|
2950
|
+
const cNorm = route.caseInsensitive ? req.normalizedPath.toLowerCase() : req.normalizedPath;
|
|
1393
2951
|
if (sNorm === '' || cNorm === '') return null;
|
|
2952
|
+
// A route behind an unresolved part (`{?expr}` -> '**') matches only as
|
|
2953
|
+
// uncertain: the value is not proven. A proven catch-all ('**' from a
|
|
2954
|
+
// path converter or `*name`) matches like any other parameter.
|
|
2955
|
+
// Unresolved parts and any-text parts both normalize to '**'.
|
|
2956
|
+
const shape = sNorm.includes('**') ? routeShape(route) : null;
|
|
2957
|
+
if (shape && shape.unresolved) {
|
|
2958
|
+
return wildcardMatches(sNorm, cNorm) ? { matchType: 'uncertain' } : null;
|
|
2959
|
+
}
|
|
1394
2960
|
|
|
1395
2961
|
// Exact: both canonical paths identical AND neither has wildcards.
|
|
1396
2962
|
if (sNorm === cNorm) {
|
|
1397
2963
|
const hasWild = sNorm.includes('*');
|
|
1398
2964
|
if (hasWild) {
|
|
1399
|
-
|
|
2965
|
+
// A client path TEMPLATE (`/users/{id}`, generated SDKs) whose
|
|
2966
|
+
// parameters sit exactly where the route's do is the same
|
|
2967
|
+
// endpoint (fix #366); a literal/interpolated client value that
|
|
2968
|
+
// only fits the wildcard stays partial.
|
|
2969
|
+
const template = !req.interp && /\{[A-Za-z_][^}?]*\}|:[A-Za-z_]/.test(req.path || '');
|
|
2970
|
+
return { matchType: template ? 'exact' : 'partial' };
|
|
1400
2971
|
}
|
|
1401
2972
|
return { matchType: 'exact' };
|
|
1402
2973
|
}
|
|
1403
2974
|
|
|
1404
|
-
// Wildcard match: server has wildcards; client has literal.
|
|
1405
|
-
|
|
2975
|
+
// Wildcard match: server has wildcards; client has literal. A Django
|
|
2976
|
+
// regex view without `$` (a trailing `{?*}`) also serves its bare prefix.
|
|
2977
|
+
if (sNorm.includes('*') && wildcardMatches(sNorm, cNorm, !!shape && shape.anyTail)) {
|
|
1406
2978
|
return { matchType: 'partial' };
|
|
1407
2979
|
}
|
|
1408
2980
|
|
|
@@ -1434,16 +3006,45 @@ function matchPath(route, req) {
|
|
|
1434
3006
|
* /users/(*) vs /users/1/2 → false (single segment)
|
|
1435
3007
|
* /users/(*) vs /users → false
|
|
1436
3008
|
*/
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
3009
|
+
const wildcardRegexCache = new Map();
|
|
3010
|
+
|
|
3011
|
+
function wildcardMatches(pattern, literal, optionalTail = false) {
|
|
3012
|
+
// The anchored regex starts with the pattern's literal text up to the
|
|
3013
|
+
// first wildcard: a literal that does not start with it cannot match.
|
|
3014
|
+
// An optional trailing '/**' does not require its '/'.
|
|
3015
|
+
const tail = optionalTail && pattern.endsWith('/**');
|
|
3016
|
+
const star = pattern.indexOf('*');
|
|
3017
|
+
const prefixEnd = tail && star === pattern.length - 2 ? star - 1 : star;
|
|
3018
|
+
if (prefixEnd > 0 && !literal.startsWith(pattern.slice(0, prefixEnd))) return false;
|
|
3019
|
+
const key = tail ? `${pattern}\0tail` : pattern;
|
|
3020
|
+
let re = wildcardRegexCache.get(key);
|
|
3021
|
+
if (!re) {
|
|
3022
|
+
re = wildcardRegex(pattern, tail);
|
|
3023
|
+
// Patterns come from one project's routes; bound the memo anyway.
|
|
3024
|
+
if (wildcardRegexCache.size > 20000) wildcardRegexCache.clear();
|
|
3025
|
+
wildcardRegexCache.set(key, re);
|
|
3026
|
+
}
|
|
1444
3027
|
return re.test(literal);
|
|
1445
3028
|
}
|
|
1446
3029
|
|
|
3030
|
+
function wildcardRegex(pattern, optionalTail = false) {
|
|
3031
|
+
// Build a regex from the pattern: '*' → one segment's text ('[^/]+');
|
|
3032
|
+
// '**' (a catch-all or an unresolved part, fix #366/#383) → non-empty
|
|
3033
|
+
// text that may span segments. With `optionalTail`, a trailing '/**'
|
|
3034
|
+
// (text that may also be empty) may be absent: a canonical path has no
|
|
3035
|
+
// trailing slash.
|
|
3036
|
+
const trailing = optionalTail && pattern.endsWith('/**');
|
|
3037
|
+
const body = trailing ? pattern.slice(0, -3) : pattern;
|
|
3038
|
+
const escaped = body
|
|
3039
|
+
.split('**')
|
|
3040
|
+
.map(part => part
|
|
3041
|
+
.split('*')
|
|
3042
|
+
.map(seg => seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
3043
|
+
.join('[^/]+'))
|
|
3044
|
+
.join('.+');
|
|
3045
|
+
return new RegExp('^' + escaped + (trailing ? '(?:/.+)?' : '') + '$');
|
|
3046
|
+
}
|
|
3047
|
+
|
|
1447
3048
|
/** Numeric confidence based on match type and method certainty. */
|
|
1448
3049
|
function scoreMatch(matchType, methodCheck) {
|
|
1449
3050
|
let base;
|
|
@@ -1490,8 +3091,16 @@ function endpoints(index, options = {}) {
|
|
|
1490
3091
|
showUncertain: options.showUncertain !== false,
|
|
1491
3092
|
};
|
|
1492
3093
|
|
|
1493
|
-
const
|
|
1494
|
-
const
|
|
3094
|
+
const testByFile = new Map();
|
|
3095
|
+
const label = r => {
|
|
3096
|
+
let isTest = testByFile.get(r.file);
|
|
3097
|
+
if (isTest === undefined) {
|
|
3098
|
+
isTest = isTestPath(r.file);
|
|
3099
|
+
testByFile.set(r.file, isTest);
|
|
3100
|
+
}
|
|
3101
|
+
return { ...r, isTest };
|
|
3102
|
+
};
|
|
3103
|
+
const inScope = r => (!options.in || index.matchesFilters(r.file, { in: options.in })) &&
|
|
1495
3104
|
!(options.excludeTests && r.isTest);
|
|
1496
3105
|
let routes = (opts.clientOnly ? [] : extractServerRoutes(index)).map(label).filter(inScope);
|
|
1497
3106
|
let requests = (opts.serverOnly ? [] : extractClientRequests(index)).map(label).filter(inScope);
|