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,988 @@
1
+ """
2
+ Call resolution helpers: context analysis, call resolution, decorators, and lambda tracking.
3
+
4
+ Contains the classes that resolve call sites to their target functions and
5
+ analyze the syntactic context of calls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .binding_tracker import BindingTracker
15
+ from .call_graph_types import (
16
+ PYTHON_BUILTINS,
17
+ CallContext,
18
+ CallGraphNode,
19
+ NodeType,
20
+ ResolutionConfidence,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from ..parsing.base import ParsedCallSite, ParsedDecorator, ParsedFile, ParsedFunction
25
+ from ..parsing.services import TypeResolver
26
+ from .flow_analysis import FlowSensitiveBindings
27
+
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # =============================================================================
33
+ # Call Context Analyzer
34
+ # =============================================================================
35
+
36
+
37
+ class CallContextAnalyzer:
38
+ """
39
+ Analyzes the syntactic context of call sites.
40
+
41
+ Determines if a call is inside a loop, conditional, try block, etc.
42
+ This information is useful for security analysis (e.g., calls in
43
+ exception handlers might indicate error handling patterns).
44
+ """
45
+
46
+ def __init__(self):
47
+ """Initialize the call context analyzer."""
48
+ # Map from (file_path, line) to context flags
49
+ self._context_cache: dict[tuple[Path, int], dict[str, Any]] = {}
50
+
51
+ # Control flow structures per file: file_path -> list of structures
52
+ self._control_flow: dict[Path, list[dict[str, Any]]] = {}
53
+
54
+ def register_control_flow(
55
+ self,
56
+ file_path: Path,
57
+ structures: list[dict[str, Any]],
58
+ ) -> None:
59
+ """
60
+ Register control flow structures from parsing.
61
+
62
+ Each structure should have:
63
+ - type: "if", "for", "while", "try", "with", "comprehension"
64
+ - start_line: int
65
+ - end_line: int
66
+ - Additional fields based on type
67
+ """
68
+ self._control_flow[file_path] = structures
69
+
70
+ def extract_control_flow_from_file(
71
+ self,
72
+ parsed_file: ParsedFile,
73
+ ) -> None:
74
+ """
75
+ Extract control flow information from a parsed file.
76
+
77
+ This analyzes functions and classes to find loops, conditionals, etc.
78
+ """
79
+ structures: list[dict[str, Any]] = []
80
+
81
+ # Extract from each function
82
+ for func in parsed_file.functions:
83
+ func_structures = self._extract_from_function(func)
84
+ structures.extend(func_structures)
85
+
86
+ # Extract from class methods
87
+ for cls in parsed_file.classes:
88
+ for method in cls.methods:
89
+ method_structures = self._extract_from_function(method)
90
+ structures.extend(method_structures)
91
+
92
+ self._control_flow[parsed_file.path] = structures
93
+
94
+ def _extract_from_function(
95
+ self,
96
+ func: ParsedFunction,
97
+ ) -> list[dict[str, Any]]:
98
+ """Extract control flow structures from a function."""
99
+ structures: list[dict[str, Any]] = []
100
+
101
+ # Check for control_flow metadata on the function
102
+ if hasattr(func, "control_flow_info") and func.control_flow_info:
103
+ cf_info = func.control_flow_info
104
+
105
+ # Extract if blocks
106
+ for if_block in cf_info.get("if_blocks", []):
107
+ structures.append(
108
+ {
109
+ "type": "if",
110
+ "start_line": if_block.get("start_line", 0),
111
+ "end_line": if_block.get("end_line", 0),
112
+ "function": func.qualified_name.full,
113
+ }
114
+ )
115
+
116
+ # Extract loops
117
+ for loop in cf_info.get("loops", []):
118
+ structures.append(
119
+ {
120
+ "type": loop.get("loop_type", "for"),
121
+ "start_line": loop.get("start_line", 0),
122
+ "end_line": loop.get("end_line", 0),
123
+ "function": func.qualified_name.full,
124
+ }
125
+ )
126
+
127
+ # Extract try blocks
128
+ for try_block in cf_info.get("try_blocks", []):
129
+ structures.append(
130
+ {
131
+ "type": "try",
132
+ "start_line": try_block.get("try_start", 0),
133
+ "end_line": try_block.get("try_end", 0),
134
+ "function": func.qualified_name.full,
135
+ }
136
+ )
137
+
138
+ # Except handlers
139
+ for exc in try_block.get("except_blocks", []):
140
+ structures.append(
141
+ {
142
+ "type": "except",
143
+ "start_line": exc.get("start_line", 0),
144
+ "end_line": exc.get("end_line", 0),
145
+ "function": func.qualified_name.full,
146
+ }
147
+ )
148
+
149
+ # Finally block
150
+ fin = try_block.get("finally_block")
151
+ if fin is not None:
152
+ structures.append(
153
+ {
154
+ "type": "finally",
155
+ "start_line": fin.get("start_line", 0),
156
+ "end_line": fin.get("end_line", 0),
157
+ "function": func.qualified_name.full,
158
+ }
159
+ )
160
+
161
+ # Extract with blocks
162
+ for with_block in cf_info.get("with_blocks", []):
163
+ structures.append(
164
+ {
165
+ "type": "with",
166
+ "start_line": with_block.get("start_line", 0),
167
+ "end_line": with_block.get("end_line", 0),
168
+ "function": func.qualified_name.full,
169
+ }
170
+ )
171
+
172
+ # Extract comprehensions
173
+ for comp in cf_info.get("comprehensions", []):
174
+ structures.append(
175
+ {
176
+ "type": "comprehension",
177
+ "start_line": comp.get("line", 0),
178
+ "end_line": comp.get("line", 0),
179
+ "function": func.qualified_name.full,
180
+ }
181
+ )
182
+
183
+ return structures
184
+
185
+ def analyze_call_context(
186
+ self,
187
+ call_site: ParsedCallSite,
188
+ parsed_file: ParsedFile,
189
+ flow_bindings: FlowSensitiveBindings | None = None,
190
+ ) -> dict[str, Any]:
191
+ """
192
+ Analyze the context of a call site.
193
+
194
+ Returns dict with context flags.
195
+ """
196
+ # Check cache (include column so co-located calls with different scopes are distinct)
197
+ cache_key = (parsed_file.path, call_site.location.line, call_site.location.column)
198
+ if cache_key in self._context_cache:
199
+ return self._context_cache[cache_key]
200
+
201
+ call_line = call_site.location.line
202
+ caller_func = None
203
+ if call_site.caller_function:
204
+ caller_func = (
205
+ call_site.caller_function.full
206
+ if hasattr(call_site.caller_function, "full")
207
+ else str(call_site.caller_function)
208
+ )
209
+
210
+ context = {
211
+ "context": CallContext.NORMAL,
212
+ "in_loop": False,
213
+ "in_conditional": False,
214
+ "in_try_block": False,
215
+ "in_except_handler": False,
216
+ "in_finally_block": False,
217
+ "in_with_block": False,
218
+ "in_comprehension": False,
219
+ "in_lambda": False,
220
+ "loop_depth": 0,
221
+ "conditional_depth": 0,
222
+ }
223
+
224
+ # Check flow-sensitive bindings for context
225
+ if flow_bindings and caller_func:
226
+ flow_ctx = flow_bindings.get_call_context(caller_func, call_line)
227
+ if flow_ctx:
228
+ context["in_loop"] = flow_ctx.in_loop
229
+ context["in_conditional"] = flow_ctx.in_conditional
230
+ context["in_try_block"] = flow_ctx.in_try
231
+ context["in_except_handler"] = flow_ctx.in_except
232
+ context["in_comprehension"] = flow_ctx.in_comprehension
233
+ if flow_ctx.in_comprehension:
234
+ context["in_loop"] = True
235
+ context["in_lambda"] = flow_ctx.in_lambda
236
+ context["in_with_block"] = flow_ctx.in_with
237
+ context["loop_depth"] = flow_ctx.loop_depth
238
+ context["conditional_depth"] = flow_ctx.conditional_depth
239
+
240
+ # Check control flow structures
241
+ file_structures = self._control_flow.get(parsed_file.path, [])
242
+
243
+ for struct in file_structures:
244
+ start = struct.get("start_line", 0)
245
+ end = struct.get("end_line", 0)
246
+
247
+ if start <= call_line <= end:
248
+ struct_type = struct.get("type", "")
249
+
250
+ if struct_type in ("for", "while"):
251
+ context["in_loop"] = True
252
+ context["loop_depth"] = context.get("loop_depth", 0) + 1
253
+ if context["context"] == CallContext.NORMAL:
254
+ context["context"] = CallContext.LOOP
255
+
256
+ elif struct_type == "if":
257
+ context["in_conditional"] = True
258
+ context["conditional_depth"] = context.get("conditional_depth", 0) + 1
259
+ if context["context"] == CallContext.NORMAL:
260
+ context["context"] = CallContext.CONDITIONAL
261
+
262
+ elif struct_type == "try":
263
+ context["in_try_block"] = True
264
+ if context["context"] == CallContext.NORMAL:
265
+ context["context"] = CallContext.TRY_BLOCK
266
+
267
+ elif struct_type == "except":
268
+ context["in_except_handler"] = True
269
+ if context["context"] == CallContext.NORMAL:
270
+ context["context"] = CallContext.EXCEPT_HANDLER
271
+
272
+ elif struct_type == "finally":
273
+ context["in_finally_block"] = True
274
+ if context["context"] == CallContext.NORMAL:
275
+ context["context"] = CallContext.FINALLY_BLOCK
276
+
277
+ elif struct_type == "with":
278
+ context["in_with_block"] = True
279
+ if context["context"] == CallContext.NORMAL:
280
+ context["context"] = CallContext.WITH_BLOCK
281
+
282
+ elif struct_type == "comprehension":
283
+ context["in_comprehension"] = True
284
+ context["in_loop"] = True
285
+ if context["context"] == CallContext.NORMAL:
286
+ context["context"] = CallContext.COMPREHENSION
287
+
288
+ # Check if caller is a lambda
289
+ if caller_func and "<lambda>" in caller_func:
290
+ context["in_lambda"] = True
291
+ context["context"] = CallContext.LAMBDA
292
+
293
+ # Check call_site attributes if available
294
+ if hasattr(call_site, "in_comprehension") and call_site.in_comprehension:
295
+ context["in_comprehension"] = True
296
+ context["in_loop"] = True
297
+ if context["context"] == CallContext.NORMAL:
298
+ context["context"] = CallContext.COMPREHENSION
299
+
300
+ if hasattr(call_site, "in_loop") and call_site.in_loop:
301
+ context["in_loop"] = True
302
+ if context["context"] == CallContext.NORMAL:
303
+ context["context"] = CallContext.LOOP
304
+
305
+ if hasattr(call_site, "in_conditional") and call_site.in_conditional:
306
+ context["in_conditional"] = True
307
+ if context["context"] == CallContext.NORMAL:
308
+ context["context"] = CallContext.CONDITIONAL
309
+
310
+ if hasattr(call_site, "in_try") and call_site.in_try:
311
+ context["in_try_block"] = True
312
+ if context["context"] == CallContext.NORMAL:
313
+ context["context"] = CallContext.TRY_BLOCK
314
+
315
+ if hasattr(call_site, "in_except") and call_site.in_except:
316
+ context["in_except_handler"] = True
317
+ if context["context"] == CallContext.NORMAL:
318
+ context["context"] = CallContext.EXCEPT_HANDLER
319
+
320
+ self._context_cache[cache_key] = context
321
+ return context
322
+
323
+
324
+ # =============================================================================
325
+ # Call Resolver
326
+ # =============================================================================
327
+
328
+
329
+ class CallResolver:
330
+ """
331
+ Resolves call sites to their target functions.
332
+
333
+ Uses:
334
+ - Import resolution (for qualified names)
335
+ - Type bindings (for method calls)
336
+ - Flow-sensitive bindings for point-specific types
337
+ - Symbol table (for all known definitions)
338
+ - Type hierarchy (for inheritance)
339
+ - Return value tracking (for higher-order functions)
340
+ - Protocol/ABC resolution
341
+ """
342
+
343
+ def __init__(
344
+ self,
345
+ symbols: dict[str, CallGraphNode],
346
+ binding_tracker: BindingTracker,
347
+ type_resolver: TypeResolver | None = None,
348
+ flow_bindings: FlowSensitiveBindings | None = None,
349
+ file_name_index: dict[tuple[Path, str], list[str]] | None = None,
350
+ ):
351
+ """Initialize the call resolver with symbol and binding data."""
352
+ self._symbols = symbols
353
+ self._bindings = binding_tracker
354
+ self._type_resolver = type_resolver
355
+ self._flow_bindings = flow_bindings
356
+ self._file_name_index = file_name_index or {}
357
+
358
+ # Import map: (file_path, local_name) -> qualified_name
359
+ self._import_map: dict[tuple[Path, str], str] = {}
360
+
361
+ # Class hierarchy: class_qname -> [base_class_qnames]
362
+ self._class_bases: dict[str, list[str]] = {}
363
+
364
+ # Star import tracking: file_path -> [exported_names]
365
+ self._star_imports: dict[Path, dict[str, str]] = {}
366
+
367
+ # Attribute type map for chained access: (type, attr) -> result_type
368
+ self._attribute_types: dict[tuple[str, str], str] = {}
369
+
370
+ def add_import_mapping(
371
+ self,
372
+ file_path: Path,
373
+ local_name: str,
374
+ qualified_name: str,
375
+ ) -> None:
376
+ """Add an import mapping for resolution."""
377
+ self._import_map[(file_path, local_name)] = qualified_name
378
+
379
+ def add_star_import(
380
+ self,
381
+ file_path: Path,
382
+ module: str,
383
+ exported_names: dict[str, str],
384
+ ) -> None:
385
+ """Add star import mappings."""
386
+ if file_path not in self._star_imports:
387
+ self._star_imports[file_path] = {}
388
+ self._star_imports[file_path].update(exported_names)
389
+
390
+ def add_class_hierarchy(
391
+ self,
392
+ class_qname: str,
393
+ base_classes: list[str],
394
+ ) -> None:
395
+ """Add class inheritance information."""
396
+ self._class_bases[class_qname] = base_classes
397
+
398
+ def add_attribute_type(
399
+ self,
400
+ owner_type: str,
401
+ attribute: str,
402
+ attribute_type: str,
403
+ ) -> None:
404
+ """Record the type of an attribute for chained access resolution."""
405
+ self._attribute_types[(owner_type, attribute)] = attribute_type
406
+
407
+ def resolve(
408
+ self,
409
+ call_site: ParsedCallSite,
410
+ caller_function: str | None,
411
+ file_path: Path,
412
+ ) -> tuple[list[str], ResolutionConfidence, str]:
413
+ """
414
+ Resolve a call site to target function(s).
415
+
416
+ Returns:
417
+ Tuple of (targets, confidence, reason)
418
+ """
419
+ callee_name = call_site.callee_name
420
+
421
+ # Case 1: Already resolved by parser
422
+ if call_site.callee_resolved and call_site.callee_qualified_name:
423
+ qname = call_site.callee_qualified_name.full
424
+ if qname in self._symbols:
425
+ return [qname], ResolutionConfidence.EXACT, "pre-resolved by parser"
426
+ # Even if not in symbols, trust the parser
427
+ return [qname], ResolutionConfidence.HIGH, "pre-resolved (external)"
428
+
429
+ # Case 2: Variable holding a callable (higher-order)
430
+ callable_targets = self._bindings.get_callable_targets(
431
+ callee_name, file_path, caller_function
432
+ )
433
+ if callable_targets:
434
+ targets = [t for t in callable_targets if t in self._symbols]
435
+ if targets:
436
+ if len(targets) == 1:
437
+ return targets, ResolutionConfidence.HIGH, "resolved via callable binding"
438
+ return targets, ResolutionConfidence.MEDIUM, "multiple callable targets"
439
+
440
+ # Case 3: Method call (obj.method())
441
+ if call_site.is_method_call and call_site.receiver_expression:
442
+ return self._resolve_method_call(call_site, caller_function, file_path)
443
+
444
+ # Case 4: Qualified call (module.func() or Class.method())
445
+ if "." in callee_name:
446
+ return self._resolve_qualified_call(callee_name, caller_function, file_path)
447
+
448
+ # Case 5: Simple call (func())
449
+ return self._resolve_simple_call(callee_name, caller_function, file_path)
450
+
451
+ def _resolve_method_call(
452
+ self,
453
+ call_site: ParsedCallSite,
454
+ caller_function: str | None,
455
+ file_path: Path,
456
+ ) -> tuple[list[str], ResolutionConfidence, str]:
457
+ """Resolve obj.method() style calls."""
458
+ receiver = call_site.receiver_expression
459
+ method_name = call_site.callee_name.split(".")[-1]
460
+ call_line = call_site.location.line
461
+
462
+ # First, try flow-sensitive bindings (most precise)
463
+ possible_types: set[str] = set()
464
+
465
+ if self._flow_bindings and caller_function:
466
+ # Get flow-sensitive types at this specific line
467
+ flow_types = self._flow_bindings.get_types_at_point(
468
+ receiver, caller_function, call_line
469
+ )
470
+ possible_types.update(flow_types)
471
+
472
+ # Also check attribute types (self.attr)
473
+ if "." in receiver:
474
+ parts = receiver.split(".", 1)
475
+ if len(parts) == 2:
476
+ recv, attr = parts
477
+ attr_types = self._flow_bindings.get_attr_types_at_point(
478
+ recv, attr, caller_function, call_line
479
+ )
480
+ possible_types.update(attr_types)
481
+
482
+ # Check cross-scope references (module-level vars used in functions)
483
+ if not possible_types:
484
+ cross_types = self._flow_bindings.resolve_cross_scope(
485
+ caller_function, receiver, file_path
486
+ )
487
+ possible_types.update(cross_types)
488
+
489
+ # Handle chained attribute access (obj.attr1.attr2.method())
490
+ if not possible_types:
491
+ possible_types = self._resolve_chained_receiver(receiver, file_path, caller_function)
492
+
493
+ # Fall back to flow-insensitive bindings
494
+ if not possible_types:
495
+ possible_types = self._bindings.get_possible_types(receiver, file_path, caller_function)
496
+
497
+ # Also check if receiver is a known symbol directly (imported module/class)
498
+ if not possible_types:
499
+ import_key = (file_path, receiver)
500
+ if import_key in self._import_map:
501
+ imported = self._import_map[import_key]
502
+ possible_types.add(imported)
503
+
504
+ # Check star imports
505
+ if not possible_types and file_path in self._star_imports:
506
+ if receiver in self._star_imports[file_path]:
507
+ possible_types.add(self._star_imports[file_path][receiver])
508
+
509
+ # If we have the receiver type from parser
510
+ if call_site.receiver_type:
511
+ possible_types.add(call_site.receiver_type)
512
+
513
+ targets: list[str] = []
514
+
515
+ for type_name in sorted(possible_types):
516
+ # Try to find method in this type
517
+ method_qname = f"{type_name}.{method_name}"
518
+ if method_qname in self._symbols:
519
+ targets.append(method_qname)
520
+ continue
521
+
522
+ # Check base classes (MRO)
523
+ targets.extend(self._resolve_in_hierarchy(type_name, method_name))
524
+
525
+ # Try type resolver for external types
526
+ if self._type_resolver:
527
+ resolved = self._type_resolver.resolve_type(type_name, file_path)
528
+ if resolved:
529
+ for ancestor in resolved.mro:
530
+ ancestor_method = f"{ancestor}.{method_name}"
531
+ if ancestor_method in self._symbols:
532
+ targets.append(ancestor_method)
533
+ break
534
+
535
+ # Check protocol implementations
536
+ implementers = self._bindings.get_protocol_implementers(type_name)
537
+ for impl in implementers:
538
+ impl_method = f"{impl}.{method_name}"
539
+ if impl_method in self._symbols:
540
+ targets.append(impl_method)
541
+
542
+ # Deduplicate and sort for deterministic resolution
543
+ targets = sorted(dict.fromkeys(targets))
544
+
545
+ if not targets:
546
+ if possible_types:
547
+ best_type = sorted(possible_types)[0]
548
+ return (
549
+ [f"{best_type}.{method_name}"],
550
+ ResolutionConfidence.LOW,
551
+ f"resolved receiver '{receiver}' to external '{best_type}'",
552
+ )
553
+ return (
554
+ [f"<unresolved>.{method_name}"],
555
+ ResolutionConfidence.UNRESOLVED,
556
+ f"could not resolve receiver '{receiver}'",
557
+ )
558
+
559
+ if len(targets) == 1:
560
+ return (
561
+ targets,
562
+ ResolutionConfidence.HIGH,
563
+ f"resolved via type binding for '{receiver}'",
564
+ )
565
+
566
+ return (
567
+ targets,
568
+ ResolutionConfidence.MEDIUM,
569
+ f"multiple possible targets for '{receiver}.{method_name}'",
570
+ )
571
+
572
+ def _resolve_chained_receiver(
573
+ self,
574
+ receiver: str,
575
+ file_path: Path,
576
+ caller_function: str | None,
577
+ ) -> set[str]:
578
+ """
579
+ Resolve chained attribute access like obj.attr1.attr2.
580
+
581
+ Returns possible types for the final expression.
582
+ """
583
+ if "." not in receiver:
584
+ return self._bindings.get_possible_types(receiver, file_path, caller_function)
585
+
586
+ parts = receiver.split(".")
587
+
588
+ # Start with the root object
589
+ current_types = self._bindings.get_possible_types(parts[0], file_path, caller_function)
590
+
591
+ # Check imports for root
592
+ if not current_types:
593
+ import_key = (file_path, parts[0])
594
+ if import_key in self._import_map:
595
+ current_types = {self._import_map[import_key]}
596
+
597
+ if not current_types:
598
+ return set()
599
+
600
+ # Follow the chain
601
+ for attr in parts[1:]:
602
+ next_types: set[str] = set()
603
+ for current_type in current_types:
604
+ # Check attribute type map
605
+ attr_key = (current_type, attr)
606
+ if attr_key in self._attribute_types:
607
+ next_types.add(self._attribute_types[attr_key])
608
+ else:
609
+ # Try class attributes
610
+ class_key = (current_type, attr)
611
+ binding = self._bindings._class_bindings.get(class_key)
612
+ if binding:
613
+ next_types.update(binding.possible_types)
614
+ else:
615
+ # Assume it's a nested attribute with same base
616
+ next_types.add(f"{current_type}.{attr}")
617
+
618
+ if not next_types:
619
+ # Can't continue the chain
620
+ return set()
621
+ current_types = next_types
622
+
623
+ return current_types
624
+
625
+ def _resolve_in_hierarchy(
626
+ self,
627
+ type_name: str,
628
+ method_name: str,
629
+ ) -> list[str]:
630
+ """Resolve method in class hierarchy (MRO)."""
631
+ targets = []
632
+ visited = set()
633
+ to_check = [type_name]
634
+
635
+ while to_check:
636
+ current = to_check.pop(0)
637
+ if current in visited:
638
+ continue
639
+ visited.add(current)
640
+
641
+ method_qname = f"{current}.{method_name}"
642
+ if method_qname in self._symbols:
643
+ targets.append(method_qname)
644
+
645
+ if current in self._class_bases:
646
+ to_check.extend(self._class_bases[current])
647
+
648
+ return sorted(targets)
649
+
650
+ def _resolve_qualified_call(
651
+ self,
652
+ callee_name: str,
653
+ caller_function: str | None,
654
+ file_path: Path,
655
+ ) -> tuple[list[str], ResolutionConfidence, str]:
656
+ """Resolve module.func() or Class.method() style calls."""
657
+ parts = callee_name.split(".")
658
+ prefix = parts[0]
659
+ rest = ".".join(parts[1:])
660
+
661
+ # Check if prefix is an import alias
662
+ import_key = (file_path, prefix)
663
+ if import_key in self._import_map:
664
+ resolved_prefix = self._import_map[import_key]
665
+ full_name = f"{resolved_prefix}.{rest}" if rest else resolved_prefix
666
+
667
+ if full_name in self._symbols:
668
+ return (
669
+ [full_name],
670
+ ResolutionConfidence.EXACT,
671
+ f"resolved via import '{prefix}'",
672
+ )
673
+
674
+ # Try without the rest (might be nested access)
675
+ if resolved_prefix in self._symbols:
676
+ return (
677
+ [resolved_prefix],
678
+ ResolutionConfidence.HIGH,
679
+ "resolved to imported module/class",
680
+ )
681
+
682
+ # Check star imports
683
+ if file_path in self._star_imports and prefix in self._star_imports[file_path]:
684
+ full_name = self._star_imports[file_path][prefix]
685
+ if rest:
686
+ full_name = f"{full_name}.{rest}"
687
+ if full_name in self._symbols:
688
+ return (
689
+ [full_name],
690
+ ResolutionConfidence.HIGH,
691
+ "resolved via star import",
692
+ )
693
+
694
+ # Try the name as-is
695
+ if callee_name in self._symbols:
696
+ return (
697
+ [callee_name],
698
+ ResolutionConfidence.EXACT,
699
+ "direct symbol match",
700
+ )
701
+
702
+ # Might be external
703
+ return (
704
+ [callee_name],
705
+ ResolutionConfidence.LOW,
706
+ "assuming external symbol",
707
+ )
708
+
709
+ def _resolve_simple_call(
710
+ self,
711
+ callee_name: str,
712
+ caller_function: str | None,
713
+ file_path: Path,
714
+ ) -> tuple[list[str], ResolutionConfidence, str]:
715
+ """Resolve simple func() style calls."""
716
+ # Check imports first
717
+ import_key = (file_path, callee_name)
718
+ if import_key in self._import_map:
719
+ resolved = self._import_map[import_key]
720
+ if resolved in self._symbols:
721
+ return (
722
+ [resolved],
723
+ ResolutionConfidence.EXACT,
724
+ "resolved via import",
725
+ )
726
+ return (
727
+ [resolved],
728
+ ResolutionConfidence.HIGH,
729
+ "resolved via import (external)",
730
+ )
731
+
732
+ # Check star imports
733
+ if file_path in self._star_imports and callee_name in self._star_imports[file_path]:
734
+ resolved = self._star_imports[file_path][callee_name]
735
+ if resolved in self._symbols:
736
+ return (
737
+ [resolved],
738
+ ResolutionConfidence.HIGH,
739
+ "resolved via star import",
740
+ )
741
+ return (
742
+ [resolved],
743
+ ResolutionConfidence.MEDIUM,
744
+ "resolved via star import (external)",
745
+ )
746
+
747
+ # Check local/nested function in same file
748
+ if caller_function:
749
+ # Check for nested function
750
+ nested_name = f"{caller_function}.{callee_name}"
751
+ if nested_name in self._symbols:
752
+ return (
753
+ [nested_name],
754
+ ResolutionConfidence.EXACT,
755
+ "nested function",
756
+ )
757
+
758
+ # Check module-level in same file
759
+ module_candidates = self._find_module_symbols(callee_name, file_path)
760
+ if module_candidates:
761
+ if len(module_candidates) == 1:
762
+ return (
763
+ module_candidates,
764
+ ResolutionConfidence.EXACT,
765
+ "module-level function",
766
+ )
767
+ return (
768
+ module_candidates,
769
+ ResolutionConfidence.MEDIUM,
770
+ "multiple module-level matches",
771
+ )
772
+
773
+ # Check if it's a builtin
774
+ if callee_name in PYTHON_BUILTINS:
775
+ return (
776
+ [f"builtins.{callee_name}"],
777
+ ResolutionConfidence.EXACT,
778
+ "builtin function",
779
+ )
780
+
781
+ # Check if callee is a callable object instance (has __call__)
782
+ call_targets = self._resolve_callable_object(callee_name, caller_function, file_path)
783
+ if call_targets:
784
+ return (
785
+ call_targets,
786
+ ResolutionConfidence.HIGH,
787
+ f"resolved via __call__ on instance '{callee_name}'",
788
+ )
789
+
790
+ # Unresolved
791
+ return (
792
+ [callee_name],
793
+ ResolutionConfidence.UNRESOLVED,
794
+ "could not resolve symbol",
795
+ )
796
+
797
+ def _find_module_symbols(
798
+ self,
799
+ name: str,
800
+ file_path: Path,
801
+ ) -> list[str]:
802
+ """Find symbols with matching name in the file's module."""
803
+ idx_key = (file_path, name)
804
+ if idx_key in self._file_name_index:
805
+ return sorted(self._file_name_index[idx_key])
806
+
807
+ matches = []
808
+ for qname, node in self._symbols.items():
809
+ if node.file_path == file_path and node.name == name:
810
+ matches.append(qname)
811
+ return sorted(matches)
812
+
813
+ def _resolve_callable_object(
814
+ self,
815
+ callee_name: str,
816
+ caller_function: str | None,
817
+ file_path: Path,
818
+ ) -> list[str]:
819
+ """Resolve ``obj()`` to ``Type.__call__`` when *obj* is an instance
820
+ of a class that defines ``__call__``.
821
+
822
+ Returns a list of resolved __call__ targets, or an empty list.
823
+ """
824
+ possible_types: set[str] = set()
825
+
826
+ # Flow-sensitive bindings first
827
+ if self._flow_bindings and caller_function:
828
+ flow_types = self._flow_bindings.get_types_at_point(callee_name, caller_function, 0)
829
+ possible_types.update(flow_types)
830
+
831
+ # Flow-insensitive bindings
832
+ if not possible_types:
833
+ possible_types = self._bindings.get_possible_types(
834
+ callee_name, file_path, caller_function
835
+ )
836
+
837
+ if not possible_types:
838
+ return []
839
+
840
+ targets: list[str] = []
841
+ for type_name in sorted(possible_types):
842
+ # Skip if the type itself is a class constructor node —
843
+ # that path is already handled by constructor resolution.
844
+ node = self._symbols.get(type_name)
845
+ if node and node.node_type == NodeType.CONSTRUCTOR:
846
+ continue
847
+
848
+ call_qname = f"{type_name}.__call__"
849
+ if call_qname in self._symbols:
850
+ targets.append(call_qname)
851
+ continue
852
+
853
+ # Walk the class hierarchy looking for __call__
854
+ targets.extend(self._resolve_in_hierarchy(type_name, "__call__"))
855
+
856
+ return sorted(dict.fromkeys(targets))
857
+
858
+
859
+ # =============================================================================
860
+ # Decorator Analyzer
861
+ # =============================================================================
862
+
863
+
864
+ class DecoratorAnalyzer:
865
+ """
866
+ Analyzes decorator applications as function calls.
867
+
868
+ Decorators are function calls:
869
+ - @decorator is equivalent to func = decorator(func)
870
+ - @decorator(args) is equivalent to func = decorator(args)(func)
871
+ """
872
+
873
+ def extract_decorator_calls(
874
+ self,
875
+ func_qname: str,
876
+ decorators: list[ParsedDecorator],
877
+ file_path: Path,
878
+ ) -> list[tuple[str, int, str]]:
879
+ """
880
+ Extract call information from decorators.
881
+
882
+ Returns list of (decorator_qname, line, call_type) tuples.
883
+ """
884
+ calls = []
885
+
886
+ for dec in decorators:
887
+ dec_name = dec.name
888
+ line = dec.location.line if dec.location else 0
889
+
890
+ # Simple decorator: @foo
891
+ if not dec.arguments:
892
+ calls.append((dec_name, line, "simple"))
893
+ else:
894
+ # Parameterized decorator: @foo(args) - this is TWO calls
895
+ # 1. foo(args) -> returns decorator
896
+ # 2. decorator(func) -> returns wrapped func
897
+ calls.append((dec_name, line, "factory"))
898
+ # The factory call result is also a call
899
+ calls.append((f"{dec_name}.<return>", line, "application"))
900
+
901
+ return calls
902
+
903
+
904
+ # =============================================================================
905
+ # Lambda and Closure Tracker
906
+ # =============================================================================
907
+
908
+
909
+ class LambdaClosureTracker:
910
+ """
911
+ Tracks lambda expressions and closures for call graph construction.
912
+
913
+ Key insight: lambdas and nested functions are first-class values that
914
+ can be assigned, passed, and returned. We need to track where they go.
915
+ """
916
+
917
+ def __init__(self):
918
+ """Initialize the lambda and closure tracker."""
919
+ # Lambda definitions: lambda_id -> CallGraphNode
920
+ self._lambdas: dict[str, CallGraphNode] = {}
921
+
922
+ # Closure definitions: closure_qname -> CallGraphNode
923
+ self._closures: dict[str, CallGraphNode] = {}
924
+
925
+ # Where lambdas/closures are assigned
926
+ self._assignments: dict[str, list[str]] = {} # callable -> [variables]
927
+
928
+ # Where lambdas/closures are returned
929
+ self._returns: dict[str, str] = {} # function_qname -> returned_callable
930
+
931
+ def add_lambda(
932
+ self,
933
+ lambda_id: str,
934
+ file_path: Path,
935
+ line: int,
936
+ enclosing_function: str | None,
937
+ ) -> CallGraphNode:
938
+ """Track a lambda expression."""
939
+ node = CallGraphNode(
940
+ qualified_name=lambda_id,
941
+ name="<lambda>",
942
+ node_type=NodeType.LAMBDA,
943
+ file_path=file_path,
944
+ line=line,
945
+ )
946
+ self._lambdas[lambda_id] = node
947
+ return node
948
+
949
+ def add_closure(
950
+ self,
951
+ closure_qname: str,
952
+ file_path: Path,
953
+ line: int,
954
+ enclosing_function: str,
955
+ ) -> CallGraphNode:
956
+ """Track a nested function (closure)."""
957
+ name = closure_qname.split(".")[-1]
958
+ node = CallGraphNode(
959
+ qualified_name=closure_qname,
960
+ name=name,
961
+ node_type=NodeType.CLOSURE,
962
+ file_path=file_path,
963
+ line=line,
964
+ )
965
+ self._closures[closure_qname] = node
966
+ return node
967
+
968
+ def add_assignment(
969
+ self,
970
+ callable_qname: str,
971
+ variable: str,
972
+ ) -> None:
973
+ """Track assignment of callable to variable."""
974
+ if callable_qname not in self._assignments:
975
+ self._assignments[callable_qname] = []
976
+ self._assignments[callable_qname].append(variable)
977
+
978
+ def add_return(
979
+ self,
980
+ function_qname: str,
981
+ returned_callable: str,
982
+ ) -> None:
983
+ """Track return of callable from function."""
984
+ self._returns[function_qname] = returned_callable
985
+
986
+ def get_returned_callable(self, function_qname: str) -> str | None:
987
+ """Get the callable returned by a function."""
988
+ return self._returns.get(function_qname)