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