codebeacon 0.1.2__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.
- codebeacon/__init__.py +1 -0
- codebeacon/__main__.py +3 -0
- codebeacon/cache.py +136 -0
- codebeacon/cli.py +391 -0
- codebeacon/common/__init__.py +0 -0
- codebeacon/common/filters.py +170 -0
- codebeacon/common/symbols.py +121 -0
- codebeacon/common/types.py +98 -0
- codebeacon/config.py +144 -0
- codebeacon/contextmap/__init__.py +0 -0
- codebeacon/contextmap/generator.py +602 -0
- codebeacon/discover/__init__.py +0 -0
- codebeacon/discover/detector.py +388 -0
- codebeacon/discover/scanner.py +192 -0
- codebeacon/export/__init__.py +0 -0
- codebeacon/export/mcp.py +515 -0
- codebeacon/export/obsidian.py +812 -0
- codebeacon/extract/__init__.py +22 -0
- codebeacon/extract/base.py +372 -0
- codebeacon/extract/components.py +357 -0
- codebeacon/extract/dependencies.py +140 -0
- codebeacon/extract/entities.py +575 -0
- codebeacon/extract/queries/README.md +116 -0
- codebeacon/extract/queries/actix.scm +115 -0
- codebeacon/extract/queries/angular.scm +155 -0
- codebeacon/extract/queries/aspnet.scm +159 -0
- codebeacon/extract/queries/django.scm +122 -0
- codebeacon/extract/queries/express.scm +124 -0
- codebeacon/extract/queries/fastapi.scm +152 -0
- codebeacon/extract/queries/flask.scm +120 -0
- codebeacon/extract/queries/gin.scm +142 -0
- codebeacon/extract/queries/ktor.scm +144 -0
- codebeacon/extract/queries/laravel.scm +172 -0
- codebeacon/extract/queries/nestjs.scm +183 -0
- codebeacon/extract/queries/rails.scm +114 -0
- codebeacon/extract/queries/react.scm +111 -0
- codebeacon/extract/queries/spring_boot.scm +204 -0
- codebeacon/extract/queries/svelte.scm +73 -0
- codebeacon/extract/queries/vapor.scm +130 -0
- codebeacon/extract/queries/vue.scm +123 -0
- codebeacon/extract/routes.py +910 -0
- codebeacon/extract/semantic.py +280 -0
- codebeacon/extract/services.py +597 -0
- codebeacon/graph/__init__.py +1 -0
- codebeacon/graph/analyze.py +281 -0
- codebeacon/graph/build.py +320 -0
- codebeacon/graph/cluster.py +160 -0
- codebeacon/graph/enrich.py +206 -0
- codebeacon/skill/SKILL.md +127 -0
- codebeacon/wave.py +292 -0
- codebeacon/wiki/__init__.py +0 -0
- codebeacon/wiki/generator.py +376 -0
- codebeacon/wiki/index.py +95 -0
- codebeacon/wiki/templates.py +467 -0
- codebeacon-0.1.2.dist-info/METADATA +319 -0
- codebeacon-0.1.2.dist-info/RECORD +59 -0
- codebeacon-0.1.2.dist-info/WHEEL +4 -0
- codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
- codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
"""Route extraction for all 17 supported frameworks.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
extract_routes(file_path, framework, project_path="") -> list[RouteInfo]
|
|
5
|
+
|
|
6
|
+
Design:
|
|
7
|
+
- Run the framework's .scm query once per file
|
|
8
|
+
- Iterate matches, build lookup dicts by start_byte, correlate
|
|
9
|
+
- SFC dispatch (.vue/.svelte) handled at the top before parse_file
|
|
10
|
+
- Convention-based routes (Next.js/Nuxt/SvelteKit file-system) augment AST results
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from codebeacon.common.types import RouteInfo
|
|
19
|
+
from codebeacon.extract.base import (
|
|
20
|
+
extract_sfc_sections,
|
|
21
|
+
load_query_file,
|
|
22
|
+
node_text,
|
|
23
|
+
parse_file,
|
|
24
|
+
parse_sfc_script,
|
|
25
|
+
run_query,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Framework → query file stem ───────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_FW_TO_QUERY: dict[str, str] = {
|
|
32
|
+
"spring-boot": "spring_boot",
|
|
33
|
+
"express": "express",
|
|
34
|
+
"koa": "express",
|
|
35
|
+
"fastify": "express",
|
|
36
|
+
"nestjs": "nestjs",
|
|
37
|
+
"nextjs": "react",
|
|
38
|
+
"react": "react",
|
|
39
|
+
"fastapi": "fastapi",
|
|
40
|
+
"django": "django",
|
|
41
|
+
"flask": "flask",
|
|
42
|
+
"gin": "gin",
|
|
43
|
+
"echo": "gin",
|
|
44
|
+
"fiber": "gin",
|
|
45
|
+
"go": "gin",
|
|
46
|
+
"rails": "rails",
|
|
47
|
+
"laravel": "laravel",
|
|
48
|
+
"aspnet": "aspnet",
|
|
49
|
+
"actix": "actix",
|
|
50
|
+
"axum": "actix",
|
|
51
|
+
"rust": "actix",
|
|
52
|
+
"vapor": "vapor",
|
|
53
|
+
"ktor": "ktor",
|
|
54
|
+
"vue": "vue",
|
|
55
|
+
"nuxt": "vue",
|
|
56
|
+
"sveltekit": "svelte",
|
|
57
|
+
"angular": "angular",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# HTTP method name normalisation
|
|
61
|
+
_HTTP_METHODS: dict[str, str] = {
|
|
62
|
+
# lowercase REST
|
|
63
|
+
"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH",
|
|
64
|
+
"delete": "DELETE", "del": "DELETE", "options": "OPTIONS",
|
|
65
|
+
"head": "HEAD", "any": "ANY", "all": "ANY", "use": "ANY",
|
|
66
|
+
# Spring Boot annotations
|
|
67
|
+
"GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT",
|
|
68
|
+
"PatchMapping": "PATCH", "DeleteMapping": "DELETE", "RequestMapping": "ANY",
|
|
69
|
+
# NestJS decorators
|
|
70
|
+
"Get": "GET", "Post": "POST", "Put": "PUT", "Patch": "PATCH",
|
|
71
|
+
"Delete": "DELETE", "Options": "OPTIONS", "Head": "HEAD", "All": "ANY",
|
|
72
|
+
# ASP.NET attributes
|
|
73
|
+
"HttpGet": "GET", "HttpPost": "POST", "HttpPut": "PUT",
|
|
74
|
+
"HttpPatch": "PATCH", "HttpDelete": "DELETE",
|
|
75
|
+
"HttpOptions": "OPTIONS", "HttpHead": "HEAD",
|
|
76
|
+
# ASP.NET Minimal API
|
|
77
|
+
"MapGet": "GET", "MapPost": "POST", "MapPut": "PUT",
|
|
78
|
+
"MapPatch": "PATCH", "MapDelete": "DELETE",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Rails / Laravel resource → 7 REST routes
|
|
82
|
+
_RESOURCE_ACTIONS: list[tuple[str, str, str]] = [
|
|
83
|
+
("GET", "{name}", "index"),
|
|
84
|
+
("GET", "{name}/new", "new"),
|
|
85
|
+
("POST", "{name}", "create"),
|
|
86
|
+
("GET", "{name}/{id}", "show"),
|
|
87
|
+
("GET", "{name}/{id}/edit", "edit"),
|
|
88
|
+
("PUT", "{name}/{id}", "update"),
|
|
89
|
+
("DELETE", "{name}/{id}", "destroy"),
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Public function ───────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def extract_routes(
|
|
96
|
+
file_path: str,
|
|
97
|
+
framework: str,
|
|
98
|
+
project_path: str = "",
|
|
99
|
+
) -> list[RouteInfo]:
|
|
100
|
+
"""Extract routes from *file_path* for the given *framework*.
|
|
101
|
+
|
|
102
|
+
For file-system routing frameworks (Next.js, Nuxt, SvelteKit), also pass
|
|
103
|
+
*project_path* to compute convention-based routes from the file path.
|
|
104
|
+
"""
|
|
105
|
+
fw = framework.lower()
|
|
106
|
+
|
|
107
|
+
# 1. File-system (convention) routes — always computed first
|
|
108
|
+
convention = _convention_routes(file_path, fw, project_path)
|
|
109
|
+
|
|
110
|
+
query_name = _FW_TO_QUERY.get(fw)
|
|
111
|
+
if not query_name:
|
|
112
|
+
return convention
|
|
113
|
+
|
|
114
|
+
query_src = load_query_file(query_name)
|
|
115
|
+
if not query_src:
|
|
116
|
+
return convention
|
|
117
|
+
|
|
118
|
+
# 2. SFC dispatch (.vue / .svelte) — extract <script> before parsing
|
|
119
|
+
ext = Path(file_path).suffix.lower()
|
|
120
|
+
if ext in (".vue", ".svelte"):
|
|
121
|
+
sfc = extract_sfc_sections(file_path)
|
|
122
|
+
if sfc is None:
|
|
123
|
+
return convention
|
|
124
|
+
parsed = parse_sfc_script(sfc)
|
|
125
|
+
else:
|
|
126
|
+
parsed = parse_file(file_path)
|
|
127
|
+
|
|
128
|
+
if parsed is None:
|
|
129
|
+
return convention
|
|
130
|
+
root, lang = parsed
|
|
131
|
+
|
|
132
|
+
# Skip queries where the file's grammar is incompatible with the query.
|
|
133
|
+
# e.g. Rust files in a sveltekit project, JS files for TypeScript-only queries.
|
|
134
|
+
from codebeacon.extract.base import is_grammar_allowed
|
|
135
|
+
if not is_grammar_allowed(query_name, lang):
|
|
136
|
+
return convention
|
|
137
|
+
|
|
138
|
+
# 3. Run query once, then dispatch to per-framework interpreter
|
|
139
|
+
try:
|
|
140
|
+
matches = run_query(lang, query_src, root)
|
|
141
|
+
except Exception:
|
|
142
|
+
return convention
|
|
143
|
+
|
|
144
|
+
_interpreters = {
|
|
145
|
+
"spring_boot": _interpret_spring_boot,
|
|
146
|
+
"express": _interpret_express,
|
|
147
|
+
"nestjs": _interpret_nestjs,
|
|
148
|
+
"fastapi": _interpret_fastapi,
|
|
149
|
+
"django": _interpret_django,
|
|
150
|
+
"flask": _interpret_flask,
|
|
151
|
+
"gin": _interpret_gin,
|
|
152
|
+
"rails": _interpret_rails,
|
|
153
|
+
"laravel": _interpret_laravel,
|
|
154
|
+
"aspnet": _interpret_aspnet,
|
|
155
|
+
"actix": _interpret_actix,
|
|
156
|
+
"vapor": _interpret_vapor,
|
|
157
|
+
"ktor": _interpret_ktor,
|
|
158
|
+
"react": _interpret_react,
|
|
159
|
+
"vue": _interpret_vue,
|
|
160
|
+
"svelte": _interpret_svelte,
|
|
161
|
+
"angular": _interpret_angular,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interpreter = _interpreters.get(query_name)
|
|
165
|
+
if interpreter is None:
|
|
166
|
+
return convention
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
ast_routes = interpreter(file_path, matches, fw)
|
|
170
|
+
except Exception:
|
|
171
|
+
ast_routes = []
|
|
172
|
+
|
|
173
|
+
return ast_routes + convention
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _clean(s: str) -> str:
|
|
179
|
+
"""Strip surrounding quotes and whitespace from a string literal node text."""
|
|
180
|
+
s = s.strip()
|
|
181
|
+
if len(s) >= 2 and s[0] in ('"', "'", "`") and s[-1] == s[0]:
|
|
182
|
+
return s[1:-1]
|
|
183
|
+
return s
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _join(*parts: str) -> str:
|
|
187
|
+
"""Join URL path segments, always starting with '/'."""
|
|
188
|
+
segments: list[str] = []
|
|
189
|
+
for p in parts:
|
|
190
|
+
p = _clean(p).strip("/")
|
|
191
|
+
if p:
|
|
192
|
+
segments.append(p)
|
|
193
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _norm_method(name: str) -> str:
|
|
197
|
+
return _HTTP_METHODS.get(name, name.upper() if name else "ANY")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _expand_resource(
|
|
201
|
+
resource: str,
|
|
202
|
+
prefix: str,
|
|
203
|
+
file_path: str,
|
|
204
|
+
framework: str,
|
|
205
|
+
line: int,
|
|
206
|
+
) -> list[RouteInfo]:
|
|
207
|
+
"""Expand a `resources :name` or `Route::resource("name")` into 7 REST routes."""
|
|
208
|
+
resource = resource.lstrip(":").strip("'\"")
|
|
209
|
+
# Simple singularization for the id param
|
|
210
|
+
singular = resource[:-1] if resource.endswith("s") else resource
|
|
211
|
+
id_param = f":{singular}_id"
|
|
212
|
+
routes: list[RouteInfo] = []
|
|
213
|
+
for method, path_tpl, action in _RESOURCE_ACTIONS:
|
|
214
|
+
path = path_tpl.replace("{name}", resource).replace("{id}", id_param)
|
|
215
|
+
routes.append(RouteInfo(
|
|
216
|
+
method=method,
|
|
217
|
+
path=_join(prefix, path),
|
|
218
|
+
handler=f"{resource}#{action}",
|
|
219
|
+
source_file=file_path,
|
|
220
|
+
line=line,
|
|
221
|
+
framework=framework,
|
|
222
|
+
))
|
|
223
|
+
return routes
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Convention-based file-system routes ──────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
def _convention_routes(file_path: str, framework: str, project_path: str) -> list[RouteInfo]:
|
|
229
|
+
if not project_path:
|
|
230
|
+
return []
|
|
231
|
+
try:
|
|
232
|
+
rel = Path(file_path).relative_to(Path(project_path))
|
|
233
|
+
except ValueError:
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
parts = rel.parts
|
|
237
|
+
route_path: Optional[str] = None
|
|
238
|
+
|
|
239
|
+
if framework in ("nextjs", "react"):
|
|
240
|
+
if parts and parts[0] == "pages":
|
|
241
|
+
route_path = _pages_to_route(parts[1:])
|
|
242
|
+
elif len(parts) >= 2 and parts[0] == "app":
|
|
243
|
+
route_path = _app_to_route(parts[1:])
|
|
244
|
+
elif framework == "nuxt":
|
|
245
|
+
if parts and parts[0] == "pages":
|
|
246
|
+
route_path = _pages_to_route(parts[1:])
|
|
247
|
+
elif framework == "sveltekit":
|
|
248
|
+
if len(parts) >= 3 and parts[0] == "src" and parts[1] == "routes":
|
|
249
|
+
route_path = _sveltekit_to_route(parts[2:])
|
|
250
|
+
|
|
251
|
+
if route_path is None:
|
|
252
|
+
return []
|
|
253
|
+
|
|
254
|
+
stem = Path(parts[-1]).stem.lstrip("+") if parts else "index"
|
|
255
|
+
return [RouteInfo(
|
|
256
|
+
method="GET",
|
|
257
|
+
path=route_path,
|
|
258
|
+
handler=stem,
|
|
259
|
+
source_file=file_path,
|
|
260
|
+
line=1,
|
|
261
|
+
framework=framework,
|
|
262
|
+
tags=["file-system-route"],
|
|
263
|
+
)]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _seg(part: str) -> str:
|
|
267
|
+
"""Convert a file path segment to a URL segment (handle [param], [...catch])."""
|
|
268
|
+
name = Path(part).stem # strip extension
|
|
269
|
+
if re.match(r"^\(.*\)$", name):
|
|
270
|
+
return "" # Next.js route group
|
|
271
|
+
name = re.sub(r"\[\.\.\.(\w+)\]", r"*", name)
|
|
272
|
+
name = re.sub(r"\[(\w+)\]", r":\1", name)
|
|
273
|
+
return name if name not in ("index",) else ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _pages_to_route(parts) -> str:
|
|
277
|
+
segments = [s for p in parts for s in [_seg(p)] if s]
|
|
278
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _app_to_route(parts) -> Optional[str]:
|
|
282
|
+
if not parts:
|
|
283
|
+
return "/"
|
|
284
|
+
stem_last = Path(parts[-1]).stem
|
|
285
|
+
if stem_last not in ("page", "route", "layout"):
|
|
286
|
+
return None
|
|
287
|
+
segments = [s for p in parts[:-1] for s in [_seg(p)] if s]
|
|
288
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _sveltekit_to_route(parts) -> Optional[str]:
|
|
292
|
+
if not parts:
|
|
293
|
+
return "/"
|
|
294
|
+
stem_last = Path(parts[-1]).stem
|
|
295
|
+
if not stem_last.startswith("+"):
|
|
296
|
+
return None
|
|
297
|
+
segments = [s for p in parts[:-1] for s in [_seg(p)] if s]
|
|
298
|
+
return "/" + "/".join(segments) if segments else "/"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Per-framework interpreters ────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
def _interpret_spring_boot(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
304
|
+
"""Spring Boot: @RestController class prefix + @GetMapping/@PostMapping method paths.
|
|
305
|
+
|
|
306
|
+
Two-pass: first collect ALL class info + prefixes (match ordering may vary),
|
|
307
|
+
then process handler methods.
|
|
308
|
+
"""
|
|
309
|
+
# Pass 1 – controller classes and class-level prefixes
|
|
310
|
+
classes: dict[int, dict] = {} # class start_byte → {name, prefix, start, end}
|
|
311
|
+
class_prefixes: dict[int, str] = {} # class_mapping start_byte → prefix
|
|
312
|
+
|
|
313
|
+
for _idx, caps in matches:
|
|
314
|
+
if "route.controller_class" in caps:
|
|
315
|
+
cls = caps["route.controller_class"][0]
|
|
316
|
+
name = node_text(caps["route.class_name"][0]) if "route.class_name" in caps else ""
|
|
317
|
+
classes.setdefault(cls.start_byte, {
|
|
318
|
+
"name": name, "prefix": "",
|
|
319
|
+
"start": cls.start_byte, "end": cls.end_byte,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
if "route.class_mapping" in caps and "route.class_path" in caps:
|
|
323
|
+
mapping = caps["route.class_mapping"][0]
|
|
324
|
+
prefix = _clean(node_text(caps["route.class_path"][0]))
|
|
325
|
+
class_prefixes[mapping.start_byte] = prefix
|
|
326
|
+
|
|
327
|
+
# Apply prefixes to classes (match by same start_byte or containment)
|
|
328
|
+
for mapping_start, prefix in class_prefixes.items():
|
|
329
|
+
for c in classes.values():
|
|
330
|
+
if c["start"] == mapping_start or (c["start"] <= mapping_start <= c["end"]):
|
|
331
|
+
c["prefix"] = prefix
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
# Pass 2 – handler methods
|
|
335
|
+
methods: dict[int, dict] = {} # method start_byte → {ann, handler, path, line, end}
|
|
336
|
+
|
|
337
|
+
for _idx, caps in matches:
|
|
338
|
+
if "route.handler_method" in caps:
|
|
339
|
+
m = caps["route.handler_method"][0]
|
|
340
|
+
key = m.start_byte
|
|
341
|
+
ann = node_text(caps["route.method_annotation"][0]) if "route.method_annotation" in caps else ""
|
|
342
|
+
handler = node_text(caps["route.method_name"][0]) if "route.method_name" in caps else ""
|
|
343
|
+
if key not in methods:
|
|
344
|
+
methods[key] = {"ann": ann, "handler": handler, "path": "", "line": m.start_point[0] + 1, "end": m.end_byte}
|
|
345
|
+
else:
|
|
346
|
+
if ann and not methods[key]["ann"]:
|
|
347
|
+
methods[key]["ann"] = ann
|
|
348
|
+
|
|
349
|
+
if "route.method_with_path" in caps:
|
|
350
|
+
m = caps["route.method_with_path"][0]
|
|
351
|
+
key = m.start_byte
|
|
352
|
+
path = _clean(node_text(caps["route.path_value"][0])) if "route.path_value" in caps else ""
|
|
353
|
+
handler = node_text(caps["route.method_name_with_path"][0]) if "route.method_name_with_path" in caps else ""
|
|
354
|
+
if key not in methods:
|
|
355
|
+
methods[key] = {"ann": "", "handler": handler, "path": path, "line": m.start_point[0] + 1, "end": m.end_byte}
|
|
356
|
+
else:
|
|
357
|
+
methods[key]["path"] = path
|
|
358
|
+
if handler and not methods[key]["handler"]:
|
|
359
|
+
methods[key]["handler"] = handler
|
|
360
|
+
|
|
361
|
+
# Combine
|
|
362
|
+
routes: list[RouteInfo] = []
|
|
363
|
+
for start, minfo in methods.items():
|
|
364
|
+
class_prefix = ""
|
|
365
|
+
class_name = ""
|
|
366
|
+
for c in classes.values():
|
|
367
|
+
if c["start"] <= start <= c["end"]:
|
|
368
|
+
class_prefix = c["prefix"]
|
|
369
|
+
class_name = c["name"]
|
|
370
|
+
break
|
|
371
|
+
routes.append(RouteInfo(
|
|
372
|
+
method=_norm_method(minfo["ann"]),
|
|
373
|
+
path=_join(class_prefix, minfo["path"]),
|
|
374
|
+
handler=f"{class_name}.{minfo['handler']}",
|
|
375
|
+
source_file=file_path,
|
|
376
|
+
line=minfo["line"],
|
|
377
|
+
framework="spring-boot",
|
|
378
|
+
))
|
|
379
|
+
return routes
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _interpret_express(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
383
|
+
"""Express / Koa / Fastify route extraction."""
|
|
384
|
+
routes: list[RouteInfo] = []
|
|
385
|
+
|
|
386
|
+
for _idx, caps in matches:
|
|
387
|
+
if "route.path" not in caps:
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
method_str = node_text(caps["route.method"][0]).lower() if "route.method" in caps else "get"
|
|
391
|
+
if method_str == "use":
|
|
392
|
+
continue # prefix mounts, not routes
|
|
393
|
+
|
|
394
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
395
|
+
obj = node_text(caps["route.object"][0]) if "route.object" in caps else ""
|
|
396
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
397
|
+
|
|
398
|
+
routes.append(RouteInfo(
|
|
399
|
+
method=_norm_method(method_str),
|
|
400
|
+
path=path if path.startswith("/") else "/" + path,
|
|
401
|
+
handler=obj,
|
|
402
|
+
source_file=file_path,
|
|
403
|
+
line=line,
|
|
404
|
+
framework=framework,
|
|
405
|
+
))
|
|
406
|
+
return routes
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _interpret_nestjs(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
410
|
+
"""NestJS @Controller prefix + @Get/@Post method paths.
|
|
411
|
+
|
|
412
|
+
Class decorators are siblings in export_statement; method decorators are
|
|
413
|
+
siblings in class_body. Use start/end byte ranges to correlate.
|
|
414
|
+
"""
|
|
415
|
+
controllers: dict[int, dict] = {}
|
|
416
|
+
handlers: list[dict] = []
|
|
417
|
+
|
|
418
|
+
for _idx, caps in matches:
|
|
419
|
+
# Controller classes (with or without prefix)
|
|
420
|
+
for cls_key in ("route.controller_with_prefix", "route.controller_no_prefix",
|
|
421
|
+
"route.controller_with_prefix_noexport"):
|
|
422
|
+
if cls_key in caps:
|
|
423
|
+
cls = caps[cls_key][0]
|
|
424
|
+
name = node_text(caps["route.class_name"][0]) if "route.class_name" in caps else ""
|
|
425
|
+
prefix = _clean(node_text(caps["route.controller_prefix"][0])) if "route.controller_prefix" in caps else ""
|
|
426
|
+
controllers.setdefault(cls.start_byte, {
|
|
427
|
+
"name": name, "prefix": prefix,
|
|
428
|
+
"start": cls.start_byte, "end": cls.end_byte,
|
|
429
|
+
})
|
|
430
|
+
break
|
|
431
|
+
|
|
432
|
+
# Handler methods (with path)
|
|
433
|
+
if "route.handler" in caps and "route.method_decorator" in caps:
|
|
434
|
+
m = caps["route.handler"][0] # class_body node
|
|
435
|
+
dec = node_text(caps["route.method_decorator"][0])
|
|
436
|
+
path = _clean(node_text(caps["route.path_value"][0])) if "route.path_value" in caps else ""
|
|
437
|
+
name = node_text(caps["route.method_name"][0]) if "route.method_name" in caps else ""
|
|
438
|
+
handlers.append({
|
|
439
|
+
"start": m.start_byte, "end": m.end_byte,
|
|
440
|
+
"dec": dec, "path": path, "name": name,
|
|
441
|
+
"line": caps["route.method_name"][0].start_point[0] + 1 if "route.method_name" in caps else m.start_point[0] + 1,
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
# Handler methods (without path)
|
|
445
|
+
if "route.handler_no_path" in caps and "route.method_decorator" in caps:
|
|
446
|
+
m = caps["route.handler_no_path"][0]
|
|
447
|
+
dec = node_text(caps["route.method_decorator"][0])
|
|
448
|
+
name = node_text(caps["route.method_name"][0]) if "route.method_name" in caps else ""
|
|
449
|
+
handlers.append({
|
|
450
|
+
"start": m.start_byte, "end": m.end_byte,
|
|
451
|
+
"dec": dec, "path": "", "name": name,
|
|
452
|
+
"line": caps["route.method_name"][0].start_point[0] + 1 if "route.method_name" in caps else m.start_point[0] + 1,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
# Deduplicate handlers by method name + line
|
|
456
|
+
seen: set[tuple[str, int]] = set()
|
|
457
|
+
routes: list[RouteInfo] = []
|
|
458
|
+
for hinfo in handlers:
|
|
459
|
+
key = (hinfo["name"], hinfo["line"])
|
|
460
|
+
if key in seen:
|
|
461
|
+
continue
|
|
462
|
+
seen.add(key)
|
|
463
|
+
# Find enclosing controller by byte range
|
|
464
|
+
class_prefix = ""
|
|
465
|
+
class_name = ""
|
|
466
|
+
for c in controllers.values():
|
|
467
|
+
if c["start"] <= hinfo["start"] <= c["end"]:
|
|
468
|
+
class_prefix = c["prefix"]
|
|
469
|
+
class_name = c["name"]
|
|
470
|
+
break
|
|
471
|
+
routes.append(RouteInfo(
|
|
472
|
+
method=_norm_method(hinfo["dec"]),
|
|
473
|
+
path=_join(class_prefix, hinfo["path"]),
|
|
474
|
+
handler=f"{class_name}.{hinfo['name']}",
|
|
475
|
+
source_file=file_path,
|
|
476
|
+
line=hinfo["line"],
|
|
477
|
+
framework="nestjs",
|
|
478
|
+
))
|
|
479
|
+
return routes
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _interpret_fastapi(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
483
|
+
"""FastAPI @app.get/@router.post with APIRouter prefix tracking."""
|
|
484
|
+
router_prefixes: dict[str, str] = {} # router_var_name → prefix
|
|
485
|
+
routes: list[RouteInfo] = []
|
|
486
|
+
|
|
487
|
+
for _idx, caps in matches:
|
|
488
|
+
# APIRouter(prefix="/api/v1")
|
|
489
|
+
if "route.router_decl" in caps and "route.prefix" in caps:
|
|
490
|
+
name = node_text(caps["route.router_name"][0]) if "route.router_name" in caps else ""
|
|
491
|
+
prefix = _clean(node_text(caps["route.prefix"][0]))
|
|
492
|
+
router_prefixes[name] = prefix
|
|
493
|
+
|
|
494
|
+
# app.include_router(router, prefix="...")
|
|
495
|
+
if "router.include" in caps and "router.include_router" in caps:
|
|
496
|
+
name = node_text(caps["router.include_router"][0])
|
|
497
|
+
if "router.include_prefix" in caps:
|
|
498
|
+
router_prefixes[name] = _clean(node_text(caps["router.include_prefix"][0]))
|
|
499
|
+
|
|
500
|
+
# @app.get("/path") or @router.post("/path")
|
|
501
|
+
if "route.handler" in caps and "route.path" in caps:
|
|
502
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
503
|
+
method = node_text(caps["route.method"][0]) if "route.method" in caps else "get"
|
|
504
|
+
obj = node_text(caps["route.object"][0]) if "route.object" in caps else ""
|
|
505
|
+
handler = node_text(caps["route.func_name"][0]) if "route.func_name" in caps else ""
|
|
506
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
507
|
+
prefix = router_prefixes.get(obj, "")
|
|
508
|
+
routes.append(RouteInfo(
|
|
509
|
+
method=method.upper(),
|
|
510
|
+
path=_join(prefix, path),
|
|
511
|
+
handler=handler,
|
|
512
|
+
source_file=file_path,
|
|
513
|
+
line=line,
|
|
514
|
+
framework="fastapi",
|
|
515
|
+
))
|
|
516
|
+
return routes
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _interpret_django(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
520
|
+
"""Django urlpatterns path() extraction."""
|
|
521
|
+
routes: list[RouteInfo] = []
|
|
522
|
+
for _idx, caps in matches:
|
|
523
|
+
if "route.urlpatterns" in caps and "route.path_str" in caps:
|
|
524
|
+
path = _clean(node_text(caps["route.path_str"][0]))
|
|
525
|
+
view = node_text(caps["route.view_name"][0]) if "route.view_name" in caps else ""
|
|
526
|
+
line = caps["route.path_str"][0].start_point[0] + 1
|
|
527
|
+
routes.append(RouteInfo(
|
|
528
|
+
method="ANY",
|
|
529
|
+
path=path if path.startswith("/") else "/" + path,
|
|
530
|
+
handler=view,
|
|
531
|
+
source_file=file_path,
|
|
532
|
+
line=line,
|
|
533
|
+
framework="django",
|
|
534
|
+
))
|
|
535
|
+
return routes
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _interpret_flask(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
539
|
+
"""Flask @app.route / Blueprint with url_prefix tracking."""
|
|
540
|
+
bp_prefixes: dict[str, str] = {} # blueprint var name → url_prefix
|
|
541
|
+
register_prefixes: dict[str, str] = {} # bp var name → registered prefix
|
|
542
|
+
routes: list[RouteInfo] = []
|
|
543
|
+
|
|
544
|
+
for _idx, caps in matches:
|
|
545
|
+
if "blueprint.decl" in caps and "blueprint.name" in caps:
|
|
546
|
+
name = node_text(caps["blueprint.name"][0])
|
|
547
|
+
prefix = _clean(node_text(caps["blueprint.url_prefix"][0])) if "blueprint.url_prefix" in caps else ""
|
|
548
|
+
bp_prefixes[name] = prefix
|
|
549
|
+
|
|
550
|
+
if "app.register" in caps and "app.register_bp" in caps:
|
|
551
|
+
name = node_text(caps["app.register_bp"][0])
|
|
552
|
+
prefix = _clean(node_text(caps["app.register_prefix"][0])) if "app.register_prefix" in caps else ""
|
|
553
|
+
register_prefixes[name] = prefix
|
|
554
|
+
|
|
555
|
+
if "route.handler" in caps and "route.path" in caps:
|
|
556
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
557
|
+
obj = node_text(caps["route.object"][0]) if "route.object" in caps else ""
|
|
558
|
+
handler = node_text(caps["route.func_name"][0]) if "route.func_name" in caps else ""
|
|
559
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
560
|
+
prefix = register_prefixes.get(obj, bp_prefixes.get(obj, ""))
|
|
561
|
+
full_path = _join(prefix, path)
|
|
562
|
+
|
|
563
|
+
method_nodes = caps.get("route.methods", [])
|
|
564
|
+
if method_nodes:
|
|
565
|
+
for mn in method_nodes:
|
|
566
|
+
routes.append(RouteInfo(
|
|
567
|
+
method=_clean(node_text(mn)).upper(),
|
|
568
|
+
path=full_path,
|
|
569
|
+
handler=handler,
|
|
570
|
+
source_file=file_path,
|
|
571
|
+
line=line,
|
|
572
|
+
framework="flask",
|
|
573
|
+
))
|
|
574
|
+
else:
|
|
575
|
+
routes.append(RouteInfo(
|
|
576
|
+
method="GET",
|
|
577
|
+
path=full_path,
|
|
578
|
+
handler=handler,
|
|
579
|
+
source_file=file_path,
|
|
580
|
+
line=line,
|
|
581
|
+
framework="flask",
|
|
582
|
+
))
|
|
583
|
+
return routes
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _interpret_gin(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
587
|
+
"""Gin / Echo / Fiber route extraction with r.Group() prefix."""
|
|
588
|
+
group_prefixes: dict[str, str] = {}
|
|
589
|
+
routes: list[RouteInfo] = []
|
|
590
|
+
|
|
591
|
+
for _idx, caps in matches:
|
|
592
|
+
if "route.group_decl" in caps and "route.group_prefix" in caps:
|
|
593
|
+
name = node_text(caps["route.group_name"][0]) if "route.group_name" in caps else ""
|
|
594
|
+
prefix = _clean(node_text(caps["route.group_prefix"][0]))
|
|
595
|
+
group_prefixes[name] = prefix
|
|
596
|
+
|
|
597
|
+
for _idx, caps in matches:
|
|
598
|
+
if ("route.call" in caps or "route.call_lower" in caps) and "route.path" in caps:
|
|
599
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
600
|
+
method = node_text(caps["route.method"][0]) if "route.method" in caps else "GET"
|
|
601
|
+
obj = node_text(caps["route.object"][0]) if "route.object" in caps else ""
|
|
602
|
+
handler = node_text(caps["route.handler_name"][0]) if "route.handler_name" in caps else ""
|
|
603
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
604
|
+
prefix = group_prefixes.get(obj, "")
|
|
605
|
+
routes.append(RouteInfo(
|
|
606
|
+
method=_norm_method(method.lower()),
|
|
607
|
+
path=_join(prefix, path),
|
|
608
|
+
handler=handler,
|
|
609
|
+
source_file=file_path,
|
|
610
|
+
line=line,
|
|
611
|
+
framework=framework,
|
|
612
|
+
))
|
|
613
|
+
return routes
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _interpret_rails(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
617
|
+
"""Rails routes.rb: resources DSL + explicit get/post/put/delete."""
|
|
618
|
+
routes: list[RouteInfo] = []
|
|
619
|
+
|
|
620
|
+
for _idx, caps in matches:
|
|
621
|
+
if ("route.resources" in caps or "route.resources_filtered" in caps) and "route.resources_name" in caps:
|
|
622
|
+
resource = node_text(caps["route.resources_name"][0])
|
|
623
|
+
line = caps["route.resources_name"][0].start_point[0] + 1
|
|
624
|
+
routes.extend(_expand_resource(resource, "", file_path, "rails", line))
|
|
625
|
+
|
|
626
|
+
elif "route.explicit" in caps and "route.path" in caps:
|
|
627
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
628
|
+
method = node_text(caps["route.http_method"][0]) if "route.http_method" in caps else "get"
|
|
629
|
+
to = _clean(node_text(caps["route.to"][0])) if "route.to" in caps else ""
|
|
630
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
631
|
+
if method == "root":
|
|
632
|
+
path = "/"
|
|
633
|
+
method = "get"
|
|
634
|
+
routes.append(RouteInfo(
|
|
635
|
+
method=method.upper(),
|
|
636
|
+
path=path if path.startswith("/") else "/" + path,
|
|
637
|
+
handler=to,
|
|
638
|
+
source_file=file_path,
|
|
639
|
+
line=line,
|
|
640
|
+
framework="rails",
|
|
641
|
+
))
|
|
642
|
+
return routes
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _interpret_laravel(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
646
|
+
"""Laravel Route:: static calls and Route::resource expansion."""
|
|
647
|
+
prefix_stack: list[str] = []
|
|
648
|
+
routes: list[RouteInfo] = []
|
|
649
|
+
|
|
650
|
+
for _idx, caps in matches:
|
|
651
|
+
if "route.prefix_group" in caps and "route.prefix" in caps:
|
|
652
|
+
prefix_stack.append(_clean(node_text(caps["route.prefix"][0])))
|
|
653
|
+
|
|
654
|
+
elif "route.call" in caps and "route.path" in caps:
|
|
655
|
+
path = _clean(node_text(caps["route.path"][0]))
|
|
656
|
+
method = node_text(caps["route.method"][0]) if "route.method" in caps else "get"
|
|
657
|
+
controller = node_text(caps["route.controller"][0]) if "route.controller" in caps else ""
|
|
658
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
659
|
+
prefix = "/".join(prefix_stack) if prefix_stack else ""
|
|
660
|
+
routes.append(RouteInfo(
|
|
661
|
+
method=method.upper(),
|
|
662
|
+
path=_join(prefix, path),
|
|
663
|
+
handler=controller,
|
|
664
|
+
source_file=file_path,
|
|
665
|
+
line=line,
|
|
666
|
+
framework="laravel",
|
|
667
|
+
))
|
|
668
|
+
|
|
669
|
+
elif "route.resource" in caps and "route.resource_name" in caps:
|
|
670
|
+
resource = _clean(node_text(caps["route.resource_name"][0]))
|
|
671
|
+
line = caps["route.resource_name"][0].start_point[0] + 1
|
|
672
|
+
prefix = "/".join(prefix_stack) if prefix_stack else ""
|
|
673
|
+
routes.extend(_expand_resource(resource, prefix, file_path, "laravel", line))
|
|
674
|
+
|
|
675
|
+
return routes
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _interpret_aspnet(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
679
|
+
"""ASP.NET Core attribute routing + Minimal API, with [controller] token replacement."""
|
|
680
|
+
controllers: dict[int, dict] = {}
|
|
681
|
+
routes: list[RouteInfo] = []
|
|
682
|
+
|
|
683
|
+
# First pass: collect controller classes
|
|
684
|
+
for _idx, caps in matches:
|
|
685
|
+
if "route.controller_class" in caps:
|
|
686
|
+
cls = caps["route.controller_class"][0]
|
|
687
|
+
name = node_text(caps["route.class_name"][0]) if "route.class_name" in caps else ""
|
|
688
|
+
template = node_text(caps["route.class_attr"][0]) if "route.class_attr" in caps else ""
|
|
689
|
+
controllers.setdefault(cls.start_byte, {
|
|
690
|
+
"name": name, "template": template,
|
|
691
|
+
"start": cls.start_byte, "end": cls.end_byte,
|
|
692
|
+
})
|
|
693
|
+
if "route.controller_bare" in caps:
|
|
694
|
+
cls = caps["route.controller_bare"][0]
|
|
695
|
+
name = node_text(caps["route.class_name"][0]) if "route.class_name" in caps else ""
|
|
696
|
+
controllers.setdefault(cls.start_byte, {
|
|
697
|
+
"name": name, "template": "api/[controller]",
|
|
698
|
+
"start": cls.start_byte, "end": cls.end_byte,
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
# Second pass: collect methods + minimal API
|
|
702
|
+
for _idx, caps in matches:
|
|
703
|
+
if "route.method_with_path" in caps or "route.method_bare" in caps:
|
|
704
|
+
key = "route.method_with_path" if "route.method_with_path" in caps else "route.method_bare"
|
|
705
|
+
m = caps[key][0]
|
|
706
|
+
attr = node_text(caps["route.method_attr"][0]) if "route.method_attr" in caps else ""
|
|
707
|
+
method_path = node_text(caps["route.method_path"][0]) if "route.method_path" in caps else ""
|
|
708
|
+
method_name = node_text(caps["route.method_name"][0]) if "route.method_name" in caps else ""
|
|
709
|
+
line = m.start_point[0] + 1
|
|
710
|
+
|
|
711
|
+
class_template = ""
|
|
712
|
+
class_name = ""
|
|
713
|
+
for c in controllers.values():
|
|
714
|
+
if c["start"] <= m.start_byte <= c["end"]:
|
|
715
|
+
class_template = c["template"]
|
|
716
|
+
class_name = c["name"]
|
|
717
|
+
break
|
|
718
|
+
|
|
719
|
+
ctrl_short = re.sub(r"Controller$", "", class_name, flags=re.IGNORECASE)
|
|
720
|
+
template = class_template.replace("[controller]", ctrl_short.lower()).replace("[Controller]", ctrl_short.lower())
|
|
721
|
+
|
|
722
|
+
routes.append(RouteInfo(
|
|
723
|
+
method=_norm_method(attr),
|
|
724
|
+
path=_join(template, method_path),
|
|
725
|
+
handler=f"{class_name}.{method_name}",
|
|
726
|
+
source_file=file_path,
|
|
727
|
+
line=line,
|
|
728
|
+
framework="aspnet",
|
|
729
|
+
))
|
|
730
|
+
|
|
731
|
+
if "route.minimal_api" in caps and "route.map_path" in caps:
|
|
732
|
+
path = node_text(caps["route.map_path"][0])
|
|
733
|
+
method = node_text(caps["route.map_method"][0]) if "route.map_method" in caps else "MapGet"
|
|
734
|
+
line = caps["route.map_path"][0].start_point[0] + 1
|
|
735
|
+
routes.append(RouteInfo(
|
|
736
|
+
method=_norm_method(method),
|
|
737
|
+
path=path if path.startswith("/") else "/" + path,
|
|
738
|
+
handler="",
|
|
739
|
+
source_file=file_path,
|
|
740
|
+
line=line,
|
|
741
|
+
framework="aspnet",
|
|
742
|
+
))
|
|
743
|
+
return routes
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _interpret_actix(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
747
|
+
"""Actix proc macros (#[get(...)]) + Axum Router::new().route(...)."""
|
|
748
|
+
routes: list[RouteInfo] = []
|
|
749
|
+
for _idx, caps in matches:
|
|
750
|
+
if "route.actix_handler" in caps and "route.path" in caps:
|
|
751
|
+
method = node_text(caps["route.proc_macro"][0]) if "route.proc_macro" in caps else "get"
|
|
752
|
+
path = node_text(caps["route.path"][0])
|
|
753
|
+
func = node_text(caps["route.func_name"][0]) if "route.func_name" in caps else ""
|
|
754
|
+
line = caps["route.actix_handler"][0].start_point[0] + 1
|
|
755
|
+
routes.append(RouteInfo(
|
|
756
|
+
method=method.upper(),
|
|
757
|
+
path=path if path.startswith("/") else "/" + path,
|
|
758
|
+
handler=func,
|
|
759
|
+
source_file=file_path,
|
|
760
|
+
line=line,
|
|
761
|
+
framework=framework,
|
|
762
|
+
))
|
|
763
|
+
|
|
764
|
+
if "route.axum_route" in caps and "route.axum_path" in caps:
|
|
765
|
+
path = node_text(caps["route.axum_path"][0])
|
|
766
|
+
method = node_text(caps["route.axum_method"][0]) if "route.axum_method" in caps else "get"
|
|
767
|
+
handler = node_text(caps["route.axum_handler"][0]) if "route.axum_handler" in caps else ""
|
|
768
|
+
line = caps["route.axum_route"][0].start_point[0] + 1
|
|
769
|
+
routes.append(RouteInfo(
|
|
770
|
+
method=method.upper(),
|
|
771
|
+
path=path if path.startswith("/") else "/" + path,
|
|
772
|
+
handler=handler,
|
|
773
|
+
source_file=file_path,
|
|
774
|
+
line=line,
|
|
775
|
+
framework=framework,
|
|
776
|
+
))
|
|
777
|
+
return routes
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _interpret_vapor(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
781
|
+
"""Vapor app.get("users", ":id") with grouped() prefix."""
|
|
782
|
+
grouped_prefixes: dict[str, str] = {}
|
|
783
|
+
routes: list[RouteInfo] = []
|
|
784
|
+
|
|
785
|
+
for _idx, caps in matches:
|
|
786
|
+
if "route.grouped_decl" in caps and "route.grouped_prefix" in caps:
|
|
787
|
+
name = node_text(caps["route.grouped_name"][0]) if "route.grouped_name" in caps else ""
|
|
788
|
+
prefix = node_text(caps["route.grouped_prefix"][0])
|
|
789
|
+
grouped_prefixes[name] = prefix
|
|
790
|
+
|
|
791
|
+
for _idx, caps in matches:
|
|
792
|
+
if "route.call" in caps and "route.method" in caps:
|
|
793
|
+
method = node_text(caps["route.method"][0])
|
|
794
|
+
if method == "grouped":
|
|
795
|
+
continue
|
|
796
|
+
obj = node_text(caps["route.object"][0]) if "route.object" in caps else "app"
|
|
797
|
+
segments = [node_text(n) for n in caps.get("route.path_segment", [])]
|
|
798
|
+
path = _join(*segments) if segments else "/"
|
|
799
|
+
line = caps["route.call"][0].start_point[0] + 1
|
|
800
|
+
prefix = grouped_prefixes.get(obj, "")
|
|
801
|
+
routes.append(RouteInfo(
|
|
802
|
+
method=method.upper(),
|
|
803
|
+
path=_join(prefix, path),
|
|
804
|
+
handler="",
|
|
805
|
+
source_file=file_path,
|
|
806
|
+
line=line,
|
|
807
|
+
framework="vapor",
|
|
808
|
+
))
|
|
809
|
+
return routes
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _interpret_ktor(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
813
|
+
"""Ktor nested routing DSL: route("/prefix") { get("/path") { } }."""
|
|
814
|
+
# Collect prefix scopes by line range
|
|
815
|
+
prefix_scopes: list[tuple[int, int, str]] = []
|
|
816
|
+
routes: list[RouteInfo] = []
|
|
817
|
+
|
|
818
|
+
for _idx, caps in matches:
|
|
819
|
+
if "route.prefix_scope" in caps and "route.route_prefix" in caps:
|
|
820
|
+
scope = caps["route.prefix_scope"][0]
|
|
821
|
+
prefix = node_text(caps["route.route_prefix"][0])
|
|
822
|
+
prefix_scopes.append((scope.start_point[0], scope.end_point[0], prefix))
|
|
823
|
+
|
|
824
|
+
for _idx, caps in matches:
|
|
825
|
+
call_key = None
|
|
826
|
+
if "route.method_call" in caps:
|
|
827
|
+
call_key = "route.method_call"
|
|
828
|
+
elif "route.method_call_simple" in caps:
|
|
829
|
+
call_key = "route.method_call_simple"
|
|
830
|
+
if call_key and "route.path" in caps and "route.method" in caps:
|
|
831
|
+
call_node = caps[call_key][0]
|
|
832
|
+
method = node_text(caps["route.method"][0])
|
|
833
|
+
path = node_text(caps["route.path"][0])
|
|
834
|
+
line = call_node.start_point[0] + 1
|
|
835
|
+
# Find innermost applicable prefix scope
|
|
836
|
+
prefix = ""
|
|
837
|
+
for start, end, pfx in prefix_scopes:
|
|
838
|
+
if start <= call_node.start_point[0] <= end:
|
|
839
|
+
prefix = pfx
|
|
840
|
+
routes.append(RouteInfo(
|
|
841
|
+
method=method.upper(),
|
|
842
|
+
path=_join(prefix, path),
|
|
843
|
+
handler="",
|
|
844
|
+
source_file=file_path,
|
|
845
|
+
line=line,
|
|
846
|
+
framework="ktor",
|
|
847
|
+
))
|
|
848
|
+
return routes
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _interpret_react(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
852
|
+
"""React/Next.js — AST routes (convention routes handled in _convention_routes)."""
|
|
853
|
+
# The react.scm doesn't capture React Router <Route path=...> elements
|
|
854
|
+
# Convention routes (file-system) are sufficient for Next.js
|
|
855
|
+
return []
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _interpret_vue(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
859
|
+
"""Vue Router routes array + Nuxt convention (handled in _convention_routes)."""
|
|
860
|
+
routes: list[RouteInfo] = []
|
|
861
|
+
seen: set[str] = set()
|
|
862
|
+
for _idx, caps in matches:
|
|
863
|
+
if "route.path" in caps:
|
|
864
|
+
path = node_text(caps["route.path"][0])
|
|
865
|
+
if not path.startswith("/"):
|
|
866
|
+
path = "/" + path
|
|
867
|
+
if path in seen:
|
|
868
|
+
continue
|
|
869
|
+
seen.add(path)
|
|
870
|
+
component = node_text(caps["route.component"][0]) if "route.component" in caps else ""
|
|
871
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
872
|
+
routes.append(RouteInfo(
|
|
873
|
+
method="GET",
|
|
874
|
+
path=path,
|
|
875
|
+
handler=component,
|
|
876
|
+
source_file=file_path,
|
|
877
|
+
line=line,
|
|
878
|
+
framework=framework,
|
|
879
|
+
))
|
|
880
|
+
return routes
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _interpret_svelte(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
884
|
+
"""SvelteKit — convention routes handled in _convention_routes."""
|
|
885
|
+
return []
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _interpret_angular(file_path: str, matches: list, framework: str) -> list[RouteInfo]:
|
|
889
|
+
"""Angular Routes array: { path: "users", component: UserListComponent }."""
|
|
890
|
+
routes: list[RouteInfo] = []
|
|
891
|
+
seen: set[str] = set()
|
|
892
|
+
for _idx, caps in matches:
|
|
893
|
+
if "route.routes" in caps and "route.path" in caps:
|
|
894
|
+
path = node_text(caps["route.path"][0])
|
|
895
|
+
if not path.startswith("/"):
|
|
896
|
+
path = "/" + path
|
|
897
|
+
if path in seen:
|
|
898
|
+
continue
|
|
899
|
+
seen.add(path)
|
|
900
|
+
component = node_text(caps["route.component"][0]) if "route.component" in caps else ""
|
|
901
|
+
line = caps["route.path"][0].start_point[0] + 1
|
|
902
|
+
routes.append(RouteInfo(
|
|
903
|
+
method="GET",
|
|
904
|
+
path=path,
|
|
905
|
+
handler=component,
|
|
906
|
+
source_file=file_path,
|
|
907
|
+
line=line,
|
|
908
|
+
framework="angular",
|
|
909
|
+
))
|
|
910
|
+
return routes
|