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,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