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.
Files changed (59) hide show
  1. codebeacon/__init__.py +1 -0
  2. codebeacon/__main__.py +3 -0
  3. codebeacon/cache.py +136 -0
  4. codebeacon/cli.py +391 -0
  5. codebeacon/common/__init__.py +0 -0
  6. codebeacon/common/filters.py +170 -0
  7. codebeacon/common/symbols.py +121 -0
  8. codebeacon/common/types.py +98 -0
  9. codebeacon/config.py +144 -0
  10. codebeacon/contextmap/__init__.py +0 -0
  11. codebeacon/contextmap/generator.py +602 -0
  12. codebeacon/discover/__init__.py +0 -0
  13. codebeacon/discover/detector.py +388 -0
  14. codebeacon/discover/scanner.py +192 -0
  15. codebeacon/export/__init__.py +0 -0
  16. codebeacon/export/mcp.py +515 -0
  17. codebeacon/export/obsidian.py +812 -0
  18. codebeacon/extract/__init__.py +22 -0
  19. codebeacon/extract/base.py +372 -0
  20. codebeacon/extract/components.py +357 -0
  21. codebeacon/extract/dependencies.py +140 -0
  22. codebeacon/extract/entities.py +575 -0
  23. codebeacon/extract/queries/README.md +116 -0
  24. codebeacon/extract/queries/actix.scm +115 -0
  25. codebeacon/extract/queries/angular.scm +155 -0
  26. codebeacon/extract/queries/aspnet.scm +159 -0
  27. codebeacon/extract/queries/django.scm +122 -0
  28. codebeacon/extract/queries/express.scm +124 -0
  29. codebeacon/extract/queries/fastapi.scm +152 -0
  30. codebeacon/extract/queries/flask.scm +120 -0
  31. codebeacon/extract/queries/gin.scm +142 -0
  32. codebeacon/extract/queries/ktor.scm +144 -0
  33. codebeacon/extract/queries/laravel.scm +172 -0
  34. codebeacon/extract/queries/nestjs.scm +183 -0
  35. codebeacon/extract/queries/rails.scm +114 -0
  36. codebeacon/extract/queries/react.scm +111 -0
  37. codebeacon/extract/queries/spring_boot.scm +204 -0
  38. codebeacon/extract/queries/svelte.scm +73 -0
  39. codebeacon/extract/queries/vapor.scm +130 -0
  40. codebeacon/extract/queries/vue.scm +123 -0
  41. codebeacon/extract/routes.py +910 -0
  42. codebeacon/extract/semantic.py +280 -0
  43. codebeacon/extract/services.py +597 -0
  44. codebeacon/graph/__init__.py +1 -0
  45. codebeacon/graph/analyze.py +281 -0
  46. codebeacon/graph/build.py +320 -0
  47. codebeacon/graph/cluster.py +160 -0
  48. codebeacon/graph/enrich.py +206 -0
  49. codebeacon/skill/SKILL.md +127 -0
  50. codebeacon/wave.py +292 -0
  51. codebeacon/wiki/__init__.py +0 -0
  52. codebeacon/wiki/generator.py +376 -0
  53. codebeacon/wiki/index.py +95 -0
  54. codebeacon/wiki/templates.py +467 -0
  55. codebeacon-0.1.2.dist-info/METADATA +319 -0
  56. codebeacon-0.1.2.dist-info/RECORD +59 -0
  57. codebeacon-0.1.2.dist-info/WHEEL +4 -0
  58. codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
  59. 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