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,576 @@
1
+ """
2
+ Path resolver for computed route paths in FastAPI/Starlette applications.
3
+
4
+ This module handles:
5
+ - f-string path resolution: f"/api/{version}/users" -> "/api/v1/users"
6
+ - Constant/variable path resolution: PREFIX + "/users" -> "/api/users"
7
+ - Config attribute resolution: settings.API_PREFIX -> "/api"
8
+ - Module-level variable tracking
9
+ - Partial resolution with confidence levels
10
+
11
+ CRITICAL: Many enterprise applications use computed paths for versioning,
12
+ tenant isolation, or dynamic configuration. This enables accurate path resolution.
13
+
14
+ PHILOSOPHY: Resolve what we can statically. Even if a value CAN be overridden
15
+ at runtime (env vars, settings), the default value is valuable information.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ import libcst as cst
26
+
27
+ if TYPE_CHECKING:
28
+ from ..base import ParsedAssignment, ParsedFile
29
+ from .constant_resolver import ConstantResolver
30
+
31
+
32
+ # =============================================================================
33
+ # Data Types
34
+ # =============================================================================
35
+
36
+
37
+ @dataclass
38
+ class ResolvedPath:
39
+ """Result of path resolution."""
40
+
41
+ # The resolved path (may be partial)
42
+ path: str
43
+
44
+ # Original path expression
45
+ original: str
46
+
47
+ # How much was resolved (0.0 = nothing, 1.0 = fully resolved)
48
+ resolution_confidence: float
49
+
50
+ # Unresolved variables (for partial resolution)
51
+ unresolved_vars: list[str] = field(default_factory=list)
52
+
53
+ # Notes about resolution
54
+ notes: list[str] = field(default_factory=list)
55
+
56
+
57
+ @dataclass
58
+ class TrackedVariable:
59
+ """A module-level variable that might be used in paths."""
60
+
61
+ name: str
62
+ value: str | None # Resolved value if known
63
+ value_source: str # Original source code
64
+ file_path: Path
65
+ line: int
66
+
67
+ # Type of variable
68
+ var_type: str # "constant", "env_var", "computed", "unknown"
69
+
70
+ # If env var: the env var name
71
+ env_var_name: str | None = None
72
+
73
+ # Default value for env vars
74
+ env_default: str | None = None
75
+
76
+
77
+ # =============================================================================
78
+ # Path Expression Analyzer
79
+ # =============================================================================
80
+
81
+
82
+ class PathExpressionAnalyzer(cst.CSTVisitor):
83
+ """
84
+ CST visitor to analyze path expressions.
85
+
86
+ Extracts:
87
+ - String literals
88
+ - f-string components
89
+ - Variable references
90
+ - Concatenation operations
91
+ """
92
+
93
+ def __init__(self):
94
+ self.components: list[dict[str, Any]] = []
95
+ self._in_fstring = False
96
+
97
+ def visit_SimpleString(self, node: cst.SimpleString) -> bool:
98
+ """Extract simple string literals."""
99
+ value = self._extract_string_value(node)
100
+ self.components.append(
101
+ {
102
+ "type": "literal",
103
+ "value": value,
104
+ }
105
+ )
106
+ return False
107
+
108
+ def visit_FormattedString(self, node: cst.FormattedString) -> bool:
109
+ """Start of f-string."""
110
+ self._in_fstring = True
111
+ return True
112
+
113
+ def leave_FormattedString(self, node: cst.FormattedString) -> None:
114
+ """End of f-string."""
115
+ self._in_fstring = False
116
+
117
+ def visit_FormattedStringText(self, node: cst.FormattedStringText) -> bool:
118
+ """Literal text within f-string."""
119
+ self.components.append(
120
+ {
121
+ "type": "literal",
122
+ "value": node.value,
123
+ }
124
+ )
125
+ return False
126
+
127
+ def visit_FormattedStringExpression(self, node: cst.FormattedStringExpression) -> bool:
128
+ """Expression within f-string: {expr}."""
129
+ expr = node.expression
130
+
131
+ # Check if it's a simple name reference
132
+ if isinstance(expr, cst.Name):
133
+ self.components.append(
134
+ {
135
+ "type": "variable",
136
+ "name": expr.value,
137
+ }
138
+ )
139
+ elif isinstance(expr, cst.Attribute):
140
+ # Handle obj.attr references
141
+ self.components.append(
142
+ {
143
+ "type": "attribute",
144
+ "source": self._node_to_code(expr),
145
+ }
146
+ )
147
+ else:
148
+ # Complex expression
149
+ self.components.append(
150
+ {
151
+ "type": "expression",
152
+ "source": self._node_to_code(expr),
153
+ }
154
+ )
155
+
156
+ return False
157
+
158
+ def visit_ConcatenatedString(self, node: cst.ConcatenatedString) -> bool:
159
+ """Handle string concatenation."""
160
+ # Process each part
161
+ return True
162
+
163
+ def visit_BinaryOperation(self, node: cst.BinaryOperation) -> bool:
164
+ """Handle + operator for string concatenation."""
165
+ if isinstance(node.operator, cst.Add):
166
+ # Process left and right separately
167
+ return True
168
+ return False
169
+
170
+ def visit_Name(self, node: cst.Name) -> bool:
171
+ """Handle standalone variable references."""
172
+ if not self._in_fstring:
173
+ self.components.append(
174
+ {
175
+ "type": "variable",
176
+ "name": node.value,
177
+ }
178
+ )
179
+ return False
180
+
181
+ def _extract_string_value(self, node: cst.SimpleString) -> str:
182
+ """Extract the actual string value from a string node."""
183
+ raw = node.value
184
+
185
+ # Remove prefixes (r, b, f, etc.)
186
+ for prefix in ["r", "b", "f", "u", "fr", "rf", "br", "rb"]:
187
+ if raw.lower().startswith(prefix):
188
+ raw = raw[len(prefix) :]
189
+ break
190
+
191
+ # Remove quotes
192
+ if raw.startswith('"""') or raw.startswith("'''"):
193
+ return raw[3:-3]
194
+ elif raw.startswith('"') or raw.startswith("'"):
195
+ return raw[1:-1]
196
+
197
+ return raw
198
+
199
+ def _node_to_code(self, node: cst.CSTNode) -> str:
200
+ """Convert a CST node to source code."""
201
+ try:
202
+ module = cst.parse_module("")
203
+ return module.code_for_node(node)
204
+ except Exception:
205
+ return str(node)
206
+
207
+
208
+ # =============================================================================
209
+ # Path Resolver
210
+ # =============================================================================
211
+
212
+
213
+ class PathResolver:
214
+ """
215
+ Resolves computed route paths to their final values.
216
+
217
+ Handles:
218
+ - f-string interpolation: f"/api/{VERSION}/users"
219
+ - String concatenation: PREFIX + "/users"
220
+ - Module-level constants: API_VERSION = "v1"
221
+ - Config attributes: settings.API_PREFIX, config.prefix
222
+ - Environment variables with defaults: os.getenv("API_VERSION", "v1")
223
+
224
+ PHILOSOPHY: Even if a value CAN be overridden at runtime, resolve the
225
+ default/static value. This captures the most common deployment configuration.
226
+
227
+ Usage:
228
+ resolver = PathResolver()
229
+
230
+ # Register known variables from parsed files
231
+ for parsed in parsed_files:
232
+ resolver.process_file(parsed)
233
+
234
+ # Optionally set a constant resolver for cross-file resolution
235
+ resolver.set_constant_resolver(constant_resolver)
236
+
237
+ # Resolve a path expression
238
+ result = resolver.resolve(path_expression, file_path)
239
+ """
240
+
241
+ def __init__(self, project_root: Path | None = None):
242
+ """Initialize the resolver."""
243
+ self._project_root = project_root
244
+ self._variables: dict[Path, dict[str, TrackedVariable]] = {}
245
+ self._global_constants: dict[str, str] = {}
246
+ self._constant_resolver: ConstantResolver | None = None
247
+
248
+ def set_constant_resolver(self, resolver: ConstantResolver) -> None:
249
+ """Set a constant resolver for cross-file config resolution."""
250
+ self._constant_resolver = resolver
251
+
252
+ def process_file(self, parsed: ParsedFile) -> None:
253
+ """Process a file to extract path-relevant variables."""
254
+ if not parsed.success:
255
+ return
256
+
257
+ file_path = parsed.path
258
+ self._variables[file_path] = {}
259
+
260
+ for assign in parsed.assignments:
261
+ var = self._extract_variable(assign, file_path)
262
+ if var:
263
+ self._variables[file_path][var.name] = var
264
+
265
+ # Track as global constant if it looks like one
266
+ if var.var_type == "constant" and var.value:
267
+ self._global_constants[var.name] = var.value
268
+
269
+ def _extract_variable(
270
+ self,
271
+ assign: ParsedAssignment,
272
+ file_path: Path,
273
+ ) -> TrackedVariable | None:
274
+ """Extract a tracked variable from an assignment."""
275
+ # Skip assignments inside functions
276
+ if assign.in_function:
277
+ return None
278
+
279
+ name = assign.target
280
+ source = assign.value_source or ""
281
+
282
+ # Check for env var pattern: os.getenv("VAR", "default")
283
+ env_match = re.search(
284
+ r'(?:os\.)?(?:getenv|environ\.get)\s*\(\s*["\']([^"\']+)["\']\s*(?:,\s*["\']([^"\']*)["\'])?\s*\)',
285
+ source,
286
+ )
287
+ if env_match:
288
+ return TrackedVariable(
289
+ name=name,
290
+ value=env_match.group(2), # Use default as value
291
+ value_source=source,
292
+ file_path=file_path,
293
+ line=assign.location.line,
294
+ var_type="env_var",
295
+ env_var_name=env_match.group(1),
296
+ env_default=env_match.group(2),
297
+ )
298
+
299
+ # Check for string literal
300
+ if assign.is_literal:
301
+ string_match = re.search(r'^["\']([^"\']*)["\']$', source.strip())
302
+ if string_match:
303
+ return TrackedVariable(
304
+ name=name,
305
+ value=string_match.group(1),
306
+ value_source=source,
307
+ file_path=file_path,
308
+ line=assign.location.line,
309
+ var_type="constant",
310
+ )
311
+
312
+ # Check for UPPER_CASE constant naming convention
313
+ if name.isupper() and "_" in name or name.isupper():
314
+ # Try to extract value
315
+ value = self._try_extract_string_value(source)
316
+ return TrackedVariable(
317
+ name=name,
318
+ value=value,
319
+ value_source=source,
320
+ file_path=file_path,
321
+ line=assign.location.line,
322
+ var_type="constant" if value else "unknown",
323
+ )
324
+
325
+ return None
326
+
327
+ def _try_extract_string_value(self, source: str) -> str | None:
328
+ """Try to extract a string value from source code."""
329
+ # Simple string literal
330
+ match = re.search(r'^["\']([^"\']*)["\']$', source.strip())
331
+ if match:
332
+ return match.group(1)
333
+
334
+ # f-string with no variables
335
+ match = re.search(r'^f["\']([^{}"\']*)["\']$', source.strip())
336
+ if match:
337
+ return match.group(1)
338
+
339
+ return None
340
+
341
+ def resolve(
342
+ self,
343
+ path_expression: str,
344
+ file_path: Path,
345
+ additional_context: dict[str, str] | None = None,
346
+ ) -> ResolvedPath:
347
+ """
348
+ Resolve a path expression to its final value.
349
+
350
+ Args:
351
+ path_expression: The path expression to resolve
352
+ file_path: File where the expression is used
353
+ additional_context: Additional variable bindings
354
+
355
+ Returns:
356
+ ResolvedPath with resolution details
357
+ """
358
+ if not path_expression:
359
+ return ResolvedPath(
360
+ path="/",
361
+ original="",
362
+ resolution_confidence=1.0,
363
+ )
364
+
365
+ # Check for simple string literal (most common case)
366
+ if path_expression.startswith('"') and path_expression.endswith('"'):
367
+ return ResolvedPath(
368
+ path=path_expression[1:-1],
369
+ original=path_expression,
370
+ resolution_confidence=1.0,
371
+ )
372
+ if path_expression.startswith("'") and path_expression.endswith("'"):
373
+ return ResolvedPath(
374
+ path=path_expression[1:-1],
375
+ original=path_expression,
376
+ resolution_confidence=1.0,
377
+ )
378
+
379
+ # If it's already a simple path (no quotes, starts with /), return as-is
380
+ if path_expression.startswith("/") and "{" not in path_expression:
381
+ return ResolvedPath(
382
+ path=path_expression,
383
+ original=path_expression,
384
+ resolution_confidence=1.0,
385
+ )
386
+
387
+ # Parse and analyze the expression
388
+ try:
389
+ expr = cst.parse_expression(path_expression)
390
+ analyzer = PathExpressionAnalyzer()
391
+ expr.walk(analyzer)
392
+ components = analyzer.components
393
+ except Exception:
394
+ # Fallback: treat as literal
395
+ return ResolvedPath(
396
+ path=path_expression,
397
+ original=path_expression,
398
+ resolution_confidence=0.5,
399
+ notes=["Failed to parse path expression"],
400
+ )
401
+
402
+ # Build context for resolution
403
+ context = dict(self._global_constants)
404
+ if file_path in self._variables:
405
+ for var in self._variables[file_path].values():
406
+ if var.value:
407
+ context[var.name] = var.value
408
+ if additional_context:
409
+ context.update(additional_context)
410
+
411
+ # Add constants from constant resolver
412
+ if self._constant_resolver:
413
+ context.update(self._constant_resolver.get_all_constants())
414
+
415
+ # Resolve components
416
+ resolved_parts: list[str] = []
417
+ unresolved: list[str] = []
418
+ confidence = 1.0
419
+ notes: list[str] = []
420
+
421
+ for comp in components:
422
+ if comp["type"] == "literal":
423
+ resolved_parts.append(comp["value"])
424
+ elif comp["type"] == "variable":
425
+ var_name = comp["name"]
426
+ if var_name in context:
427
+ resolved_parts.append(context[var_name])
428
+ else:
429
+ # Try constant resolver
430
+ resolved_value = self._try_resolve_name(var_name, file_path)
431
+ if resolved_value:
432
+ resolved_parts.append(resolved_value)
433
+ notes.append(f"Resolved {var_name} to default value (may vary at runtime)")
434
+ confidence *= 0.9 # Slight reduction since it's a default
435
+ else:
436
+ # Use placeholder
437
+ resolved_parts.append(f"{{{var_name}}}")
438
+ unresolved.append(var_name)
439
+ confidence *= 0.5
440
+ notes.append(f"Unresolved variable: {var_name}")
441
+ elif comp["type"] == "attribute":
442
+ # Try to resolve attribute access (config.PREFIX, settings.api_prefix)
443
+ source = comp.get("source", "?")
444
+ resolved_value = self._try_resolve_name(source, file_path)
445
+ if resolved_value:
446
+ resolved_parts.append(resolved_value)
447
+ notes.append(f"Resolved {source} to default value (may vary at runtime)")
448
+ confidence *= 0.85 # Config values can be overridden
449
+ else:
450
+ resolved_parts.append(f"{{{source}}}")
451
+ unresolved.append(source)
452
+ confidence *= 0.3
453
+ notes.append(f"Cannot resolve attribute access: {source}")
454
+ elif comp["type"] == "expression":
455
+ # Complex expression - try to resolve if it's a simple call
456
+ source = comp.get("source", "?")
457
+ resolved_value = self._try_resolve_expression(source, file_path)
458
+ if resolved_value:
459
+ resolved_parts.append(resolved_value)
460
+ notes.append(f"Resolved {source} to default value")
461
+ confidence *= 0.8
462
+ else:
463
+ resolved_parts.append(f"{{{source}}}")
464
+ unresolved.append(source)
465
+ confidence *= 0.2
466
+ notes.append(f"Cannot resolve complex expression: {source}")
467
+
468
+ resolved_path = "".join(resolved_parts)
469
+
470
+ # Normalize path
471
+ if resolved_path and not resolved_path.startswith("/"):
472
+ resolved_path = "/" + resolved_path
473
+
474
+ return ResolvedPath(
475
+ path=resolved_path,
476
+ original=path_expression,
477
+ resolution_confidence=confidence,
478
+ unresolved_vars=unresolved,
479
+ notes=notes,
480
+ )
481
+
482
+ def _try_resolve_name(self, name: str, file_path: Path) -> str | None:
483
+ """
484
+ Try to resolve a name using all available sources.
485
+
486
+ Checks in order:
487
+ 1. Global constants
488
+ 2. File-local variables
489
+ 3. Constant resolver (config classes, settings)
490
+ """
491
+ # 1. Check global constants
492
+ if name in self._global_constants:
493
+ return self._global_constants[name]
494
+
495
+ # 2. Check file-local variables
496
+ if file_path in self._variables:
497
+ var = self._variables[file_path].get(name)
498
+ if var and var.value:
499
+ return var.value
500
+
501
+ # 3. Check constant resolver
502
+ if self._constant_resolver:
503
+ resolved = self._constant_resolver.resolve(name, file_path)
504
+ if resolved:
505
+ return resolved.value
506
+
507
+ return None
508
+
509
+ def _try_resolve_expression(self, expr: str, file_path: Path) -> str | None:
510
+ """
511
+ Try to resolve a simple expression.
512
+
513
+ Handles:
514
+ - os.getenv("VAR", "default") -> "default"
515
+ - config.get("key", "default") -> "default"
516
+ """
517
+ import re
518
+
519
+ # os.getenv with default
520
+ env_match = re.search(
521
+ r'(?:os\.)?(?:getenv|environ\.get)\s*\(\s*["\'][^"\']+["\']\s*,\s*["\']([^"\']*)["\']',
522
+ expr,
523
+ )
524
+ if env_match:
525
+ return env_match.group(1)
526
+
527
+ # .get() with default
528
+ get_match = re.search(
529
+ r'\.get\s*\(\s*["\'][^"\']+["\']\s*,\s*["\']([^"\']*)["\']',
530
+ expr,
531
+ )
532
+ if get_match:
533
+ return get_match.group(1)
534
+
535
+ return None
536
+
537
+ def get_variable(self, name: str, file_path: Path) -> TrackedVariable | None:
538
+ """Get a tracked variable by name."""
539
+ if file_path in self._variables:
540
+ return self._variables[file_path].get(name)
541
+ return None
542
+
543
+ def get_all_constants(self) -> dict[str, str]:
544
+ """Get all global constants."""
545
+ result = dict(self._global_constants)
546
+ if self._constant_resolver:
547
+ result.update(self._constant_resolver.get_all_constants())
548
+ return result
549
+
550
+
551
+ # =============================================================================
552
+ # Convenience Functions
553
+ # =============================================================================
554
+
555
+
556
+ def resolve_route_path(
557
+ path_expression: str,
558
+ parsed_file: ParsedFile,
559
+ resolver: PathResolver | None = None,
560
+ ) -> ResolvedPath:
561
+ """
562
+ Resolve a route path expression.
563
+
564
+ Args:
565
+ path_expression: The path expression from decorator
566
+ parsed_file: The parsed file containing the route
567
+ resolver: Optional pre-configured resolver
568
+
569
+ Returns:
570
+ ResolvedPath with resolution details
571
+ """
572
+ if resolver is None:
573
+ resolver = PathResolver()
574
+ resolver.process_file(parsed_file)
575
+
576
+ return resolver.resolve(path_expression, parsed_file.path)