ucn 5.4.1 → 5.5.0

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