ucn 5.4.2 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/.claude/skills/ucn/SKILL.md +58 -236
  2. package/.claude/skills/ucn/references/commands.md +144 -68
  3. package/.claude/skills/ucn/references/resolution-rules.md +171 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +27 -33
  5. package/README.md +27 -16
  6. package/cli/index.js +45 -14
  7. package/core/accessors.js +118 -3
  8. package/core/account.js +62 -11
  9. package/core/analysis.js +517 -266
  10. package/core/ast-analysis.js +76 -117
  11. package/core/async-producers.js +1729 -0
  12. package/core/bridge.js +1877 -268
  13. package/core/brief.js +6 -0
  14. package/core/build-worker.js +78 -14
  15. package/core/cache.js +529 -31
  16. package/core/callers.js +12228 -921
  17. package/core/check.js +30 -38
  18. package/core/class-identity.js +1028 -0
  19. package/core/command-contracts.js +6 -6
  20. package/core/configuration-items.js +86 -0
  21. package/core/contract-membership.js +2262 -0
  22. package/core/cpp-scope.js +532 -0
  23. package/core/deadcode.js +171 -49
  24. package/core/decorator-binding.js +260 -0
  25. package/core/discovery.js +281 -164
  26. package/core/errors.js +52 -0
  27. package/core/execute.js +21 -9
  28. package/core/external-macros.js +326 -0
  29. package/core/graph-build.js +277 -8
  30. package/core/graph.js +35 -2
  31. package/core/ignore-rules.js +472 -0
  32. package/core/imports.js +504 -42
  33. package/core/index-ir.js +94 -11
  34. package/core/inprocess-clients.js +636 -0
  35. package/core/ir.js +87 -8
  36. package/core/macro-expansion.js +1416 -0
  37. package/core/output/analysis.js +18 -2
  38. package/core/output/brief.js +3 -0
  39. package/core/output/doctor.js +4 -0
  40. package/core/output/endpoints.js +15 -6
  41. package/core/output/find.js +7 -2
  42. package/core/output/refactoring.js +121 -14
  43. package/core/output/reporting.js +31 -3
  44. package/core/output/shared.js +14 -0
  45. package/core/output-budget.js +305 -122
  46. package/core/parallel-build.js +124 -47
  47. package/core/parser.js +3 -2
  48. package/core/project.js +472 -66
  49. package/core/provenance-facts.js +156 -12
  50. package/core/provenance-overload.js +30 -3
  51. package/core/provenance.js +104 -30
  52. package/core/pytest-fixtures.js +434 -0
  53. package/core/python-fixture-flow.js +17 -3
  54. package/core/python-modules.js +173 -0
  55. package/core/reflection.js +877 -0
  56. package/core/registry.js +1 -1
  57. package/core/reporting.js +234 -52
  58. package/core/request-helpers.js +561 -0
  59. package/core/route-graph.js +1922 -0
  60. package/core/route-lists.js +2414 -0
  61. package/core/rust-macro-expansion.js +3026 -0
  62. package/core/rust-modules.js +203 -0
  63. package/core/search.js +121 -29
  64. package/core/shared.js +19 -1
  65. package/core/test-name-bindings.js +256 -0
  66. package/core/type-denotation.js +1185 -0
  67. package/core/type-references.js +3229 -0
  68. package/core/verify.js +3360 -672
  69. package/languages/adapter.js +31 -5
  70. package/languages/c-family.js +4061 -212
  71. package/languages/c-macro-invocations.js +380 -0
  72. package/languages/c-preprocessor.js +254 -0
  73. package/languages/csharp.js +1406 -49
  74. package/languages/go.js +1243 -70
  75. package/languages/index.js +728 -6
  76. package/languages/java.js +473 -61
  77. package/languages/javascript.js +1270 -197
  78. package/languages/lexical-scope.js +1280 -0
  79. package/languages/python.js +1557 -72
  80. package/languages/rust-macro-rules.js +1020 -0
  81. package/languages/rust-value-flow.js +416 -0
  82. package/languages/rust.js +2265 -112
  83. package/languages/utils.js +298 -4
  84. package/mcp/server.js +22 -8
  85. package/package.json +1 -1
@@ -0,0 +1,2414 @@
1
+ /**
2
+ * core/route-lists.js - Declarative Python route tables for `endpoints`
3
+ * (fix #373).
4
+ *
5
+ * Django, Starlette, aiohttp and Django REST framework declare routes as
6
+ * DATA: `urlpatterns = [path(...), re_path(...), path("x/", include(...))]`,
7
+ * `routes = [Route(...), Mount("/api", routes=[...])]`,
8
+ * `app.add_routes([web.get(...)])`, `router.register("users", ViewSet)`.
9
+ * Flask `add_url_rule`, Starlette/FastAPI `add_route` / `add_api_route` and
10
+ * aiohttp `router.add_get` register imperatively on a proven app. None of
11
+ * these are decorators, so the symbol-driven extraction never saw them.
12
+ *
13
+ * Model (all AST, over the files that import a routing constructor):
14
+ *
15
+ * - A CONTAINER is a route list: a variable (`urlpatterns`, `routes`,
16
+ * `app = Starlette(routes=...)`, a DRF router, an aiohttp app), keyed by
17
+ * its declaring binding (`file:name` at module level, `file@scope:name`
18
+ * in a function), or an inline list / constructor call (`file#offset`).
19
+ * - A route constructor contributes a route to the container its value
20
+ * flows into (list elements, `+`, `*splat`, `urlpatterns += ...`,
21
+ * `.append/.extend`, `routes=` keyword arguments).
22
+ * - `include()`, `Mount`, `Host`, `add_subapp` and container references in
23
+ * a list are EDGES (target container served under the mounting
24
+ * container's prefixes + the edge prefix). Composition reuses the mount
25
+ * composition of core/bridge.js, so a Starlette/FastAPI app mounted
26
+ * inside a route list also composes with decorator routes.
27
+ * - `include("pkg.urls")` resolves the dotted module like an import (from
28
+ * the including file); the module's `urlpatterns` is the container.
29
+ * A module named by a `ROOT_URLCONF` setting is also a root.
30
+ *
31
+ * Route paths fold through core/route-graph.js constant evaluation; an
32
+ * unprovable part is a disclosed `{?expr}` segment. An include/mount target
33
+ * that cannot be resolved (external module, dynamic value) is never dropped:
34
+ * it becomes a disclosed `prefix/{?target}` route (derived 'include' /
35
+ * 'mount'). Regex routes (`re_path`, `url`) are normalized: named groups
36
+ * become `<name>` parameters, unnamed groups `<argN>`, an optional trailing
37
+ * slash is dropped, and the first construct with no path equivalent turns
38
+ * the remainder into `{?regex}` (derived 'regex').
39
+ *
40
+ * Methods come from the view's own definition: Django `require_*` / DRF
41
+ * `api_view` decorators, class-based view handlers (`get`/`post`/...,
42
+ * inherited through project classes; framework base classes contribute
43
+ * their documented handlers), Starlette `methods=` / function default GET,
44
+ * DRF viewset actions. A view that cannot be resolved serves 'ALL'.
45
+ */
46
+
47
+ 'use strict';
48
+
49
+ const path = require('path');
50
+ const routeGraph = require('./route-graph');
51
+ const { resolveImport } = require('./imports');
52
+ const { splitParentList } = require('./graph-build');
53
+ const { getCachedCalls } = require('./callers');
54
+ const { getParser } = require('../languages');
55
+
56
+ const { field, namedChildren, unwrap, evalString, findDecl } = routeGraph;
57
+
58
+ // ============================================================================
59
+ // FRAMEWORK API ROLES (by defining module + exported name)
60
+ // ============================================================================
61
+
62
+ const ROLES = new Map([
63
+ ['django.urls', { path: 'dj-path', re_path: 'dj-re-path', include: 'dj-include' }],
64
+ ['django.conf.urls', { url: 'dj-re-path', re_path: 'dj-re-path', include: 'dj-include' }],
65
+ ['django.conf.urls.i18n', { i18n_patterns: 'dj-i18n' }],
66
+ ['django.conf.urls.static', { static: 'dj-static' }],
67
+ ['starlette.routing', {
68
+ Route: 'st-route', WebSocketRoute: 'st-ws', Mount: 'st-mount', Host: 'st-host', Router: 'st-router',
69
+ }],
70
+ ['fastapi.routing', {
71
+ Route: 'st-route', WebSocketRoute: 'st-ws', Mount: 'st-mount', Host: 'st-host', APIRouter: 'st-router',
72
+ }],
73
+ ['starlette.applications', { Starlette: 'st-app' }],
74
+ ['starlette.staticfiles', { StaticFiles: 'st-static' }],
75
+ ['fastapi.staticfiles', { StaticFiles: 'st-static' }],
76
+ ['fastapi', { FastAPI: 'st-app', APIRouter: 'st-router' }],
77
+ ['rest_framework.routers', { DefaultRouter: 'drf-router', SimpleRouter: 'drf-router' }],
78
+ ['aiohttp.web', {
79
+ get: 'aio-verb', post: 'aio-verb', put: 'aio-verb', patch: 'aio-verb', delete: 'aio-verb',
80
+ head: 'aio-verb', options: 'aio-verb', route: 'aio-route', view: 'aio-view', static: 'aio-static',
81
+ Application: 'aio-app', RouteTableDef: 'aio-table',
82
+ }],
83
+ ]);
84
+
85
+ // Roles whose presence makes a file worth parsing (constructors that hold or
86
+ // declare routes; container-only roles such as FastAPI() do not).
87
+ const ANCHOR_ROLES = new Set([
88
+ 'dj-path', 'dj-re-path', 'dj-include', 'dj-i18n', 'dj-static',
89
+ 'st-route', 'st-ws', 'st-mount', 'st-host', 'st-router', 'st-app',
90
+ 'drf-router', 'aio-verb', 'aio-route', 'aio-view', 'aio-static', 'aio-app',
91
+ ]);
92
+
93
+ // Imperative registration methods, proven by the receiver's framework.
94
+ const METHOD_REGISTRATIONS = new Map([
95
+ ['add_url_rule', 'flask'],
96
+ ['add_route', 'starlette'],
97
+ ['add_websocket_route', 'starlette'],
98
+ ['add_api_route', 'starlette'],
99
+ ['add_api_websocket_route', 'starlette'],
100
+ ]);
101
+ const AIO_ROUTER_METHODS = {
102
+ add_get: 'GET', add_post: 'POST', add_put: 'PUT', add_patch: 'PATCH', add_delete: 'DELETE',
103
+ add_head: 'HEAD', add_options: 'OPTIONS', add_route: null, add_view: 'VIEW', add_static: 'STATIC',
104
+ };
105
+
106
+ // Modules whose import proves a receiver-registered framework in the file.
107
+ const FRAMEWORK_MODULES = {
108
+ flask: /^flask(\.|$)/,
109
+ starlette: /^(starlette|fastapi)(\.|$)/,
110
+ };
111
+
112
+ const REGISTRATION_NAMES = new Set([
113
+ ...METHOD_REGISTRATIONS.keys(), ...Object.keys(AIO_ROUTER_METHODS),
114
+ 'mount', 'add_routes', 'add_subapp', 'append', 'extend', 'insert', 'add', 'register',
115
+ ]);
116
+
117
+ const HTTP_VERB_HANDLERS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
118
+
119
+ // Framework base classes: the handlers they define (a class-based view's
120
+ // methods = its own handlers + those of its ancestors). `null` = serves
121
+ // every method. Keyed by defining-module prefix, then class name.
122
+ const FRAMEWORK_VIEW_BASES = [
123
+ [/^django\.views(\.generic(\.base|\.edit|\.list|\.detail|\.dates)?)?$/, {
124
+ View: [], TemplateView: ['get'], RedirectView: null,
125
+ ListView: ['get'], DetailView: ['get'], ArchiveIndexView: ['get'], YearArchiveView: ['get'],
126
+ MonthArchiveView: ['get'], WeekArchiveView: ['get'], DayArchiveView: ['get'],
127
+ TodayArchiveView: ['get'], DateDetailView: ['get'],
128
+ FormView: ['get', 'post', 'put'], CreateView: ['get', 'post', 'put'],
129
+ UpdateView: ['get', 'post', 'put'], DeleteView: ['get', 'post', 'delete'],
130
+ }],
131
+ [/^rest_framework\.views$/, { APIView: [] }],
132
+ [/^rest_framework\.generics$/, {
133
+ GenericAPIView: [], ListAPIView: ['get'], CreateAPIView: ['post'], RetrieveAPIView: ['get'],
134
+ DestroyAPIView: ['delete'], UpdateAPIView: ['put', 'patch'], ListCreateAPIView: ['get', 'post'],
135
+ RetrieveUpdateAPIView: ['get', 'put', 'patch'], RetrieveDestroyAPIView: ['get', 'delete'],
136
+ RetrieveUpdateDestroyAPIView: ['get', 'put', 'patch', 'delete'],
137
+ }],
138
+ [/^starlette\.endpoints$/, { HTTPEndpoint: [], WebSocketEndpoint: ['websocket'] }],
139
+ [/^flask\.views$/, { MethodView: [], View: [] }],
140
+ [/^aiohttp\.web(_urldispatcher)?$/, { View: [] }],
141
+ ];
142
+
143
+ // DRF viewset actions provided by framework bases.
144
+ const DRF_ACTION_BASES = [
145
+ [/^rest_framework\.viewsets$/, {
146
+ ViewSet: [], GenericViewSet: [], ViewSetMixin: [],
147
+ ModelViewSet: ['list', 'create', 'retrieve', 'update', 'partial_update', 'destroy'],
148
+ ReadOnlyModelViewSet: ['list', 'retrieve'],
149
+ }],
150
+ [/^rest_framework\.mixins$/, {
151
+ ListModelMixin: ['list'], CreateModelMixin: ['create'], RetrieveModelMixin: ['retrieve'],
152
+ UpdateModelMixin: ['update', 'partial_update'], DestroyModelMixin: ['destroy'],
153
+ }],
154
+ [/^rest_framework\.generics$/, { GenericAPIView: [] }],
155
+ ];
156
+ const DRF_LIST_ACTIONS = [['list', 'GET'], ['create', 'POST']];
157
+ const DRF_DETAIL_ACTIONS = [['retrieve', 'GET'], ['update', 'PUT'], ['partial_update', 'PATCH'], ['destroy', 'DELETE']];
158
+
159
+ const DECORATOR_METHODS = {
160
+ require_GET: ['GET'], require_POST: ['POST'], require_safe: ['GET', 'HEAD'],
161
+ };
162
+ const DECORATOR_MODULES = /^(django\.views\.decorators|rest_framework\.decorators)/;
163
+
164
+ // Roles that are route-list elements (a list holding one is a route list).
165
+ const ROUTE_ELEMENT_ROLES = new Set([
166
+ 'dj-path', 'dj-re-path', 'dj-static', 'st-route', 'st-ws', 'st-mount', 'st-host',
167
+ 'aio-verb', 'aio-route', 'aio-view', 'aio-static',
168
+ ]);
169
+
170
+ // Constructor roles whose value is a route list/app (include protocol).
171
+ const CONTAINER_ROLES = new Set(['dj-include', 'dj-i18n', 'st-router', 'st-app', 'drf-router', 'aio-app', 'aio-table']);
172
+
173
+ // Python nodes that hold statements (a function's return statements are
174
+ // found without visiting expressions).
175
+ const STATEMENT_CONTAINERS = new Set([
176
+ 'block', 'if_statement', 'elif_clause', 'else_clause', 'for_statement', 'while_statement',
177
+ 'try_statement', 'except_clause', 'except_group_clause', 'finally_clause', 'with_statement',
178
+ 'match_statement', 'case_clause',
179
+ ]);
180
+
181
+ const TRANSLATION_CALLS = new Set(['gettext', 'gettext_lazy', 'pgettext', 'pgettext_lazy', 'ugettext', 'ugettext_lazy']);
182
+
183
+ const MAX_DEPTH = 8;
184
+
185
+ // ============================================================================
186
+ // SMALL HELPERS
187
+ // ============================================================================
188
+
189
+ function positionalArgs(argsNode) {
190
+ return namedChildren(argsNode).filter(a => a.type !== 'keyword_argument' &&
191
+ a.type !== 'dictionary_splat' && a.type !== 'list_splat' && !a.type.includes('comment'));
192
+ }
193
+
194
+ function keywordArg(argsNode, name) {
195
+ for (const a of namedChildren(argsNode)) {
196
+ if (a.type !== 'keyword_argument') continue;
197
+ if ((field(a, 'name') || a.namedChild(0))?.text === name) return field(a, 'value') || a.namedChild(1);
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function callArgs(callNode) {
203
+ return field(callNode, 'arguments');
204
+ }
205
+
206
+ /** Argument by position, else by keyword. */
207
+ function argAt(callNode, index, name) {
208
+ const args = callArgs(callNode);
209
+ if (!args) return null;
210
+ const pos = positionalArgs(args);
211
+ return pos[index] || (name ? keywordArg(args, name) : null);
212
+ }
213
+
214
+ function lineOf(node) {
215
+ return node.startPosition.row + 1;
216
+ }
217
+
218
+ function stringList(sess, file, node) {
219
+ const n = unwrap(node);
220
+ if (!n) return null;
221
+ if (n.type === 'list' || n.type === 'tuple' || n.type === 'set') {
222
+ const out = [];
223
+ for (const el of namedChildren(n)) {
224
+ if (el.type.includes('comment')) continue;
225
+ const v = evalString(sess, file, el);
226
+ if (routeGraph.hasUnresolved(v)) return null;
227
+ out.push(v);
228
+ }
229
+ return out;
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /** Path-shaped folded route string: '' or starting with '/'. */
235
+ function rootedPath(value) {
236
+ return typeof value === 'string' && (value === '' || value.startsWith('/'));
237
+ }
238
+
239
+ /** Local binding of `name` in a Python file: { module, name } of the imported
240
+ * thing, `moduleBinding` when it binds a module. */
241
+ function importedAs(entry, local) {
242
+ const bindings = entry?.importBindings || [];
243
+ let binding = bindings.find(b => (b.alias || b.name) === local);
244
+ let original = binding ? binding.name : local;
245
+ if (!binding) {
246
+ const alias = (entry?.importAliases || []).find(a => a.local === local);
247
+ if (alias) {
248
+ original = alias.original;
249
+ binding = bindings.find(b => b.name === original);
250
+ }
251
+ }
252
+ if (!binding) return null;
253
+ return { binding, original, module: String(binding.module || '') };
254
+ }
255
+
256
+ // ============================================================================
257
+ // REGEX ROUTES
258
+ // ============================================================================
259
+
260
+ // Every character but the regex metacharacters matches itself (non-ASCII
261
+ // letters included, fix #383).
262
+ const REGEX_LITERAL = /[^\\^$.|?*+()[\]{}]/;
263
+ const NO_SLASH_ESCAPES = new Set(['d', 'w', 'D', 'W', 's']);
264
+
265
+ /**
266
+ * Whether a regular expression can match a '/' character (fix #383): a
267
+ * route parameter constrained by such a pattern (`(?P<url>.*)`,
268
+ * `{tail:.*}`) spans segments. Walks the pattern's atoms: `.`, `/`, the
269
+ * negated shorthands `\S` `\W` `\D`, and character classes whose set
270
+ * admits '/'.
271
+ */
272
+ function regexCanMatchSlash(src) {
273
+ const s = String(src || '');
274
+ const slashInClass = (body) => {
275
+ // body: class contents without brackets and negation.
276
+ for (let i = 0; i < body.length; i++) {
277
+ const c = body[i];
278
+ if (c === '\\') {
279
+ const n = body[i + 1];
280
+ i++;
281
+ if (n === '/' || n === 'S' || n === 'W' || n === 'D') return true;
282
+ continue;
283
+ }
284
+ if (c === '/') return true;
285
+ if (body[i + 1] === '-' && i + 2 < body.length && body[i + 2] !== ']') {
286
+ const lo = c.charCodeAt(0);
287
+ let hiChar = body[i + 2];
288
+ if (hiChar === '\\') hiChar = body[i + 3] || '';
289
+ const hi = hiChar.charCodeAt(0);
290
+ if (lo <= 0x2f && hi >= 0x2f) return true;
291
+ i += 2;
292
+ }
293
+ }
294
+ return false;
295
+ };
296
+ for (let i = 0; i < s.length; i++) {
297
+ const c = s[i];
298
+ if (c === '\\') {
299
+ const n = s[i + 1];
300
+ i++;
301
+ if (n === '/' || n === 'S' || n === 'W' || n === 'D') return true;
302
+ continue;
303
+ }
304
+ if (c === '.' || c === '/') return true;
305
+ if (c === '[') {
306
+ let j = i + 1;
307
+ const negated = s[j] === '^';
308
+ if (negated) j++;
309
+ const start = j;
310
+ if (s[j] === ']') j++;
311
+ while (j < s.length && s[j] !== ']') { if (s[j] === '\\') j++; j++; }
312
+ const hasSlash = slashInClass(s.slice(start, j));
313
+ if (negated ? !hasSlash : hasSlash) return true;
314
+ i = j;
315
+ }
316
+ }
317
+ return false;
318
+ }
319
+
320
+ // Text a Django regex route matches without spelling it (an unanchored
321
+ // start or end, fix #383). Spelled like an unresolved part, but proven: the
322
+ // matcher treats it as a catch-all, not as an unknown value (no expression
323
+ // text can produce it: unresolved parts never hold a bare `*`).
324
+ const ANY_TEXT = '{?*}';
325
+
326
+ /**
327
+ * Normalize a Django regex route into path form. Returns { path, exact,
328
+ * anchored, startAnchored }: `exact` false when part of the regex has no
329
+ * path equivalent (the rest is a `{?regex}` segment); `startAnchored` /
330
+ * `anchored` whether the regex is anchored at its start (`^`) / end (`$`,
331
+ * `\Z`). A group whose pattern can match '/' spans segments and is written
332
+ * `<path:name>` (Django's own spelling for such a parameter, fix #383).
333
+ */
334
+ function regexToPath(regex) {
335
+ let src = String(regex);
336
+ let startAnchored = false;
337
+ if (src.startsWith('^')) { src = src.slice(1); startAnchored = true; }
338
+ else if (src.startsWith('\\A')) { src = src.slice(2); startAnchored = true; }
339
+ let anchored = false;
340
+ if (src.endsWith('\\Z')) { src = src.slice(0, -2); anchored = true; }
341
+ else if (src.endsWith('$') && !src.endsWith('\\$')) { src = src.slice(0, -1); anchored = true; }
342
+ let out = '';
343
+ let argIndex = 0;
344
+ let i = 0;
345
+ // The unconverted remainder keeps its literal '/' separators (fix #383):
346
+ // each top-level piece between them is literal text or a disclosed
347
+ // unresolved part, so a concatenating resolver joins the right text.
348
+ const rest = () => {
349
+ const remainder = src.slice(i);
350
+ if (!remainder) return routeGraph.unresolved('regex');
351
+ const pieces = splitTopLevelSlashes(remainder);
352
+ if (pieces.length === 1) return routeGraph.unresolved(remainder);
353
+ return pieces.map(piece => {
354
+ if (!piece) return '';
355
+ const sub = regexToPath(piece);
356
+ return sub.exact && sub.startAnchored === false && !sub.anchored ? sub.path : routeGraph.unresolved(piece);
357
+ }).join('/');
358
+ };
359
+ const quantifierAt = (j) => j < src.length && /[?*+{]/.test(src[j]);
360
+ while (i < src.length) {
361
+ const ch = src[i];
362
+ // A disclosed unresolved part of the folded regex string.
363
+ if (ch === '{' && src[i + 1] === '?') {
364
+ const close = src.indexOf('}', i);
365
+ if (close < 0) return { path: out + rest(), exact: false, startAnchored };
366
+ out += src.slice(i, close + 1);
367
+ i = close + 1;
368
+ continue;
369
+ }
370
+ if (ch === '(') {
371
+ const close = matchParen(src, i);
372
+ if (close < 0) return { path: out + rest(), exact: false, startAnchored };
373
+ const inner = src.slice(i + 1, close);
374
+ if (quantifierAt(close + 1)) return { path: out + rest(), exact: false, startAnchored };
375
+ const named = inner.match(/^\?P<([A-Za-z_][A-Za-z0-9_]*)>([\s\S]*)$/);
376
+ if (named) {
377
+ out += regexCanMatchSlash(named[2]) ? `<path:${named[1]}>` : `<${named[1]}>`;
378
+ } else if (inner.startsWith('?')) {
379
+ return { path: out + rest(), exact: false, startAnchored };
380
+ } else {
381
+ argIndex++;
382
+ out += regexCanMatchSlash(inner) ? `<path:arg${argIndex}>` : `<arg${argIndex}>`;
383
+ }
384
+ i = close + 1;
385
+ continue;
386
+ }
387
+ if (ch === '\\' && i + 1 < src.length) {
388
+ const next = src[i + 1];
389
+ if (NO_SLASH_ESCAPES.has(next) || /[A-Za-z0-9]/.test(next)) {
390
+ return { path: out + rest(), exact: false, startAnchored };
391
+ }
392
+ if (quantifierAt(i + 2)) return { path: out + rest(), exact: false, startAnchored };
393
+ out += next;
394
+ i += 2;
395
+ continue;
396
+ }
397
+ if (ch === '/' || REGEX_LITERAL.test(ch)) {
398
+ if (quantifierAt(i + 1)) {
399
+ // `/?` closing the pattern: the trailing slash is optional.
400
+ if (ch === '/' && src[i + 1] === '?' && i + 2 === src.length) { i += 2; continue; }
401
+ return { path: out + rest(), exact: false, startAnchored };
402
+ }
403
+ out += ch;
404
+ i++;
405
+ continue;
406
+ }
407
+ return { path: out + rest(), exact: false, startAnchored };
408
+ }
409
+ return { path: out, exact: true, anchored, startAnchored };
410
+ }
411
+
412
+ /** Split a regex at the '/' characters outside classes and groups. */
413
+ function splitTopLevelSlashes(src) {
414
+ const pieces = [];
415
+ let depth = 0;
416
+ let inClass = false;
417
+ let start = 0;
418
+ for (let j = 0; j < src.length; j++) {
419
+ const c = src[j];
420
+ if (c === '\\') { j++; continue; }
421
+ if (inClass) { if (c === ']') inClass = false; continue; }
422
+ if (c === '[') { inClass = true; continue; }
423
+ if (c === '(') depth++;
424
+ else if (c === ')') depth--;
425
+ else if (c === '/' && depth === 0) {
426
+ pieces.push(src.slice(start, j));
427
+ start = j + 1;
428
+ }
429
+ }
430
+ pieces.push(src.slice(start));
431
+ return pieces;
432
+ }
433
+
434
+ function matchParen(src, open) {
435
+ let depth = 0;
436
+ let inClass = false;
437
+ for (let j = open; j < src.length; j++) {
438
+ const c = src[j];
439
+ if (c === '\\') { j++; continue; }
440
+ if (inClass) { if (c === ']') inClass = false; continue; }
441
+ if (c === '[') { inClass = true; continue; }
442
+ if (c === '(') depth++;
443
+ else if (c === ')') { depth--; if (depth === 0) return j; }
444
+ }
445
+ return -1;
446
+ }
447
+
448
+ // ============================================================================
449
+ // SESSION
450
+ // ============================================================================
451
+
452
+ // Candidate files smaller than this parse fully (a partial parse only pays
453
+ // off on large modules holding a few route lists, e.g. test modules).
454
+ const PARTIAL_PARSE_MIN_CHARS = 16384;
455
+
456
+ /**
457
+ * The route-list pass's AST session. While the pass processes a large
458
+ * candidate file (the ACTIVE file), that file is parsed over the ranges the
459
+ * pass reads (tree positions stay file positions) and its scope
460
+ * declarations are cached apart; every other lookup - including lookups
461
+ * into that file from other files - sees the shared endpoints session's full
462
+ * trees. Partial trees never enter the shared session or the parser's
463
+ * content cache.
464
+ */
465
+ class ListSession extends routeGraph.AstSession {
466
+ constructor(index, main, rangesFor) {
467
+ super(index);
468
+ this.main = main;
469
+ this.rangesFor = rangesFor;
470
+ this.activeFile = null;
471
+ this.activePartial = false;
472
+ const shared = new Map();
473
+ const partial = new Map();
474
+ const route = key => (this.activePartial && key.startsWith(`${this.activeFile}#`) ? partial : shared);
475
+ this.partialScopes = partial;
476
+ this.scopeDecls = {
477
+ get: key => route(key).get(key),
478
+ set: (key, value) => route(key).set(key, value),
479
+ has: key => route(key).has(key),
480
+ };
481
+ }
482
+
483
+ /** Process `file` with its partial tree (if any) until `leave()`. */
484
+ enter(file) {
485
+ this.activeFile = file;
486
+ this.partialScopes.clear();
487
+ this.activePartial = !!this.partialTree(file);
488
+ }
489
+
490
+ leave() {
491
+ this.activeFile = null;
492
+ this.activePartial = false;
493
+ this.partialScopes.clear();
494
+ }
495
+
496
+ root(file) {
497
+ if (file === this.activeFile && this.activePartial) return this.partialTree(file).rootNode;
498
+ return this.main.root(file);
499
+ }
500
+
501
+ partialTree(file) {
502
+ if (this.trees.has(file)) return this.trees.get(file);
503
+ const ranges = this.rangesFor(file);
504
+ let tree = null;
505
+ if (ranges) {
506
+ const parser = getParser('python');
507
+ const text = this.text(file);
508
+ for (const size of [1 << 20, text.length * 2, text.length * 4, 64 << 20]) {
509
+ try {
510
+ tree = parser.parse(text, undefined, { includedRanges: ranges, bufferSize: Math.max(size, 1 << 20) });
511
+ break;
512
+ } catch (e) { tree = null; }
513
+ }
514
+ }
515
+ this.trees.set(file, tree);
516
+ return tree;
517
+ }
518
+
519
+ text(file) { return this.main.text(file); }
520
+
521
+ snippet(lang, code) { return this.main.snippet(lang, code); }
522
+ }
523
+
524
+ // ============================================================================
525
+ // COLLECTOR
526
+ // ============================================================================
527
+
528
+ class RouteListCollector {
529
+ constructor(index, sess, sharedFiles = new Set()) {
530
+ this.index = index;
531
+ this.plans = new Map();
532
+ this.sess = new ListSession(index, sess, file => this.partialRanges(file, sharedFiles));
533
+ this.routes = []; // { containerKey, method, path, handler, file, line, framework, derived, raw }
534
+ this.edges = []; // { targetKey, mounterKey, prefix }
535
+ this.roots = new Set(); // container keys served at '/' in addition to their mounts
536
+ this.containers = new Set(); // app/router constructor keys (fix #392)
537
+ this.processed = new Set();
538
+ this.moduleFileMemo = new Map();
539
+ this.defMemo = new Map();
540
+ this.methodMemo = new Map();
541
+ this.deferred = [];
542
+ this.roleMemo = new Map();
543
+ this.returnsDone = new Set();
544
+ }
545
+
546
+ // ── candidates ───────────────────────────────────────────────────────
547
+
548
+ /**
549
+ * Per candidate file, the call records worth locating: calls of a
550
+ * route-table constructor bound by import, calls on an imported routing
551
+ * module (`web.get`), and registration methods in files that import the
552
+ * registering framework. Settings modules naming ROOT_URLCONF are listed
553
+ * separately. Files without such records are never parsed.
554
+ */
555
+ candidates() {
556
+ const files = [];
557
+ const settings = [];
558
+ for (const [file, entry] of this.index.files) {
559
+ if (entry.language !== 'python') continue;
560
+ if ((entry.moduleAssignedNames || []).includes('ROOT_URLCONF')) settings.push(file);
561
+ const plan = this.filePlan(file, entry);
562
+ if (plan) files.push([file, plan]);
563
+ }
564
+ return { files, settings };
565
+ }
566
+
567
+ filePlan(file, entry) {
568
+ const bindings = entry.importBindings || [];
569
+ if (bindings.length === 0) return null;
570
+ const roleNames = new Set(); // bare-call names bound to an anchor role
571
+ const moduleNames = new Set(); // module bindings holding anchor roles
572
+ let drf = false;
573
+ const frameworks = new Set();
574
+ for (const b of bindings) {
575
+ const module = String(b.module || '');
576
+ const local = b.alias || b.name;
577
+ const roles = ROLES.get(module);
578
+ if (roles && ANCHOR_ROLES.has(roles[b.name])) {
579
+ roleNames.add(local);
580
+ if (roles[b.name] === 'drf-router') drf = true;
581
+ }
582
+ const sub = ROLES.get(`${module}.${b.name}`);
583
+ if (sub && Object.values(sub).some(r => ANCHOR_ROLES.has(r))) {
584
+ moduleNames.add(local);
585
+ if (Object.values(sub).includes('drf-router')) drf = true;
586
+ }
587
+ if (module.startsWith('flask') || module.startsWith('starlette') || module.startsWith('fastapi')) {
588
+ for (const [fw, re] of Object.entries(FRAMEWORK_MODULES)) if (re.test(module)) frameworks.add(fw);
589
+ }
590
+ }
591
+ for (const a of entry.importAliases || []) {
592
+ const b = bindings.find(x => x.name === a.original);
593
+ const roles = b && ROLES.get(String(b.module || ''));
594
+ if (roles && ANCHOR_ROLES.has(roles[a.original])) {
595
+ roleNames.add(a.local);
596
+ if (roles[a.original] === 'drf-router') drf = true;
597
+ }
598
+ }
599
+ const anchored = roleNames.size > 0 || moduleNames.size > 0;
600
+ if (!anchored && frameworks.size === 0) return null;
601
+ const calls = getCachedCalls(this.index, file) || [];
602
+ const pointed = [];
603
+ for (const c of calls) {
604
+ if (!c.callSite || c.callSite.start == null) continue;
605
+ if (!c.isMethod) {
606
+ if (roleNames.has(c.name)) pointed.push(c);
607
+ continue;
608
+ }
609
+ const recv = c.receiver || c.receiverRoot;
610
+ if (moduleNames.has(recv)) { pointed.push(c); continue; }
611
+ if (frameworks.has(METHOD_REGISTRATIONS.get(c.name)) ||
612
+ (frameworks.has('starlette') && c.name === 'mount') ||
613
+ (anchored && (AIO_ROUTER_METHODS[c.name] !== undefined || c.name === 'add_routes' ||
614
+ c.name === 'add_subapp')) ||
615
+ (drf && c.name === 'register')) {
616
+ pointed.push(c);
617
+ }
618
+ }
619
+ const urlconf = anchored && (entry.moduleAssignedNames || []).includes('urlpatterns');
620
+ if (pointed.length === 0 && !urlconf) return null;
621
+ return { pointed, urlconf };
622
+ }
623
+
624
+ // ── roles ────────────────────────────────────────────────────────────
625
+
626
+ /** Framework role of a callee expression in `file`, or null. */
627
+ roleOf(file, calleeNode) {
628
+ const fn = unwrap(calleeNode);
629
+ if (!fn) return null;
630
+ const key = `${file}#${fn.startIndex}:${fn.endIndex}`;
631
+ let role = this.roleMemo.get(key);
632
+ if (role === undefined) {
633
+ role = this.computeRole(file, fn);
634
+ this.roleMemo.set(key, role);
635
+ }
636
+ return role;
637
+ }
638
+
639
+ computeRole(file, fn) {
640
+ const entry = this.index.files.get(file);
641
+ if (fn.type === 'identifier') {
642
+ const imp = importedAs(entry, fn.text);
643
+ if (!imp || imp.binding.kind === 'import') return null;
644
+ // A local redefinition shadows the import.
645
+ const decl = findDecl(this.sess, file, fn.text, fn);
646
+ if (decl && decl.kind !== 'param') return null;
647
+ const roles = ROLES.get(imp.module);
648
+ return (roles && roles[imp.original]) || null;
649
+ }
650
+ if (fn.type === 'attribute') {
651
+ const obj = unwrap(field(fn, 'object'));
652
+ const attr = field(fn, 'attribute')?.text;
653
+ if (!obj || obj.type !== 'identifier' || !attr) return null;
654
+ const imp = importedAs(entry, obj.text);
655
+ if (!imp) return null;
656
+ const module = imp.binding.kind === 'import' ? imp.module : `${imp.module}.${imp.original}`;
657
+ const roles = ROLES.get(module);
658
+ return (roles && roles[attr]) || null;
659
+ }
660
+ return null;
661
+ }
662
+
663
+ // ── keys ─────────────────────────────────────────────────────────────
664
+
665
+ inlineKey(file, node) {
666
+ return `${file}#${node.startIndex}`;
667
+ }
668
+
669
+ /** Container key of a variable reference (declaring binding). */
670
+ varKey(file, identNode, depth = 0) {
671
+ const name = identNode.text;
672
+ // A class attribute (`urlpatterns = [...]` in a class used as a
673
+ // URLconf) belongs to its class, not to the module.
674
+ for (let n = identNode.parent; n; n = n.parent) {
675
+ if (n.type === 'function_definition' || n.type === 'lambda' || n.type === 'module') break;
676
+ if (n.type === 'class_definition') return `${file}@${n.startIndex}:${name}`;
677
+ }
678
+ const decl = findDecl(this.sess, file, name, identNode);
679
+ if (decl) {
680
+ if (decl.scope.parent === null) return `${file}:${name}`;
681
+ return `${file}@${decl.scope.startIndex}:${name}`;
682
+ }
683
+ const entry = this.index.files.get(file);
684
+ const imp = importedAs(entry, name);
685
+ const target = imp && this.importTarget(entry, imp);
686
+ if (target && !target.module) return `${target.file}:${target.name}`;
687
+ return `${file}:${name}`;
688
+ }
689
+
690
+ /** Key of a module-level VARIABLE imported into `file` (chased through
691
+ * re-exports); a module binding answers the module's `urlpatterns`.
692
+ * Functions, classes and unknown names answer null. */
693
+ importedKey(file, name, depth = 0) {
694
+ if (depth > MAX_DEPTH) return null;
695
+ const entry = this.index.files.get(file);
696
+ const imp = importedAs(entry, name);
697
+ if (!imp) return null;
698
+ const target = this.importTarget(entry, imp);
699
+ if (!target) return null;
700
+ if (target.module) return `${target.file}:urlpatterns`;
701
+ return this.moduleVarKey(target.file, target.name, depth + 1);
702
+ }
703
+
704
+ /** `file:name` when `name` is assigned at module level of `file`. */
705
+ moduleVarKey(file, name, depth = 0) {
706
+ const entry = this.index.files.get(file);
707
+ if (!entry || depth > MAX_DEPTH) return null;
708
+ if ((entry.moduleAssignedNames || []).includes(name)) return `${file}:${name}`;
709
+ return this.importedKey(file, name, depth + 1);
710
+ }
711
+
712
+ /** What an import binding denotes: { file, module: true } for a module,
713
+ * { file, name } for a module-level name. */
714
+ importTarget(entry, imp) {
715
+ const resolved = spec => entry.moduleResolved?.[spec];
716
+ const abs = rel => path.join(this.index.root, rel);
717
+ const { binding, original, module } = imp;
718
+ if (binding.kind === 'import') {
719
+ const rel = resolved(module);
720
+ return rel ? { file: abs(rel), module: true } : null;
721
+ }
722
+ const subSpec = module.endsWith('.') ? module + original : `${module}.${original}`;
723
+ const subRel = resolved(subSpec);
724
+ if (subRel) return { file: abs(subRel), module: true };
725
+ const rel = resolved(module);
726
+ return rel ? { file: abs(rel), name: original } : null;
727
+ }
728
+
729
+ /** Dotted module path (`include("blog.urls")`) -> indexed file. */
730
+ moduleFile(file, spec) {
731
+ const key = `${path.dirname(file)}\0${spec}`;
732
+ if (this.moduleFileMemo.has(key)) return this.moduleFileMemo.get(key);
733
+ let out = null;
734
+ if (/^\.?[A-Za-z_][\w.]*$/.test(spec)) {
735
+ try {
736
+ const abs = resolveImport(spec, file, { language: 'python', root: this.index.root });
737
+ if (abs && this.index.files.has(abs)) out = abs;
738
+ } catch (e) { out = null; }
739
+ }
740
+ this.moduleFileMemo.set(key, out);
741
+ return out;
742
+ }
743
+
744
+ /** Container key a reference expression denotes, or null. */
745
+ refKey(file, node) {
746
+ const n = unwrap(node);
747
+ if (!n) return null;
748
+ if (n.type === 'identifier') {
749
+ const decl = findDecl(this.sess, file, n.text, n);
750
+ if (decl) {
751
+ if (decl.kind !== 'var') return null;
752
+ return this.varKey(file, n);
753
+ }
754
+ return this.importedKey(file, n.text) ||
755
+ ((this.index.files.get(file)?.moduleAssignedNames || []).includes(n.text) ? `${file}:${n.text}` : null);
756
+ }
757
+ if (n.type === 'attribute') {
758
+ const obj = unwrap(field(n, 'object'));
759
+ const attr = field(n, 'attribute')?.text;
760
+ if (!obj || !attr || obj.type !== 'identifier') return null;
761
+ // `router.urls` (DRF router), `app.routes` (Starlette app).
762
+ const valueRole = this.valueRole(file, obj);
763
+ if (valueRole === 'drf-router' && attr === 'urls') return this.varKey(file, obj);
764
+ if ((valueRole === 'st-app' || valueRole === 'st-router') && attr === 'routes') return this.varKey(file, obj);
765
+ // `module.urlpatterns` / `module.name`.
766
+ const decl = findDecl(this.sess, file, obj.text, obj);
767
+ if (decl) return null;
768
+ const entry = this.index.files.get(file);
769
+ const imp = importedAs(entry, obj.text);
770
+ const target = imp && this.importTarget(entry, imp);
771
+ if (target && target.module) return this.moduleVarKey(target.file, attr);
772
+ }
773
+ return null;
774
+ }
775
+
776
+ /** Role of the constructor call a variable was assigned from. */
777
+ valueRole(file, identNode, depth = 0) {
778
+ if (depth > MAX_DEPTH) return null;
779
+ const decl = findDecl(this.sess, file, identNode.text, identNode);
780
+ if (decl) {
781
+ const v = unwrap(decl.valueNode);
782
+ if (decl.count === 1 && v && v.type === 'call') return this.roleOf(file, field(v, 'function'));
783
+ return null;
784
+ }
785
+ const entry = this.index.files.get(file);
786
+ const imp = importedAs(entry, identNode.text);
787
+ const target = imp && this.importTarget(entry, imp);
788
+ if (!target || target.module) return null;
789
+ const root = this.sess.root(target.file);
790
+ const tdecl = root && routeGraph.scopeDecls(this.sess, target.file, 'python', root).get(target.name);
791
+ const v = tdecl && unwrap(tdecl.valueNode);
792
+ if (tdecl && tdecl.count === 1 && v && v.type === 'call') return this.roleOf(target.file, field(v, 'function'));
793
+ return null;
794
+ }
795
+
796
+ // ── emission ─────────────────────────────────────────────────────────
797
+
798
+ addRoute(containerKey, rec) {
799
+ this.routes.push({ containerKey, ...rec });
800
+ }
801
+
802
+ /** A mount/include edge. `framework` decides how the prefix joins what
803
+ * it mounts (fix #383): Django concatenates the pattern strings. */
804
+ addEdge(targetKey, mounterKey, prefix, framework = null) {
805
+ if (!targetKey || (targetKey === mounterKey && !prefix)) return;
806
+ this.edges.push({ targetKey, mounterKey, prefix: prefix || '',
807
+ ...(framework === 'django' && { join: 'concat' }) });
808
+ }
809
+
810
+ /** An include/mount whose target cannot be resolved: disclosed wildcard. */
811
+ addUnresolvedMount(file, containerKey, node, prefix, framework, derived, handler) {
812
+ const literal = routeGraph.isStringLiteral(unwrap(node)) ? evalString(this.sess, file, node) : null;
813
+ const segment = literal && !routeGraph.hasUnresolved(literal) ? routeGraph.unresolved(literal)
814
+ : routeGraph.unresolved(node);
815
+ const own = joinSegment(prefix, segment, framework);
816
+ this.addRoute(containerKey, {
817
+ method: 'ALL', path: own, handler: handler || (unwrap(node)?.type === 'call' ? shortText(node) : exprText(node)), file, line: lineOf(node),
818
+ framework, derived,
819
+ });
820
+ }
821
+
822
+ // ── walk ─────────────────────────────────────────────────────────────
823
+
824
+ collectFile(file, plan) {
825
+ this.sess.enter(file);
826
+ try {
827
+ this.collectActiveFile(file, plan);
828
+ } finally {
829
+ this.sess.leave();
830
+ }
831
+ }
832
+
833
+ collectActiveFile(file, plan) {
834
+ const root = this.sess.root(file);
835
+ if (!root) return;
836
+ // Module-level `urlpatterns` statements (Django's URLconf protocol),
837
+ // including those under module-level if/try blocks.
838
+ if (plan.urlconf) {
839
+ const visit = (container) => {
840
+ for (const stmt of namedChildren(container)) {
841
+ if (stmt.type === 'expression_statement') {
842
+ const e = stmt.namedChild(0);
843
+ if (e && (e.type === 'assignment' || e.type === 'augmented_assignment')) this.visitAssignment(file, e);
844
+ } else if (stmt.type === 'if_statement' || stmt.type === 'try_statement' ||
845
+ stmt.type === 'else_clause' || stmt.type === 'elif_clause' || stmt.type === 'except_clause' ||
846
+ stmt.type === 'finally_clause' || stmt.type === 'block' || stmt.type === 'with_statement') {
847
+ visit(stmt);
848
+ }
849
+ }
850
+ };
851
+ visit(root);
852
+ }
853
+ for (const rec of plan.pointed) {
854
+ const call = this.locateCall(root, rec);
855
+ if (!call || this.isDone(file, call)) continue;
856
+ this.visitContext(file, call);
857
+ }
858
+ }
859
+
860
+ /** The call node a record points at (its callee name span). */
861
+ locateCall(root, rec) {
862
+ let n = root.descendantForIndex(rec.callSite.start, rec.callSite.end);
863
+ if (!n) return null;
864
+ if (n.parent && n.parent.type === 'attribute' && field(n.parent, 'attribute') &&
865
+ field(n.parent, 'attribute').startIndex === n.startIndex) {
866
+ n = n.parent;
867
+ }
868
+ const call = n.parent;
869
+ if (!call || call.type !== 'call') return null;
870
+ const fn = field(call, 'function');
871
+ return fn && fn.startIndex === n.startIndex && fn.endIndex === n.endIndex ? call : null;
872
+ }
873
+
874
+ /** Process the statement a pointed call belongs to, top-down, so the
875
+ * call lands in the container its value flows into. */
876
+ visitContext(file, call) {
877
+ for (let n = call.parent; n; n = n.parent) {
878
+ if (n.type === 'function_definition') { this.processReturns(file, n); break; }
879
+ if (n.type === 'lambda' || n.type === 'class_definition') break;
880
+ }
881
+ if (this.isDone(file, call)) return;
882
+ let top = call;
883
+ while (top.parent) {
884
+ const p = top.parent;
885
+ if (p.type === 'expression_statement' || p.type === 'return_statement' || p.type === 'block' ||
886
+ p.type === 'module' || p.type === 'lambda' || p.type === 'decorator') break;
887
+ top = p;
888
+ }
889
+ if (top.type === 'assignment' || top.type === 'augmented_assignment') {
890
+ // The pointed call sits in the assigned value when it is a role call.
891
+ const right = field(top, 'right');
892
+ const inRight = right && call.startIndex >= right.startIndex && call.endIndex <= right.endIndex;
893
+ this.visitAssignment(file, top, inRight && !!this.roleOf(file, field(call, 'function')));
894
+ } else if (top.type === 'call' && !this.isDone(file, top)) {
895
+ this.visitCall(file, top, null);
896
+ }
897
+ if (this.isDone(file, call)) return;
898
+ // Outermost role/registration call between the statement and the
899
+ // pointed call (route lists passed to a helper, returned, ...).
900
+ const chain = [];
901
+ for (let n = call; n; n = n.parent) {
902
+ if (n.type === 'call') chain.push(n);
903
+ if (n.startIndex === top.startIndex && n.endIndex === top.endIndex) break;
904
+ }
905
+ for (let i = chain.length - 1; i >= 0; i--) {
906
+ const c = chain[i];
907
+ if (this.isDone(file, c)) continue;
908
+ if (this.roleOf(file, field(c, 'function')) || this.methodRegistration(file, c)) {
909
+ this.visitCall(file, c, null);
910
+ if (this.isDone(file, call)) return;
911
+ }
912
+ }
913
+ if (!this.isDone(file, call)) this.visitCall(file, call, null);
914
+ }
915
+
916
+ visitAssignment(file, node, holdsRoleCall = false) {
917
+ const left = field(node, 'left');
918
+ const right = field(node, 'right');
919
+ if (!left || !right || left.type !== 'identifier') return;
920
+ const moduleLevel = isModuleLevel(node);
921
+ // Django's URLconf protocol: a module's `urlpatterns` is its route list.
922
+ const isUrlconf = moduleLevel && left.text === 'urlpatterns';
923
+ if (!isUrlconf && !holdsRoleCall && !this.containsRoleCall(file, right)) return;
924
+ const key = this.varKey(file, left);
925
+ this.evalContainer(file, right, key, 0, isUrlconf);
926
+ }
927
+
928
+ containsRoleCall(file, node, budget = { n: 200 }) {
929
+ const stack = [node];
930
+ while (stack.length > 0) {
931
+ const n = stack.pop();
932
+ if (--budget.n < 0) return false;
933
+ if (n.type === 'call') {
934
+ if (this.roleOf(file, field(n, 'function'))) return true;
935
+ }
936
+ if (n.type === 'lambda' || n.type === 'function_definition') continue;
937
+ for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
938
+ }
939
+ return false;
940
+ }
941
+
942
+ /**
943
+ * Evaluate a route-list expression into `containerKey`. `proven`: the
944
+ * value is known to be a route list (a URLconf's `urlpatterns`, an
945
+ * include()/`routes=` argument, a list with a route-constructor
946
+ * element), so an element that is not a route constructor is an
947
+ * include-like entry (linked or disclosed); in any other list it is
948
+ * ignored.
949
+ */
950
+ evalContainer(file, node, containerKey, depth = 0, proven = true) {
951
+ const n = unwrap(node);
952
+ if (!n || depth > 40) return;
953
+ switch (n.type) {
954
+ case 'list':
955
+ case 'tuple':
956
+ case 'expression_list':
957
+ case 'set': {
958
+ const elements = namedChildren(n).filter(el => !el.type.includes('comment'));
959
+ const listProven = proven || elements.some(el => this.isRouteElement(file, el));
960
+ for (const el of elements) this.evalContainer(file, el, containerKey, depth + 1, listProven);
961
+ return;
962
+ }
963
+ case 'list_splat':
964
+ case 'parenthesized_expression':
965
+ for (const el of namedChildren(n)) this.evalContainer(file, el, containerKey, depth + 1, proven);
966
+ return;
967
+ case 'binary_operator':
968
+ this.evalContainer(file, field(n, 'left'), containerKey, depth + 1, proven);
969
+ this.evalContainer(file, field(n, 'right'), containerKey, depth + 1, proven);
970
+ return;
971
+ case 'conditional_expression':
972
+ for (const el of namedChildren(n).slice(0, 1).concat(namedChildren(n).slice(2))) {
973
+ this.evalContainer(file, el, containerKey, depth + 1, proven);
974
+ }
975
+ return;
976
+ case 'call': {
977
+ if (this.isDone(file, n)) return;
978
+ const role = this.roleOf(file, field(n, 'function'));
979
+ if (role || this.methodRegistration(file, n)) {
980
+ this.visitCall(file, n, containerKey);
981
+ return;
982
+ }
983
+ // A transform of route lists (`format_suffix_patterns([...])`,
984
+ // `list(...)`): the routes it wraps flow into the container.
985
+ if (this.containsRoleCall(file, n)) {
986
+ this.markDone(file, n);
987
+ for (const a of namedChildren(callArgs(n))) {
988
+ const v = a.type === 'keyword_argument' ? field(a, 'value') : a;
989
+ if (v) this.evalContainer(file, v, containerKey, depth + 1, false);
990
+ }
991
+ return;
992
+ }
993
+ if (!proven) return;
994
+ this.markDone(file, n);
995
+ this.linkTarget(file, n, containerKey, '', this.frameworkOfContainerFile(file), 'include');
996
+ return;
997
+ }
998
+ case 'identifier':
999
+ case 'attribute':
1000
+ if (proven) this.linkTarget(file, n, containerKey, '', this.frameworkOfContainerFile(file), 'include');
1001
+ return;
1002
+ case 'list_comprehension':
1003
+ case 'generator_expression': {
1004
+ const body = field(n, 'body');
1005
+ if (body) this.evalContainer(file, body, containerKey, depth + 1, proven);
1006
+ return;
1007
+ }
1008
+ default:
1009
+ return;
1010
+ }
1011
+ }
1012
+
1013
+ /** A direct route-constructor element of a list. */
1014
+ isRouteElement(file, el) {
1015
+ const n = unwrap(el);
1016
+ if (!n || n.type !== 'call') return false;
1017
+ const role = this.roleOf(file, field(n, 'function'));
1018
+ return !!role && ROUTE_ELEMENT_ROLES.has(role);
1019
+ }
1020
+
1021
+ frameworkOfContainerFile(file) {
1022
+ const entry = this.index.files.get(file);
1023
+ const modules = (entry?.importBindings || []).map(b => String(b.module || ''));
1024
+ if (modules.some(m => /^django\.(urls|conf\.urls)/.test(m))) return 'django';
1025
+ if (modules.some(m => /^aiohttp/.test(m))) return 'aiohttp';
1026
+ return 'starlette';
1027
+ }
1028
+
1029
+ /** Method registrations on a proven app (`app.add_url_rule`, ...). */
1030
+ methodRegistration(file, callNode) {
1031
+ const fn = unwrap(field(callNode, 'function'));
1032
+ if (!fn || fn.type !== 'attribute') return null;
1033
+ const method = field(fn, 'attribute')?.text;
1034
+ const recv = unwrap(field(fn, 'object'));
1035
+ if (!method || !recv || !REGISTRATION_NAMES.has(method)) return null;
1036
+ const fw = METHOD_REGISTRATIONS.get(method);
1037
+ if (fw && recv.type === 'identifier' && recv.text !== 'self' && recv.text !== 'cls' &&
1038
+ this.fileFrameworks(file).has(fw)) {
1039
+ return { kind: fw, method, recv };
1040
+ }
1041
+ if (method === 'mount' && recv.type === 'identifier' && this.fileFrameworks(file).has('starlette')) {
1042
+ return { kind: 'mount', method, recv };
1043
+ }
1044
+ // aiohttp: `app.router.add_get(...)`, `app.add_routes([...])`,
1045
+ // `app.add_subapp(prefix, sub)` on a web.Application() value.
1046
+ if (recv.type === 'attribute' && field(recv, 'attribute')?.text === 'router' &&
1047
+ Object.prototype.hasOwnProperty.call(AIO_ROUTER_METHODS, method)) {
1048
+ const app = unwrap(field(recv, 'object'));
1049
+ if (app && app.type === 'identifier' && this.valueRole(file, app) === 'aio-app') {
1050
+ return { kind: 'aiohttp', method, recv: app };
1051
+ }
1052
+ }
1053
+ if ((method === 'add_routes' || method === 'add_subapp') && recv.type === 'identifier' &&
1054
+ this.valueRole(file, recv) === 'aio-app') {
1055
+ return { kind: 'aiohttp', method, recv };
1056
+ }
1057
+ if (method === 'add_routes' && recv.type === 'attribute' && field(recv, 'attribute')?.text === 'router') {
1058
+ const app = unwrap(field(recv, 'object'));
1059
+ if (app && app.type === 'identifier' && this.valueRole(file, app) === 'aio-app') {
1060
+ return { kind: 'aiohttp', method, recv: app };
1061
+ }
1062
+ }
1063
+ // `urlpatterns.append(path(...))`, `routes.extend([...])`
1064
+ if ((method === 'append' || method === 'extend' || method === 'insert' || method === 'add') &&
1065
+ recv.type === 'identifier' && this.containsRoleCall(file, callArgs(callNode) || callNode)) {
1066
+ return { kind: 'list-mutation', method, recv };
1067
+ }
1068
+ // `router.register(...)` on a DRF router value.
1069
+ if (method === 'register' && recv.type === 'identifier' && this.valueRole(file, recv) === 'drf-router') {
1070
+ return { kind: 'drf', method, recv };
1071
+ }
1072
+ return null;
1073
+ }
1074
+
1075
+ markDone(file, node) {
1076
+ this.processed.add(`${file}#${node.startIndex}:${node.endIndex}`);
1077
+ }
1078
+
1079
+ isDone(file, node) {
1080
+ return this.processed.has(`${file}#${node.startIndex}:${node.endIndex}`);
1081
+ }
1082
+
1083
+ fileFrameworks(file) {
1084
+ if (!this.frameworksMemo) this.frameworksMemo = new Map();
1085
+ let set = this.frameworksMemo.get(file);
1086
+ if (!set) {
1087
+ set = new Set();
1088
+ for (const b of this.index.files.get(file)?.importBindings || []) {
1089
+ const module = String(b.module || '');
1090
+ for (const [fw, re] of Object.entries(FRAMEWORK_MODULES)) if (re.test(module)) set.add(fw);
1091
+ }
1092
+ this.frameworksMemo.set(file, set);
1093
+ }
1094
+ return set;
1095
+ }
1096
+
1097
+ visitCall(file, callNode, containerKey) {
1098
+ const role = this.roleOf(file, field(callNode, 'function'));
1099
+ if (role) {
1100
+ this.markDone(file, callNode);
1101
+ this.visitRoleCall(file, callNode, role, containerKey);
1102
+ return;
1103
+ }
1104
+ const reg = this.methodRegistration(file, callNode);
1105
+ if (!reg) return;
1106
+ this.markDone(file, callNode);
1107
+ this.visitRegistration(file, callNode, reg);
1108
+ }
1109
+
1110
+ /** Own container of a constructor call (assigned variable or inline). */
1111
+ ctorKey(file, callNode) {
1112
+ const parent = callNode.parent;
1113
+ if (parent && (parent.type === 'assignment') && field(parent, 'right') &&
1114
+ field(parent, 'right').startIndex === callNode.startIndex) {
1115
+ const left = field(parent, 'left');
1116
+ if (left && left.type === 'identifier') return this.varKey(file, left);
1117
+ }
1118
+ return this.inlineKey(file, callNode);
1119
+ }
1120
+
1121
+ visitRoleCall(file, callNode, role, containerKey) {
1122
+ switch (role) {
1123
+ case 'dj-path':
1124
+ case 'dj-re-path':
1125
+ this.visitDjangoPath(file, callNode, role, containerKey);
1126
+ return;
1127
+ case 'dj-include': {
1128
+ // A bare include() outside path(): its target joins the container.
1129
+ this.linkTarget(file, callNode, containerKey, '', 'django', 'include');
1130
+ return;
1131
+ }
1132
+ case 'dj-i18n': {
1133
+ // LocalePrefixPattern matches `<language_code>/` (and
1134
+ // nothing for the default language when
1135
+ // prefix_default_language=False), concatenated with the
1136
+ // patterns it holds.
1137
+ const key = this.inlineKey(file, callNode);
1138
+ const prefixDefault = keywordArg(callArgs(callNode), 'prefix_default_language');
1139
+ this.addEdge(key, containerKey, '{?language_code}/', 'django');
1140
+ if (prefixDefault && prefixDefault.text === 'False') this.addEdge(key, containerKey, '', 'django');
1141
+ for (const a of positionalArgs(callArgs(callNode))) this.evalContainer(file, a, key);
1142
+ return;
1143
+ }
1144
+ case 'dj-static': {
1145
+ const prefix = argAt(callNode, 0, 'prefix');
1146
+ const value = prefix ? evalString(this.sess, file, prefix) : '{?prefix}';
1147
+ this.addRoute(containerKey, {
1148
+ method: 'GET', path: joinSegment(stripLeading(value), '<path:path>', 'django'), handler: 'serve',
1149
+ file, line: lineOf(callNode), framework: 'django', derived: 'static',
1150
+ });
1151
+ return;
1152
+ }
1153
+ case 'st-route':
1154
+ case 'st-ws':
1155
+ this.visitStarletteRoute(file, callNode, role, containerKey);
1156
+ return;
1157
+ case 'st-mount':
1158
+ case 'st-host':
1159
+ this.visitStarletteMount(file, callNode, role, containerKey);
1160
+ return;
1161
+ case 'st-router':
1162
+ case 'st-app': {
1163
+ const key = this.ctorKey(file, callNode);
1164
+ // Every app/router constructed, routes or not (fix #392: an
1165
+ // in-process client of an app with no routes reaches none).
1166
+ this.containers.add(key);
1167
+ if (containerKey && key !== containerKey) this.addEdge(key, containerKey, '');
1168
+ const routes = keywordArg(callArgs(callNode), 'routes') ||
1169
+ (role === 'st-router' ? argAt(callNode, 0, null) : null);
1170
+ if (routes) this.evalContainer(file, routes, key);
1171
+ return;
1172
+ }
1173
+ case 'st-static':
1174
+ if (containerKey) {
1175
+ this.addRoute(containerKey, {
1176
+ method: 'GET', path: '/{path:path}', handler: 'StaticFiles', file,
1177
+ line: lineOf(callNode), framework: 'starlette', derived: 'static',
1178
+ });
1179
+ }
1180
+ return;
1181
+ case 'aio-verb':
1182
+ case 'aio-route':
1183
+ case 'aio-view':
1184
+ case 'aio-static':
1185
+ this.visitAiohttpDef(file, callNode, role, containerKey);
1186
+ return;
1187
+ case 'aio-app':
1188
+ case 'aio-table':
1189
+ case 'drf-router':
1190
+ return;
1191
+ default:
1192
+ return;
1193
+ }
1194
+ }
1195
+
1196
+ // ── Django ───────────────────────────────────────────────────────────
1197
+
1198
+ visitDjangoPath(file, callNode, role, containerKey) {
1199
+ const routeNode = argAt(callNode, 0, role === 'dj-path' ? 'route' : 'regex') ||
1200
+ argAt(callNode, 0, 'route');
1201
+ const viewNode = unwrap(argAt(callNode, 1, 'view'));
1202
+ if (!routeNode) return;
1203
+ const raw = this.routeString(file, routeNode);
1204
+ let own = raw;
1205
+ let derived = null;
1206
+ const isInclude = !!viewNode && this.isIncludeView(file, viewNode);
1207
+ if (role === 'dj-re-path') {
1208
+ const r = regexToPath(raw);
1209
+ own = r.path;
1210
+ derived = 'regex';
1211
+ // Django matches a regex route with `search` (fix #383): without
1212
+ // `^` it matches after any leading text, and an endpoint without
1213
+ // `$` also serves any trailing text. A part that already spans
1214
+ // segments absorbs it.
1215
+ const resolved = r.exact && !routeGraph.hasUnresolved(raw);
1216
+ if (resolved && !r.startAnchored && !own.startsWith('<path:')) own = `${ANY_TEXT}${own}`;
1217
+ if (resolved && !isInclude && !r.anchored && !/<path:[A-Za-z0-9_]+>$/.test(own)) own = `${own}${ANY_TEXT}`;
1218
+ }
1219
+ const line = lineOf(callNode);
1220
+ if (!viewNode) {
1221
+ this.addRoute(containerKey, { method: 'ALL', path: own, handler: '<anonymous>', file, line,
1222
+ framework: 'django', derived, raw: `path ${raw}` });
1223
+ return;
1224
+ }
1225
+ // include(...) / a URL list / `x.urls`: an edge.
1226
+ if (isInclude) {
1227
+ this.linkTarget(file, viewNode, containerKey, own, 'django', 'include');
1228
+ return;
1229
+ }
1230
+ const view = this.viewMethods(file, viewNode, 'django');
1231
+ for (const method of view.methods) {
1232
+ this.addRoute(containerKey, {
1233
+ method, path: own, handler: view.handler, file, line, framework: 'django',
1234
+ derived, raw: `${method} ${raw}`,
1235
+ });
1236
+ }
1237
+ }
1238
+
1239
+ /** Whether a path() view argument is an include (Django: a list/tuple
1240
+ * of patterns, include(), or an object's `urls` include tuple) rather
1241
+ * than a callable view. */
1242
+ isIncludeView(file, viewNode) {
1243
+ const n = unwrap(viewNode);
1244
+ if (n.type === 'call') return this.roleOf(file, field(n, 'function')) === 'dj-include';
1245
+ if (n.type === 'list' || n.type === 'tuple') return true;
1246
+ if (n.type !== 'identifier' && n.type !== 'attribute') return false;
1247
+ if (n.type === 'attribute' && field(n, 'attribute')?.text === 'urls') return true;
1248
+ const v = this.refValue(file, n);
1249
+ if (v) return this.isContainerValue(v.file, v.node);
1250
+ return (n.type === 'identifier' ? n.text : field(n, 'attribute')?.text) === 'urlpatterns';
1251
+ }
1252
+
1253
+ isContainerValue(file, node) {
1254
+ const n = unwrap(node);
1255
+ if (!n) return false;
1256
+ if (n.type === 'list' || n.type === 'tuple' || n.type === 'list_comprehension' ||
1257
+ n.type === 'binary_operator') return true;
1258
+ if (n.type === 'call') return CONTAINER_ROLES.has(this.roleOf(file, field(n, 'function')));
1259
+ if (n.type === 'attribute' && field(n, 'attribute')?.text === 'urls') return true;
1260
+ return false;
1261
+ }
1262
+
1263
+ inlineList(file, listNode) {
1264
+ const key = this.inlineKey(file, listNode);
1265
+ this.evalContainer(file, listNode, key);
1266
+ return key;
1267
+ }
1268
+
1269
+ /**
1270
+ * Link what an include/mount/list reference denotes into `mounterKey`
1271
+ * under `prefix`: a dotted module path, a URL list (inline or by
1272
+ * reference), an inline router/app, a project function that builds and
1273
+ * returns a URL list (decided once every file is collected), else a
1274
+ * disclosed `prefix/{?target}` route.
1275
+ */
1276
+ linkTarget(file, node, mounterKey, prefix, framework, derived) {
1277
+ const n = unwrap(node);
1278
+ if (!n) return;
1279
+ if (routeGraph.isStringLiteral(n)) {
1280
+ const spec = evalString(this.sess, file, n);
1281
+ const modFile = routeGraph.hasUnresolved(spec) ? null : this.moduleFile(file, spec);
1282
+ if (modFile) this.addEdge(`${modFile}:urlpatterns`, mounterKey, prefix, framework);
1283
+ else this.addUnresolvedMount(file, mounterKey, n, prefix, framework, derived);
1284
+ return;
1285
+ }
1286
+ if (n.type === 'tuple') {
1287
+ const first = namedChildren(n).find(c => !c.type.includes('comment'));
1288
+ if (first) this.linkTarget(file, first, mounterKey, prefix, framework, derived);
1289
+ return;
1290
+ }
1291
+ if (n.type === 'list') {
1292
+ this.addEdge(this.inlineList(file, n), mounterKey, prefix, framework);
1293
+ return;
1294
+ }
1295
+ if (n.type === 'call') {
1296
+ const role = this.roleOf(file, field(n, 'function'));
1297
+ if (role === 'dj-include') {
1298
+ this.markDone(file, n);
1299
+ const arg = argAt(n, 0, 'arg');
1300
+ if (arg) this.linkTarget(file, arg, mounterKey, prefix, framework, derived);
1301
+ return;
1302
+ }
1303
+ if (role === 'st-router' || role === 'st-app') {
1304
+ this.markDone(file, n);
1305
+ const inner = this.inlineKey(file, n);
1306
+ this.addEdge(inner, mounterKey, prefix, framework);
1307
+ const r = keywordArg(callArgs(n), 'routes') || (role === 'st-router' ? argAt(n, 0, null) : null);
1308
+ if (r) this.evalContainer(file, r, inner);
1309
+ return;
1310
+ }
1311
+ const def = role ? null : this.resolveDef(file, field(n, 'function'));
1312
+ if (def && def.kind === 'function') {
1313
+ this.deferred.push({ file, retKey: this.retKeyOfDef(def), mounterKey, prefix, node: n, framework, derived });
1314
+ return;
1315
+ }
1316
+ }
1317
+ if (n.type === 'identifier' || n.type === 'attribute') {
1318
+ const key = this.refKey(file, n);
1319
+ if (key && this.refIsContainer(file, n)) { this.addEdge(key, mounterKey, prefix, framework); return; }
1320
+ // A parameter: the routes are declared (and listed) at the caller.
1321
+ if (n.type === 'identifier' && findDecl(this.sess, file, n.text, n)?.kind === 'param') return;
1322
+ }
1323
+ this.addUnresolvedMount(file, mounterKey, n, prefix, framework, derived);
1324
+ }
1325
+
1326
+ /** A reference whose single assigned value is a route list or app (an
1327
+ * unknown value is trusted: the reference sits where routes go). */
1328
+ refIsContainer(file, n) {
1329
+ if (n.type === 'attribute' && field(n, 'attribute')?.text === 'urls') return true;
1330
+ const v = this.refValue(file, n);
1331
+ return !v || this.isContainerValue(v.file, v.node);
1332
+ }
1333
+
1334
+ /** Value node of a variable reference: { file, node } or null. */
1335
+ refValue(file, node, depth = 0) {
1336
+ const n = unwrap(node);
1337
+ if (!n || depth > MAX_DEPTH) return null;
1338
+ if (n.type === 'identifier') {
1339
+ const decl = findDecl(this.sess, file, n.text, n);
1340
+ if (decl) {
1341
+ return decl.kind === 'var' && decl.count === 1 && decl.valueNode
1342
+ ? { file, node: decl.valueNode } : null;
1343
+ }
1344
+ const entry = this.index.files.get(file);
1345
+ const imp = importedAs(entry, n.text);
1346
+ const target = imp && this.importTarget(entry, imp);
1347
+ return target && !target.module ? this.moduleVarValue(target.file, target.name, depth + 1) : null;
1348
+ }
1349
+ if (n.type === 'attribute') {
1350
+ const obj = unwrap(field(n, 'object'));
1351
+ const attr = field(n, 'attribute')?.text;
1352
+ if (!obj || !attr || obj.type !== 'identifier' || findDecl(this.sess, file, obj.text, obj)) return null;
1353
+ const entry = this.index.files.get(file);
1354
+ const imp = importedAs(entry, obj.text);
1355
+ const target = imp && this.importTarget(entry, imp);
1356
+ return target && target.module ? this.moduleVarValue(target.file, attr, depth + 1) : null;
1357
+ }
1358
+ return null;
1359
+ }
1360
+
1361
+ moduleVarValue(file, name, depth = 0) {
1362
+ const entry = this.index.files.get(file);
1363
+ if (!entry || depth > MAX_DEPTH) return null;
1364
+ if ((entry.moduleAssignedNames || []).includes(name)) {
1365
+ const root = this.sess.root(file);
1366
+ const decl = root && routeGraph.scopeDecls(this.sess, file, 'python', root).get(name);
1367
+ return decl && decl.count === 1 && decl.valueNode ? { file, node: decl.valueNode } : null;
1368
+ }
1369
+ const imp = importedAs(entry, name);
1370
+ const target = imp && this.importTarget(entry, imp);
1371
+ return target && !target.module ? this.moduleVarValue(target.file, target.name, depth + 1) : null;
1372
+ }
1373
+
1374
+ // ── function-built URL lists ─────────────────────────────────────────
1375
+
1376
+ retKeyOfDef(def) {
1377
+ const line = def.node ? field(def.node, 'name').startPosition.row + 1
1378
+ : (def.sym.nameLine || def.sym.startLine);
1379
+ return `${def.file}#ret:${def.name}:${line}`;
1380
+ }
1381
+
1382
+ /** Route lists a function returns become its return container. */
1383
+ processReturns(file, fnNode) {
1384
+ if (this.returnsDone.has(`${file}#${fnNode.startIndex}`)) return;
1385
+ this.returnsDone.add(`${file}#${fnNode.startIndex}`);
1386
+ const nameNode = field(fnNode, 'name');
1387
+ if (!nameNode) return;
1388
+ const key = this.retKeyOfDef({ file, name: nameNode.text, node: fnNode });
1389
+ // Statements only: return statements live in blocks and compound
1390
+ // statement clauses, never inside expressions.
1391
+ const stack = [field(fnNode, 'body')];
1392
+ while (stack.length > 0) {
1393
+ const n = stack.pop();
1394
+ if (!n) continue;
1395
+ if (n.type === 'return_statement') {
1396
+ const expr = n.namedChild(0);
1397
+ if (expr && this.isReturnedList(file, expr)) this.evalContainer(file, expr, key);
1398
+ continue;
1399
+ }
1400
+ if (!STATEMENT_CONTAINERS.has(n.type)) continue;
1401
+ for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
1402
+ }
1403
+ }
1404
+
1405
+ isReturnedList(file, expr) {
1406
+ const n = unwrap(expr);
1407
+ if (!n) return false;
1408
+ if (this.isContainerValue(file, n)) return true;
1409
+ if (n.type === 'call') return !!this.roleOf(file, field(n, 'function'));
1410
+ if (n.type === 'identifier') {
1411
+ const decl = findDecl(this.sess, file, n.text, n);
1412
+ return !!(decl && decl.kind === 'var' && decl.valueNode && this.isContainerValue(file, decl.valueNode));
1413
+ }
1414
+ return false;
1415
+ }
1416
+
1417
+ finalizeDeferred() {
1418
+ const filled = new Set();
1419
+ for (const r of this.routes) if (r.containerKey) filled.add(r.containerKey);
1420
+ for (const e of this.edges) if (e.mounterKey) filled.add(e.mounterKey);
1421
+ for (const d of this.deferred) {
1422
+ if (filled.has(d.retKey)) this.addEdge(d.retKey, d.mounterKey, d.prefix, d.framework);
1423
+ else this.addUnresolvedMount(d.file, d.mounterKey, d.node, d.prefix, d.framework, d.derived);
1424
+ }
1425
+ }
1426
+
1427
+ /** A Django route string: a lazily translated route (`_("users/")`,
1428
+ * gettext_lazy) is declared by its untranslated text. */
1429
+ routeString(file, node) {
1430
+ const n = unwrap(node);
1431
+ if (n && n.type === 'call') {
1432
+ const fn = unwrap(field(n, 'function'));
1433
+ const imp = fn && fn.type === 'identifier' ? importedAs(this.index.files.get(file), fn.text) : null;
1434
+ if (imp && imp.module === 'django.utils.translation' && TRANSLATION_CALLS.has(imp.original)) {
1435
+ const arg = argAt(n, /^p/.test(imp.original) ? 1 : 0, 'message');
1436
+ if (arg) return evalString(this.sess, file, arg);
1437
+ }
1438
+ }
1439
+ return evalString(this.sess, file, node);
1440
+ }
1441
+
1442
+ // ── Starlette ────────────────────────────────────────────────────────
1443
+
1444
+ visitStarletteRoute(file, callNode, role, containerKey) {
1445
+ const pathNode = argAt(callNode, 0, 'path');
1446
+ const endpoint = unwrap(argAt(callNode, 1, 'endpoint'));
1447
+ if (!pathNode) return;
1448
+ const own = evalString(this.sess, file, pathNode);
1449
+ const line = lineOf(callNode);
1450
+ let methods;
1451
+ let handler;
1452
+ if (role === 'st-ws') {
1453
+ methods = ['WS'];
1454
+ handler = endpoint ? (endpoint.type === 'call' ? exprText(field(endpoint, 'function')) : shortText(endpoint)) : '<anonymous>';
1455
+ } else {
1456
+ const explicit = keywordArg(callArgs(callNode), 'methods');
1457
+ const list = explicit && stringList(this.sess, file, explicit);
1458
+ const view = endpoint ? this.viewMethods(file, endpoint, 'starlette') : { methods: ['ALL'], handler: '<anonymous>' };
1459
+ methods = list && list.length > 0 ? [...new Set(list.map(m => m.toUpperCase()))] : view.methods;
1460
+ handler = view.handler;
1461
+ }
1462
+ for (const method of methods) {
1463
+ this.addRoute(containerKey, { method, path: own, handler, file, line, framework: 'starlette',
1464
+ raw: `${method} ${own}` });
1465
+ }
1466
+ }
1467
+
1468
+ visitStarletteMount(file, callNode, role, containerKey) {
1469
+ const args = callArgs(callNode);
1470
+ const prefixNode = role === 'st-mount' ? argAt(callNode, 0, 'path') : null;
1471
+ const prefix = prefixNode ? evalString(this.sess, file, prefixNode) : '';
1472
+ const routes = keywordArg(args, 'routes');
1473
+ const app = unwrap(keywordArg(args, 'app') || positionalArgs(args)[1] || null);
1474
+ const key = this.inlineKey(file, callNode);
1475
+ if (routes) {
1476
+ this.addEdge(key, containerKey, prefix);
1477
+ this.evalContainer(file, routes, key);
1478
+ return;
1479
+ }
1480
+ if (!app) return;
1481
+ if (app.type === 'call') {
1482
+ const appRole = this.roleOf(file, field(app, 'function'));
1483
+ if (appRole === 'st-router' || appRole === 'st-app') {
1484
+ this.markDone(file, app);
1485
+ const inner = this.inlineKey(file, app);
1486
+ this.addEdge(inner, containerKey, prefix);
1487
+ const r = keywordArg(callArgs(app), 'routes') || (appRole === 'st-router' ? argAt(app, 0, null) : null);
1488
+ if (r) this.evalContainer(file, r, inner);
1489
+ return;
1490
+ }
1491
+ if (appRole === 'st-static') {
1492
+ this.markDone(file, app);
1493
+ this.addRoute(containerKey, { method: 'GET', path: joinSegment(prefix, '{path:path}'),
1494
+ handler: 'StaticFiles', file, line: lineOf(callNode), framework: 'starlette', derived: 'static' });
1495
+ return;
1496
+ }
1497
+ }
1498
+ this.linkTarget(file, app, containerKey, prefix, 'starlette', 'mount');
1499
+ }
1500
+
1501
+ // ── aiohttp ──────────────────────────────────────────────────────────
1502
+
1503
+ visitAiohttpDef(file, callNode, role, containerKey) {
1504
+ const fn = unwrap(field(callNode, 'function'));
1505
+ const verb = field(fn, 'attribute')?.text || fn.text;
1506
+ const line = lineOf(callNode);
1507
+ if (role === 'aio-static') {
1508
+ const prefix = evalString(this.sess, file, argAt(callNode, 0, 'prefix'));
1509
+ this.addRoute(containerKey, { method: 'GET', path: joinSegment(prefix, '{filename:.*}'),
1510
+ handler: 'static', file, line, framework: 'aiohttp', derived: 'static' });
1511
+ return;
1512
+ }
1513
+ let method;
1514
+ let pathNode;
1515
+ let handlerNode;
1516
+ if (role === 'aio-route') {
1517
+ const m = evalString(this.sess, file, argAt(callNode, 0, 'method'));
1518
+ method = routeGraph.hasUnresolved(m) ? 'ALL' : (m === '*' ? 'ALL' : m.toUpperCase());
1519
+ pathNode = argAt(callNode, 1, 'path');
1520
+ handlerNode = argAt(callNode, 2, 'handler');
1521
+ } else {
1522
+ pathNode = argAt(callNode, 0, 'path');
1523
+ handlerNode = argAt(callNode, 1, 'handler');
1524
+ method = role === 'aio-view' ? null : verb.toUpperCase();
1525
+ }
1526
+ if (!pathNode) return;
1527
+ const own = evalString(this.sess, file, pathNode);
1528
+ const view = handlerNode ? this.viewMethods(file, unwrap(handlerNode), 'aiohttp') : { methods: ['ALL'], handler: '<anonymous>' };
1529
+ const methods = method ? [method] : view.methods;
1530
+ for (const m of methods) {
1531
+ this.addRoute(containerKey, { method: m, path: own, handler: view.handler, file, line,
1532
+ framework: 'aiohttp', raw: `${m} ${own}` });
1533
+ }
1534
+ }
1535
+
1536
+ // ── imperative registrations ────────────────────────────────────────
1537
+
1538
+ visitRegistration(file, callNode, reg) {
1539
+ const line = lineOf(callNode);
1540
+ if (reg.kind === 'list-mutation') {
1541
+ const key = this.varKey(file, reg.recv);
1542
+ const args = positionalArgs(callArgs(callNode));
1543
+ const values = reg.method === 'insert' ? args.slice(1) : args;
1544
+ for (const v of values) this.evalContainer(file, v, key);
1545
+ return;
1546
+ }
1547
+ if (reg.kind === 'drf') {
1548
+ this.visitDrfRegister(file, callNode, this.varKey(file, reg.recv), reg.recv);
1549
+ return;
1550
+ }
1551
+ const appKey = this.varKey(file, reg.recv);
1552
+ if (reg.kind === 'mount') {
1553
+ this.visitAppMount(file, callNode, appKey);
1554
+ return;
1555
+ }
1556
+ if (reg.kind === 'aiohttp') {
1557
+ if (reg.method === 'add_routes') {
1558
+ for (const v of positionalArgs(callArgs(callNode))) this.evalContainer(file, v, appKey);
1559
+ return;
1560
+ }
1561
+ if (reg.method === 'add_subapp') {
1562
+ const prefix = evalString(this.sess, file, argAt(callNode, 0, 'prefix'));
1563
+ const sub = argAt(callNode, 1, 'subapp');
1564
+ if (sub) this.linkTarget(file, sub, appKey, prefix, 'aiohttp', 'mount');
1565
+ return;
1566
+ }
1567
+ const fixed = AIO_ROUTER_METHODS[reg.method];
1568
+ if (fixed === 'STATIC') {
1569
+ const prefix = evalString(this.sess, file, argAt(callNode, 0, 'prefix'));
1570
+ this.addRoute(appKey, { method: 'GET', path: joinSegment(prefix, '{filename:.*}'), handler: 'static',
1571
+ file, line, framework: 'aiohttp', derived: 'static' });
1572
+ return;
1573
+ }
1574
+ let method = fixed;
1575
+ let offset = 0;
1576
+ if (fixed === null) {
1577
+ const m = evalString(this.sess, file, argAt(callNode, 0, 'method'));
1578
+ method = routeGraph.hasUnresolved(m) || m === '*' ? 'ALL' : m.toUpperCase();
1579
+ offset = 1;
1580
+ }
1581
+ const pathNode = argAt(callNode, offset, 'path');
1582
+ if (!pathNode) return;
1583
+ const own = evalString(this.sess, file, pathNode);
1584
+ const handlerNode = argAt(callNode, offset + 1, 'handler');
1585
+ const view = handlerNode ? this.viewMethods(file, unwrap(handlerNode), 'aiohttp') : { methods: ['ALL'], handler: '<anonymous>' };
1586
+ const methods = method === 'VIEW' ? view.methods : [method];
1587
+ for (const m of methods) {
1588
+ this.addRoute(appKey, { method: m, path: own, handler: view.handler, file, line, framework: 'aiohttp',
1589
+ raw: `${m} ${own}` });
1590
+ }
1591
+ return;
1592
+ }
1593
+ // Flask add_url_rule / Starlette add_route family.
1594
+ const pathNode = argAt(callNode, 0, reg.kind === 'flask' ? 'rule' : 'path');
1595
+ if (!pathNode) return;
1596
+ const own = evalString(this.sess, file, pathNode);
1597
+ if (!rootedPath(own)) return;
1598
+ const args = callArgs(callNode);
1599
+ const explicit = keywordArg(args, 'methods');
1600
+ const list = explicit && stringList(this.sess, file, explicit);
1601
+ let endpoint;
1602
+ let framework;
1603
+ let methods;
1604
+ if (reg.kind === 'flask') {
1605
+ framework = 'flask';
1606
+ endpoint = unwrap(keywordArg(args, 'view_func') || positionalArgs(args)[2] || null);
1607
+ } else {
1608
+ framework = reg.method.startsWith('add_api') ? 'fastapi' : 'starlette';
1609
+ // Starlette's add_route/add_websocket_route name the endpoint
1610
+ // `route`; FastAPI's add_api_route names it `endpoint`.
1611
+ endpoint = unwrap(argAt(callNode, 1, framework === 'fastapi' ? 'endpoint' : 'route'));
1612
+ }
1613
+ if (reg.method.includes('websocket')) methods = ['WS'];
1614
+ // FastAPI API routes default to GET.
1615
+ else if (framework === 'fastapi' && !(list && list.length > 0)) methods = ['GET'];
1616
+ // Flask: a rule with no view function serves GET (the endpoint's view
1617
+ // is bound later); Starlette requires an endpoint.
1618
+ const view = endpoint ? this.viewMethods(file, endpoint, reg.kind)
1619
+ : { methods: reg.kind === 'flask' ? ['GET'] : ['ALL'], handler: '<anonymous>' };
1620
+ if (!methods) methods = list && list.length > 0 ? [...new Set(list.map(m => m.toUpperCase()))] : view.methods;
1621
+ for (const method of methods) {
1622
+ this.addRoute(appKey, { method, path: own, handler: view.handler, file, line, framework,
1623
+ raw: `${method} ${own}` });
1624
+ }
1625
+ }
1626
+
1627
+ /** `app.mount(path, <app>)` with an inline app value: static files,
1628
+ * an inline Starlette/Router, or a disclosed unresolved mount. Named
1629
+ * project routers are composed by the Python router mounts. */
1630
+ visitAppMount(file, callNode, appKey) {
1631
+ const prefixNode = argAt(callNode, 0, 'path');
1632
+ const app = unwrap(argAt(callNode, 1, 'app'));
1633
+ if (!prefixNode || !app) return;
1634
+ const prefix = evalString(this.sess, file, prefixNode);
1635
+ if (!rootedPath(prefix)) return;
1636
+ if (app.type === 'identifier' || app.type === 'attribute') return;
1637
+ if (app.type === 'call') {
1638
+ const appRole = this.roleOf(file, field(app, 'function'));
1639
+ if (appRole === 'st-static') {
1640
+ this.markDone(file, app);
1641
+ this.addRoute(appKey, { method: 'GET', path: joinSegment(prefix, '{path:path}'), handler: 'StaticFiles',
1642
+ file, line: lineOf(callNode), framework: 'starlette', derived: 'static' });
1643
+ return;
1644
+ }
1645
+ if (appRole === 'st-router' || appRole === 'st-app') {
1646
+ this.markDone(file, app);
1647
+ const inner = this.inlineKey(file, app);
1648
+ this.addEdge(inner, appKey, prefix);
1649
+ const r = keywordArg(callArgs(app), 'routes') || (appRole === 'st-router' ? argAt(app, 0, null) : null);
1650
+ if (r) this.evalContainer(file, r, inner);
1651
+ return;
1652
+ }
1653
+ }
1654
+ this.addUnresolvedMount(file, appKey, app, prefix, 'starlette', 'mount');
1655
+ }
1656
+
1657
+ // ── DRF routers ──────────────────────────────────────────────────────
1658
+
1659
+ visitDrfRegister(file, callNode, routerKey, routerIdent) {
1660
+ const prefixNode = argAt(callNode, 0, 'prefix');
1661
+ const viewsetNode = unwrap(argAt(callNode, 1, 'viewset'));
1662
+ if (!prefixNode || !viewsetNode) return;
1663
+ const prefixRaw = evalString(this.sess, file, prefixNode);
1664
+ const prefix = regexToPath(prefixRaw).path;
1665
+ const line = lineOf(callNode);
1666
+ const trailing = this.drfTrailingSlash(file, routerIdent);
1667
+ const cls = this.resolveDef(file, viewsetNode);
1668
+ const handler = shortText(viewsetNode);
1669
+ const info = cls && cls.kind === 'class' ? this.viewsetInfo(cls) : null;
1670
+ const emit = (method, p, derivedHandler) => this.addRoute(routerKey, {
1671
+ method, path: p, handler: derivedHandler || handler, file, line,
1672
+ framework: 'django', derived: 'drf-router', raw: `${method} ${p}`,
1673
+ });
1674
+ const listPath = prefix + trailing;
1675
+ const lookup = info?.lookup || 'pk';
1676
+ const detailPath = `${prefix}/<${lookup}>${trailing}`;
1677
+ if (!info || info.actions == null) {
1678
+ emit('ALL', listPath);
1679
+ emit('ALL', detailPath);
1680
+ } else {
1681
+ for (const [action, method] of DRF_LIST_ACTIONS) if (info.actions.has(action)) emit(method, listPath);
1682
+ for (const [action, method] of DRF_DETAIL_ACTIONS) if (info.actions.has(action)) emit(method, detailPath);
1683
+ }
1684
+ for (const extra of info?.extraActions || []) {
1685
+ const base = extra.detail ? `${prefix}/<${lookup}>` : prefix;
1686
+ for (const m of extra.methods) emit(m, `${base}/${extra.urlPath}${trailing}`, `${handler}.${extra.name}`);
1687
+ }
1688
+ // DefaultRouter serves an API root view at the router's mount point.
1689
+ if (!this.drfRootEmitted) this.drfRootEmitted = new Set();
1690
+ if (!this.drfRootEmitted.has(routerKey) && this.isDefaultRouter(file, routerIdent)) {
1691
+ this.drfRootEmitted.add(routerKey);
1692
+ emit('GET', '', 'api-root');
1693
+ }
1694
+ }
1695
+
1696
+ routerCtor(file, routerIdent) {
1697
+ const decl = findDecl(this.sess, file, routerIdent.text, routerIdent);
1698
+ const v = decl && unwrap(decl.valueNode);
1699
+ return v && v.type === 'call' ? v : null;
1700
+ }
1701
+
1702
+ isDefaultRouter(file, routerIdent) {
1703
+ const call = this.routerCtor(file, routerIdent);
1704
+ const fn = call && unwrap(field(call, 'function'));
1705
+ const name = fn && (fn.type === 'attribute' ? field(fn, 'attribute')?.text : fn.text);
1706
+ return name === 'DefaultRouter';
1707
+ }
1708
+
1709
+ drfTrailingSlash(file, routerIdent) {
1710
+ const call = this.routerCtor(file, routerIdent);
1711
+ const arg = call && keywordArg(callArgs(call), 'trailing_slash');
1712
+ if (!arg) return '/';
1713
+ if (arg.type === 'false') return '';
1714
+ if (arg.type === 'true') return '/';
1715
+ const v = evalString(this.sess, file, arg);
1716
+ return routeGraph.hasUnresolved(v) ? '/' : (v === '/?' ? '' : v);
1717
+ }
1718
+
1719
+ // ── view resolution ──────────────────────────────────────────────────
1720
+
1721
+ /** { methods: [...], handler } for a view/endpoint expression. */
1722
+ viewMethods(file, viewNode, framework, depth = 0) {
1723
+ const n = unwrap(viewNode);
1724
+ let handler = shortText(n);
1725
+ if (!n) return { methods: ['ALL'], handler: '<anonymous>' };
1726
+ // `View.as_view(...)` (Django / Flask class-based views).
1727
+ if (n.type === 'call') {
1728
+ const fn = unwrap(field(n, 'function'));
1729
+ if (fn && fn.type === 'attribute' && field(fn, 'attribute')?.text === 'as_view') {
1730
+ const clsNode = unwrap(field(fn, 'object'));
1731
+ handler = shortText(clsNode);
1732
+ const cls = this.resolveDef(file, clsNode);
1733
+ if (cls && cls.kind === 'class') {
1734
+ const methods = this.classViewMethods(cls, framework);
1735
+ return { methods: methods || ['ALL'], handler };
1736
+ }
1737
+ // A framework view class used directly (`TemplateView.as_view()`).
1738
+ if (clsNode && (clsNode.type === 'identifier' || clsNode.type === 'attribute')) {
1739
+ const r = this.resolveBase(file, clsNode.text);
1740
+ const provided = r.external ? frameworkProvided(FRAMEWORK_VIEW_BASES, r.external) : undefined;
1741
+ if (provided && provided.length > 0) {
1742
+ return { methods: [...new Set(provided.map(m => (m === 'websocket' ? 'WS' : m.toUpperCase())))], handler };
1743
+ }
1744
+ }
1745
+ return { methods: ['ALL'], handler };
1746
+ }
1747
+ // `functools.partial(view, ...)`: the framework unwraps partials.
1748
+ if (fn && this.isFunctoolsPartial(file, fn) && argAt(n, 0, null) && depth < 4) {
1749
+ return this.viewMethods(file, argAt(n, 0, null), framework, depth + 1);
1750
+ }
1751
+ return { methods: ['ALL'], handler: fn ? exprText(fn) : handler };
1752
+ }
1753
+ if (n.type === 'lambda') {
1754
+ return { methods: framework === 'starlette' || framework === 'flask' ? ['GET'] : ['ALL'], handler: '<lambda>' };
1755
+ }
1756
+ if (n.type !== 'identifier' && n.type !== 'attribute') return { methods: ['ALL'], handler };
1757
+ const def = this.resolveDef(file, n);
1758
+ if (!def) {
1759
+ // A variable holding a view (`login = LoginView.as_view()`).
1760
+ const v = depth < 4 ? this.refValue(file, n) : null;
1761
+ if (v) {
1762
+ const inner = this.viewMethods(v.file, v.node, framework, depth + 1);
1763
+ return { methods: inner.methods, handler };
1764
+ }
1765
+ return { methods: ['ALL'], handler };
1766
+ }
1767
+ if (def.kind === 'class') {
1768
+ const methods = this.classViewMethods(def, framework);
1769
+ return { methods: methods || ['ALL'], handler };
1770
+ }
1771
+ // Function views.
1772
+ const fromDecorators = this.decoratorMethods(def);
1773
+ if (fromDecorators) return { methods: fromDecorators, handler };
1774
+ if (framework === 'starlette' || framework === 'flask') return { methods: ['GET'], handler };
1775
+ return { methods: ['ALL'], handler };
1776
+ }
1777
+
1778
+ isFunctoolsPartial(file, fn) {
1779
+ const entry = this.index.files.get(file);
1780
+ if (fn.type === 'identifier') {
1781
+ const imp = importedAs(entry, fn.text);
1782
+ return !!imp && imp.module === 'functools' && imp.original === 'partial';
1783
+ }
1784
+ if (fn.type === 'attribute' && field(fn, 'attribute')?.text === 'partial') {
1785
+ const obj = unwrap(field(fn, 'object'));
1786
+ const imp = obj && obj.type === 'identifier' ? importedAs(entry, obj.text) : null;
1787
+ return !!imp && imp.binding.kind === 'import' && imp.module === 'functools';
1788
+ }
1789
+ return false;
1790
+ }
1791
+
1792
+ /**
1793
+ * Resolve a name/attribute to a function or class definition:
1794
+ * { kind, file, name, sym|null, node|null }.
1795
+ */
1796
+ resolveDef(file, node, depth = 0) {
1797
+ const n = unwrap(node);
1798
+ if (!n || depth > MAX_DEPTH) return null;
1799
+ const memoKey = `${file}#${n.startIndex}:${n.endIndex}`;
1800
+ if (depth === 0 && this.defMemo.has(memoKey)) return this.defMemo.get(memoKey);
1801
+ let out = null;
1802
+ if (n.type === 'identifier') {
1803
+ const decl = findDecl(this.sess, file, n.text, n);
1804
+ if (decl) {
1805
+ if (decl.kind === 'function' || decl.kind === 'class') {
1806
+ out = { kind: decl.kind, file, name: n.text, node: decl.valueNode, sym: null };
1807
+ } else if (decl.kind === 'var' && decl.count === 1 && decl.valueNode) {
1808
+ const v = unwrap(decl.valueNode);
1809
+ if (v.type === 'identifier' || v.type === 'attribute') out = this.resolveDef(file, v, depth + 1);
1810
+ }
1811
+ } else {
1812
+ const entry = this.index.files.get(file);
1813
+ const imp = importedAs(entry, n.text);
1814
+ const target = imp && this.importTarget(entry, imp);
1815
+ if (target && !target.module) out = this.topLevelDef(target.file, target.name, depth + 1);
1816
+ }
1817
+ } else if (n.type === 'attribute') {
1818
+ const obj = unwrap(field(n, 'object'));
1819
+ const attr = field(n, 'attribute')?.text;
1820
+ if (obj && attr && obj.type === 'identifier' && !findDecl(this.sess, file, obj.text, obj)) {
1821
+ const entry = this.index.files.get(file);
1822
+ const imp = importedAs(entry, obj.text);
1823
+ const target = imp && this.importTarget(entry, imp);
1824
+ if (target && target.module) out = this.topLevelDef(target.file, attr, depth + 1);
1825
+ }
1826
+ // `Class.method` (a class's function member).
1827
+ if (!out && obj && attr && (obj.type === 'identifier' || obj.type === 'attribute')) {
1828
+ const cls = this.resolveDef(file, obj, depth + 1);
1829
+ if (cls && cls.kind === 'class') {
1830
+ const member = this.classShape(cls).members.get(attr);
1831
+ if (member) out = member;
1832
+ }
1833
+ }
1834
+ }
1835
+ if (depth === 0) this.defMemo.set(memoKey, out);
1836
+ return out;
1837
+ }
1838
+
1839
+ /** A module-level def of `name` in `file` from the index symbols, chased
1840
+ * through the module's own from-imports. */
1841
+ topLevelDef(file, name, depth) {
1842
+ if (depth > MAX_DEPTH) return null;
1843
+ const entry = this.index.files.get(file);
1844
+ if (!entry) return null;
1845
+ const sym = (entry.symbols || []).find(s => s.name === name && !s.className &&
1846
+ (s.type === 'function' || s.type === 'class'));
1847
+ if (sym) return { kind: sym.type, file, name, sym, node: null };
1848
+ const imp = importedAs(entry, name);
1849
+ const target = imp && this.importTarget(entry, imp);
1850
+ if (target && !target.module) return this.topLevelDef(target.file, target.name, depth + 1);
1851
+ return null;
1852
+ }
1853
+
1854
+ /** Decorator expressions of a def: AST nodes (local def) or snippet
1855
+ * parses of the recorded decorator text (indexed def). */
1856
+ decoratorsOf(def) {
1857
+ if (def.node) {
1858
+ const parent = def.node.parent;
1859
+ if (!parent || parent.type !== 'decorated_definition') return [];
1860
+ return namedChildren(parent).filter(c => c.type === 'decorator')
1861
+ .map(d => namedChildren(d).find(c => !c.type.includes('comment'))).filter(Boolean);
1862
+ }
1863
+ const out = [];
1864
+ for (const text of def.sym?.decorators || []) {
1865
+ const root = this.sess.snippet('python', String(text));
1866
+ const stmt = root && root.namedChild(0);
1867
+ const expr = stmt && (stmt.type === 'expression_statement' ? stmt.namedChild(0) : stmt);
1868
+ if (expr) out.push(expr);
1869
+ }
1870
+ return out;
1871
+ }
1872
+
1873
+ decoratorMethods(def) {
1874
+ const key = `dm\0${this.defKey(def)}`;
1875
+ if (this.methodMemo.has(key)) return this.methodMemo.get(key);
1876
+ const out = this.computeDecoratorMethods(def);
1877
+ this.methodMemo.set(key, out);
1878
+ return out;
1879
+ }
1880
+
1881
+ computeDecoratorMethods(def) {
1882
+ const entry = this.index.files.get(def.file);
1883
+ for (const expr of this.decoratorsOf(def)) {
1884
+ const call = expr.type === 'call' ? expr : null;
1885
+ const fn = unwrap(call ? field(call, 'function') : expr);
1886
+ if (!fn || fn.type !== 'identifier') continue;
1887
+ const imp = importedAs(entry, fn.text);
1888
+ if (!imp || !DECORATOR_MODULES.test(imp.module)) continue;
1889
+ if (DECORATOR_METHODS[imp.original]) return DECORATOR_METHODS[imp.original];
1890
+ if ((imp.original === 'require_http_methods' || imp.original === 'api_view') ) {
1891
+ if (!call) return imp.original === 'api_view' ? ['GET'] : null;
1892
+ const first = argAt(call, 0, imp.original === 'api_view' ? 'http_method_names' : 'request_method_list');
1893
+ if (!first) return imp.original === 'api_view' ? ['GET'] : null;
1894
+ const list = this.literalList(first);
1895
+ if (list && list.length > 0) return [...new Set(list.map(m => m.toUpperCase()))];
1896
+ return null;
1897
+ }
1898
+ }
1899
+ return null;
1900
+ }
1901
+
1902
+ /** String list of a literal node (snippet-safe: literals only). */
1903
+ literalList(node) {
1904
+ const n = unwrap(node);
1905
+ if (!n || !(n.type === 'list' || n.type === 'tuple' || n.type === 'set')) return null;
1906
+ const out = [];
1907
+ for (const el of namedChildren(n)) {
1908
+ if (el.type.includes('comment')) continue;
1909
+ if (!routeGraph.isStringLiteral(el)) return null;
1910
+ const v = routeGraph.readLiteral(el, () => null);
1911
+ if (v == null) return null;
1912
+ out.push(v);
1913
+ }
1914
+ return out;
1915
+ }
1916
+
1917
+ // ── class-based views ────────────────────────────────────────────────
1918
+
1919
+ /** Method names and base-class spellings of a class def. */
1920
+ classShape(def) {
1921
+ const key = `shape\0${this.defKey(def)}`;
1922
+ const cached = this.methodMemo.get(key);
1923
+ if (cached) return cached;
1924
+ const methods = [];
1925
+ let bases;
1926
+ const decorators = new Map();
1927
+ if (def.node) {
1928
+ for (const stmt of namedChildren(field(def.node, 'body'))) {
1929
+ const fnNode = stmt.type === 'decorated_definition' ? field(stmt, 'definition') : stmt;
1930
+ if (fnNode && fnNode.type === 'function_definition') {
1931
+ const nm = field(fnNode, 'name')?.text;
1932
+ if (nm) {
1933
+ methods.push(nm);
1934
+ decorators.set(nm, { kind: 'function', file: def.file, name: nm, node: fnNode, sym: null });
1935
+ }
1936
+ }
1937
+ }
1938
+ const supers = field(def.node, 'superclasses');
1939
+ bases = namedChildren(supers).filter(c => c.type !== 'keyword_argument' && !c.type.includes('comment'))
1940
+ .map(c => c.text);
1941
+ } else {
1942
+ const entry = this.index.files.get(def.file);
1943
+ const sym = def.sym;
1944
+ for (const s of entry?.symbols || []) {
1945
+ if (s.className === def.name && s.startLine >= sym.startLine && s.endLine <= sym.endLine &&
1946
+ (s.type === 'method' || s.isMethod)) {
1947
+ methods.push(s.name);
1948
+ decorators.set(s.name, { kind: 'function', file: def.file, name: s.name, node: null, sym: s });
1949
+ }
1950
+ }
1951
+ bases = sym.extends ? splitParentList(sym.extends).filter(b => !b.includes('=')) : [];
1952
+ }
1953
+ const shape = { methods, bases, members: decorators };
1954
+ this.methodMemo.set(key, shape);
1955
+ return shape;
1956
+ }
1957
+
1958
+ /** Resolve a base-class spelling from the class's file. */
1959
+ resolveBase(file, spelled) {
1960
+ const parts = spelled.split('.');
1961
+ const entry = this.index.files.get(file);
1962
+ if (parts.length === 1) {
1963
+ const local = (entry?.symbols || []).find(s => s.name === spelled && s.type === 'class' && !s.className);
1964
+ if (local) return { def: { kind: 'class', file, name: spelled, sym: local, node: null } };
1965
+ const imp = importedAs(entry, spelled);
1966
+ if (!imp) return { unknown: true };
1967
+ const target = this.importTarget(entry, imp);
1968
+ if (target && !target.module) {
1969
+ const d = this.topLevelDef(target.file, target.name, 1);
1970
+ if (d && d.kind === 'class') return { def: d };
1971
+ }
1972
+ return { external: { module: imp.module, name: imp.original } };
1973
+ }
1974
+ const head = parts[0];
1975
+ const imp = importedAs(entry, head);
1976
+ if (!imp) return { unknown: true };
1977
+ const module = imp.binding.kind === 'import' ? imp.module
1978
+ : `${imp.module}.${imp.original}`;
1979
+ const fullModule = [module, ...parts.slice(1, -1)].join('.');
1980
+ const target = this.importTarget(entry, imp);
1981
+ if (target && target.module && parts.length === 2) {
1982
+ const d = this.topLevelDef(target.file, parts[1], 1);
1983
+ if (d && d.kind === 'class') return { def: d };
1984
+ }
1985
+ return { external: { module: fullModule, name: parts[parts.length - 1] } };
1986
+ }
1987
+
1988
+ /** Walk a class and its ancestors: handler names defined, whether any
1989
+ * ancestor is unknown. `table` maps framework bases to handlers. */
1990
+ walkClass(def, table, collect, depth = 0, seen = new Set()) {
1991
+ const key = `${def.file}\0${def.name}`;
1992
+ if (depth > MAX_DEPTH || seen.has(key)) return { unknown: false };
1993
+ seen.add(key);
1994
+ const shape = this.classShape(def);
1995
+ collect(shape, def, depth);
1996
+ let unknown = false;
1997
+ for (const base of shape.bases) {
1998
+ if (base === 'object') continue;
1999
+ const r = this.resolveBase(def.file, base);
2000
+ if (r.def) {
2001
+ if (this.walkClass(r.def, table, collect, depth + 1, seen).unknown) unknown = true;
2002
+ continue;
2003
+ }
2004
+ if (r.external) {
2005
+ const provided = frameworkProvided(table, r.external);
2006
+ if (provided === undefined) { unknown = true; continue; }
2007
+ if (provided === null) { unknown = true; continue; }
2008
+ collect({ methods: provided, bases: [], members: new Map() }, null, depth + 1);
2009
+ continue;
2010
+ }
2011
+ unknown = true;
2012
+ }
2013
+ return { unknown };
2014
+ }
2015
+
2016
+ defKey(def) {
2017
+ return `${def.file}\0${def.name}\0${def.sym ? def.sym.startLine : def.node.startIndex}`;
2018
+ }
2019
+
2020
+ /** HTTP methods a class-based view serves, or null (every method). */
2021
+ classViewMethods(def, framework) {
2022
+ const key = `cv\0${framework}\0${this.defKey(def)}`;
2023
+ if (this.methodMemo.has(key)) return this.methodMemo.get(key);
2024
+ const out = this.computeClassViewMethods(def, framework);
2025
+ this.methodMemo.set(key, out);
2026
+ return out;
2027
+ }
2028
+
2029
+ computeClassViewMethods(def, framework) {
2030
+ const found = new Set();
2031
+ const { unknown } = this.walkClass(def, FRAMEWORK_VIEW_BASES, (shape, owner, depth) => {
2032
+ for (const m of shape.methods) {
2033
+ if (m === 'websocket') { found.add('WS'); continue; }
2034
+ if (!HTTP_VERB_HANDLERS.includes(m)) continue;
2035
+ // OPTIONS is answered by every framework base view; it is
2036
+ // listed only when the view class itself defines it.
2037
+ if (m === 'options' && depth > 0) continue;
2038
+ found.add(m.toUpperCase());
2039
+ }
2040
+ });
2041
+ if (framework === 'flask' && found.size === 0 && !unknown) {
2042
+ const methods = this.classAttrList(def, 'methods');
2043
+ if (methods) return methods.map(m => m.toUpperCase());
2044
+ return ['GET'];
2045
+ }
2046
+ if (unknown || found.size === 0) return null;
2047
+ return [...found].sort(verbOrder);
2048
+ }
2049
+
2050
+ /** DRF viewset: available actions (null = unknown), lookup kwarg, @action extras. */
2051
+ viewsetInfo(def) {
2052
+ const actions = new Set();
2053
+ const extraActions = [];
2054
+ const allActions = new Set([...DRF_LIST_ACTIONS, ...DRF_DETAIL_ACTIONS].map(a => a[0]));
2055
+ const { unknown } = this.walkClass(def, DRF_ACTION_BASES, (shape) => {
2056
+ for (const m of shape.methods) {
2057
+ if (allActions.has(m)) actions.add(m);
2058
+ const member = shape.members.get(m);
2059
+ if (!member) continue;
2060
+ const extra = this.drfAction(member);
2061
+ if (extra && !extraActions.some(e => e.name === extra.name)) extraActions.push(extra);
2062
+ }
2063
+ });
2064
+ const lookup = this.classAttrString(def, 'lookup_url_kwarg') || this.classAttrString(def, 'lookup_field');
2065
+ return { actions: unknown ? null : actions, extraActions, lookup };
2066
+ }
2067
+
2068
+ drfAction(member) {
2069
+ const entry = this.index.files.get(member.file);
2070
+ for (const expr of this.decoratorsOf(member)) {
2071
+ const call = expr.type === 'call' ? expr : null;
2072
+ const fn = unwrap(call ? field(call, 'function') : expr);
2073
+ if (!fn || fn.type !== 'identifier') continue;
2074
+ const imp = importedAs(entry, fn.text);
2075
+ if (!imp || imp.original !== 'action' || !/^rest_framework\.decorators$/.test(imp.module)) continue;
2076
+ const args = call ? callArgs(call) : null;
2077
+ const detailNode = args && keywordArg(args, 'detail');
2078
+ const methodsNode = args && keywordArg(args, 'methods');
2079
+ const urlPathNode = args && keywordArg(args, 'url_path');
2080
+ const methods = methodsNode ? this.literalList(methodsNode) : ['get'];
2081
+ const urlPath = urlPathNode && routeGraph.isStringLiteral(urlPathNode)
2082
+ ? routeGraph.readLiteral(urlPathNode, () => null) : member.name;
2083
+ return {
2084
+ name: member.name,
2085
+ detail: detailNode ? detailNode.type === 'true' : false,
2086
+ methods: (methods || ['ALL']).map(m => m.toUpperCase()),
2087
+ urlPath: urlPath || member.name,
2088
+ };
2089
+ }
2090
+ return null;
2091
+ }
2092
+
2093
+ /** A class attribute's literal string (own class, then project bases). */
2094
+ classAttrString(def, name, depth = 0) {
2095
+ if (depth > MAX_DEPTH) return null;
2096
+ const node = this.classNode(def);
2097
+ if (node) {
2098
+ for (const stmt of namedChildren(field(node, 'body'))) {
2099
+ const assign = stmt.type === 'expression_statement' ? stmt.namedChild(0) : null;
2100
+ if (!assign || assign.type !== 'assignment') continue;
2101
+ const left = field(assign, 'left');
2102
+ if (left && left.type === 'identifier' && left.text === name) {
2103
+ const v = evalString(this.classSess(def), def.file, field(assign, 'right'));
2104
+ return routeGraph.hasUnresolved(v) ? null : v;
2105
+ }
2106
+ }
2107
+ }
2108
+ for (const base of this.classShape(def).bases) {
2109
+ const r = this.resolveBase(def.file, base);
2110
+ if (r.def) {
2111
+ const v = this.classAttrString(r.def, name, depth + 1);
2112
+ if (v) return v;
2113
+ }
2114
+ }
2115
+ return null;
2116
+ }
2117
+
2118
+ classAttrList(def, name) {
2119
+ const node = this.classNode(def);
2120
+ if (!node) return null;
2121
+ for (const stmt of namedChildren(field(node, 'body'))) {
2122
+ const assign = stmt.type === 'expression_statement' ? stmt.namedChild(0) : null;
2123
+ if (!assign || assign.type !== 'assignment') continue;
2124
+ const left = field(assign, 'left');
2125
+ if (left && left.type === 'identifier' && left.text === name) {
2126
+ return stringList(this.classSess(def), def.file, field(assign, 'right'));
2127
+ }
2128
+ }
2129
+ return null;
2130
+ }
2131
+
2132
+ /** Session a class def's nodes belong to: its own tree when found in
2133
+ * the active file, else the shared session's full tree. */
2134
+ classSess(def) {
2135
+ return def.node ? this.sess : this.sess.main;
2136
+ }
2137
+
2138
+ /** AST class_definition node of a def (the shared session's full tree
2139
+ * of its file). */
2140
+ classNode(def) {
2141
+ if (def.node) return def.node;
2142
+ if (!def.sym) return null;
2143
+ const root = this.sess.main.root(def.file);
2144
+ if (!root) return null;
2145
+ const row = (def.sym.nameLine || def.sym.startLine) - 1;
2146
+ const stack = [root];
2147
+ while (stack.length > 0) {
2148
+ const n = stack.pop();
2149
+ if (n.type === 'class_definition' && field(n, 'name')?.text === def.name &&
2150
+ n.startPosition.row <= row && n.endPosition.row >= row) {
2151
+ return n;
2152
+ }
2153
+ if (n.startPosition.row > row || n.endPosition.row < row) continue;
2154
+ for (let i = 0; i < n.namedChildCount; i++) stack.push(n.namedChild(i));
2155
+ }
2156
+ return null;
2157
+ }
2158
+
2159
+ /**
2160
+ * Included ranges for parsing a large candidate file partially: every
2161
+ * module-level statement, the top-level definitions holding a located
2162
+ * record, and (to a fixpoint) the top-level definitions whose name the
2163
+ * included text spells, so every lookup this pass makes inside the file
2164
+ * sees the same declarations a full parse would. Null = parse fully
2165
+ * (small files, files the router-mount pass parses anyway).
2166
+ */
2167
+ partialRanges(file, sharedFiles) {
2168
+ const plan = this.plans.get(file);
2169
+ if (!plan || sharedFiles.has(file)) return null;
2170
+ const entry = this.index.files.get(file);
2171
+ const text = this.sess.text(file);
2172
+ if (!entry || text.length < PARTIAL_PARSE_MIN_CHARS) return null;
2173
+ const lineStarts = [0];
2174
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) lineStarts.push(i + 1);
2175
+ const lineCount = lineStarts.length;
2176
+ const symbols = entry.symbols || [];
2177
+ // Outermost definitions among `list` (a definition nested in another
2178
+ // one belongs to it).
2179
+ const outermost = (list) => {
2180
+ const out = [];
2181
+ let lastEnd = 0;
2182
+ for (const s of [...list].sort((a, b) => a.startLine - b.startLine || b.endLine - a.endLine)) {
2183
+ if (s.startLine <= lastEnd) continue;
2184
+ out.push({ name: s.name, startLine: s.startLine, endLine: s.endLine, type: s.type, members: null });
2185
+ lastEnd = s.endLine;
2186
+ }
2187
+ return out;
2188
+ };
2189
+ // Top-level units: column-0 definitions; a class's direct members are
2190
+ // sub-units, so a large class holding one route list parses only the
2191
+ // member that holds it.
2192
+ const units = outermost(symbols.filter(s => !s.className && (s.type === 'function' || s.type === 'class')))
2193
+ .filter(d => {
2194
+ const start = lineStarts[d.startLine - 1];
2195
+ return start != null && !/\s/.test(text[start] || ' ');
2196
+ });
2197
+ if (units.length === 0) return null;
2198
+ for (const u of units) {
2199
+ if (u.type !== 'class') continue;
2200
+ u.members = outermost(symbols.filter(s => s.className === u.name &&
2201
+ s.startLine > u.startLine && s.endLine <= u.endLine));
2202
+ }
2203
+ const lines = plan.pointed.map(r => r.line);
2204
+ const holds = d => lines.some(l => l >= d.startLine && l <= d.endLine);
2205
+ const keep = new Map(); // unit/member -> true
2206
+ for (const u of units) {
2207
+ if (!holds(u)) continue;
2208
+ keep.set(u, true);
2209
+ for (const m of u.members || []) if (holds(m)) keep.set(m, true);
2210
+ }
2211
+ const fullClass = new Set(); // classes kept whole (spelled by name)
2212
+ const rangeText = (a, b) => text.slice(lineStarts[a - 1], b < lineCount ? lineStarts[b] : text.length);
2213
+ // Included line spans in order.
2214
+ const spans = () => {
2215
+ const out = [];
2216
+ let cursor = 1;
2217
+ for (const u of units) {
2218
+ if (u.startLine > cursor) out.push([cursor, u.startLine - 1]);
2219
+ if (keep.has(u)) {
2220
+ if (!u.members || fullClass.has(u)) out.push([u.startLine, u.endLine]);
2221
+ else {
2222
+ let c = u.startLine;
2223
+ for (const m of u.members) {
2224
+ if (m.startLine > c) out.push([c, m.startLine - 1]);
2225
+ if (keep.has(m)) out.push([m.startLine, m.endLine]);
2226
+ c = m.endLine + 1;
2227
+ }
2228
+ if (c <= u.endLine) out.push([c, u.endLine]);
2229
+ }
2230
+ }
2231
+ cursor = u.endLine + 1;
2232
+ }
2233
+ if (cursor <= lineCount) out.push([cursor, lineCount]);
2234
+ return out;
2235
+ };
2236
+ // Fixpoint: a definition whose name the included text spells is
2237
+ // included whole, so every lookup sees what a full parse would.
2238
+ let included = '';
2239
+ for (let changed = true; changed;) {
2240
+ changed = false;
2241
+ included = spans().map(([a, b]) => rangeText(a, b)).join('\n');
2242
+ // A definition's own `def`/`class` header does not reference it.
2243
+ const words = new Set(included.replace(/\b(?:def|class)\s+[A-Za-z_][A-Za-z0-9_]*/g, ' ')
2244
+ .match(/[A-Za-z_][A-Za-z0-9_]*/g) || []);
2245
+ for (const u of units) {
2246
+ if (!words.has(u.name)) {
2247
+ for (const m of (keep.has(u) && u.members) || []) {
2248
+ if (!keep.has(m) && words.has(m.name)) { keep.set(m, true); changed = true; }
2249
+ }
2250
+ continue;
2251
+ }
2252
+ if (!keep.has(u)) { keep.set(u, true); changed = true; }
2253
+ if (u.members && !fullClass.has(u)) { fullClass.add(u); changed = true; }
2254
+ }
2255
+ }
2256
+ if (included.length > text.length * 0.7) return null;
2257
+ const ranges = [];
2258
+ for (const [a, b] of spans()) {
2259
+ const startIndex = lineStarts[a - 1];
2260
+ const endIndex = b < lineCount ? lineStarts[b] : text.length;
2261
+ if (endIndex <= startIndex) continue;
2262
+ const endPosition = b < lineCount ? { row: b, column: 0 }
2263
+ : { row: lineCount - 1, column: endIndex - lineStarts[lineCount - 1] };
2264
+ const last = ranges[ranges.length - 1];
2265
+ if (last && last.endIndex === startIndex) {
2266
+ last.endIndex = endIndex;
2267
+ last.endPosition = endPosition;
2268
+ continue;
2269
+ }
2270
+ ranges.push({ startIndex, endIndex, startPosition: { row: a - 1, column: 0 }, endPosition });
2271
+ }
2272
+ return ranges;
2273
+ }
2274
+
2275
+ // ── settings ─────────────────────────────────────────────────────────
2276
+
2277
+ /** `ROOT_URLCONF = "pkg.urls"` in a settings module: that URLconf is a root. */
2278
+ collectRootUrlconf(file) {
2279
+ const root = this.sess.root(file);
2280
+ if (!root) return;
2281
+ for (const stmt of namedChildren(root)) {
2282
+ const assign = stmt.type === 'expression_statement' ? stmt.namedChild(0) : null;
2283
+ if (!assign || assign.type !== 'assignment') continue;
2284
+ const left = field(assign, 'left');
2285
+ if (!left || left.text !== 'ROOT_URLCONF') continue;
2286
+ const spec = evalString(this.sess, file, field(assign, 'right'));
2287
+ if (routeGraph.hasUnresolved(spec)) continue;
2288
+ const modFile = this.moduleFile(file, spec) || this.moduleFileFromRoot(spec);
2289
+ if (modFile) this.roots.add(`${modFile}:urlpatterns`);
2290
+ }
2291
+ }
2292
+
2293
+ moduleFileFromRoot(spec) {
2294
+ const rel = spec.replace(/\./g, '/');
2295
+ for (const cand of [`${rel}.py`, `${rel}/__init__.py`]) {
2296
+ const abs = path.join(this.index.root, cand);
2297
+ if (this.index.files.has(abs)) return abs;
2298
+ }
2299
+ return null;
2300
+ }
2301
+
2302
+ run() {
2303
+ const { files, settings } = this.candidates();
2304
+ for (const [f, plan] of files) this.plans.set(f, plan);
2305
+ for (const [f, plan] of files) this.collectFile(f, plan);
2306
+ for (const f of settings) this.collectRootUrlconf(f);
2307
+ this.finalizeDeferred();
2308
+ return this;
2309
+ }
2310
+ }
2311
+
2312
+ // ============================================================================
2313
+ // PATH HELPERS
2314
+ // ============================================================================
2315
+
2316
+ /** Handlers (or actions) a framework base class provides: an array, null
2317
+ * (serves every method), or undefined (not a known framework class). */
2318
+ function frameworkProvided(table, external) {
2319
+ const row = table.find(([re]) => re.test(external.module));
2320
+ if (!row || !Object.prototype.hasOwnProperty.call(row[1], external.name)) return undefined;
2321
+ return row[1][external.name];
2322
+ }
2323
+
2324
+ function isModuleLevel(node) {
2325
+ let n = node.parent;
2326
+ while (n) {
2327
+ if (n.type === 'function_definition' || n.type === 'class_definition' || n.type === 'lambda') return false;
2328
+ if (n.type === 'module') return true;
2329
+ n = n.parent;
2330
+ }
2331
+ return false;
2332
+ }
2333
+
2334
+ function shortText(node) {
2335
+ if (!node) return '<anonymous>';
2336
+ const n = unwrap(node);
2337
+ if (n.type === 'identifier') return n.text;
2338
+ if (n.type === 'attribute') return field(n, 'attribute')?.text || n.text;
2339
+ if (n.type === 'call') {
2340
+ const fn = unwrap(field(n, 'function'));
2341
+ return fn ? `${shortText(fn)}()` : '<anonymous>';
2342
+ }
2343
+ return String(n.text).replace(/\s+/g, ' ').slice(0, 40);
2344
+ }
2345
+
2346
+ function exprText(node) {
2347
+ return String(node?.text || '<anonymous>').replace(/\s+/g, ' ').slice(0, 60);
2348
+ }
2349
+
2350
+ function stripLeading(p) {
2351
+ return String(p || '').replace(/^\/+/, '');
2352
+ }
2353
+
2354
+ /** Join a route fragment and a sub-segment the way the framework composes
2355
+ * (fix #383): Django concatenates pattern strings; Starlette and aiohttp
2356
+ * join path segments with one '/'. */
2357
+ function joinSegment(prefix, segment, framework = null) {
2358
+ const p = String(prefix || '');
2359
+ if (!p) return segment;
2360
+ if (framework === 'django') return p + segment;
2361
+ return p.endsWith('/') ? p + segment : `${p}/${segment}`;
2362
+ }
2363
+
2364
+ const VERB_ORDER = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE', 'WS'];
2365
+ function verbOrder(a, b) {
2366
+ return VERB_ORDER.indexOf(a) - VERB_ORDER.indexOf(b);
2367
+ }
2368
+
2369
+ /**
2370
+ * Collect declarative / imperatively registered Python routes.
2371
+ * @returns {{ routes, edges, roots }}
2372
+ */
2373
+ function collectRouteLists(index, sess, sharedFiles = new Set()) {
2374
+ const collector = new RouteListCollector(index, sess, sharedFiles).run();
2375
+ return { routes: collector.routes, edges: collector.edges, roots: collector.roots,
2376
+ containers: collector.containers };
2377
+ }
2378
+
2379
+ /**
2380
+ * Container key of a Python name reference, in the collector's key scheme
2381
+ * (fix #392): the declaring binding (`file:name` at module level,
2382
+ * `file@scope:name` in a function), else the module-level name an import
2383
+ * binds (`defining-file:name`).
2384
+ */
2385
+ function pythonContainerKey(index, sess, file, identNode) {
2386
+ if (!identNode || identNode.type !== 'identifier') return null;
2387
+ const name = identNode.text;
2388
+ const decl = findDecl(sess, file, name, identNode);
2389
+ if (decl) {
2390
+ if (decl.kind !== 'var') return null;
2391
+ if (decl.scope.parent === null) return `${file}:${name}`;
2392
+ return `${file}@${decl.scope.startIndex}:${name}`;
2393
+ }
2394
+ const entry = index.files.get(file);
2395
+ const imp = importedAs(entry, name);
2396
+ if (imp) {
2397
+ const { binding, original, module } = imp;
2398
+ if (binding.kind === 'import') return null;
2399
+ const resolved = spec => entry.moduleResolved?.[spec];
2400
+ const subSpec = module.endsWith('.') ? module + original : `${module}.${original}`;
2401
+ if (resolved(subSpec)) return null;
2402
+ const rel = resolved(module);
2403
+ return rel ? `${path.join(index.root, rel)}:${original}` : null;
2404
+ }
2405
+ return (entry?.moduleAssignedNames || []).includes(name) ? `${file}:${name}` : null;
2406
+ }
2407
+
2408
+ module.exports = {
2409
+ collectRouteLists,
2410
+ pythonContainerKey,
2411
+ regexToPath,
2412
+ regexCanMatchSlash,
2413
+ ANY_TEXT,
2414
+ };