attackmap-analyzer-node-service 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,1553 @@
1
+ """Route-level auth and mount paths for the AttackMap#256 contract (#4).
2
+
3
+ Each emitted ``Route`` carries ``auth`` (``required`` / ``anonymous`` /
4
+ ``unknown``), the ``guards`` that apply and the ``guard_evidence`` source text
5
+ that established the state. Core trusts a declared state over its own
6
+ same-file resolution, so this only declares what the source verifiably says.
7
+ Routes also get the prefixes of every router mount above them, across files.
8
+
9
+ **Express / Koa / Hono / Elysia / Fastify** — routers are tracked by variable:
10
+
11
+ - roots (``express()``, ``new Koa()``, ``new Hono()``, ``new Elysia()``,
12
+ ``fastify()``) and routers (``express.Router()``, ``new Router({ prefix })``,
13
+ ``new Elysia({ prefix })``, Hono ``app.basePath('/api')``);
14
+ - ``X.use([path,] mw...)``, ``X.all('*', mw...)``, Fastify
15
+ ``X.addHook('onRequest' | 'preHandler' | ..., mw)`` and Elysia
16
+ ``X.onBeforeHandle(mw)`` / ``X.guard({ beforeHandle })`` guard the routes
17
+ registered on ``X`` *after* them (in order, as the frameworks run them),
18
+ under ``path`` when one is given;
19
+ - ``X.use([path,] mw..., router)`` / ``X.use(router.routes())`` /
20
+ ``X.route(path, sub)`` mount a router, Fastify ``X.register(plugin,
21
+ { prefix })`` a plugin (``fastify-plugin`` wrapped plugins ignore
22
+ ``prefix``), Elysia ``X.group(path, (g) => ...)`` a callback, and
23
+ ``plugin(X)`` / ``require('./routes')(X)`` an exported route function: the
24
+ mount's prefix and middleware, and ``X``'s earlier middleware, apply to
25
+ every route below it;
26
+ - calls chain: ``new Hono().use(mw).get('/x', h).route('/y', sub)`` is the
27
+ same as one statement per call;
28
+ - a router, plugin or route function imported from another file is followed
29
+ to that file's export (see ``modules``);
30
+ - route-local middleware: ``router.post('/x', requireAuth, h)`` (arrays
31
+ flattened), ``router.route('/x').post(mw, h)``, and route options
32
+ ``{ preHandler | onRequest | preValidation | beforeHandle: [...] }``.
33
+
34
+ A middleware is a guard when its name says it authenticates (``requireAuth``,
35
+ ``isAuthenticated``, ``ensureLoggedIn``, ``passport.authenticate('jwt')``,
36
+ ``expressjwt(...)``, ``jwt(...)``, ``verifyToken``, ``bearerAuth(...)``) or
37
+ checks a role (``requireRole('admin')``, ``isAdmin``, ``authorize('admin')``,
38
+ ``guard.check([...])``), or when an inline hook calls ``jwtVerify()``.
39
+ ``optionalAuth``-style middleware, ``passport.authenticate('anonymous')``,
40
+ rate limiters, body parsers, CORS / CSRF / helmet and trackers such as
41
+ ``updateAuthenticatedUsers`` are not guards.
42
+
43
+ ``guard.unless({ path: ['/login', ...] })`` (express-unless) is an explicit
44
+ opt-out: a route whose full path is listed is ``anonymous`` when no other
45
+ guard applies. A list with regexes or other keys makes the guard uncertain.
46
+
47
+ **NestJS** — ``@UseGuards(...)`` on the method or controller, and a global
48
+ guard (``app.useGlobalGuards(...)`` / ``{ provide: APP_GUARD, useClass }``)
49
+ make a route ``required``; ``Optional...`` guards don't. A metadata-driven
50
+ role guard (``RolesGuard``, ``PermissionsGuard``) counts only next to its
51
+ ``@Roles(...)`` / ``@Permissions(...)`` metadata. ``@Public()`` /
52
+ ``@SkipAuth()`` / ``@AllowAnonymous()`` / ``@SetMetadata('isPublic', true)``
53
+ make a route ``anonymous``, unless a guard is Passport's own
54
+ ``AuthGuard('...')``, which ignores that metadata.
55
+
56
+ Anything else stays ``unknown``. Brackets are matched in one cached pass per
57
+ file, argument lists are split by jumping over nested functions, and chains,
58
+ lookups, mounts and paths are bounded, so every scan is linear.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import bisect
64
+ import functools
65
+ import re
66
+ from dataclasses import dataclass, field
67
+
68
+ from .modules import ImportRef, ModuleResolver, Reexports, file_candidates, parse_imports, parse_reexports
69
+
70
+ REQUIRED = "required"
71
+ ANONYMOUS = "anonymous"
72
+ UNKNOWN = "unknown"
73
+
74
+ AUTHN = "authn"
75
+ ROLE = "role"
76
+
77
+ _EVIDENCE_MAX = 200
78
+ _ARG_SCAN = 4000
79
+ _LOOKUP_LIMIT = 64 # bindings of one name searched
80
+ _MAX_DEPTH = 16 # mount levels followed
81
+ _MAX_PATHS = 64 # mount paths explored per route
82
+ _MAX_VARIANTS = 8 # distinct mounted paths emitted per route
83
+ _MAX_USES = 32 # guarding use() calls kept per router
84
+ _MAX_GUARDS = 8 # guards kept per use() / mount / route registration
85
+ _MAX_MIDDLEWARE_ARGS = 64 # middleware arguments inspected per call
86
+ _MAX_CHAIN = 256 # calls followed in one chain
87
+ _MAX_REEXPORT_DEPTH = 6
88
+ _MAX_LOOKUP_STEPS = 256 # modules expanded per export lookup
89
+ _MAX_STAR_EXPORTS = 16
90
+
91
+
92
+ def clip(text: str) -> str:
93
+ text = " ".join(text.split())
94
+ return text if len(text) <= _EVIDENCE_MAX else text[: _EVIDENCE_MAX - 1] + "…"
95
+
96
+
97
+ # ---------- Brackets ----------
98
+
99
+ _SCAN_TOKEN = re.compile(r"[\"'`]|//|/\*|[()\[\]{}]")
100
+ _ARG_TOKEN = re.compile(r"[\"'`,]|//|/\*|[(\[{]")
101
+ _STRING_BODY = {
102
+ '"': re.compile(r'[^"\\\n]*(?:\\.[^"\\\n]*)*"'),
103
+ "'": re.compile(r"[^'\\\n]*(?:\\.[^'\\\n]*)*'"),
104
+ "`": re.compile(r"[^`\\]*(?:\\.[^`\\]*)*`", re.DOTALL),
105
+ }
106
+ _OPEN = "([{"
107
+
108
+
109
+ def _skip(content: str, token: str, pos: int, limit: int) -> int:
110
+ if token in _STRING_BODY:
111
+ body = _STRING_BODY[token].match(content, pos, limit)
112
+ return body.end() if body else pos
113
+ if token == "//":
114
+ nl = content.find("\n", pos, limit)
115
+ return limit if nl < 0 else nl
116
+ if token == "/*":
117
+ end = content.find("*/", pos, limit)
118
+ return limit if end < 0 else end + 2
119
+ return pos
120
+
121
+
122
+ @functools.lru_cache(maxsize=4)
123
+ def bracket_pairs(content: str) -> dict[int, int]:
124
+ """Opening-bracket offset -> closing-bracket offset, in one forward pass
125
+ (strings, template literals and comments skipped)."""
126
+ pairs: dict[int, int] = {}
127
+ stack: list[int] = []
128
+ pos = 0
129
+ n = len(content)
130
+ while pos < n:
131
+ match = _SCAN_TOKEN.search(content, pos)
132
+ if not match:
133
+ break
134
+ token = match.group()
135
+ pos = match.end()
136
+ if token in _OPEN:
137
+ stack.append(match.start())
138
+ elif token in ")]}":
139
+ if stack:
140
+ pairs[stack.pop()] = match.start()
141
+ else:
142
+ pos = _skip(content, token, pos, n)
143
+ return pairs
144
+
145
+
146
+ def matching_close(content: str, open_idx: int) -> int:
147
+ if open_idx < 0:
148
+ return -1
149
+ return bracket_pairs(content).get(open_idx, -1)
150
+
151
+
152
+ def split_args(content: str, open_idx: int, close: int) -> list[tuple[int, int]]:
153
+ """``(start, end)`` of each top-level element inside the bracket at
154
+ ``open_idx``; nested brackets and strings are jumped over."""
155
+ pairs = bracket_pairs(content)
156
+ args: list[tuple[int, int]] = []
157
+ start = pos = open_idx + 1
158
+ while pos < close:
159
+ match = _ARG_TOKEN.search(content, pos, close)
160
+ if not match:
161
+ break
162
+ token = match.group()
163
+ pos = match.end()
164
+ if token == ",":
165
+ args.append((start, match.start()))
166
+ start = pos
167
+ elif token in _OPEN:
168
+ pos = max(pos, pairs.get(match.start(), close - 1) + 1)
169
+ else:
170
+ pos = _skip(content, token, pos, close)
171
+ if content[start:close].strip():
172
+ args.append((start, close))
173
+ return args
174
+
175
+
176
+ def _strip_span(content: str, start: int, end: int) -> tuple[int, int]:
177
+ while start < end and content[start].isspace():
178
+ start += 1
179
+ while end > start and content[end - 1].isspace():
180
+ end -= 1
181
+ return start, end
182
+
183
+
184
+ _STRING_LITERAL = re.compile(r"""\s*(['"`])([^'"`\\$]*)\1\s*$""")
185
+
186
+
187
+ def string_literal(text: str) -> str | None:
188
+ match = _STRING_LITERAL.match(text)
189
+ return match.group(2) if match else None
190
+
191
+
192
+ # ---------- Guards ----------
193
+
194
+ _ARG_NAME = re.compile(r"\s*(?:await\s+)?(?:new\s+)?([A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*)*)")
195
+ # Names are compared lower-cased with `_` removed, one dotted segment at a time.
196
+ _AUTHN_NAME = re.compile(
197
+ # requireAuth, isAuthenticated, authenticate, authenticateJWT, authMiddleware,
198
+ # withAuth, isAuthorized, checkAuth
199
+ r"(?:require|requires|ensure|check|verify|is|has|must|assert|enforce|with|need|needs)?"
200
+ r"(?:auth|authn|authed|authenticate|authenticated|authentication|authorized|authorised)"
201
+ r"(?:user|token|jwt|request|req|session|middleware|mw|guard|handler|check|required|only)?"
202
+ # isLoggedIn, ensureLoggedIn, requireLogin, verifyToken, checkJwt, requireSession
203
+ r"|(?:require|requires|ensure|check|verify|is|has|must|assert|enforce|need|needs)"
204
+ r"(?:loggedin|login|signedin|signin|session|token|jwt|accesstoken|idtoken|bearer|bearertoken|apikey|apitoken"
205
+ r"|authtoken|jwttoken|validtoken|validjwt|validsession|firebasetoken)"
206
+ r"(?:middleware|mw|guard|handler|check|required)?"
207
+ r"|validate(?:session|token|jwt|accesstoken|bearertoken|apikey|apitoken)"
208
+ r"|(?:require|ensure)(?:user|currentuser|signedinuser)"
209
+ r"|jwt|expressjwt|ejwt|jwt(?:middleware|guard|check|verify|required|authentication)|passportjwt"
210
+ r"|loginrequired|requiresauth|protect|protected|protectroute|protectedroute|clerkexpressrequireauth"
211
+ r"|isloggedin|loggedin"
212
+ )
213
+ # Ends in an auth word: webhookAuth, basicAuth, bearerAuth, apiKeyAuth, jwtAuthGuard.
214
+ _AUTHN_SUFFIX = ("auth", "authguard", "authmiddleware", "authrequired", "authcheck", "authhandler")
215
+ _ROLE_NAME = re.compile(
216
+ # isAdmin, requireRole, hasAnyRole, checkPermissions, requireScopes, adminOnly
217
+ r"(?:require|requires|ensure|check|verify|is|has|must|assert|only|restrict|allow|need|needs)(?:any)?"
218
+ r"(?:role|roles|admin|admins|administrator|superuser|superadmin|staff|permission|permissions|perm|perms"
219
+ r"|scope|scopes|privilege|privileges)"
220
+ r"(?:middleware|mw|guard|check|required|only|access|user)?"
221
+ r"|(?:admin|administrator|superuser|superadmin|staff|role|roles|permission|permissions|scope|scopes)"
222
+ r"(?:only|required|guard|middleware|mw|check|access)"
223
+ r"|authori[sz]e\w*|permit|restrictto|requiredscopes|rbac|acl|onlyadmins?"
224
+ )
225
+ # Objects whose generic method is an auth check: `auth.required`, `keycloak.protect()`.
226
+ _AUTH_OBJECTS = frozenset({"auth", "authn", "authentication", "authmiddleware", "authmw", "jwt", "passport", "keycloak"})
227
+ _AUTH_OBJECT_METHODS = frozenset(
228
+ {"required", "require", "verify", "check", "protect", "ensure", "authenticate", "authenticated", "jwt", "user",
229
+ "isauthenticated", "loggedin", "isloggedin", "token", "guard"}
230
+ )
231
+ _NOT_GUARD_NAME = re.compile(
232
+ r"optional|maybe|soft|tryauth|noauth|skipauth|withoutauth|bypass|public|anonymous|guest|unauth|notauth"
233
+ r"|ratelimit|throttl|cors|csrf|xsrf|helmet|logout|signout|callback|clerkexpresswithauth"
234
+ )
235
+ _INLINE_VERIFY = re.compile(r"\.\s*jwtVerify\s*\(")
236
+ _FUNCTION_START = re.compile(r"\s*(?:async\s+)?(?:function\b|\([^()]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)")
237
+ _UNLESS = re.compile(r"\.\s*unless\s*\(")
238
+ _ANONYMOUS_STRATEGY = re.compile(r"""['"]anonymous['"]""")
239
+ _SPACES = re.compile(r"\s+")
240
+ _NEW = re.compile(r"^new\s+")
241
+
242
+
243
+ def guard_kind(name: str) -> str | None:
244
+ """``authn`` / ``role`` when a middleware's (dotted) name says it
245
+ authenticates or checks a role; None otherwise."""
246
+ norm = name.lower().replace("_", "").replace(" ", "")
247
+ if not norm or _NOT_GUARD_NAME.search(norm):
248
+ return None
249
+ segments = norm.split(".")
250
+ final = segments[-1]
251
+ if _ROLE_NAME.fullmatch(final) or (len(segments) > 1 and segments[-2] == "guard" and final == "check"):
252
+ return ROLE
253
+ if _AUTHN_NAME.fullmatch(final):
254
+ return AUTHN
255
+ if final.endswith(_AUTHN_SUFFIX) and not final.endswith("oauth") and final != "oauth":
256
+ return AUTHN
257
+ if len(segments) > 1 and segments[-2] in _AUTH_OBJECTS and final in _AUTH_OBJECT_METHODS:
258
+ return AUTHN
259
+ return None
260
+
261
+
262
+ @dataclass
263
+ class Skip:
264
+ """An ``.unless({ path: [...] })`` opt-out list."""
265
+
266
+ exact: set[str]
267
+ characterized: bool
268
+ evidence: str
269
+
270
+
271
+ @dataclass
272
+ class Guard:
273
+ name: str
274
+ evidence: str
275
+ skip: Skip | None = None
276
+ uncertain: bool = False
277
+ prefix: str | None = None # `use('/api', mw)`: only under this path
278
+
279
+
280
+ def _unless(content: str, start: int, end: int) -> Skip | None:
281
+ match = _UNLESS.search(content, start, end)
282
+ if not match:
283
+ return None
284
+ close = matching_close(content, match.end() - 1)
285
+ if close < 0:
286
+ return Skip(set(), False, "")
287
+ evidence = clip(content[match.start() + 1 : min(close + 1, match.start() + _EVIDENCE_MAX)])
288
+ args = split_args(content, match.end() - 1, close)
289
+ if len(args) != 1 or content[_strip_span(content, *args[0])[0]] != "{":
290
+ return Skip(set(), False, evidence)
291
+ obj_open = _strip_span(content, *args[0])[0]
292
+ obj_close = matching_close(content, obj_open)
293
+ exact: set[str] = set()
294
+ characterized = obj_close > 0
295
+ for s, e in split_args(content, obj_open, obj_close) if obj_close > 0 else []:
296
+ key, _, value = content[s:e].partition(":")
297
+ if key.strip().strip("'\"") != "path":
298
+ characterized = False
299
+ continue
300
+ value_start = s + len(key) + 1
301
+ vs, ve = _strip_span(content, value_start, e)
302
+ items = [(vs, ve)]
303
+ if content[vs:vs + 1] == "[":
304
+ vclose = matching_close(content, vs)
305
+ items = split_args(content, vs, vclose) if vclose > 0 else []
306
+ characterized = characterized and vclose > 0
307
+ for item_s, item_e in items:
308
+ literal = string_literal(content[item_s:item_e])
309
+ if literal is None:
310
+ characterized = False # a regex, an object, a variable
311
+ else:
312
+ exact.add(literal)
313
+ return Skip(exact, characterized and bool(exact), evidence)
314
+
315
+
316
+ def guard_from_arg(content: str, start: int, end: int, context: str | None = None) -> Guard | None:
317
+ """A Guard when the middleware argument ``content[start:end]`` authenticates
318
+ or checks a role."""
319
+ end = min(end, start + _ARG_SCAN)
320
+ text = content[start:end]
321
+ evidence = clip(context if context is not None else text)
322
+ if _FUNCTION_START.match(text):
323
+ # An inline hook / middleware: a guard when it verifies a JWT.
324
+ return Guard("jwtVerify", evidence) if _INLINE_VERIFY.search(text) else None
325
+ match = _ARG_NAME.match(content, start, end)
326
+ if not match:
327
+ return None
328
+ name = _SPACES.sub("", match.group(1))
329
+ if guard_kind(name) is None:
330
+ return None
331
+ if name.endswith("passport.authenticate"):
332
+ # `passport.authenticate('anonymous')` / `(['jwt', 'anonymous'])` admit
333
+ # callers without credentials.
334
+ paren = content.find("(", match.end(), end)
335
+ close = matching_close(content, paren) if paren >= 0 else -1
336
+ args = split_args(content, paren, close) if 0 < close <= end else []
337
+ if args and _ANONYMOUS_STRATEGY.search(content, args[0][0], args[0][1]):
338
+ return None
339
+ return Guard(name, evidence, _unless(content, start, end))
340
+
341
+
342
+ _LOGIN_STRATEGY = re.compile(r"""\s*['"]local['"]""")
343
+
344
+
345
+ def guard_only_registration(content: str, spans: list[tuple[int, int]], context: str) -> list[Guard]:
346
+ """The guards of a route registration whose every argument is a guard,
347
+ else []. Passport's ``local`` strategy there is the login handler itself."""
348
+ guards: list[Guard] = []
349
+ for start, end in flatten(content, spans)[:_MAX_MIDDLEWARE_ARGS]:
350
+ guard = guard_from_arg(content, start, end, context)
351
+ if guard is None:
352
+ return []
353
+ if guard.name.endswith("passport.authenticate"):
354
+ paren = content.find("(", start, end)
355
+ if paren >= 0 and _LOGIN_STRATEGY.match(content, paren + 1, end):
356
+ return []
357
+ guards.append(guard)
358
+ return guards[:_MAX_GUARDS]
359
+
360
+
361
+ def flatten(content: str, spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
362
+ """Expand array arguments (``[requireAuth, validate]``) into their elements."""
363
+ out: list[tuple[int, int]] = []
364
+ for start, end in spans:
365
+ s, e = _strip_span(content, start, end)
366
+ if content[s:s + 1] == "[":
367
+ close = matching_close(content, s)
368
+ if close > 0:
369
+ out += split_args(content, s, close)
370
+ continue
371
+ out.append((start, end))
372
+ return out
373
+
374
+
375
+ # Used with .match(content, pos): `^` would only match at the string start.
376
+ _HOOK_KEYS = re.compile(r"""\s*['"]?(preHandler|onRequest|preValidation|preParsing|beforeHandle)['"]?\s*:""")
377
+
378
+
379
+ def route_option_guards(content: str, start: int, end: int, context: str) -> list[Guard]:
380
+ """Route options ``{ preHandler: [...], onRequest: fn, beforeHandle: mw }``."""
381
+ s, e = _strip_span(content, start, end)
382
+ if content[s:s + 1] != "{":
383
+ return []
384
+ close = matching_close(content, s)
385
+ if close < 0:
386
+ return []
387
+ guards: list[Guard] = []
388
+ for ps, pe in split_args(content, s, close):
389
+ key = _HOOK_KEYS.match(content, ps, pe)
390
+ if not key:
391
+ continue
392
+ for vs, ve in flatten(content, [(key.end(), pe)])[:_MAX_MIDDLEWARE_ARGS]:
393
+ guard = guard_from_arg(content, vs, ve, context)
394
+ if guard:
395
+ guards.append(guard)
396
+ return guards[:_MAX_GUARDS]
397
+
398
+
399
+ # ---------- Functions ----------
400
+
401
+ _FN_KEYWORD = re.compile(r"\s*(?:async\s+)?function\b\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*(?:<[^()]{0,200}>)?\s*\(")
402
+ _FN_ARROW_PARAMS = re.compile(r"\s*(?:async\s*)?(?:<[^()]{0,200}>\s*)?\(")
403
+ _FN_SINGLE = re.compile(r"\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>")
404
+ _FIRST_PARAM = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*[?]?\s*[:,)=]")
405
+ _ARROW = re.compile(r"\s*(?::\s*[^=;{}()]{1,200})?=>")
406
+ _BLOCK_OPEN = re.compile(r"\s*(?::\s*[^{};=]{1,200})?\{")
407
+ _EXPR_BODY_OPEN = re.compile(r"\s*\{")
408
+ # A request handler / middleware, not a route function taking the app.
409
+ _NOT_ROUTER_PARAMS = frozenset(
410
+ {"req", "request", "res", "response", "reply", "ctx", "c", "context", "next", "err", "error", "e", "event",
411
+ "_", "args", "options", "opts", "props", "config", "env"}
412
+ )
413
+
414
+
415
+ def function_at(content: str, start: int, end: int, *, block_only: bool = False) -> tuple[str, int, int] | None:
416
+ """``(first parameter, body start, body end)`` of the function expression
417
+ at ``content[start:end]``; an arrow with an expression body ends at
418
+ ``end`` (not with ``block_only``)."""
419
+ keyword = _FN_KEYWORD.match(content, start, end)
420
+ head = keyword or _FN_ARROW_PARAMS.match(content, start, end)
421
+ if head:
422
+ paren = head.end() - 1
423
+ close = matching_close(content, paren)
424
+ if close < 0 or close >= end:
425
+ return None
426
+ param = _FIRST_PARAM.match(content, paren + 1, close + 1)
427
+ if not param:
428
+ return None
429
+ after = close + 1
430
+ if keyword is None:
431
+ arrow = _ARROW.match(content, after, end)
432
+ if not arrow:
433
+ return None
434
+ after = arrow.end()
435
+ name = param.group(1)
436
+ else:
437
+ single = _FN_SINGLE.match(content, start, end)
438
+ if not single:
439
+ return None
440
+ name, after = single.group(1), single.end()
441
+ keyword = None
442
+ if name in _NOT_ROUTER_PARAMS:
443
+ return None
444
+ body = (_BLOCK_OPEN if keyword else _EXPR_BODY_OPEN).match(content, after, end)
445
+ if body:
446
+ open_idx = body.end() - 1
447
+ close = matching_close(content, open_idx)
448
+ return (name, open_idx, close) if 0 < close <= end else None
449
+ if keyword or block_only:
450
+ return None
451
+ return name, after, end
452
+
453
+
454
+ # ---------- Router bindings ----------
455
+
456
+
457
+ @dataclass
458
+ class Mount:
459
+ owner: "Binding"
460
+ offset: int
461
+ prefix: str
462
+ guards: list[Guard]
463
+ routers: "FileRouters"
464
+
465
+
466
+ @dataclass
467
+ class Binding:
468
+ name: str
469
+ offset: int
470
+ scope: tuple[int, int]
471
+ root: bool = False # express() / new Koa() / new Hono() / fastify()
472
+ prefix: str = "" # koa `new Router({ prefix })`, elysia `{ prefix }`, hono basePath, register `{ prefix }`
473
+ uses: list[tuple[int, list[Guard]]] = field(default_factory=list)
474
+ mounts: list[Mount] = field(default_factory=list)
475
+ overflow: bool = False
476
+ skip_prefix: bool = False # a fastify-plugin wrapped plugin ignores `register(..., { prefix })`
477
+
478
+
479
+ @dataclass
480
+ class ChainRoute:
481
+ """``.get('/x', ...)`` called on the result of another router call."""
482
+
483
+ binding: Binding
484
+ method: str
485
+ path: str
486
+ offset: int
487
+ paren: int
488
+ close: int
489
+ head: str | None # receiver name of the chain's first call; None after a constructor
490
+
491
+
492
+ _IDENT = r"[A-Za-z_$][\w$]*"
493
+ _SPEC = r"""['"]([^'"\n]{1,300})['"]"""
494
+ _CTOR_NAMES = (
495
+ r"express\s*\.\s*Router|require\s*\(\s*['\"]express['\"]\s*\)\s*\.\s*Router|express|Router|fastify|Fastify|polka"
496
+ r"|new\s+(?:Koa|Hono|OpenAPIHono|Elysia|Router|KoaRouter)\b"
497
+ )
498
+ _PREFIXED_CTORS = re.compile(r"Router|Elysia") # `new Router({ prefix })`, `new Elysia({ prefix })`
499
+ _IDENT_RE = re.compile(_IDENT)
500
+ _VALUE_IDENT = re.compile(rf"\s*({_IDENT})\s*(?:[;,)\n]|$)")
501
+ _ROOT_CTORS = re.compile(r"express|fastify|Fastify|polka|new\s+(?:Koa|Hono|OpenAPIHono|Elysia)")
502
+ _ASSIGN_CTOR = re.compile(
503
+ rf"\b({_IDENT})\s*(?::\s*[^=;\n]{{1,200}})?=\s*(?:await\s+)?(?P<ctor>{_CTOR_NAMES})"
504
+ )
505
+ _EXPORT_CTOR = re.compile(rf"(?:\bmodule\s*\.\s*exports\s*=(?!=)|\bexport\s+default\b)\s*(?P<ctor>{_CTOR_NAMES})")
506
+ # `(` after a constructor name, past TypeScript generics: `new Hono<{ Bindings: Env }>()`.
507
+ _CTOR_PAREN = re.compile(r"\s*(?:<[^()<>;]{0,300}(?:<[^()<>;]{0,300}>[^()<>;]{0,300})*>)?\s*\(")
508
+ _BASEPATH_ASSIGN = re.compile(
509
+ rf"\b(?:const|let|var)\s+({_IDENT})\s*=\s*({_IDENT})\s*\.\s*basePath\s*\(\s*['\"]([^'\"\n]*)['\"]\s*\)"
510
+ )
511
+ _VERBS = frozenset({"get", "post", "put", "patch", "delete", "options", "head", "all"})
512
+ _CALL_HEAD = re.compile(
513
+ rf"\b({_IDENT})\s*\.\s*(get|post|put|patch|delete|options|head|all|use|route|group|guard|basePath|addHook"
514
+ rf"|register|onBeforeHandle)\s*\("
515
+ )
516
+ _WS = re.compile(r"(?:\s|//[^\n]{0,500}\n|/\*(?:[^*]|\*(?!/)){0,500}\*/)*")
517
+ def skip_space(content: str, pos: int) -> int:
518
+ """The offset after whitespace and comments at ``pos`` (bounded)."""
519
+ return _WS.match(content, pos).end()
520
+
521
+
522
+ _LINK = re.compile(rf"\.\s*({_IDENT})\s*(?:<[^()<>;]{{0,200}}>\s*)?\(")
523
+ # Chainable calls that return the same app (Hono / Elysia / Express / Fastify).
524
+ _PASSTHROUGH = frozenset(
525
+ {"onError", "notFound", "decorate", "state", "derive", "resolve", "model", "onRequest", "onAfterHandle",
526
+ "onParse", "onTransform", "onResponse", "onAfterResponse", "mapResponse", "error", "setErrorHandler",
527
+ "setNotFoundHandler", "decorateRequest", "decorateReply", "mount", "doc", "openapi", "param", "set",
528
+ "engine", "disable", "enable", "macro", "headers", "onStart", "onStop", "onError"}
529
+ )
530
+ _HOOK_NAME = re.compile(r"""\s*['"](?:onRequest|preHandler|preValidation|preParsing)['"]\s*,""")
531
+ _PREFIX_OPTION = re.compile(r"""\bprefix\s*:\s*['"]([^'"]*)['"]""")
532
+ _ROUTES_CALL = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*\.\s*(?:routes|middleware)\s*\(\s*\)\s*$")
533
+ _IDENT_ONLY = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*$")
534
+ _MEMBER = re.compile(r"\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*$")
535
+ _INLINE_IMPORT = re.compile(
536
+ rf"\s*(?:\(\s*)?(?:await\s+)?(?:require|import)\s*\(\s*{_SPEC}\s*\)\s*\)?(?:\s*\.\s*({_IDENT}))?\s*$"
537
+ )
538
+ _PLUGIN_WRAP = re.compile(r"\s*(?:fp|fastifyPlugin)\s*\(")
539
+ _CALL_MOUNT = re.compile(rf"(?<![.\w$])({_IDENT})\s*\(\s*({_IDENT})\s*[,)]")
540
+ # `function name(app) {`, `name(app) {` (a method): definitions, not calls.
541
+ _DEFINITION_AFTER = re.compile(r"\s*(?::[^{};=]{1,200})?(?:\{|=>)")
542
+ _REQUIRE_CALL_MOUNT = re.compile(rf"\brequire\s*\(\s*{_SPEC}\s*\)(?:\s*\.\s*({_IDENT}))?\s*\(\s*({_IDENT})\s*[,)]")
543
+ _FN_DECL = re.compile(rf"\b(?:async\s+)?function\s*\*?\s*({_IDENT})\s*(?:<[^()]{{0,200}}>)?\s*\(")
544
+ _FN_ASSIGN = re.compile(rf"\b(?:const|let|var)\s+({_IDENT})\s*(?::\s*[^=;\n]{{1,200}})?=(?!=)")
545
+ _EXPORT_DEFAULT = re.compile(r"(?:\bmodule\s*\.\s*exports\s*=(?!=)|\bexport\s+default\b)")
546
+ _EXPORT_DECL = re.compile(rf"\bexport\s+(?:(?:const|let|var)\s+({_IDENT})|(?:async\s+)?function\s*\*?\s*({_IDENT}))")
547
+ _EXPORT_LIST = re.compile(r"\bexport\s*\{([^{}]{0,1000})\}(?!\s*from)")
548
+ _CJS_EXPORT = re.compile(rf"\b(?:module\s*\.\s*)?exports\s*\.\s*({_IDENT})\s*=(?!=)")
549
+ _EXPORT_AS = re.compile(rf"^\s*({_IDENT})(?:\s+as\s+({_IDENT}))?\s*$")
550
+ _CONVENTIONAL = frozenset({"app", "router", "server", "fastify"})
551
+ _PATH_VARIABLE = re.compile(rf"{_IDENT}(?:\s*\.\s*{_IDENT})*")
552
+ _KEYWORDS = frozenset(
553
+ {"if", "for", "while", "switch", "catch", "function", "return", "typeof", "await", "new", "require", "import",
554
+ "super", "with", "void", "delete", "in", "of", "do", "else", "case", "throw", "yield"}
555
+ )
556
+
557
+
558
+ def join_path(prefix: str, path: str) -> str:
559
+ if not prefix:
560
+ return path
561
+ if not path or path == "/":
562
+ return "/" + prefix.strip("/") if prefix.strip("/") else "/"
563
+ return "/" + "/".join(p for p in (prefix.strip("/"), path.strip("/")) if p)
564
+
565
+
566
+ def _under(path: str, prefix: str) -> bool:
567
+ """Express/Hono path-prefix match (`/api` covers `/api` and `/api/x`;
568
+ `/api/*` covers `/api/x` but not `/api`, as in Express 4)."""
569
+ if prefix.endswith("/*"):
570
+ return path.startswith(prefix[:-1])
571
+ prefix = prefix.rstrip("*")
572
+ if prefix in ("", "/"):
573
+ return True
574
+ if prefix.endswith("/"):
575
+ return path.startswith(prefix) or path == prefix.rstrip("/")
576
+ return path == prefix or path.startswith(prefix + "/")
577
+
578
+
579
+ _FN_BODY_ARROW = re.compile(r"=>\s*$")
580
+ _FN_BODY_AFTER_PARAMS = re.compile(r"\)\s*(?::[^{};()=]{1,150})?\s*$")
581
+ _ARROW_PARAM = re.compile(rf"(?<![\w$.])({_IDENT})\s*$")
582
+ _PARAM_NAME = re.compile(rf"(?:^|,)\s*(?:\.\.\.)?({_IDENT})")
583
+ _CONTROL_KEYWORD = re.compile(r"(?:\b(?:if|for|while|switch|catch|with)|\bfor\s+await)\s*$")
584
+ _MAX_PARAMS_TEXT = 400
585
+
586
+
587
+ def _function_params(content: str, open_idx: int, opens: dict[int, int]) -> list[str] | None:
588
+ """The parameter names of the function / method / arrow whose body the
589
+ ``{`` at ``open_idx`` opens, or None when it opens something else (a
590
+ class, an object literal, an ``if`` / ``for`` block)."""
591
+ head = content[max(0, open_idx - 200) : open_idx]
592
+ arrow = _FN_BODY_ARROW.search(head)
593
+ if arrow:
594
+ head = head[: arrow.start()]
595
+ single = _ARROW_PARAM.search(head)
596
+ if single and not _FN_BODY_AFTER_PARAMS.search(head):
597
+ return [single.group(1)]
598
+ params = _FN_BODY_AFTER_PARAMS.search(head)
599
+ if not params:
600
+ return [] if arrow else None
601
+ close = open_idx - (open_idx - max(0, open_idx - 200)) + params.start()
602
+ paren = opens.get(close)
603
+ if paren is None:
604
+ return [] if arrow else None
605
+ if not arrow and _CONTROL_KEYWORD.search(content, max(0, paren - 20), paren):
606
+ return None
607
+ text = content[paren + 1 : min(close, paren + 1 + _MAX_PARAMS_TEXT)]
608
+ names: list[str] = []
609
+ depth = 0
610
+ start = 0
611
+ # Top-level parameter names only: `(router: Router, { a, b } = {}, ...rest)`.
612
+ for i, ch in enumerate(text):
613
+ if ch in "([{<":
614
+ depth += 1
615
+ elif ch in ")]}>":
616
+ depth = max(0, depth - 1)
617
+ elif ch == "," and depth == 0:
618
+ match = _PARAM_NAME.match(text, start)
619
+ if match:
620
+ names.append(match.group(1))
621
+ start = i
622
+ match = _PARAM_NAME.match(text, start)
623
+ if match:
624
+ names.append(match.group(1))
625
+ return names
626
+
627
+
628
+ def _is_function_body(content: str, open_idx: int, opens: dict[int, int]) -> bool:
629
+ return _function_params(content, open_idx, opens) is not None
630
+
631
+
632
+ class FileRouters:
633
+ """Router variables, their middleware, mounts, imports and exports in one file."""
634
+
635
+ def __init__(self, content: str, file: str) -> None:
636
+ self.content = content
637
+ self.file = file
638
+ self._length = len(content)
639
+ pairs = bracket_pairs(content)
640
+ blocks = sorted((o, c) for o, c in pairs.items() if content[o] == "{")
641
+ # A router passed in as a parameter is bound per function: the
642
+ # outermost function body around a call (a class or object literal
643
+ # isn't one, so each method gets its own).
644
+ opens = {c: o for o, c in pairs.items() if content[o] == "("}
645
+ self._top: list[tuple[int, int]] = []
646
+ # Function bodies by parameter name: a router passed in as a
647
+ # parameter belongs to its own function, not to a sibling's.
648
+ self._param_scopes: dict[str, list[tuple[int, int]]] = {}
649
+ for block in blocks:
650
+ params = _function_params(content, block[0], opens)
651
+ if params is None:
652
+ continue
653
+ if not self._top or block[0] > self._top[-1][1]:
654
+ self._top.append(block)
655
+ for param in params[:16]:
656
+ self._param_scopes.setdefault(param, []).append(block)
657
+ self._top_starts = [s for s, _ in self._top]
658
+ self._blocks = blocks
659
+ self._stack: list[tuple[int, int]] = []
660
+ self._next_block = 0
661
+ self._by_name: dict[str, list[Binding]] = {}
662
+ self._offsets: dict[str, list[int]] = {}
663
+ self._free: dict[tuple[str, int], Binding] = {}
664
+ self._anonymous: dict[int, Binding] = {} # export-default constructor offset -> binding
665
+ self._fn_defs: dict[str, int] | None = None
666
+ self.imports: dict[str, ImportRef] = parse_imports(content)
667
+ self.reexports: Reexports = parse_reexports(content)
668
+ self.import_mounts: list[tuple[ImportRef, Mount]] = []
669
+ self.exports: dict[str, Binding] = {}
670
+ self.chain_routes: list[ChainRoute] = []
671
+
672
+ self._fn_params: dict[str, Binding] = {}
673
+ # Router variables first, then the functions whose parameter shadows
674
+ # one (`async function routes(fastify) {...}` next to `const fastify =
675
+ # Fastify()`), then the calls, so each call sees the right binding.
676
+ declarations: list[tuple[int, int, re.Match]] = []
677
+ for kind, pattern in enumerate((_ASSIGN_CTOR, _EXPORT_CTOR, _BASEPATH_ASSIGN)):
678
+ declarations += [(m.start(), kind, m) for m in pattern.finditer(content)]
679
+ declarations.sort(key=lambda e: (e[0], e[1]))
680
+ calls: list[tuple[int, int, re.Match]] = []
681
+ for kind, pattern in enumerate((_CALL_HEAD, _CALL_MOUNT, _REQUIRE_CALL_MOUNT), start=3):
682
+ calls += [(m.start(), kind, m) for m in pattern.finditer(content)]
683
+ calls.sort(key=lambda e: (e[0], e[1]))
684
+ for _, kind, match in declarations:
685
+ if kind <= 1:
686
+ self._constructor(match, kind == 1)
687
+ else:
688
+ self._base_path(match)
689
+ self._shadowing_parameters()
690
+ for _, kind, match in calls:
691
+ if kind == 3:
692
+ self._call_head(match)
693
+ elif kind == 4:
694
+ self._call_mount(match)
695
+ else:
696
+ self._require_call_mount(match)
697
+ self._parse_exports()
698
+ # Only bindings are needed from here on; don't keep every file's source.
699
+ self.content = ""
700
+ self._fn_defs = None
701
+
702
+ # -- scopes / bindings --
703
+
704
+ def _advance(self, offset: int) -> tuple[int, int]:
705
+ while self._next_block < len(self._blocks) and self._blocks[self._next_block][0] < offset:
706
+ block = self._blocks[self._next_block]
707
+ self._next_block += 1
708
+ while self._stack and self._stack[-1][1] < block[0]:
709
+ self._stack.pop()
710
+ self._stack.append(block)
711
+ while self._stack and self._stack[-1][1] < offset:
712
+ self._stack.pop()
713
+ # Module scope for top-level `const app = express()`.
714
+ return self._stack[-1] if self._stack else (0, self._length)
715
+
716
+ def _top_level(self, offset: int) -> tuple[int, int]:
717
+ index = bisect.bisect_right(self._top_starts, offset) - 1
718
+ if index >= 0 and self._top[index][1] >= offset:
719
+ return self._top[index]
720
+ return (0, self._length)
721
+
722
+ def _param_scope(self, name: str, offset: int) -> tuple[int, int] | None:
723
+ """The innermost function body around ``offset`` that takes ``name``
724
+ as a parameter."""
725
+ scopes = self._param_scopes.get(name)
726
+ if not scopes:
727
+ return None
728
+ index = bisect.bisect_right(scopes, (offset, self._length + 1)) - 1
729
+ for _ in range(_LOOKUP_LIMIT):
730
+ if index < 0:
731
+ break
732
+ if scopes[index][0] <= offset <= scopes[index][1]:
733
+ return scopes[index]
734
+ index -= 1
735
+ return None
736
+
737
+ def _bind(self, binding: Binding) -> None:
738
+ offsets = self._offsets.setdefault(binding.name, [])
739
+ index = bisect.bisect_right(offsets, binding.offset)
740
+ offsets.insert(index, binding.offset)
741
+ self._by_name.setdefault(binding.name, []).insert(index, binding)
742
+
743
+ def names(self) -> set[str]:
744
+ """Names bound to a router somewhere in the file."""
745
+ return {name for name in self._by_name if name}
746
+
747
+ def resolve(self, name: str, offset: int, *, create: bool = True) -> Binding | None:
748
+ """The binding of ``name`` visible at ``offset``; else a free binding
749
+ for the enclosing top-level function (a router passed in)."""
750
+ bindings = self._by_name.get(name, [])
751
+ index = bisect.bisect_left(self._offsets.get(name, []), offset) - 1
752
+ for _ in range(_LOOKUP_LIMIT):
753
+ if index < 0:
754
+ break
755
+ binding = bindings[index]
756
+ if binding.scope[0] <= offset <= binding.scope[1]:
757
+ return binding
758
+ index -= 1
759
+ else:
760
+ if index >= 0:
761
+ return None
762
+ if not create:
763
+ return None
764
+ scope = self._param_scope(name, offset) or self._top_level(offset)
765
+ free = self._free.get((name, scope[0]))
766
+ if free is None:
767
+ free = self._free[(name, scope[0])] = Binding(name, scope[0], scope)
768
+ return free
769
+
770
+ def _context(self, start: int, close: int) -> str:
771
+ return self.content[start : min(close + 1, start + 2 * _EVIDENCE_MAX)]
772
+
773
+ def _param_binding(self, fn: tuple[str, int, int]) -> Binding:
774
+ """The router a route function receives as its first parameter (the
775
+ same free binding its body's routes resolve to)."""
776
+ name, body_start, _ = fn
777
+ return self.resolve(name, body_start + 1) # type: ignore[return-value]
778
+
779
+ def _definitions(self) -> dict[str, int]:
780
+ """Same-file ``function name(...)`` / ``const name = (...) =>`` offsets."""
781
+ if self._fn_defs is None:
782
+ self._fn_defs = {}
783
+ for match in _FN_DECL.finditer(self.content):
784
+ self._fn_defs.setdefault(match.group(1), match.start())
785
+ for match in _FN_ASSIGN.finditer(self.content):
786
+ self._fn_defs.setdefault(match.group(1), match.end())
787
+ return self._fn_defs
788
+
789
+ def _function_at(self, start: int) -> tuple[str, int, int] | None:
790
+ return function_at(self.content, start, min(len(self.content), start + _ARG_SCAN * 50), block_only=True)
791
+
792
+ def _shadowing_parameters(self) -> None:
793
+ """A named function whose first parameter has the name of a router
794
+ variable gets its own binding for its body."""
795
+ if not self._by_name:
796
+ return
797
+ for name, start in self._definitions().items():
798
+ fn = self._function_at(start)
799
+ if fn is not None and fn[0] in self._by_name:
800
+ binding = Binding(fn[0], fn[1], (fn[1], fn[2]))
801
+ self._bind(binding)
802
+ self._fn_params[name] = binding
803
+
804
+ def _function_binding(self, name: str) -> Binding | None:
805
+ """The parameter binding of a same-file ``function name(app)`` /
806
+ ``const name = (app) => {...}``."""
807
+ if name in self._fn_params:
808
+ return self._fn_params[name]
809
+ start = self._definitions().get(name)
810
+ fn = self._function_at(start) if start is not None else None
811
+ return self._param_binding(fn) if fn else None
812
+
813
+ def _import_ref(self, start: int, end: int) -> ImportRef | None:
814
+ """An imported module named by an argument: ``require('./x')``,
815
+ ``import('./x')``, ``imported``, ``ns.member``."""
816
+ end = min(end, start + 400)
817
+ inline = _INLINE_IMPORT.match(self.content, start, end)
818
+ if inline:
819
+ member = inline.group(2)
820
+ return ImportRef(inline.group(1), member if member and member != "default" else "default")
821
+ member = _MEMBER.match(self.content, start, end)
822
+ if member and member.group(1) in self.imports:
823
+ ref = self.imports[member.group(1)]
824
+ if ref.name in ("*", "default"):
825
+ return ImportRef(ref.spec, member.group(2))
826
+ return None
827
+ ident = _IDENT_ONLY.match(self.content, start, end)
828
+ if ident and ident.group(1) in self.imports:
829
+ ref = self.imports[ident.group(1)]
830
+ return ImportRef(ref.spec, "default") if ref.name == "*" else ref
831
+ return None
832
+
833
+ # -- events --
834
+
835
+ def _constructor(self, match: re.Match, exported: bool) -> None:
836
+ ctor = match.group("ctor")
837
+ paren_match = _CTOR_PAREN.match(self.content, match.end("ctor"))
838
+ if paren_match is None and not ctor.startswith("new"):
839
+ return # `express.static(...)`, a property named `Router`, ...
840
+ name = "" if exported else match.group(1)
841
+ if name == "exports":
842
+ return
843
+ scope = self._advance(match.start())
844
+ binding = Binding(name, match.start(), scope, root=bool(_ROOT_CTORS.fullmatch(ctor)))
845
+ close = -1
846
+ if paren_match is not None:
847
+ paren = paren_match.end() - 1
848
+ close = matching_close(self.content, paren)
849
+ if close > 0 and _PREFIXED_CTORS.search(ctor):
850
+ option = _PREFIX_OPTION.search(self.content, paren, min(close, paren + 400))
851
+ binding.prefix = option.group(1) if option else ""
852
+ if exported:
853
+ self._anonymous[match.start()] = binding
854
+ else:
855
+ self._bind(binding)
856
+ if close > 0:
857
+ self._walk_chain(binding, close, None, binding)
858
+
859
+ def _base_path(self, match: re.Match) -> None:
860
+ """``const api = app.basePath('/api')`` (Hono)."""
861
+ parent = self.resolve(match.group(2), match.start())
862
+ if parent is None:
863
+ return
864
+ child = Binding(match.group(1), match.start(), self._advance(match.start()), prefix=match.group(3))
865
+ child.mounts.append(Mount(parent, match.start(), "", [], self))
866
+ self._bind(child)
867
+
868
+ def _call_head(self, match: re.Match) -> None:
869
+ name, method = match.group(1), match.group(2)
870
+ paren = match.end() - 1
871
+ close = matching_close(self.content, paren)
872
+ owner = self.resolve(name, match.start())
873
+ if owner is None or close < 0:
874
+ return
875
+ # `app\n .get('/x', h)`: the analyzer's route pattern wants `app.get(`.
876
+ spaced = self.content[match.end(1)] != "."
877
+ following = self._call(owner, method, match.start(), paren, close, head=name, link=spaced, ctor=None)
878
+ if following is not None:
879
+ self._walk_chain(following, close, name, None)
880
+
881
+ def _walk_chain(self, owner: Binding, close: int, head: str | None, ctor: Binding | None) -> None:
882
+ pos = close + 1
883
+ for _ in range(_MAX_CHAIN):
884
+ link = _LINK.match(self.content, _WS.match(self.content, pos).end())
885
+ if not link:
886
+ return
887
+ paren = link.end() - 1
888
+ link_close = matching_close(self.content, paren)
889
+ if link_close < 0:
890
+ return
891
+ following = self._call(owner, link.group(1), link.start(1), paren, link_close, head=head, link=True, ctor=ctor)
892
+ if following is None:
893
+ return
894
+ owner, pos = following, link_close + 1
895
+
896
+ def _call(
897
+ self, owner: Binding, method: str, offset: int, paren: int, close: int,
898
+ *, head: str | None, link: bool, ctor: Binding | None,
899
+ ) -> Binding | None:
900
+ """Apply one router call; return the binding a chained call continues
901
+ on, or None when the result isn't the router any more. ``link``
902
+ records a verb call as a route here (the analyzer finds the others)."""
903
+ if method in _VERBS:
904
+ if link:
905
+ args = split_args(self.content, paren, close)
906
+ path = string_literal(self.content[args[0][0] : args[0][1]]) if args else None
907
+ if path is not None:
908
+ self.chain_routes.append(ChainRoute(owner, method.upper(), path, offset, paren, close, head))
909
+ if method == "all":
910
+ self._use(owner, offset, paren, close, "all")
911
+ return owner
912
+ if method in ("use", "route"):
913
+ return owner if self._use(owner, offset, paren, close, method) else None
914
+ if method == "addHook":
915
+ if _HOOK_NAME.match(self.content, paren + 1, close):
916
+ self._hooks(owner, offset, paren, close, 1)
917
+ return owner
918
+ if method == "onBeforeHandle":
919
+ self._hooks(owner, offset, paren, close, 0)
920
+ return owner
921
+ if method == "register":
922
+ self._register(owner, offset, paren, close)
923
+ return owner
924
+ if method in ("group", "guard"):
925
+ self._group(owner, offset, paren, close, method == "group")
926
+ return owner
927
+ if method == "basePath":
928
+ args = split_args(self.content, paren, close)
929
+ literal = string_literal(self.content[args[0][0] : args[0][1]]) if len(args) == 1 else None
930
+ if literal is None:
931
+ return owner
932
+ if ctor is owner:
933
+ owner.prefix = join_path(owner.prefix, literal)
934
+ return owner
935
+ child = Binding("", offset, owner.scope, prefix=literal)
936
+ child.mounts.append(Mount(owner, offset, "", [], self))
937
+ return child
938
+ return owner if method in _PASSTHROUGH else None
939
+
940
+ def _mount_target(self, start: int, end: int, verb: str) -> Binding | ImportRef | None:
941
+ """A router mounted by a use()/route() argument: a binding in this
942
+ file, or an imported module's export. Hono's ``route(path, sub)``
943
+ always takes a sub-app; a ``use()`` argument named like auth
944
+ middleware is middleware."""
945
+ text = self.content[start : min(end, start + 300)]
946
+ routes_call = _ROUTES_CALL.match(text)
947
+ ident = routes_call or _IDENT_ONLY.match(text)
948
+ if ident:
949
+ binding = self.resolve(ident.group(1), start, create=False)
950
+ if binding is not None:
951
+ return binding
952
+ if verb == "use" and not routes_call and guard_kind(ident.group(1)) is not None:
953
+ return None # imported auth middleware
954
+ if routes_call:
955
+ return self._import_ref(start + routes_call.start(1), start + routes_call.end(1))
956
+ return self._import_ref(start, end)
957
+
958
+ def _use(self, owner: Binding, offset: int, paren: int, close: int, verb: str) -> bool:
959
+ """``use()`` / ``all('*')`` / ``route()``; False for Express's
960
+ one-argument ``router.route('/x')``, whose result is a route."""
961
+ args = split_args(self.content, paren, close)
962
+ if not args:
963
+ return verb != "route"
964
+ prefix: str | None = None
965
+ prefixes: list[str] | None = None # `use(['/a', '/b'], mw)`
966
+ unknown_path = False # `use(API_PREFIX, mw)`, `use(/^\/api/, mw)`
967
+ first = string_literal(self.content[args[0][0] : args[0][1]])
968
+ if first is not None:
969
+ prefix, args = first, args[1:]
970
+ elif verb in ("all", "route"):
971
+ return True # `.all(handler)`, Fastify `.route({ ... })`
972
+ elif len(args) > 1:
973
+ kind = self._path_argument(*args[0])
974
+ if kind is not None:
975
+ args = args[1:]
976
+ if isinstance(kind, list):
977
+ prefixes = kind
978
+ else:
979
+ unknown_path = True
980
+ if verb == "route" and not args:
981
+ return False
982
+ if verb == "all" and not prefix.endswith("*"):
983
+ return True # a real `.all('/x', h)` route, not a catch-all guard
984
+ context = self._context(offset, close)
985
+ args = flatten(self.content, args)[:_MAX_MIDDLEWARE_ARGS]
986
+ target = self._mount_target(*args[-1], verb) if args and verb in ("use", "route") else None
987
+ middleware = args[:-1] if target is not None else args
988
+ guards = [] if verb == "route" else [
989
+ g for s, e in middleware if (g := guard_from_arg(self.content, s, e, context))
990
+ ][:_MAX_GUARDS]
991
+ if unknown_path:
992
+ # A path this file can't read: the guard may not cover the route.
993
+ guards = [Guard(g.name, g.evidence, uncertain=True) for g in guards]
994
+ if target is not None:
995
+ for mount_prefix in (prefixes or [prefix or ""])[:_MAX_GUARDS]:
996
+ mount = Mount(owner, offset, mount_prefix, guards, self)
997
+ if isinstance(target, Binding):
998
+ if target is not owner:
999
+ target.mounts.append(mount)
1000
+ else:
1001
+ self.import_mounts.append((target, mount))
1002
+ return True
1003
+ if not guards:
1004
+ return True
1005
+ if prefixes is not None:
1006
+ # One copy per literal prefix of `use(['/a', '/b'], mw)`.
1007
+ guards = [
1008
+ Guard(g.name, g.evidence, g.skip, g.uncertain, p) for p in prefixes[:_MAX_GUARDS] for g in guards
1009
+ ][:_MAX_GUARDS * 2]
1010
+ else:
1011
+ for guard in guards:
1012
+ guard.prefix = prefix
1013
+ self._add_use(owner, offset, guards)
1014
+ return True
1015
+
1016
+ def _path_argument(self, start: int, end: int) -> list[str] | bool | None:
1017
+ """What a non-string first ``use()`` argument is: a list of literal
1018
+ paths, True for a path this file can't read (a regex, a template, a
1019
+ variable such as ``API_PREFIX``), or None for middleware."""
1020
+ s, e = _strip_span(self.content, start, end)
1021
+ text = self.content[s:e]
1022
+ if text.startswith("["):
1023
+ close = matching_close(self.content, s)
1024
+ items = split_args(self.content, s, close) if close > 0 else []
1025
+ literals = [string_literal(self.content[a:b]) for a, b in items]
1026
+ if items and all(lit is not None for lit in literals):
1027
+ return literals # type: ignore[return-value]
1028
+ return True
1029
+ if text.startswith(("/", "`")):
1030
+ return True # a regex or template literal
1031
+ if _PATH_VARIABLE.fullmatch(text) and guard_kind(text) is None and self.resolve(text, s, create=False) is None:
1032
+ return True # a bare identifier / member that isn't middleware we know
1033
+ return None
1034
+
1035
+ def _add_use(self, owner: Binding, offset: int, guards: list[Guard]) -> None:
1036
+ if len(owner.uses) >= _MAX_USES:
1037
+ owner.overflow = True
1038
+ else:
1039
+ owner.uses.append((offset, guards))
1040
+
1041
+ def _hooks(self, owner: Binding, offset: int, paren: int, close: int, first: int) -> None:
1042
+ args = split_args(self.content, paren, close)[first:]
1043
+ context = self._context(offset, close)
1044
+ guards = [
1045
+ g for s, e in flatten(self.content, args)[:_MAX_MIDDLEWARE_ARGS]
1046
+ if (g := guard_from_arg(self.content, s, e, context))
1047
+ ][:_MAX_GUARDS]
1048
+ if guards:
1049
+ self._add_use(owner, offset, guards)
1050
+
1051
+ def _register(self, owner: Binding, offset: int, paren: int, close: int) -> None:
1052
+ """Fastify ``register(plugin, { prefix })``: an inline closure, a
1053
+ same-file function or an imported module."""
1054
+ args = split_args(self.content, paren, close)
1055
+ if not args:
1056
+ return
1057
+ prefix = ""
1058
+ if len(args) > 1:
1059
+ option = _PREFIX_OPTION.search(self.content, args[1][0], min(args[1][1], args[1][0] + 400))
1060
+ prefix = option.group(1) if option else ""
1061
+ start, end = _strip_span(self.content, *args[0])
1062
+ skip_prefix = False
1063
+ wrapped = _PLUGIN_WRAP.match(self.content, start, end)
1064
+ if wrapped:
1065
+ inner_close = matching_close(self.content, wrapped.end() - 1)
1066
+ inner = split_args(self.content, wrapped.end() - 1, inner_close) if 0 < inner_close <= end else []
1067
+ if not inner:
1068
+ return
1069
+ start, end = _strip_span(self.content, *inner[0])
1070
+ skip_prefix = True
1071
+ mount = Mount(owner, offset, "" if skip_prefix else prefix, [], self)
1072
+ fn = function_at(self.content, start, end, block_only=True)
1073
+ if fn is not None:
1074
+ child = Binding(fn[0], fn[1], (fn[1], fn[2]))
1075
+ child.mounts.append(mount)
1076
+ self._bind(child)
1077
+ return
1078
+ ref = self._import_ref(start, end)
1079
+ if ref is not None:
1080
+ self.import_mounts.append((ref, mount))
1081
+ return
1082
+ ident = _IDENT_ONLY.match(self.content, start, end)
1083
+ binding = self._function_binding(ident.group(1)) if ident else None
1084
+ if binding is not None and binding is not owner:
1085
+ binding.mounts.append(mount)
1086
+
1087
+ def _group(self, owner: Binding, offset: int, paren: int, close: int, with_prefix: bool) -> None:
1088
+ """Elysia ``group('/x', [opts,] (g) => ...)`` / ``guard(opts, (g) => ...)``."""
1089
+ args = split_args(self.content, paren, close)
1090
+ prefix = ""
1091
+ if with_prefix:
1092
+ literal = string_literal(self.content[args[0][0] : args[0][1]]) if args else None
1093
+ if literal is None:
1094
+ return
1095
+ prefix, args = literal, args[1:]
1096
+ if not args:
1097
+ return
1098
+ context = self._context(offset, close)
1099
+ callback = function_at(self.content, args[-1][0], args[-1][1])
1100
+ options = args[:-1] if callback else args
1101
+ guards = [g for s, e in options for g in route_option_guards(self.content, s, e, context)][:_MAX_GUARDS]
1102
+ if callback is None:
1103
+ if guards and not with_prefix:
1104
+ self._add_use(owner, offset, guards)
1105
+ return
1106
+ child = Binding(callback[0], callback[1], (callback[1], callback[2]), prefix=prefix)
1107
+ child.mounts.append(Mount(owner, offset, "", guards, self))
1108
+ self._bind(child)
1109
+
1110
+ def _call_mount(self, match: re.Match) -> None:
1111
+ """``usersRoutes(app)``: an imported / same-file route function
1112
+ registers its routes on ``app``."""
1113
+ function, argument = match.group(1), match.group(2)
1114
+ if function in _KEYWORDS or argument in _KEYWORDS:
1115
+ return
1116
+ paren = self.content.find("(", match.start() + len(function))
1117
+ close = matching_close(self.content, paren)
1118
+ if close < 0 or _DEFINITION_AFTER.match(self.content, close + 1):
1119
+ return
1120
+ if self.resolve(argument, match.start(), create=argument in _CONVENTIONAL) is None:
1121
+ return
1122
+ target: Binding | ImportRef | None = None
1123
+ if function in self.imports:
1124
+ ref = self.imports[function]
1125
+ target = ImportRef(ref.spec, "default") if ref.name == "*" else ref
1126
+ elif function not in self._by_name:
1127
+ target = self._function_binding(function)
1128
+ if target is None:
1129
+ return
1130
+ self._mount_call(target, argument, match.start())
1131
+
1132
+ def _require_call_mount(self, match: re.Match) -> None:
1133
+ """``require('./routes/users')(app)``."""
1134
+ member = match.group(2)
1135
+ ref = ImportRef(match.group(1), member if member and member != "default" else "default")
1136
+ self._mount_call(ref, match.group(3), match.start())
1137
+
1138
+ def _mount_call(self, target: Binding | ImportRef, argument: str, offset: int) -> None:
1139
+ owner = self.resolve(argument, offset, create=argument in _CONVENTIONAL)
1140
+ if owner is None or target is owner:
1141
+ return
1142
+ mount = Mount(owner, offset, "", [], self)
1143
+ if isinstance(target, Binding):
1144
+ target.mounts.append(mount)
1145
+ else:
1146
+ self.import_mounts.append((target, mount))
1147
+
1148
+ # -- exports --
1149
+
1150
+ def _value_binding(self, start: int) -> Binding | None:
1151
+ """The router / route function an export's value names."""
1152
+ end = min(len(self.content), start + _ARG_SCAN)
1153
+ skip_prefix = False
1154
+ wrapped = _PLUGIN_WRAP.match(self.content, start, end)
1155
+ if wrapped:
1156
+ inner_close = matching_close(self.content, wrapped.end() - 1)
1157
+ inner = split_args(self.content, wrapped.end() - 1, inner_close) if inner_close > 0 else []
1158
+ if not inner:
1159
+ return None
1160
+ start, end = inner[0]
1161
+ skip_prefix = True
1162
+ binding: Binding | None = None
1163
+ fn = function_at(self.content, start, min(len(self.content), start + _ARG_SCAN * 50), block_only=True)
1164
+ if fn is not None:
1165
+ binding = self._param_binding(fn)
1166
+ else:
1167
+ ident = _VALUE_IDENT.match(self.content, start, end)
1168
+ if ident:
1169
+ binding = self.resolve(ident.group(1), start, create=False) or self._function_binding(ident.group(1))
1170
+ if binding is not None and skip_prefix:
1171
+ binding.skip_prefix = True
1172
+ return binding
1173
+
1174
+ def _parse_exports(self) -> None:
1175
+ last = None
1176
+ for last in _EXPORT_DEFAULT.finditer(self.content):
1177
+ pass
1178
+ if last is not None:
1179
+ binding = self._anonymous.get(last.start()) or self._value_binding(last.end())
1180
+ if binding is not None:
1181
+ self.exports["default"] = binding
1182
+ elif self.content.startswith("module", last.start()):
1183
+ self._object_exports(_WS.match(self.content, last.end()).end())
1184
+ for match in _EXPORT_DECL.finditer(self.content):
1185
+ name = match.group(1) or match.group(2)
1186
+ binding = self.resolve(name, match.end(), create=False) if match.group(1) else None
1187
+ binding = binding or self._function_binding(name)
1188
+ if binding is not None:
1189
+ self.exports.setdefault(name, binding)
1190
+ for match in _EXPORT_LIST.finditer(self.content):
1191
+ for part in match.group(1).split(","):
1192
+ names = _EXPORT_AS.match(part)
1193
+ if names:
1194
+ local = names.group(1)
1195
+ binding = self.resolve(local, match.start(), create=False) or self._function_binding(local)
1196
+ if binding is not None:
1197
+ self.exports.setdefault(names.group(2) or local, binding)
1198
+ for match in _CJS_EXPORT.finditer(self.content):
1199
+ binding = self._value_binding(match.end())
1200
+ if binding is not None:
1201
+ self.exports.setdefault(match.group(1), binding)
1202
+
1203
+ def _object_exports(self, open_idx: int) -> None:
1204
+ """``module.exports = { users, admin: adminRouter }``."""
1205
+ if self.content[open_idx : open_idx + 1] != "{":
1206
+ return
1207
+ close = matching_close(self.content, open_idx)
1208
+ if close < 0:
1209
+ return
1210
+ for s, e in split_args(self.content, open_idx, close)[:256]:
1211
+ key, colon, value = self.content[s:e].partition(":")
1212
+ key = key.strip().strip("'\"")
1213
+ local = (value if colon else key).strip()
1214
+ if _IDENT_RE.fullmatch(key) and _IDENT_RE.fullmatch(local):
1215
+ binding = self.resolve(local, s, create=False) or self._function_binding(local)
1216
+ if binding is not None:
1217
+ self.exports.setdefault(key, binding)
1218
+
1219
+
1220
+ @dataclass
1221
+ class Resolution:
1222
+ auth: str = UNKNOWN
1223
+ guards: list[str] = field(default_factory=list)
1224
+ evidence: str | None = None
1225
+ # The routers each mount chain ends at (by identity): routes reaching one
1226
+ # app by the same path and method are the same endpoint to Express.
1227
+ roots: frozenset[int] = field(default_factory=frozenset)
1228
+
1229
+
1230
+ _TEST_DIRS = frozenset({"tests", "test", "__tests__", "spec", "specs", "testing", "e2e", "__mocks__", "fixtures"})
1231
+ _TEST_FILE = re.compile(r"[._](?:test|spec)\.[a-z0-9]+$", re.IGNORECASE)
1232
+
1233
+
1234
+ def is_test_path(path: str) -> bool:
1235
+ """Test / spec / fixture code (mirrors core's test-file heuristic)."""
1236
+ parts = path.split("/")
1237
+ return any(part.lower() in _TEST_DIRS for part in parts[:-1]) or bool(_TEST_FILE.search(parts[-1]))
1238
+
1239
+
1240
+ class Registry:
1241
+ """Every file's routers, so mounts of imported routers can be followed."""
1242
+
1243
+ def __init__(self, resolver: ModuleResolver | None = None) -> None:
1244
+ self.files: dict[str, FileRouters] = {}
1245
+ self.reexports: dict[str, Reexports] = {}
1246
+ self.resolver = resolver or ModuleResolver(None)
1247
+ self._export_memo: dict[tuple[str, str], Binding | None] = {}
1248
+ # Every name some file exports (or re-exports by name): `export *`
1249
+ # adds none, so a name outside it is a miss without a walk.
1250
+ self._exported: set[str] = set()
1251
+ self._module_memo: dict[tuple[str, str], str | None] = {}
1252
+
1253
+ def add(self, routers: FileRouters) -> None:
1254
+ self.files[routers.file] = routers
1255
+ self._exported.update(routers.exports)
1256
+ self._exported.update(routers.reexports.named)
1257
+
1258
+ def add_module(self, file: str, content: str) -> None:
1259
+ """A file without routers that may re-export one (a barrel)."""
1260
+ if file not in self.files:
1261
+ reexports = parse_reexports(content)
1262
+ if reexports.named or reexports.star:
1263
+ self.reexports[file] = reexports
1264
+ self._exported.update(reexports.named)
1265
+
1266
+ def resolve_module(self, importer: str, spec: str) -> str | None:
1267
+ key = (importer, spec)
1268
+ if key not in self._module_memo:
1269
+ self._module_memo[key] = next(
1270
+ (
1271
+ candidate
1272
+ for base in self.resolver.bases(importer, spec)
1273
+ for candidate in file_candidates(base)
1274
+ if candidate in self.files or candidate in self.reexports
1275
+ ),
1276
+ None,
1277
+ )
1278
+ return self._module_memo[key]
1279
+
1280
+ def lookup_export(self, file: str, name: str) -> Binding | None:
1281
+ """The binding ``file`` exports as ``name``, through re-exports.
1282
+ Every (module, name) result is memoized, so each pair is expanded
1283
+ once per scan; a pair met again while it is still being looked up
1284
+ (an `export *` cycle) is a miss."""
1285
+ if name == "*":
1286
+ name = "default"
1287
+ if name not in self._exported:
1288
+ return None
1289
+ return self._lookup(file, name, 0, set(), [_MAX_LOOKUP_STEPS])
1290
+
1291
+ def _lookup(
1292
+ self, file: str, name: str, depth: int, visiting: set[tuple[str, str]], budget: list[int]
1293
+ ) -> Binding | None:
1294
+ key = (file, name)
1295
+ if key in self._export_memo:
1296
+ return self._export_memo[key]
1297
+ budget[0] -= 1
1298
+ if key in visiting or depth >= _MAX_REEXPORT_DEPTH or budget[0] < 0:
1299
+ # A cycle (the pair is still being looked up), a chain too deep,
1300
+ # or a lookup that has expanded too many modules.
1301
+ return None
1302
+ visiting.add(key)
1303
+ found: Binding | None = None
1304
+ routers = self.files.get(file)
1305
+ if routers is not None and name in routers.exports:
1306
+ found = routers.exports[name]
1307
+ else:
1308
+ reexports = routers.reexports if routers is not None else self.reexports.get(file)
1309
+ ref = reexports.named.get(name) if reexports is not None else None
1310
+ if ref is not None:
1311
+ target = self.resolve_module(file, ref.spec)
1312
+ found = self._lookup(target, ref.name, depth + 1, visiting, budget) if target else None
1313
+ elif reexports is not None:
1314
+ for spec in reexports.star[:_MAX_STAR_EXPORTS]:
1315
+ target = self.resolve_module(file, spec)
1316
+ found = self._lookup(target, name, depth + 1, visiting, budget) if target else None
1317
+ if found is not None:
1318
+ break
1319
+ visiting.discard(key)
1320
+ # Every result is kept, misses included, so each (file, name) is
1321
+ # expanded once per scan. A miss cut short by a cycle stays a miss.
1322
+ self._export_memo[key] = found
1323
+ return found
1324
+
1325
+ def finalize(self) -> None:
1326
+ """Attach cross-file mounts to the export they mount. Mounts made by
1327
+ test code would add paths that production never serves."""
1328
+ for routers in self.files.values():
1329
+ if is_test_path(routers.file):
1330
+ continue
1331
+ for ref, mount in routers.import_mounts:
1332
+ target = self.resolve_module(routers.file, ref.spec)
1333
+ binding = self.lookup_export(target, ref.name) if target else None
1334
+ if binding is not None and binding is not mount.owner:
1335
+ binding.mounts.append(mount)
1336
+
1337
+ def route_variants(
1338
+ self, binding: Binding | None, offset: int, path: str, local: list[Guard], fallback: str
1339
+ ) -> list[tuple[str, Resolution]]:
1340
+ """``(full path, auth)`` for each distinct path the route is mounted
1341
+ at. ``fallback`` (the file-local path) is kept when no mount or prefix
1342
+ applies."""
1343
+ if binding is None:
1344
+ return [(fallback, Resolution())]
1345
+ chains = self._paths(binding, offset, path, list(local), 0, [_MAX_PATHS])
1346
+ mounted = any(full != path for _, full, _, _ in chains)
1347
+ grouped: dict[str, list[Resolution]] = {}
1348
+ roots: dict[str, set[int]] = {}
1349
+ for guards, full, complete, root in chains:
1350
+ key = full if mounted else fallback
1351
+ if key in grouped or len(grouped) < _MAX_VARIANTS:
1352
+ grouped.setdefault(key, []).append(evaluate(guards, full if complete else None, complete))
1353
+ roots.setdefault(key, set()).add(root)
1354
+ out: list[tuple[str, Resolution]] = []
1355
+ for key, results in grouped.items():
1356
+ resolution = combine(results)
1357
+ resolution.roots = frozenset(roots[key])
1358
+ out.append((key, resolution))
1359
+ return out
1360
+
1361
+ def _paths(
1362
+ self, binding: Binding, offset: int, rel_path: str, applied: list[Guard], depth: int, budget: list[int]
1363
+ ) -> list[tuple[list[Guard], str, bool, int]]:
1364
+ """Every (guards, path, whole chain seen, top router) from a route up
1365
+ to a root."""
1366
+ budget[0] -= 1
1367
+ if binding.overflow or depth >= _MAX_DEPTH or budget[0] < 0:
1368
+ return [(applied, rel_path, False, id(binding))]
1369
+ applied = list(applied)
1370
+ for use_offset, guards in binding.uses:
1371
+ if use_offset >= offset:
1372
+ continue
1373
+ for guard in guards:
1374
+ if guard.prefix is None or _under(rel_path, guard.prefix):
1375
+ applied.append(guard)
1376
+ up_path = join_path(binding.prefix, rel_path)
1377
+ if not binding.mounts:
1378
+ return [(applied, up_path, binding.root, id(binding))]
1379
+ if len(binding.mounts) > _MAX_PATHS:
1380
+ return [(applied, up_path, False, id(binding))]
1381
+ out: list[tuple[list[Guard], str, bool, int]] = []
1382
+ for mount in binding.mounts:
1383
+ prefix = "" if binding.skip_prefix else mount.prefix
1384
+ out += self._paths(mount.owner, mount.offset, join_path(prefix, up_path), applied + mount.guards, depth + 1, budget)
1385
+ return out
1386
+
1387
+
1388
+ def combine(results: list[Resolution]) -> Resolution:
1389
+ """One route's state over every mount chain that reaches it."""
1390
+ if not results or any(r.auth == UNKNOWN for r in results):
1391
+ return Resolution()
1392
+ if all(r.auth == REQUIRED for r in results):
1393
+ names = list(dict.fromkeys(n for r in results for n in r.guards))
1394
+ return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(r.evidence for r in results if r.evidence))))
1395
+ if all(r.auth == ANONYMOUS for r in results):
1396
+ return Resolution(ANONYMOUS, [], clip("; ".join(dict.fromkeys(r.evidence for r in results if r.evidence))))
1397
+ return Resolution()
1398
+
1399
+
1400
+ def evaluate(guards: list[Guard], full_path: str | None, complete: bool) -> Resolution:
1401
+ required: list[Guard] = []
1402
+ skipped: list[Guard] = []
1403
+ uncertain = False
1404
+ for guard in guards:
1405
+ if guard.uncertain:
1406
+ uncertain = True
1407
+ elif guard.skip is None:
1408
+ required.append(guard)
1409
+ elif not guard.skip.characterized or full_path is None:
1410
+ uncertain = True
1411
+ elif full_path in guard.skip.exact:
1412
+ skipped.append(guard)
1413
+ else:
1414
+ required.append(guard)
1415
+ if required:
1416
+ names = list(dict.fromkeys(g.name for g in required))
1417
+ return Resolution(REQUIRED, names, clip("; ".join(dict.fromkeys(g.evidence for g in required))))
1418
+ if uncertain or not complete:
1419
+ return Resolution()
1420
+ if skipped:
1421
+ return Resolution(ANONYMOUS, [], clip("; ".join(dict.fromkeys(g.skip.evidence for g in skipped if g.skip))))
1422
+ return Resolution()
1423
+
1424
+
1425
+ # ---------- NestJS ----------
1426
+
1427
+ _DECORATOR = re.compile(r"@([A-Za-z_$][\w$]*)\s*(\()?")
1428
+ _CLASS_AFTER = re.compile(r"(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\b")
1429
+ _PUBLIC_DECORATORS = frozenset({"Public", "SkipAuth", "AllowAnonymous", "IsPublic", "Anonymous", "NoAuth", "PublicRoute"})
1430
+ _PUBLIC_METADATA = re.compile(r"""^\s*['"](?:isPublic|IS_PUBLIC_KEY|public|skipAuth)['"]\s*,\s*true\s*$|^\s*IS_PUBLIC_KEY\s*,\s*true\s*$""")
1431
+ _PASSPORT_GUARD = re.compile(r"^\s*AuthGuard\s*\(")
1432
+ _NEST_GUARD_NAME = re.compile(r"auth(?!or)|jwt|session|api_?key|bearer|logged_?in|clerk|firebase", re.IGNORECASE)
1433
+ # Admits callers without credentials: not a guard.
1434
+ _NEST_NOT_GUARD = re.compile(r"optional|public|anonymous|guest|maybe|soft", re.IGNORECASE)
1435
+ # Reads role metadata (`@Roles(...)`); without that metadata it lets requests through.
1436
+ _NEST_METADATA_ROLE_GUARD = re.compile(r"role|permission|polic|casl|abilit|pbac|acl|scope", re.IGNORECASE)
1437
+ _NEST_ROLE_GUARD = re.compile(r"admin|staff|superuser", re.IGNORECASE)
1438
+ _NEST_ROLE_METADATA = re.compile(
1439
+ r"\w*Roles?|\w*Permissions?|CheckPolicies|Policies|Pbac|Scopes|RequireScopes|CheckAbilities|Abilities"
1440
+ )
1441
+ _GLOBAL_GUARDS = re.compile(r"\.\s*useGlobalGuards\s*\(")
1442
+ _APP_GUARD = re.compile(r"provide\s*:\s*APP_GUARD\s*,\s*use(?:Class|Existing)\s*:\s*([A-Za-z_$][\w$]*)")
1443
+
1444
+
1445
+ @dataclass
1446
+ class DecoratorBlock:
1447
+ decorators: list[tuple[int, str, str]] # (offset, name, argument text)
1448
+ is_class: bool
1449
+
1450
+
1451
+ def decorator_blocks(content: str) -> list[DecoratorBlock]:
1452
+ """Consecutive ``@Decorator(...)`` runs and whether a class follows."""
1453
+ blocks: list[DecoratorBlock] = []
1454
+ current: list[tuple[int, str, str]] = []
1455
+ pos = 0
1456
+ while True:
1457
+ match = _DECORATOR.search(content, pos)
1458
+ if not match:
1459
+ break
1460
+ args = ""
1461
+ end = match.end()
1462
+ if match.group(2):
1463
+ close = matching_close(content, match.end() - 1)
1464
+ if close < 0:
1465
+ pos = match.end()
1466
+ continue
1467
+ args = content[match.end() : min(close, match.end() + _ARG_SCAN)]
1468
+ end = close + 1
1469
+ current.append((match.start(), match.group(1), args))
1470
+ nxt = _WS.match(content, end).end()
1471
+ pos = end
1472
+ if content.startswith("@", nxt):
1473
+ continue
1474
+ blocks.append(DecoratorBlock(current, bool(_CLASS_AFTER.match(content, nxt))))
1475
+ current = []
1476
+ return blocks
1477
+
1478
+
1479
+ def _nest_guards(args: str) -> list[tuple[str, bool, str]]:
1480
+ """``(guard name, is Passport's AuthGuard(...), kind)`` for the auth and
1481
+ role guards in ``@UseGuards(...)`` / ``useGlobalGuards(...)`` arguments.
1482
+ ``kind`` is ``authn``, ``role`` or ``metadata`` (a role guard that needs
1483
+ role metadata to reject anything)."""
1484
+ out = []
1485
+ for part in args.split(","):
1486
+ part = part.strip()
1487
+ name = _NEW.sub("", part).split("(", 1)[0].strip()
1488
+ if not name or _NEST_NOT_GUARD.search(name):
1489
+ continue
1490
+ passport = bool(_PASSPORT_GUARD.match(part))
1491
+ if _NEST_GUARD_NAME.search(name):
1492
+ out.append((part if passport else name, passport, AUTHN))
1493
+ elif _NEST_ROLE_GUARD.search(name):
1494
+ out.append((name, False, ROLE))
1495
+ elif _NEST_METADATA_ROLE_GUARD.search(name):
1496
+ out.append((name, False, "metadata"))
1497
+ return out
1498
+
1499
+
1500
+ def nest_global_guards(content: str) -> list[tuple[str, bool, str, str]]:
1501
+ found = []
1502
+ for match in _GLOBAL_GUARDS.finditer(content):
1503
+ close = matching_close(content, match.end() - 1)
1504
+ if close > 0:
1505
+ args = content[match.end() : min(close, match.end() + _ARG_SCAN)]
1506
+ evidence = clip(content[match.start() + 1 : close + 1])
1507
+ found += [(name, passport, kind, evidence) for name, passport, kind in _nest_guards(args)]
1508
+ for match in _APP_GUARD.finditer(content):
1509
+ for name, passport, kind in _nest_guards(match.group(1)):
1510
+ found.append((name, passport, kind, clip(match.group(0))))
1511
+ return found
1512
+
1513
+
1514
+ class NestFile:
1515
+ """Decorator blocks of one file, for its route decorators."""
1516
+
1517
+ def __init__(self, content: str) -> None:
1518
+ blocks = decorator_blocks(content)
1519
+ self._by_offset = {off: block for block in blocks for off, _, _ in block.decorators}
1520
+ self._classes = [b for b in blocks if b.is_class]
1521
+ self._class_starts = [b.decorators[0][0] for b in self._classes]
1522
+
1523
+ def resolve(self, route_offset: int, global_guards: list[tuple[str, bool, str, str]]) -> Resolution:
1524
+ block = self._by_offset.get(route_offset)
1525
+ if block is None:
1526
+ return Resolution()
1527
+ index = bisect.bisect_left(self._class_starts, route_offset) - 1
1528
+ owner = self._classes[index] if index >= 0 else None
1529
+ guards: list[tuple[str, bool, str, str]] = list(global_guards)
1530
+ public: list[str] = []
1531
+ metadata: list[str] = []
1532
+ for decorated in (owner, block):
1533
+ if decorated is None:
1534
+ continue
1535
+ for _, name, args in decorated.decorators:
1536
+ if name == "UseGuards":
1537
+ evidence = clip(f"@UseGuards({args})")
1538
+ guards += [(g, passport, kind, evidence) for g, passport, kind in _nest_guards(args)]
1539
+ elif name in _PUBLIC_DECORATORS or name == "SetMetadata" and _PUBLIC_METADATA.match(args):
1540
+ public.append(clip(f"@{name}({args})"))
1541
+ elif _NEST_ROLE_METADATA.fullmatch(name):
1542
+ metadata.append(clip(f"@{name}({args})"))
1543
+ # A metadata-driven role guard only rejects routes that carry its metadata.
1544
+ guards = [g for g in guards if g[2] != "metadata" or metadata]
1545
+ if public and not any(passport for _, passport, _, _ in guards):
1546
+ return Resolution(ANONYMOUS, [], clip("; ".join(public)))
1547
+ if guards:
1548
+ names = list(dict.fromkeys(g for g, _, _, _ in guards))
1549
+ evidence = list(dict.fromkeys(ev for _, _, _, ev in guards))
1550
+ if any(kind != AUTHN for _, _, kind, _ in guards):
1551
+ evidence += metadata
1552
+ return Resolution(REQUIRED, names, clip("; ".join(evidence)))
1553
+ return Resolution()