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,1054 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-file type and symbol resolution for Python projects.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Building an import graph across all parsed files
|
|
6
|
+
- Resolving type references to their definitions
|
|
7
|
+
- Resolving import aliases
|
|
8
|
+
- Building multi-level inheritance hierarchies (transitive closure)
|
|
9
|
+
- Tracking Pydantic model relationships through inheritance chains
|
|
10
|
+
- Computing Method Resolution Order (MRO) for diamond inheritance
|
|
11
|
+
- Supporting src-layout and non-standard project structures
|
|
12
|
+
|
|
13
|
+
CRITICAL: This enables proper analysis of enterprise-scale codebases
|
|
14
|
+
where types and models are defined in separate files from their usage.
|
|
15
|
+
|
|
16
|
+
IMPROVEMENTS:
|
|
17
|
+
1. Multi-level inheritance with cycle detection
|
|
18
|
+
2. src-layout project structure support
|
|
19
|
+
3. External package detection (pydantic, etc.)
|
|
20
|
+
4. Full MRO computation for correct field inheritance
|
|
21
|
+
5. Inherited field deduplication
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from ..base import ParsedClass, ParsedFile, ParsedFunction, ParsedImport
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Known External Packages
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Pydantic base classes that indicate a model
|
|
42
|
+
PYDANTIC_BASE_CLASSES = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"BaseModel",
|
|
45
|
+
"BaseSettings",
|
|
46
|
+
"pydantic.BaseModel",
|
|
47
|
+
"pydantic.BaseSettings",
|
|
48
|
+
"pydantic.main.BaseModel",
|
|
49
|
+
"pydantic_settings.BaseSettings",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Dataclass indicators
|
|
54
|
+
DATACLASS_DECORATORS = frozenset(
|
|
55
|
+
{
|
|
56
|
+
"dataclass",
|
|
57
|
+
"dataclasses.dataclass",
|
|
58
|
+
"attrs.define",
|
|
59
|
+
"attr.s",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Known external packages (for detecting external dependencies)
|
|
64
|
+
KNOWN_EXTERNAL_PACKAGES = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"pydantic",
|
|
67
|
+
"fastapi",
|
|
68
|
+
"starlette",
|
|
69
|
+
"sqlalchemy",
|
|
70
|
+
"sqlmodel",
|
|
71
|
+
"typing",
|
|
72
|
+
"typing_extensions",
|
|
73
|
+
"collections",
|
|
74
|
+
"abc",
|
|
75
|
+
"enum",
|
|
76
|
+
"datetime",
|
|
77
|
+
"decimal",
|
|
78
|
+
"uuid",
|
|
79
|
+
"pathlib",
|
|
80
|
+
"os",
|
|
81
|
+
"sys",
|
|
82
|
+
"re",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# Resolution Data Types
|
|
89
|
+
# =============================================================================
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ResolvedSymbol:
|
|
94
|
+
"""A fully resolved symbol with its definition location."""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
qualified_name: str
|
|
98
|
+
kind: str # "class", "function", "variable", "module"
|
|
99
|
+
|
|
100
|
+
# Where it's defined
|
|
101
|
+
defined_in_file: Path | None = None
|
|
102
|
+
defined_at_line: int = 0
|
|
103
|
+
|
|
104
|
+
# For classes
|
|
105
|
+
is_pydantic_model: bool = False
|
|
106
|
+
is_dataclass: bool = False
|
|
107
|
+
base_classes: list[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
# Multi-level inheritance (full chain)
|
|
110
|
+
all_bases: list[str] = field(default_factory=list) # All ancestors
|
|
111
|
+
mro: list[str] = field(default_factory=list) # Method Resolution Order
|
|
112
|
+
|
|
113
|
+
# External dependency info
|
|
114
|
+
is_external: bool = False
|
|
115
|
+
external_package: str | None = None
|
|
116
|
+
|
|
117
|
+
# The actual definition object
|
|
118
|
+
definition: ParsedClass | ParsedFunction | None = None
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def file_path(self) -> Path | None:
|
|
122
|
+
"""Alias for defined_in_file (used by language_services)."""
|
|
123
|
+
return self.defined_in_file
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def bases(self) -> list[str]:
|
|
127
|
+
"""Alias for base_classes (used by language_services)."""
|
|
128
|
+
return self.base_classes
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_class(self) -> bool:
|
|
132
|
+
"""Whether this symbol is a class."""
|
|
133
|
+
return self.kind == "class"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class ResolvedImport:
|
|
138
|
+
"""A resolved import with its target."""
|
|
139
|
+
|
|
140
|
+
local_name: str # Name as used in the importing file
|
|
141
|
+
original_name: str # Original name in source module
|
|
142
|
+
source_module: str # Module path
|
|
143
|
+
|
|
144
|
+
# Resolution status
|
|
145
|
+
resolved: bool = False
|
|
146
|
+
target: ResolvedSymbol | None = None
|
|
147
|
+
|
|
148
|
+
# For re-exports
|
|
149
|
+
is_reexport: bool = False
|
|
150
|
+
reexport_chain: list[str] = field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
# External package info
|
|
153
|
+
is_external: bool = False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class ImportGraph:
|
|
158
|
+
"""Graph of imports across the project."""
|
|
159
|
+
|
|
160
|
+
# File -> list of imports
|
|
161
|
+
file_imports: dict[Path, list[ResolvedImport]] = field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
# qualified_name -> ResolvedSymbol
|
|
164
|
+
symbols: dict[str, ResolvedSymbol] = field(default_factory=dict)
|
|
165
|
+
|
|
166
|
+
# local_name -> qualified_name per file
|
|
167
|
+
name_to_qualified: dict[Path, dict[str, str]] = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
# Module path -> file path
|
|
170
|
+
module_to_file: dict[str, Path] = field(default_factory=dict)
|
|
171
|
+
|
|
172
|
+
# Inheritance graph (child -> [parents])
|
|
173
|
+
inheritance_graph: dict[str, list[str]] = field(default_factory=dict)
|
|
174
|
+
|
|
175
|
+
# Reverse inheritance (parent -> [children])
|
|
176
|
+
reverse_inheritance: dict[str, list[str]] = field(default_factory=dict)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# =============================================================================
|
|
180
|
+
# Module Path Utilities
|
|
181
|
+
# =============================================================================
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def detect_src_layout(project_root: Path) -> tuple[bool, Path | None]:
|
|
185
|
+
"""
|
|
186
|
+
Detect if project uses src-layout.
|
|
187
|
+
|
|
188
|
+
Common layouts:
|
|
189
|
+
1. Standard: project/package/__init__.py
|
|
190
|
+
2. src-layout: project/src/package/__init__.py
|
|
191
|
+
3. Namespace: project/src/namespace/package/__init__.py
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Tuple of (is_src_layout, src_path)
|
|
195
|
+
"""
|
|
196
|
+
src_dir = project_root / "src"
|
|
197
|
+
if src_dir.is_dir():
|
|
198
|
+
# Check if src contains packages (dirs with __init__.py)
|
|
199
|
+
for child in src_dir.iterdir():
|
|
200
|
+
if child.is_dir():
|
|
201
|
+
init_file = child / "__init__.py"
|
|
202
|
+
if init_file.exists():
|
|
203
|
+
return True, src_dir
|
|
204
|
+
# Also check for namespace packages (PEP 420)
|
|
205
|
+
if any(
|
|
206
|
+
(child / subdir / "__init__.py").exists()
|
|
207
|
+
for subdir in child.iterdir()
|
|
208
|
+
if subdir.is_dir()
|
|
209
|
+
):
|
|
210
|
+
return True, src_dir
|
|
211
|
+
return False, None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def infer_module_path(
|
|
215
|
+
file_path: Path,
|
|
216
|
+
project_root: Path | None = None,
|
|
217
|
+
src_path: Path | None = None,
|
|
218
|
+
) -> str:
|
|
219
|
+
"""
|
|
220
|
+
Infer the Python module path from a file path.
|
|
221
|
+
|
|
222
|
+
Handles:
|
|
223
|
+
- Standard layout: project/package/module.py -> package.module
|
|
224
|
+
- src-layout: project/src/package/module.py -> package.module
|
|
225
|
+
- Namespace packages: project/src/ns/package/module.py -> ns.package.module
|
|
226
|
+
- __init__.py files correctly
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
file_path: Path to the Python file
|
|
230
|
+
project_root: Optional project root for relative resolution
|
|
231
|
+
src_path: Optional src directory for src-layout projects
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Module path like "app.models.user"
|
|
235
|
+
"""
|
|
236
|
+
# Determine effective root (either src_path or project_root)
|
|
237
|
+
effective_root = src_path or project_root
|
|
238
|
+
|
|
239
|
+
# Try to make path relative to effective root
|
|
240
|
+
try:
|
|
241
|
+
if effective_root and file_path.is_relative_to(effective_root):
|
|
242
|
+
rel_path = file_path.relative_to(effective_root)
|
|
243
|
+
elif project_root and file_path.is_relative_to(project_root):
|
|
244
|
+
rel_path = file_path.relative_to(project_root)
|
|
245
|
+
else:
|
|
246
|
+
rel_path = file_path
|
|
247
|
+
except ValueError:
|
|
248
|
+
rel_path = file_path
|
|
249
|
+
|
|
250
|
+
# Remove .py extension
|
|
251
|
+
stem = file_path.stem
|
|
252
|
+
if stem == "__init__":
|
|
253
|
+
# For __init__.py, use parent directory as module
|
|
254
|
+
parts = list(rel_path.parent.parts) if len(rel_path.parts) > 1 else [file_path.parent.name]
|
|
255
|
+
else:
|
|
256
|
+
parts = list(rel_path.with_suffix("").parts)
|
|
257
|
+
|
|
258
|
+
# Remove 'src' from path if present at start (src-layout)
|
|
259
|
+
if parts and parts[0] == "src":
|
|
260
|
+
parts = parts[1:]
|
|
261
|
+
|
|
262
|
+
# Join parts to form module path
|
|
263
|
+
module_path = ".".join(parts)
|
|
264
|
+
|
|
265
|
+
# Fallback to walking up from file
|
|
266
|
+
if not module_path or module_path == stem:
|
|
267
|
+
return _infer_module_path_by_walk(file_path, project_root)
|
|
268
|
+
|
|
269
|
+
return module_path
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _infer_module_path_by_walk(file_path: Path, project_root: Path | None = None) -> str:
|
|
273
|
+
"""Fallback module path inference by walking up directory tree."""
|
|
274
|
+
stem = file_path.stem
|
|
275
|
+
if stem == "__init__":
|
|
276
|
+
stem = file_path.parent.name
|
|
277
|
+
parts = []
|
|
278
|
+
current = file_path.parent.parent
|
|
279
|
+
else:
|
|
280
|
+
parts = [stem]
|
|
281
|
+
current = file_path.parent
|
|
282
|
+
|
|
283
|
+
# Walk up looking for __init__.py or until we hit project_root
|
|
284
|
+
max_depth = 20 # Prevent infinite loops
|
|
285
|
+
depth = 0
|
|
286
|
+
|
|
287
|
+
while current.name and depth < max_depth:
|
|
288
|
+
if project_root and current == project_root:
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
init_file = current / "__init__.py"
|
|
292
|
+
pyproject = current / "pyproject.toml"
|
|
293
|
+
setup_py = current / "setup.py"
|
|
294
|
+
|
|
295
|
+
# Stop at project root markers
|
|
296
|
+
if pyproject.exists() or setup_py.exists():
|
|
297
|
+
break
|
|
298
|
+
|
|
299
|
+
if init_file.exists():
|
|
300
|
+
parts.append(current.name)
|
|
301
|
+
current = current.parent
|
|
302
|
+
depth += 1
|
|
303
|
+
else:
|
|
304
|
+
# Check if this is a namespace package (PEP 420)
|
|
305
|
+
# by looking if parent contains other packages
|
|
306
|
+
has_sibling_packages = (
|
|
307
|
+
any(
|
|
308
|
+
(sibling / "__init__.py").exists()
|
|
309
|
+
for sibling in current.parent.iterdir()
|
|
310
|
+
if sibling.is_dir() and sibling != current
|
|
311
|
+
)
|
|
312
|
+
if current.parent.exists()
|
|
313
|
+
else False
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if has_sibling_packages:
|
|
317
|
+
parts.append(current.name)
|
|
318
|
+
current = current.parent
|
|
319
|
+
depth += 1
|
|
320
|
+
else:
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
parts.reverse()
|
|
324
|
+
return ".".join(parts) if parts else stem
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_relative_import(
|
|
328
|
+
importing_file: Path,
|
|
329
|
+
module: str,
|
|
330
|
+
level: int,
|
|
331
|
+
project_root: Path | None = None,
|
|
332
|
+
) -> str:
|
|
333
|
+
"""
|
|
334
|
+
Resolve a relative import to an absolute module path.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
importing_file: File containing the import
|
|
338
|
+
module: The module being imported (may be empty for "from . import x")
|
|
339
|
+
level: Number of dots (1 = ".", 2 = "..", etc.)
|
|
340
|
+
project_root: Optional project root
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
Absolute module path
|
|
344
|
+
"""
|
|
345
|
+
# Start from importing file's directory
|
|
346
|
+
current = importing_file.parent
|
|
347
|
+
|
|
348
|
+
# Go up 'level' directories (level=1 means same package)
|
|
349
|
+
for _ in range(level - 1):
|
|
350
|
+
if current.parent.exists():
|
|
351
|
+
current = current.parent
|
|
352
|
+
|
|
353
|
+
# Get the base package path
|
|
354
|
+
base_parts = []
|
|
355
|
+
temp = current
|
|
356
|
+
max_depth = 20
|
|
357
|
+
depth = 0
|
|
358
|
+
|
|
359
|
+
while temp.name and depth < max_depth:
|
|
360
|
+
# Stop at project root
|
|
361
|
+
if project_root and temp == project_root:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
# Check for package indicators
|
|
365
|
+
init_exists = (temp / "__init__.py").exists()
|
|
366
|
+
at_project_root = (temp / "pyproject.toml").exists() or (temp / "setup.py").exists()
|
|
367
|
+
|
|
368
|
+
if at_project_root:
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
if init_exists or _is_namespace_package(temp):
|
|
372
|
+
base_parts.append(temp.name)
|
|
373
|
+
temp = temp.parent
|
|
374
|
+
depth += 1
|
|
375
|
+
else:
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
base_parts.reverse()
|
|
379
|
+
|
|
380
|
+
# Combine with the module being imported
|
|
381
|
+
if module:
|
|
382
|
+
return ".".join(base_parts + [module]) if base_parts else module
|
|
383
|
+
return ".".join(base_parts) if base_parts else ""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _is_namespace_package(path: Path) -> bool:
|
|
387
|
+
"""Check if path is a PEP 420 namespace package."""
|
|
388
|
+
if not path.is_dir():
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
# A namespace package contains subpackages but no __init__.py
|
|
392
|
+
has_init = (path / "__init__.py").exists()
|
|
393
|
+
if has_init:
|
|
394
|
+
return False
|
|
395
|
+
|
|
396
|
+
# Check for subpackages
|
|
397
|
+
return any(child.is_dir() and (child / "__init__.py").exists() for child in path.iterdir())
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def is_external_module(module: str) -> bool:
|
|
401
|
+
"""Check if a module is from an external package."""
|
|
402
|
+
if not module:
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
root_module = module.split(".")[0]
|
|
406
|
+
return root_module in KNOWN_EXTERNAL_PACKAGES
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def get_external_package(module: str) -> str | None:
|
|
410
|
+
"""Get the external package name for a module."""
|
|
411
|
+
if not module:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
root_module = module.split(".")[0]
|
|
415
|
+
if root_module in KNOWN_EXTERNAL_PACKAGES:
|
|
416
|
+
return root_module
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# =============================================================================
|
|
421
|
+
# Cross-File Resolver
|
|
422
|
+
# =============================================================================
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class CrossFileResolver:
|
|
426
|
+
"""
|
|
427
|
+
Resolves types and symbols across multiple Python files.
|
|
428
|
+
|
|
429
|
+
Features:
|
|
430
|
+
- Multi-level inheritance with full transitive closure
|
|
431
|
+
- MRO (Method Resolution Order) computation for diamond inheritance
|
|
432
|
+
- src-layout project support
|
|
433
|
+
- External package detection
|
|
434
|
+
- Inherited field aggregation with deduplication
|
|
435
|
+
|
|
436
|
+
Usage:
|
|
437
|
+
resolver = CrossFileResolver()
|
|
438
|
+
|
|
439
|
+
# Add all parsed files
|
|
440
|
+
for parsed in parsed_files:
|
|
441
|
+
resolver.add_file(parsed)
|
|
442
|
+
|
|
443
|
+
# Build the resolution graph
|
|
444
|
+
resolver.resolve()
|
|
445
|
+
|
|
446
|
+
# Query resolved types
|
|
447
|
+
symbol = resolver.resolve_name("UserModel", in_file=some_path)
|
|
448
|
+
"""
|
|
449
|
+
|
|
450
|
+
def __init__(self, project_root: Path | None = None):
|
|
451
|
+
"""
|
|
452
|
+
Initialize the resolver.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
project_root: Optional project root for module path inference
|
|
456
|
+
"""
|
|
457
|
+
self._project_root = project_root
|
|
458
|
+
self._src_path: Path | None = None
|
|
459
|
+
self._parsed_files: dict[Path, ParsedFile] = {}
|
|
460
|
+
self._graph = ImportGraph()
|
|
461
|
+
self._resolved = False
|
|
462
|
+
|
|
463
|
+
# Detect src-layout
|
|
464
|
+
if project_root:
|
|
465
|
+
is_src_layout, src_path = detect_src_layout(project_root)
|
|
466
|
+
if is_src_layout:
|
|
467
|
+
self._src_path = src_path
|
|
468
|
+
|
|
469
|
+
def add_file(self, parsed: ParsedFile) -> None:
|
|
470
|
+
"""Add a parsed file to the resolver."""
|
|
471
|
+
if not parsed.success:
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
self._parsed_files[parsed.path] = parsed
|
|
475
|
+
self._resolved = False
|
|
476
|
+
|
|
477
|
+
# Infer module path and map it
|
|
478
|
+
module_path = infer_module_path(parsed.path, self._project_root, self._src_path)
|
|
479
|
+
self._graph.module_to_file[module_path] = parsed.path
|
|
480
|
+
|
|
481
|
+
def resolve(self) -> ImportGraph:
|
|
482
|
+
"""
|
|
483
|
+
Build the complete resolution graph.
|
|
484
|
+
|
|
485
|
+
This performs:
|
|
486
|
+
1. Symbol registration (all classes, functions)
|
|
487
|
+
2. Import resolution
|
|
488
|
+
3. Alias tracking
|
|
489
|
+
4. Multi-level inheritance chain building
|
|
490
|
+
5. MRO computation
|
|
491
|
+
6. Pydantic model detection through inheritance
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
The completed ImportGraph
|
|
495
|
+
"""
|
|
496
|
+
# Phase 1: Register all symbols
|
|
497
|
+
self._register_all_symbols()
|
|
498
|
+
|
|
499
|
+
# Phase 2: Resolve all imports
|
|
500
|
+
self._resolve_all_imports()
|
|
501
|
+
|
|
502
|
+
# Phase 3: Build inheritance graph (direct parents)
|
|
503
|
+
self._build_direct_inheritance()
|
|
504
|
+
|
|
505
|
+
# Phase 4: Compute transitive closure (all ancestors)
|
|
506
|
+
self._compute_inheritance_closure()
|
|
507
|
+
|
|
508
|
+
# Phase 5: Compute MRO for all classes
|
|
509
|
+
self._compute_all_mro()
|
|
510
|
+
|
|
511
|
+
# Phase 6: Propagate Pydantic model status
|
|
512
|
+
self._propagate_pydantic_status()
|
|
513
|
+
|
|
514
|
+
self._resolved = True
|
|
515
|
+
return self._graph
|
|
516
|
+
|
|
517
|
+
def _register_all_symbols(self) -> None:
|
|
518
|
+
"""Register all symbols from all files."""
|
|
519
|
+
for file_path, parsed in self._parsed_files.items():
|
|
520
|
+
module_path = infer_module_path(file_path, self._project_root, self._src_path)
|
|
521
|
+
|
|
522
|
+
# Register classes
|
|
523
|
+
for cls in parsed.classes:
|
|
524
|
+
qualified_name = f"{module_path}.{cls.name}" if module_path else cls.name
|
|
525
|
+
|
|
526
|
+
# Check for direct Pydantic inheritance
|
|
527
|
+
is_direct_pydantic = any(base in PYDANTIC_BASE_CLASSES for base in cls.base_classes)
|
|
528
|
+
|
|
529
|
+
symbol = ResolvedSymbol(
|
|
530
|
+
name=cls.name,
|
|
531
|
+
qualified_name=qualified_name,
|
|
532
|
+
kind="class",
|
|
533
|
+
defined_in_file=file_path,
|
|
534
|
+
defined_at_line=cls.location.line,
|
|
535
|
+
is_pydantic_model=cls.is_pydantic_model or is_direct_pydantic,
|
|
536
|
+
is_dataclass=cls.is_dataclass,
|
|
537
|
+
base_classes=list(cls.base_classes),
|
|
538
|
+
definition=cls,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
self._graph.symbols[qualified_name] = symbol
|
|
542
|
+
|
|
543
|
+
# Also register by simple name for same-file resolution
|
|
544
|
+
# But don't overwrite if already exists (prefer qualified)
|
|
545
|
+
if cls.name not in self._graph.symbols:
|
|
546
|
+
self._graph.symbols[cls.name] = symbol
|
|
547
|
+
|
|
548
|
+
# Register functions
|
|
549
|
+
for func in parsed.functions:
|
|
550
|
+
qualified_name = f"{module_path}.{func.name}" if module_path else func.name
|
|
551
|
+
|
|
552
|
+
self._graph.symbols[qualified_name] = ResolvedSymbol(
|
|
553
|
+
name=func.name,
|
|
554
|
+
qualified_name=qualified_name,
|
|
555
|
+
kind="function",
|
|
556
|
+
defined_in_file=file_path,
|
|
557
|
+
defined_at_line=func.location.line,
|
|
558
|
+
definition=func,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def _resolve_all_imports(self) -> None:
|
|
562
|
+
"""Resolve imports in all files."""
|
|
563
|
+
for file_path, parsed in self._parsed_files.items():
|
|
564
|
+
resolved_imports: list[ResolvedImport] = []
|
|
565
|
+
name_mapping: dict[str, str] = {}
|
|
566
|
+
|
|
567
|
+
for imp in parsed.imports:
|
|
568
|
+
resolved_list = self._resolve_import(imp, file_path)
|
|
569
|
+
resolved_imports.extend(resolved_list)
|
|
570
|
+
|
|
571
|
+
# Build name mapping
|
|
572
|
+
for resolved in resolved_list:
|
|
573
|
+
if resolved.resolved and resolved.target:
|
|
574
|
+
name_mapping[resolved.local_name] = resolved.target.qualified_name
|
|
575
|
+
elif resolved.is_external:
|
|
576
|
+
# Map external imports to their full names
|
|
577
|
+
name_mapping[resolved.local_name] = (
|
|
578
|
+
f"{resolved.source_module}.{resolved.original_name}"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
self._graph.file_imports[file_path] = resolved_imports
|
|
582
|
+
self._graph.name_to_qualified[file_path] = name_mapping
|
|
583
|
+
|
|
584
|
+
def _resolve_import(
|
|
585
|
+
self,
|
|
586
|
+
imp: ParsedImport,
|
|
587
|
+
importing_file: Path,
|
|
588
|
+
) -> list[ResolvedImport]:
|
|
589
|
+
"""Resolve a single import statement."""
|
|
590
|
+
results: list[ResolvedImport] = []
|
|
591
|
+
|
|
592
|
+
# Handle relative imports
|
|
593
|
+
source_module = imp.module
|
|
594
|
+
if imp.is_relative:
|
|
595
|
+
source_module = resolve_relative_import(
|
|
596
|
+
importing_file, imp.module, imp.relative_level, self._project_root
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Check if external
|
|
600
|
+
is_external = is_external_module(source_module)
|
|
601
|
+
|
|
602
|
+
if imp.is_from_import:
|
|
603
|
+
# "from X import a, b, c"
|
|
604
|
+
for name in imp.names:
|
|
605
|
+
# Check if imported as alias
|
|
606
|
+
alias = imp.alias if len(imp.names) == 1 else None
|
|
607
|
+
local_name = alias or name
|
|
608
|
+
|
|
609
|
+
# Try to resolve the imported name
|
|
610
|
+
qualified_name = f"{source_module}.{name}" if source_module else name
|
|
611
|
+
target = self._graph.symbols.get(qualified_name)
|
|
612
|
+
|
|
613
|
+
results.append(
|
|
614
|
+
ResolvedImport(
|
|
615
|
+
local_name=local_name,
|
|
616
|
+
original_name=name,
|
|
617
|
+
source_module=source_module,
|
|
618
|
+
resolved=target is not None,
|
|
619
|
+
target=target,
|
|
620
|
+
is_external=is_external,
|
|
621
|
+
)
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
# "import X" or "import X as Y"
|
|
625
|
+
local_name = imp.alias or source_module.split(".")[-1]
|
|
626
|
+
|
|
627
|
+
# Check if module itself is registered
|
|
628
|
+
target = self._graph.symbols.get(source_module)
|
|
629
|
+
|
|
630
|
+
results.append(
|
|
631
|
+
ResolvedImport(
|
|
632
|
+
local_name=local_name,
|
|
633
|
+
original_name=source_module,
|
|
634
|
+
source_module=source_module,
|
|
635
|
+
resolved=target is not None,
|
|
636
|
+
target=target,
|
|
637
|
+
is_external=is_external,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
return results
|
|
642
|
+
|
|
643
|
+
def _build_direct_inheritance(self) -> None:
|
|
644
|
+
"""Build direct inheritance relationships."""
|
|
645
|
+
for symbol in self._graph.symbols.values():
|
|
646
|
+
if symbol.kind != "class":
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
resolved_bases = []
|
|
650
|
+
for base in symbol.base_classes:
|
|
651
|
+
# Try to find the base class
|
|
652
|
+
base_symbol = self._resolve_type_name(base, symbol.defined_in_file)
|
|
653
|
+
if base_symbol:
|
|
654
|
+
resolved_bases.append(base_symbol.qualified_name)
|
|
655
|
+
elif base in PYDANTIC_BASE_CLASSES:
|
|
656
|
+
# Keep Pydantic bases as-is for detection
|
|
657
|
+
resolved_bases.append(base)
|
|
658
|
+
symbol.is_pydantic_model = True
|
|
659
|
+
else:
|
|
660
|
+
# Unknown base - might be external
|
|
661
|
+
resolved_bases.append(base)
|
|
662
|
+
|
|
663
|
+
# Update base classes with resolved names
|
|
664
|
+
symbol.base_classes = resolved_bases
|
|
665
|
+
|
|
666
|
+
# Add to inheritance graph
|
|
667
|
+
self._graph.inheritance_graph[symbol.qualified_name] = resolved_bases
|
|
668
|
+
|
|
669
|
+
# Build reverse inheritance
|
|
670
|
+
for base in resolved_bases:
|
|
671
|
+
if base not in self._graph.reverse_inheritance:
|
|
672
|
+
self._graph.reverse_inheritance[base] = []
|
|
673
|
+
self._graph.reverse_inheritance[base].append(symbol.qualified_name)
|
|
674
|
+
|
|
675
|
+
def _compute_inheritance_closure(self) -> None:
|
|
676
|
+
"""Compute transitive closure of inheritance (all ancestors)."""
|
|
677
|
+
for symbol in self._graph.symbols.values():
|
|
678
|
+
if symbol.kind != "class":
|
|
679
|
+
continue
|
|
680
|
+
|
|
681
|
+
# BFS/DFS to find all ancestors
|
|
682
|
+
all_bases: set[str] = set()
|
|
683
|
+
visited: set[str] = set()
|
|
684
|
+
queue = list(symbol.base_classes)
|
|
685
|
+
|
|
686
|
+
while queue:
|
|
687
|
+
base = queue.pop(0)
|
|
688
|
+
if base in visited:
|
|
689
|
+
continue
|
|
690
|
+
visited.add(base)
|
|
691
|
+
all_bases.add(base)
|
|
692
|
+
|
|
693
|
+
# Get parents of this base
|
|
694
|
+
base_symbol = self._graph.symbols.get(base)
|
|
695
|
+
if base_symbol and base_symbol.kind == "class":
|
|
696
|
+
for parent in base_symbol.base_classes:
|
|
697
|
+
if parent not in visited:
|
|
698
|
+
queue.append(parent)
|
|
699
|
+
|
|
700
|
+
# Also check inheritance graph
|
|
701
|
+
if base in self._graph.inheritance_graph:
|
|
702
|
+
for parent in self._graph.inheritance_graph[base]:
|
|
703
|
+
if parent not in visited:
|
|
704
|
+
queue.append(parent)
|
|
705
|
+
|
|
706
|
+
symbol.all_bases = list(all_bases)
|
|
707
|
+
|
|
708
|
+
def _compute_all_mro(self) -> None:
|
|
709
|
+
"""Compute Method Resolution Order for all classes."""
|
|
710
|
+
for symbol in self._graph.symbols.values():
|
|
711
|
+
if symbol.kind != "class":
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
mro = self._compute_mro(symbol.qualified_name)
|
|
715
|
+
symbol.mro = mro
|
|
716
|
+
|
|
717
|
+
def _compute_mro(self, class_name: str, visited: set[str] | None = None) -> list[str]:
|
|
718
|
+
"""
|
|
719
|
+
Compute MRO using C3 linearization algorithm.
|
|
720
|
+
|
|
721
|
+
Simplified version that handles most cases.
|
|
722
|
+
"""
|
|
723
|
+
if visited is None:
|
|
724
|
+
visited = set()
|
|
725
|
+
|
|
726
|
+
if class_name in visited:
|
|
727
|
+
return [] # Cycle detected
|
|
728
|
+
|
|
729
|
+
visited.add(class_name)
|
|
730
|
+
|
|
731
|
+
symbol = self._graph.symbols.get(class_name)
|
|
732
|
+
if not symbol or symbol.kind != "class":
|
|
733
|
+
return [class_name]
|
|
734
|
+
|
|
735
|
+
# Start with this class
|
|
736
|
+
mro = [class_name]
|
|
737
|
+
|
|
738
|
+
# Get parent MROs
|
|
739
|
+
parent_mros = []
|
|
740
|
+
for base in symbol.base_classes:
|
|
741
|
+
base_mro = self._compute_mro(base, visited.copy())
|
|
742
|
+
if base_mro:
|
|
743
|
+
parent_mros.append(base_mro)
|
|
744
|
+
|
|
745
|
+
# Add parents list
|
|
746
|
+
if symbol.base_classes:
|
|
747
|
+
parent_mros.append(list(symbol.base_classes))
|
|
748
|
+
|
|
749
|
+
# Merge using C3 linearization
|
|
750
|
+
while parent_mros:
|
|
751
|
+
# Find a good head (not in tail of any other list)
|
|
752
|
+
head = None
|
|
753
|
+
for mro_list in parent_mros:
|
|
754
|
+
if not mro_list:
|
|
755
|
+
continue
|
|
756
|
+
candidate = mro_list[0]
|
|
757
|
+
|
|
758
|
+
# Check if candidate is in tail of any other list
|
|
759
|
+
in_tail = False
|
|
760
|
+
for other_list in parent_mros:
|
|
761
|
+
if candidate in other_list[1:]:
|
|
762
|
+
in_tail = True
|
|
763
|
+
break
|
|
764
|
+
|
|
765
|
+
if not in_tail:
|
|
766
|
+
head = candidate
|
|
767
|
+
break
|
|
768
|
+
|
|
769
|
+
if head is None:
|
|
770
|
+
# No valid head found - inconsistent hierarchy
|
|
771
|
+
break
|
|
772
|
+
|
|
773
|
+
mro.append(head)
|
|
774
|
+
|
|
775
|
+
# Remove head from all lists
|
|
776
|
+
new_parent_mros = []
|
|
777
|
+
for mro_list in parent_mros:
|
|
778
|
+
if mro_list and mro_list[0] == head:
|
|
779
|
+
mro_list = mro_list[1:]
|
|
780
|
+
if mro_list:
|
|
781
|
+
new_parent_mros.append(mro_list)
|
|
782
|
+
parent_mros = new_parent_mros
|
|
783
|
+
|
|
784
|
+
return mro
|
|
785
|
+
|
|
786
|
+
def _propagate_pydantic_status(self) -> None:
|
|
787
|
+
"""Propagate Pydantic model status through inheritance."""
|
|
788
|
+
# Mark classes whose ancestors include Pydantic bases
|
|
789
|
+
for symbol in self._graph.symbols.values():
|
|
790
|
+
if symbol.kind != "class" or symbol.is_pydantic_model:
|
|
791
|
+
continue
|
|
792
|
+
|
|
793
|
+
# Check if any ancestor is a Pydantic model
|
|
794
|
+
for base in symbol.all_bases:
|
|
795
|
+
if base in PYDANTIC_BASE_CLASSES:
|
|
796
|
+
symbol.is_pydantic_model = True
|
|
797
|
+
break
|
|
798
|
+
|
|
799
|
+
base_symbol = self._graph.symbols.get(base)
|
|
800
|
+
if base_symbol and base_symbol.is_pydantic_model:
|
|
801
|
+
symbol.is_pydantic_model = True
|
|
802
|
+
break
|
|
803
|
+
|
|
804
|
+
# =========================================================================
|
|
805
|
+
# Query Methods
|
|
806
|
+
# =========================================================================
|
|
807
|
+
|
|
808
|
+
def resolve_name(
|
|
809
|
+
self,
|
|
810
|
+
name: str,
|
|
811
|
+
in_file: Path | None = None,
|
|
812
|
+
) -> ResolvedSymbol | None:
|
|
813
|
+
"""
|
|
814
|
+
Resolve a name to its symbol.
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
name: The name to resolve
|
|
818
|
+
in_file: Optional file context for local name resolution
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
ResolvedSymbol if found, None otherwise
|
|
822
|
+
"""
|
|
823
|
+
if not self._resolved:
|
|
824
|
+
self.resolve()
|
|
825
|
+
|
|
826
|
+
return self._resolve_type_name(name, in_file)
|
|
827
|
+
|
|
828
|
+
def _resolve_type_name(
|
|
829
|
+
self,
|
|
830
|
+
name: str,
|
|
831
|
+
in_file: Path | None,
|
|
832
|
+
) -> ResolvedSymbol | None:
|
|
833
|
+
"""Internal resolution of a type name."""
|
|
834
|
+
# 1. Check if it's already a qualified name
|
|
835
|
+
if name in self._graph.symbols:
|
|
836
|
+
return self._graph.symbols[name]
|
|
837
|
+
|
|
838
|
+
# 2. Check file-local name mapping (import aliases)
|
|
839
|
+
if in_file and in_file in self._graph.name_to_qualified:
|
|
840
|
+
mapping = self._graph.name_to_qualified[in_file]
|
|
841
|
+
if name in mapping:
|
|
842
|
+
qualified = mapping[name]
|
|
843
|
+
return self._graph.symbols.get(qualified)
|
|
844
|
+
|
|
845
|
+
# 3. Check symbols defined in the same file
|
|
846
|
+
if in_file:
|
|
847
|
+
module_path = infer_module_path(in_file, self._project_root, self._src_path)
|
|
848
|
+
qualified = f"{module_path}.{name}" if module_path else name
|
|
849
|
+
if qualified in self._graph.symbols:
|
|
850
|
+
return self._graph.symbols[qualified]
|
|
851
|
+
|
|
852
|
+
# 4. Check by simple name (might be ambiguous, prefer local file)
|
|
853
|
+
candidates = []
|
|
854
|
+
for _qname, symbol in self._graph.symbols.items():
|
|
855
|
+
if symbol.name == name:
|
|
856
|
+
candidates.append(symbol)
|
|
857
|
+
|
|
858
|
+
if candidates:
|
|
859
|
+
# Prefer symbol from same file if available
|
|
860
|
+
if in_file:
|
|
861
|
+
for symbol in candidates:
|
|
862
|
+
if symbol.defined_in_file == in_file:
|
|
863
|
+
return symbol
|
|
864
|
+
return candidates[0]
|
|
865
|
+
|
|
866
|
+
return None
|
|
867
|
+
|
|
868
|
+
def resolve_symbol(self, name: str, in_file: Path | None = None) -> ResolvedSymbol | None:
|
|
869
|
+
"""Resolve a symbol by name (alias for resolve_name)."""
|
|
870
|
+
return self.resolve_name(name, in_file)
|
|
871
|
+
|
|
872
|
+
def get_class_definition(self, name: str, in_file: Path | None = None) -> ParsedClass | None:
|
|
873
|
+
"""Get the ParsedClass definition for a type name."""
|
|
874
|
+
symbol = self.resolve_name(name, in_file)
|
|
875
|
+
if symbol and symbol.kind == "class" and isinstance(symbol.definition, ParsedClass):
|
|
876
|
+
return symbol.definition
|
|
877
|
+
return None
|
|
878
|
+
|
|
879
|
+
def is_pydantic_model(self, name: str, in_file: Path | None = None) -> bool:
|
|
880
|
+
"""Check if a type name refers to a Pydantic model."""
|
|
881
|
+
symbol = self.resolve_name(name, in_file)
|
|
882
|
+
return symbol.is_pydantic_model if symbol else False
|
|
883
|
+
|
|
884
|
+
def get_all_pydantic_models(self) -> dict[str, ParsedClass]:
|
|
885
|
+
"""Get all Pydantic models in the project."""
|
|
886
|
+
if not self._resolved:
|
|
887
|
+
self.resolve()
|
|
888
|
+
|
|
889
|
+
models = {}
|
|
890
|
+
for _name, symbol in self._graph.symbols.items():
|
|
891
|
+
if symbol.is_pydantic_model and isinstance(symbol.definition, ParsedClass):
|
|
892
|
+
# Only include once per unique qualified name
|
|
893
|
+
if symbol.qualified_name not in models:
|
|
894
|
+
models[symbol.qualified_name] = symbol.definition
|
|
895
|
+
return models
|
|
896
|
+
|
|
897
|
+
def get_import_aliases(self, file_path: Path) -> dict[str, str]:
|
|
898
|
+
"""
|
|
899
|
+
Get import alias mapping for a file.
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
Dict mapping local names to their original/qualified names
|
|
903
|
+
"""
|
|
904
|
+
if not self._resolved:
|
|
905
|
+
self.resolve()
|
|
906
|
+
|
|
907
|
+
result = {}
|
|
908
|
+
if file_path in self._graph.file_imports:
|
|
909
|
+
for imp in self._graph.file_imports[file_path]:
|
|
910
|
+
if imp.local_name != imp.original_name:
|
|
911
|
+
result[imp.local_name] = imp.original_name
|
|
912
|
+
return result
|
|
913
|
+
|
|
914
|
+
def get_model_fields(
|
|
915
|
+
self,
|
|
916
|
+
model_name: str,
|
|
917
|
+
in_file: Path | None = None,
|
|
918
|
+
include_inherited: bool = True,
|
|
919
|
+
) -> list[dict]:
|
|
920
|
+
"""
|
|
921
|
+
Get fields for a Pydantic model.
|
|
922
|
+
|
|
923
|
+
Uses MRO for correct field inheritance order and deduplication.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
model_name: Name of the model class
|
|
927
|
+
in_file: File context for resolution
|
|
928
|
+
include_inherited: Whether to include fields from base classes
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
List of field dicts with name, type, required, etc.
|
|
932
|
+
"""
|
|
933
|
+
symbol = self.resolve_name(model_name, in_file)
|
|
934
|
+
if not symbol or symbol.kind != "class":
|
|
935
|
+
return []
|
|
936
|
+
|
|
937
|
+
cls = symbol.definition
|
|
938
|
+
if not isinstance(cls, ParsedClass):
|
|
939
|
+
return []
|
|
940
|
+
|
|
941
|
+
if not include_inherited:
|
|
942
|
+
# Only this class's fields
|
|
943
|
+
return self._class_fields_to_dicts(cls)
|
|
944
|
+
|
|
945
|
+
# Use MRO for correct inheritance order
|
|
946
|
+
all_fields: list[dict] = []
|
|
947
|
+
seen_names: set[str] = set()
|
|
948
|
+
|
|
949
|
+
# Walk MRO in reverse (base classes first)
|
|
950
|
+
mro = symbol.mro if symbol.mro else [symbol.qualified_name]
|
|
951
|
+
|
|
952
|
+
for class_name in reversed(mro):
|
|
953
|
+
class_symbol = self._graph.symbols.get(class_name)
|
|
954
|
+
if not class_symbol or not isinstance(class_symbol.definition, ParsedClass):
|
|
955
|
+
continue
|
|
956
|
+
|
|
957
|
+
class_def = class_symbol.definition
|
|
958
|
+
for f in class_def.fields:
|
|
959
|
+
if f.name not in seen_names:
|
|
960
|
+
all_fields.append(self._field_to_dict(f))
|
|
961
|
+
seen_names.add(f.name)
|
|
962
|
+
else:
|
|
963
|
+
# Field override - update existing
|
|
964
|
+
for i, existing in enumerate(all_fields):
|
|
965
|
+
if existing["name"] == f.name:
|
|
966
|
+
all_fields[i] = self._field_to_dict(f)
|
|
967
|
+
break
|
|
968
|
+
|
|
969
|
+
return all_fields
|
|
970
|
+
|
|
971
|
+
def _class_fields_to_dicts(self, cls: ParsedClass) -> list[dict]:
|
|
972
|
+
"""Convert class fields to dicts."""
|
|
973
|
+
return [self._field_to_dict(f) for f in cls.fields]
|
|
974
|
+
|
|
975
|
+
def _field_to_dict(self, f) -> dict:
|
|
976
|
+
"""Convert a ParsedField to a dict."""
|
|
977
|
+
return {
|
|
978
|
+
"name": f.name,
|
|
979
|
+
"type_annotation": f.type_annotation,
|
|
980
|
+
"required": f.is_required,
|
|
981
|
+
"default": f.default_value,
|
|
982
|
+
"alias": f.alias,
|
|
983
|
+
"description": f.description,
|
|
984
|
+
"constraints": f.constraints,
|
|
985
|
+
"nested_model": f.nested_model_name,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
def get_inheritance_chain(self, class_name: str, in_file: Path | None = None) -> list[str]:
|
|
989
|
+
"""
|
|
990
|
+
Get the full inheritance chain for a class.
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
List of all ancestor class names
|
|
994
|
+
"""
|
|
995
|
+
symbol = self.resolve_name(class_name, in_file)
|
|
996
|
+
if symbol and symbol.kind == "class":
|
|
997
|
+
return list(symbol.all_bases)
|
|
998
|
+
return []
|
|
999
|
+
|
|
1000
|
+
def get_mro(self, class_name: str, in_file: Path | None = None) -> list[str]:
|
|
1001
|
+
"""
|
|
1002
|
+
Get the Method Resolution Order for a class.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
List of class names in MRO order
|
|
1006
|
+
"""
|
|
1007
|
+
symbol = self.resolve_name(class_name, in_file)
|
|
1008
|
+
if symbol and symbol.kind == "class":
|
|
1009
|
+
return list(symbol.mro)
|
|
1010
|
+
return []
|
|
1011
|
+
|
|
1012
|
+
def get_subclasses(self, class_name: str) -> list[str]:
|
|
1013
|
+
"""
|
|
1014
|
+
Get all direct subclasses of a class.
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
List of subclass qualified names
|
|
1018
|
+
"""
|
|
1019
|
+
if not self._resolved:
|
|
1020
|
+
self.resolve()
|
|
1021
|
+
|
|
1022
|
+
symbol = self.resolve_name(class_name, None)
|
|
1023
|
+
if not symbol:
|
|
1024
|
+
return []
|
|
1025
|
+
|
|
1026
|
+
return self._graph.reverse_inheritance.get(symbol.qualified_name, [])
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
# =============================================================================
|
|
1030
|
+
# Convenience Functions
|
|
1031
|
+
# =============================================================================
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def build_cross_file_resolver(
|
|
1035
|
+
parsed_files: list[ParsedFile],
|
|
1036
|
+
project_root: Path | None = None,
|
|
1037
|
+
) -> CrossFileResolver:
|
|
1038
|
+
"""
|
|
1039
|
+
Build a cross-file resolver from parsed files.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
parsed_files: List of successfully parsed files
|
|
1043
|
+
project_root: Optional project root
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
Configured and resolved CrossFileResolver
|
|
1047
|
+
"""
|
|
1048
|
+
resolver = CrossFileResolver(project_root)
|
|
1049
|
+
|
|
1050
|
+
for parsed in parsed_files:
|
|
1051
|
+
resolver.add_file(parsed)
|
|
1052
|
+
|
|
1053
|
+
resolver.resolve()
|
|
1054
|
+
return resolver
|