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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- 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)
|