apisec-code-bolt 0.1.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.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,532 @@
1
+ """
2
+ Dynamic route detection for FastAPI/Starlette applications.
3
+
4
+ This module handles routes that are registered programmatically rather than
5
+ via decorators:
6
+
7
+ 1. Direct registration: app.add_api_route("/path", handler, methods=["GET"])
8
+ 2. Factory functions: create_crud_routes(model=User) returning routes
9
+ 3. Loop-based registration: for r in routes: app.add_api_route(...)
10
+ 4. Route tables: routes = [Route("/path", handler, methods=["GET"])]
11
+
12
+ CRITICAL: Enterprise applications often use these patterns for:
13
+ - CRUD generation (SQLAlchemy-Admin, FastAPI-CRUD)
14
+ - Dynamic plugin systems
15
+ - Multi-tenant routing
16
+ - Generic REST resource creation
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ if TYPE_CHECKING:
27
+ from ..base import ParsedFile, ParsedFunction
28
+
29
+
30
+ # =============================================================================
31
+ # Data Types
32
+ # =============================================================================
33
+
34
+
35
+ @dataclass
36
+ class DynamicRoute:
37
+ """A route registered dynamically (not via decorator)."""
38
+
39
+ # Route info (may be partially resolved)
40
+ path: str
41
+ methods: list[str] # ["GET"], ["GET", "POST"], etc.
42
+
43
+ # Handler info
44
+ handler_name: str # Function name or reference
45
+
46
+ # Registration details
47
+ registration_type: str # "add_api_route", "add_route", "route_table", "factory"
48
+
49
+ # Optional fields with defaults
50
+ handler_qualified: str | None = None
51
+ file_path: Path | None = None
52
+ line: int = 0
53
+
54
+ # Resolution status
55
+ is_fully_resolved: bool = False
56
+ confidence: float = 0.5
57
+
58
+ # For factory-generated routes
59
+ factory_function: str | None = None
60
+ factory_arguments: dict[str, Any] = field(default_factory=dict)
61
+
62
+ # Notes about detection
63
+ notes: list[str] = field(default_factory=list)
64
+
65
+
66
+ @dataclass
67
+ class RouteFactory:
68
+ """A function that generates routes."""
69
+
70
+ name: str
71
+ qualified_name: str
72
+ file_path: Path
73
+ line: int
74
+
75
+ # What kind of factory?
76
+ factory_type: str # "crud", "resource", "generic", "unknown"
77
+
78
+ # Parameters that affect route generation
79
+ model_param: str | None = None # Parameter name for model class
80
+ prefix_param: str | None = None # Parameter name for path prefix
81
+
82
+ # Detected route patterns
83
+ generated_routes: list[DynamicRoute] = field(default_factory=list)
84
+
85
+ # Confidence in detection
86
+ confidence: float = 0.5
87
+
88
+
89
+ @dataclass
90
+ class LoopRouteRegistration:
91
+ """Route registration happening in a loop."""
92
+
93
+ # Loop info
94
+ file_path: Path
95
+ line: int
96
+
97
+ # What's being iterated
98
+ iterable_name: str # e.g., "routes", "endpoints"
99
+
100
+ # Registration call info
101
+ registration_call: str # e.g., "app.add_api_route"
102
+
103
+ # Optional fields with defaults
104
+ iterable_source: str | None = None # Where the iterable comes from
105
+
106
+ # Detected static routes from the iterable
107
+ detected_routes: list[DynamicRoute] = field(default_factory=list)
108
+
109
+ # Notes
110
+ notes: list[str] = field(default_factory=list)
111
+
112
+
113
+ # =============================================================================
114
+ # Dynamic Route Detector
115
+ # =============================================================================
116
+
117
+
118
+ class DynamicRouteDetector:
119
+ """
120
+ Detects dynamically registered routes in FastAPI/Starlette applications.
121
+
122
+ This handles cases where routes are not registered via decorators but
123
+ through programmatic calls like:
124
+
125
+ - app.add_api_route("/users", list_users, methods=["GET"])
126
+ - routes = [Route("/users", endpoint=list_users)]
127
+ - for route in crud_routes: app.add_api_route(...)
128
+
129
+ Usage:
130
+ detector = DynamicRouteDetector()
131
+
132
+ # Process files
133
+ for parsed in parsed_files:
134
+ detector.process_file(parsed)
135
+
136
+ # Get detected routes
137
+ routes = detector.get_all_routes()
138
+ """
139
+
140
+ def __init__(self, project_root: Path | None = None):
141
+ """Initialize the detector."""
142
+ self._project_root = project_root
143
+ self._dynamic_routes: list[DynamicRoute] = []
144
+ self._factories: dict[str, RouteFactory] = {}
145
+ self._loop_registrations: list[LoopRouteRegistration] = []
146
+ self._route_tables: dict[Path, dict[str, list[DynamicRoute]]] = {}
147
+ self._app_vars: dict[Path, set[str]] = {}
148
+
149
+ def process_file(self, parsed: ParsedFile) -> None:
150
+ """Process a parsed file to detect dynamic routes."""
151
+ if not parsed.success:
152
+ return
153
+
154
+ file_path = parsed.path
155
+ self._app_vars[file_path] = set()
156
+ self._route_tables[file_path] = {}
157
+
158
+ # Step 1: Identify app/router variables
159
+ self._identify_app_routers(parsed)
160
+
161
+ # Step 2: Find add_api_route / add_route calls
162
+ self._detect_add_route_calls(parsed)
163
+
164
+ # Step 3: Find route table definitions
165
+ self._detect_route_tables(parsed)
166
+
167
+ # Step 4: Find factory functions
168
+ self._detect_factory_functions(parsed)
169
+
170
+ # Step 5: Find loop-based registrations
171
+ self._detect_loop_registrations(parsed)
172
+
173
+ def _identify_app_routers(self, parsed: ParsedFile) -> None:
174
+ """Identify FastAPI/Router variables."""
175
+ file_path = parsed.path
176
+
177
+ for assign in parsed.assignments:
178
+ if assign.source_type == "call":
179
+ called = assign.source_call or ""
180
+ if any(x in called for x in ["FastAPI", "APIRouter", "Starlette", "Router"]):
181
+ self._app_vars[file_path].add(assign.target)
182
+
183
+ def _detect_add_route_calls(self, parsed: ParsedFile) -> None:
184
+ """Detect add_api_route and add_route calls."""
185
+ file_path = parsed.path
186
+ app_vars = self._app_vars.get(file_path, set())
187
+
188
+ for call in parsed.call_sites:
189
+ callee = call.callee_name
190
+
191
+ # Check for add_api_route or add_route
192
+ is_add_route = False
193
+ if callee.endswith("add_api_route") or callee.endswith("add_route"):
194
+ # Verify it's on an app/router variable
195
+ parts = callee.rsplit(".", 1)
196
+ if len(parts) >= 2:
197
+ var_name = parts[0].split(".")[-1] # Handle chained attrs
198
+ if var_name in app_vars or var_name in {"app", "router", "api"}:
199
+ is_add_route = True
200
+
201
+ if not is_add_route:
202
+ continue
203
+
204
+ # Extract route info from call arguments
205
+ route = self._extract_route_from_add_call(call, file_path)
206
+ if route:
207
+ self._dynamic_routes.append(route)
208
+
209
+ def _extract_route_from_add_call(self, call, file_path: Path) -> DynamicRoute | None:
210
+ """Extract route info from add_api_route call."""
211
+ path = ""
212
+ handler = ""
213
+ methods: list[str] = []
214
+
215
+ for arg in call.arguments:
216
+ # Path is typically first positional arg
217
+ if arg.position == 0 or arg.name == "path":
218
+ path = self._extract_arg_value(arg)
219
+
220
+ # Handler is second positional or 'endpoint'
221
+ elif arg.position == 1 or arg.name == "endpoint":
222
+ handler = arg.variable_name or self._extract_arg_value(arg)
223
+
224
+ # Methods
225
+ elif arg.name == "methods":
226
+ methods = self._extract_methods(arg)
227
+
228
+ if not path:
229
+ return None
230
+
231
+ # Default method is GET
232
+ if not methods:
233
+ methods = ["GET"]
234
+
235
+ return DynamicRoute(
236
+ path=path,
237
+ methods=methods,
238
+ handler_name=handler,
239
+ registration_type="add_api_route",
240
+ file_path=file_path,
241
+ line=call.location.line,
242
+ is_fully_resolved=not self._has_variable_ref(path),
243
+ confidence=0.9 if not self._has_variable_ref(path) else 0.6,
244
+ )
245
+
246
+ def _detect_route_tables(self, parsed: ParsedFile) -> None:
247
+ """Detect route table assignments like routes = [Route(...), ...]."""
248
+ file_path = parsed.path
249
+
250
+ for assign in parsed.assignments:
251
+ # Look for list assignments with Route() calls
252
+ source = assign.value_source or ""
253
+
254
+ # Check for Route or APIRoute in a list
255
+ if not ("[" in source and "Route(" in source):
256
+ continue
257
+
258
+ routes = self._parse_route_table(source, file_path, assign.location.line)
259
+ if routes:
260
+ self._route_tables[file_path][assign.target] = routes
261
+ self._dynamic_routes.extend(routes)
262
+
263
+ def _parse_route_table(
264
+ self,
265
+ source: str,
266
+ file_path: Path,
267
+ line: int,
268
+ ) -> list[DynamicRoute]:
269
+ """Parse a route table definition."""
270
+ routes: list[DynamicRoute] = []
271
+
272
+ # Pattern for Route("/path", endpoint=handler, methods=["GET"])
273
+ route_pattern = r'(?:API)?Route\s*\(\s*["\']([^"\']+)["\']\s*(?:,\s*(?:endpoint\s*=\s*)?(\w+))?\s*(?:,\s*methods\s*=\s*\[([^\]]*)\])?'
274
+
275
+ for match in re.finditer(route_pattern, source):
276
+ path = match.group(1)
277
+ handler = match.group(2) or ""
278
+ methods_str = match.group(3) or ""
279
+
280
+ # Parse methods
281
+ methods = re.findall(r'["\'](\w+)["\']', methods_str) or ["GET"]
282
+
283
+ routes.append(
284
+ DynamicRoute(
285
+ path=path,
286
+ methods=[m.upper() for m in methods],
287
+ handler_name=handler,
288
+ registration_type="route_table",
289
+ file_path=file_path,
290
+ line=line,
291
+ is_fully_resolved=True,
292
+ confidence=0.95,
293
+ )
294
+ )
295
+
296
+ return routes
297
+
298
+ def _detect_factory_functions(self, parsed: ParsedFile) -> None:
299
+ """Detect functions that generate routes."""
300
+
301
+ for func in parsed.functions:
302
+ factory = self._analyze_factory_function(func, parsed)
303
+ if factory:
304
+ self._factories[factory.qualified_name] = factory
305
+
306
+ def _analyze_factory_function(
307
+ self,
308
+ func: ParsedFunction,
309
+ parsed: ParsedFile,
310
+ ) -> RouteFactory | None:
311
+ """Analyze a function to determine if it's a route factory."""
312
+ file_path = parsed.path
313
+ name = func.name.lower()
314
+
315
+ # Check naming patterns
316
+ factory_patterns = [
317
+ "create_routes",
318
+ "create_crud",
319
+ "generate_routes",
320
+ "make_routes",
321
+ "crud_routes",
322
+ "resource_routes",
323
+ "register_routes",
324
+ "build_routes",
325
+ "create_api",
326
+ "crud_generator",
327
+ "crud_factory",
328
+ ]
329
+
330
+ is_likely_factory = False
331
+ factory_type = "unknown"
332
+
333
+ for pattern in factory_patterns:
334
+ if pattern in name:
335
+ is_likely_factory = True
336
+ if "crud" in pattern:
337
+ factory_type = "crud"
338
+ elif "resource" in pattern:
339
+ factory_type = "resource"
340
+ else:
341
+ factory_type = "generic"
342
+ break
343
+
344
+ # Check return type
345
+ if func.return_type:
346
+ ret_lower = func.return_type.lower()
347
+ if any(x in ret_lower for x in ["router", "apirouter", "list[route"]):
348
+ is_likely_factory = True
349
+
350
+ if not is_likely_factory:
351
+ return None
352
+
353
+ # Analyze parameters
354
+ model_param = None
355
+ prefix_param = None
356
+
357
+ for param in func.parameters:
358
+ param_name = param.name.lower()
359
+ if param_name in {"model", "model_class", "schema", "orm_model"}:
360
+ model_param = param.name
361
+ elif param_name in {"prefix", "path_prefix", "base_path"}:
362
+ prefix_param = param.name
363
+
364
+ # Try to detect generated routes from function body
365
+ generated_routes = self._detect_routes_in_function(func, parsed)
366
+
367
+ return RouteFactory(
368
+ name=func.name,
369
+ qualified_name=func.qualified_name.full,
370
+ file_path=file_path,
371
+ line=func.location.line,
372
+ factory_type=factory_type,
373
+ model_param=model_param,
374
+ prefix_param=prefix_param,
375
+ generated_routes=generated_routes,
376
+ confidence=0.7 if generated_routes else 0.4,
377
+ )
378
+
379
+ def _detect_routes_in_function(
380
+ self,
381
+ func: ParsedFunction,
382
+ parsed: ParsedFile,
383
+ ) -> list[DynamicRoute]:
384
+ """Detect routes defined within a function (factory pattern)."""
385
+ routes: list[DynamicRoute] = []
386
+ file_path = parsed.path
387
+
388
+ # Find decorators within the function that create routes
389
+ # This is tricky because we need to look at nested functions
390
+
391
+ # For now, look for common CRUD patterns
392
+ crud_methods = [
393
+ ("", ["GET"], "list"),
394
+ ("/{id}", ["GET"], "read"),
395
+ ("", ["POST"], "create"),
396
+ ("/{id}", ["PUT", "PATCH"], "update"),
397
+ ("/{id}", ["DELETE"], "delete"),
398
+ ]
399
+
400
+ # Check if function body contains these patterns
401
+ if func.docstring and "crud" in func.docstring.lower():
402
+ for path_suffix, methods, operation in crud_methods:
403
+ routes.append(
404
+ DynamicRoute(
405
+ path=f"{{prefix}}{path_suffix}",
406
+ methods=methods,
407
+ handler_name=f"{func.name}_{operation}",
408
+ registration_type="factory",
409
+ file_path=file_path,
410
+ line=func.location.line,
411
+ factory_function=func.name,
412
+ is_fully_resolved=False,
413
+ confidence=0.5,
414
+ notes=[f"Inferred from CRUD factory: {func.name}"],
415
+ )
416
+ )
417
+
418
+ return routes
419
+
420
+ def _detect_loop_registrations(self, parsed: ParsedFile) -> None:
421
+ """Detect loop-based route registrations."""
422
+ file_path = parsed.path
423
+
424
+ # This requires more sophisticated analysis of control flow
425
+ # For now, we look for patterns in call sites
426
+
427
+ for call in parsed.call_sites:
428
+ if not (
429
+ call.callee_name.endswith("add_api_route") or call.callee_name.endswith("add_route")
430
+ ):
431
+ continue
432
+
433
+ # Check if any argument references a loop variable
434
+ # This is a heuristic - we check for common patterns
435
+ for arg in call.arguments:
436
+ expr = arg.expression_text or ""
437
+
438
+ # Patterns like: route["path"], route.path, item["endpoint"]
439
+ if re.search(r"\w+\[.+\]|\w+\.\w+", expr):
440
+ loop_reg = LoopRouteRegistration(
441
+ file_path=file_path,
442
+ line=call.location.line,
443
+ iterable_name="routes", # Inferred
444
+ registration_call=call.callee_name,
445
+ notes=["Detected dictionary/attribute access in route registration"],
446
+ )
447
+ self._loop_registrations.append(loop_reg)
448
+ break
449
+
450
+ def _extract_arg_value(self, arg) -> str:
451
+ """Extract value from a call argument."""
452
+ if arg.literal_value is not None:
453
+ return str(arg.literal_value)
454
+ if arg.expression_text:
455
+ # Try to extract string literal
456
+ match = re.search(r'["\']([^"\']*)["\']', arg.expression_text)
457
+ if match:
458
+ return match.group(1)
459
+ return arg.expression_text
460
+ return ""
461
+
462
+ def _extract_methods(self, arg) -> list[str]:
463
+ """Extract HTTP methods from argument."""
464
+ methods = []
465
+
466
+ if isinstance(arg.literal_value, list):
467
+ return [str(m).upper() for m in arg.literal_value]
468
+
469
+ if arg.expression_text:
470
+ matches = re.findall(r'["\'](\w+)["\']', arg.expression_text)
471
+ methods = [m.upper() for m in matches]
472
+
473
+ return methods or ["GET"]
474
+
475
+ def _has_variable_ref(self, value: str) -> bool:
476
+ """Check if value contains unresolved variable references."""
477
+ # f-string variables: {var}
478
+ if re.search(r"\{[^}]+\}", value):
479
+ return True
480
+ # Not a literal
481
+ return bool(not (value.startswith("/") or value.startswith('"') or value.startswith("'")))
482
+
483
+ # =========================================================================
484
+ # Query Methods
485
+ # =========================================================================
486
+
487
+ def get_all_routes(self) -> list[DynamicRoute]:
488
+ """Get all detected dynamic routes."""
489
+ return list(self._dynamic_routes)
490
+
491
+ def get_routes_for_file(self, file_path: Path) -> list[DynamicRoute]:
492
+ """Get dynamic routes defined in a specific file."""
493
+ return [r for r in self._dynamic_routes if r.file_path == file_path]
494
+
495
+ def get_factories(self) -> dict[str, RouteFactory]:
496
+ """Get all detected route factories."""
497
+ return dict(self._factories)
498
+
499
+ def get_loop_registrations(self) -> list[LoopRouteRegistration]:
500
+ """Get all detected loop-based registrations."""
501
+ return list(self._loop_registrations)
502
+
503
+ def get_route_tables(self, file_path: Path) -> dict[str, list[DynamicRoute]]:
504
+ """Get route tables defined in a file."""
505
+ return self._route_tables.get(file_path, {})
506
+
507
+
508
+ # =============================================================================
509
+ # Convenience Functions
510
+ # =============================================================================
511
+
512
+
513
+ def detect_dynamic_routes(
514
+ parsed_files: list[ParsedFile],
515
+ project_root: Path | None = None,
516
+ ) -> tuple[list[DynamicRoute], dict[str, RouteFactory]]:
517
+ """
518
+ Detect all dynamic routes in a project.
519
+
520
+ Args:
521
+ parsed_files: List of successfully parsed files
522
+ project_root: Optional project root
523
+
524
+ Returns:
525
+ Tuple of (dynamic_routes, factories)
526
+ """
527
+ detector = DynamicRouteDetector(project_root)
528
+
529
+ for parsed in parsed_files:
530
+ detector.process_file(parsed)
531
+
532
+ return detector.get_all_routes(), detector.get_factories()