adl-toolkit 0.0.0-stage → 0.1.1
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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
"""Python call-resolution strategy (P4.4, PROBLEMS.md).
|
|
2
|
+
|
|
3
|
+
Behavior-preserving extraction: everything here moved out of
|
|
4
|
+
`graph/builder.py` verbatim (only `self.` -> `self.builder.` for the
|
|
5
|
+
graph-mechanics/id-resolution helpers that stayed shared, plus the
|
|
6
|
+
hardcoded `("self", "cls")` / `b"super"` receiver checks becoming
|
|
7
|
+
`self.config.receiver_self_names` / `self.config.receiver_super_names` reads
|
|
8
|
+
so the policy really is config-driven, not just relocated). Python's
|
|
9
|
+
resolution results must not change — see `tests/test_graph.py` and friends,
|
|
10
|
+
re-run unchanged after this move.
|
|
11
|
+
|
|
12
|
+
Owns:
|
|
13
|
+
- `PYTHON_BUILTINS` / `DECORATOR_STUB_TABLE` — the P2.7 deny-list and P2.15
|
|
14
|
+
decorator-stub table, now packaged into a `ResolverConfig` via
|
|
15
|
+
`default_python_resolver_config()` instead of living as bare module
|
|
16
|
+
globals `graph/builder.py` reached into directly.
|
|
17
|
+
- `PythonCallResolver` — the full second-pass CST walk: `resolve_calls()`
|
|
18
|
+
(self/cls/super, same-module, import-map, chained/typed receivers, P1.13
|
|
19
|
+
local/instance-attribute type inference, P1.14 return-type chains, P2.14
|
|
20
|
+
`__getattr__` fallback), `resolve_decorator_types()` (P2.15 stub/own-
|
|
21
|
+
inference), and `harvest_literal_candidates()` (P2.13/P2.22/P2.23 literal
|
|
22
|
+
dict-router / callable-alias idioms).
|
|
23
|
+
|
|
24
|
+
Grammar-agnostic graph-id resolution (`_resolve_name`, `_contains_child`,
|
|
25
|
+
`_find_method_on_ancestors`, `_resolve_annotation`, `_resolve_chain_root`,
|
|
26
|
+
`_lookup_qualified`, `_node_for_dotted`, edge dedupe) stayed on
|
|
27
|
+
`CallGraphBuilder` — reached here via `self.builder` — since none of it is
|
|
28
|
+
Python-CST-coupled; `lang/java/resolver.py` reuses the exact same helpers.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import builtins
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple
|
|
37
|
+
|
|
38
|
+
from adl_drift.graph.edges import EdgeType, GraphEdge
|
|
39
|
+
from adl_drift.graph.node import NodeType
|
|
40
|
+
from adl_drift.lang.base import CallResolver, ParseResult, ResolverConfig
|
|
41
|
+
from adl_drift.utils.types import LOCALS_MARKER, AnalysisResult
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from adl_drift.graph.builder import CallGraphBuilder
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"PYTHON_BUILTINS",
|
|
48
|
+
"DECORATOR_STUB_TABLE",
|
|
49
|
+
"PythonCallResolver",
|
|
50
|
+
"default_python_resolver_config",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# P2.7: names that must never be resolved via the fuzzy / over-approximation
|
|
54
|
+
# strategies. Structural strategies (same-module def, import map, self/cls)
|
|
55
|
+
# still run first, so a genuine local ``def get(...)`` resolves normally —
|
|
56
|
+
# this set only blocks the "link x.get() to every function named get" path.
|
|
57
|
+
_COMMON_METHOD_NAMES = {
|
|
58
|
+
# dict / set
|
|
59
|
+
"get", "items", "keys", "values", "update", "pop", "popitem",
|
|
60
|
+
"setdefault", "clear", "copy", "fromkeys", "add", "discard",
|
|
61
|
+
"union", "intersection", "difference", "issubset", "issuperset",
|
|
62
|
+
# list
|
|
63
|
+
"append", "extend", "insert", "remove", "sort", "reverse",
|
|
64
|
+
"index", "count",
|
|
65
|
+
# str / bytes
|
|
66
|
+
"join", "split", "rsplit", "strip", "lstrip", "rstrip", "replace",
|
|
67
|
+
"startswith", "endswith", "format", "format_map", "lower", "upper",
|
|
68
|
+
"title", "capitalize", "casefold", "encode", "decode", "find", "rfind",
|
|
69
|
+
"ljust", "rjust", "zfill", "splitlines", "partition", "rpartition",
|
|
70
|
+
# file-like
|
|
71
|
+
"read", "write", "readline", "readlines", "close", "flush", "seek",
|
|
72
|
+
}
|
|
73
|
+
PYTHON_BUILTINS: Set[str] = set(dir(builtins)) | _COMMON_METHOD_NAMES
|
|
74
|
+
|
|
75
|
+
# P2.15: framework stub table. Maps decorator simple name → set of attribute
|
|
76
|
+
# names that the framework wrapper adds to the decorated callable.
|
|
77
|
+
# ``send_email.delay()`` where ``send_email`` is ``@task``-decorated resolves
|
|
78
|
+
# to the decorated function itself (conf 0.8, via=decorator_stub).
|
|
79
|
+
DECORATOR_STUB_TABLE: Dict[str, Set[str]] = {
|
|
80
|
+
# Celery / task queues
|
|
81
|
+
"task": {
|
|
82
|
+
"delay", "apply_async", "apply", "signature", "s", "si",
|
|
83
|
+
"chunks", "map", "starmap", "retry", "run", "AsyncResult", "subtask",
|
|
84
|
+
},
|
|
85
|
+
"shared_task": {
|
|
86
|
+
"delay", "apply_async", "apply", "signature", "s", "si",
|
|
87
|
+
"chunks", "map", "starmap", "retry", "run", "AsyncResult", "subtask",
|
|
88
|
+
},
|
|
89
|
+
# functools caching
|
|
90
|
+
"lru_cache": {"cache_clear", "cache_info"},
|
|
91
|
+
"cache": {"cache_clear", "cache_info"},
|
|
92
|
+
# Click
|
|
93
|
+
"command": {"callback", "invoke", "make_context", "main", "params", "name"},
|
|
94
|
+
"group": {
|
|
95
|
+
"callback", "invoke", "make_context", "main", "params", "name",
|
|
96
|
+
"add_command", "result_callback",
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def default_python_resolver_config() -> ResolverConfig:
|
|
102
|
+
"""The real Python `ResolverConfig` — used both by `PythonBackend.
|
|
103
|
+
resolver_config()` and as `CallGraphBuilder`'s zero-arg fallback (every
|
|
104
|
+
pre-P4.4 call site constructs `CallGraphBuilder()` with no config)."""
|
|
105
|
+
cfg = ResolverConfig(
|
|
106
|
+
receiver_self_names=("self", "cls"),
|
|
107
|
+
receiver_super_names=("super",),
|
|
108
|
+
builtins_deny_list=frozenset(PYTHON_BUILTINS),
|
|
109
|
+
annotation_stub_table={k: frozenset(v) for k, v in DECORATOR_STUB_TABLE.items()},
|
|
110
|
+
)
|
|
111
|
+
cfg.resolver_factory = lambda builder, config: PythonCallResolver(builder, config)
|
|
112
|
+
return cfg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PythonCallResolver(CallResolver):
|
|
116
|
+
"""Python's `CallResolver` (P4.4) — the exact pre-P4.4
|
|
117
|
+
`CallGraphBuilder.resolve_calls()`/`_resolve_target()`/
|
|
118
|
+
`resolve_decorator_types()`/`harvest_literal_candidates()` behavior,
|
|
119
|
+
relocated."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, builder: "CallGraphBuilder", config: ResolverConfig) -> None:
|
|
122
|
+
super().__init__(builder, config)
|
|
123
|
+
# P1.13: {class_id: {attr: target_class_id}} from `self.x = Cls(...)`
|
|
124
|
+
# anywhere in the class's methods. Populated by resolve_calls(),
|
|
125
|
+
# reused by harvest_literal_candidates()'s P2.22 typed-attribute hop.
|
|
126
|
+
self._self_attr_types: Dict[str, Dict[str, str]] = {}
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ main pass
|
|
129
|
+
|
|
130
|
+
def resolve_calls(
|
|
131
|
+
self,
|
|
132
|
+
parses: Dict[Path, ParseResult],
|
|
133
|
+
results: Dict[Path, AnalysisResult],
|
|
134
|
+
) -> int:
|
|
135
|
+
"""Second pass: emit CALLS edges by walking each function body.
|
|
136
|
+
|
|
137
|
+
Resolution order for each call site:
|
|
138
|
+
1. self.X / cls.X → method X on enclosing class
|
|
139
|
+
2. bare identifier X → function/class X in same module
|
|
140
|
+
3. module.X via import → resolved import alias → module → symbol X
|
|
141
|
+
4. obj.X (unknown obj) → over-approximate: every node ending in .X
|
|
142
|
+
(confidence flag set on edge)
|
|
143
|
+
"""
|
|
144
|
+
# Indices
|
|
145
|
+
by_simple_name: Dict[str, List[str]] = defaultdict(list)
|
|
146
|
+
by_module_symbol: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
147
|
+
var_by_module: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
148
|
+
# P2.14: class IDs whose instances have an open attribute universe
|
|
149
|
+
# (they define __getattr__). Attribute reads on such classes with no
|
|
150
|
+
# structural match route to Class.__getattr__ instead of vanishing.
|
|
151
|
+
_getattr_class_ids: Set[str] = set()
|
|
152
|
+
for nid, node in self.builder._nodes.items():
|
|
153
|
+
if node.language != "python":
|
|
154
|
+
continue
|
|
155
|
+
if node.node_type in (NodeType.FUNCTION, NodeType.CLASS):
|
|
156
|
+
by_simple_name[node.name].append(nid)
|
|
157
|
+
by_module_symbol[node.module][node.name] = nid
|
|
158
|
+
# P2.14: record classes that define __getattr__
|
|
159
|
+
if (node.node_type == NodeType.FUNCTION
|
|
160
|
+
and node.name == "__getattr__"
|
|
161
|
+
and node.contained_in):
|
|
162
|
+
class_id = f"{node.module}:{node.contained_in}"
|
|
163
|
+
if class_id in self.builder._nodes:
|
|
164
|
+
_getattr_class_ids.add(class_id)
|
|
165
|
+
elif node.node_type == NodeType.VARIABLE and not node.contained_in:
|
|
166
|
+
# module-level variables/constants (class attributes are
|
|
167
|
+
# reached via the self.X path instead)
|
|
168
|
+
var_by_module[node.module][node.name] = nid
|
|
169
|
+
|
|
170
|
+
# P2.2: param annotation index — caller node id → {param: annotation}.
|
|
171
|
+
# Lets the resolver infer receiver types for obj.method() calls.
|
|
172
|
+
param_types_by_id: Dict[str, Dict[str, str]] = {}
|
|
173
|
+
for ar in results.values():
|
|
174
|
+
mod = self.builder._module_for_result(ar)
|
|
175
|
+
for sym in ar.symbols:
|
|
176
|
+
if sym.kind == "function" and sym.param_types:
|
|
177
|
+
param_types_by_id[self.builder._symbol_to_id(sym, mod)] = sym.param_types
|
|
178
|
+
|
|
179
|
+
# P1.13: instance-attribute type inference — `self.X = ClassName(...)`
|
|
180
|
+
# anywhere in a class's methods → {class_id: {attr: target_class_id}}.
|
|
181
|
+
# Built in a pre-pass so a method using `self.db.query()` resolves even
|
|
182
|
+
# when `__init__` (where `self.db = Session()` lives) is visited later.
|
|
183
|
+
self_attr_types: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
184
|
+
for file_path, parse in parses.items():
|
|
185
|
+
result = results.get(file_path)
|
|
186
|
+
if result is None:
|
|
187
|
+
continue
|
|
188
|
+
module_name = self.builder._module_for_result(result)
|
|
189
|
+
import_map = self.builder._import_map_for(result, module_name)
|
|
190
|
+
for func_cst, caller_id in self._iter_func_cst(
|
|
191
|
+
parse.tree.root_node, parse.source_bytes, module_name
|
|
192
|
+
):
|
|
193
|
+
node = self.builder._nodes.get(caller_id)
|
|
194
|
+
if node is None or not node.contained_in:
|
|
195
|
+
continue
|
|
196
|
+
class_id = f"{module_name}:{node.contained_in}"
|
|
197
|
+
if class_id not in self.builder._nodes:
|
|
198
|
+
continue
|
|
199
|
+
body = func_cst.child_by_field_name("body")
|
|
200
|
+
if body is None:
|
|
201
|
+
continue
|
|
202
|
+
for kind, name, cls_id in self._iter_typed_assignments(
|
|
203
|
+
body, parse.source_bytes, module_name,
|
|
204
|
+
import_map, by_module_symbol,
|
|
205
|
+
):
|
|
206
|
+
if kind == "self":
|
|
207
|
+
self_attr_types[class_id].setdefault(name, cls_id)
|
|
208
|
+
|
|
209
|
+
# P2.22: stash for the literal-harvest pass (runs after resolve_calls),
|
|
210
|
+
# so its callable-dict dispatch can do the same typed-attribute hop.
|
|
211
|
+
self._self_attr_types = self_attr_types
|
|
212
|
+
|
|
213
|
+
edges_added = 0
|
|
214
|
+
for file_path, parse in parses.items():
|
|
215
|
+
result = results.get(file_path)
|
|
216
|
+
if result is None:
|
|
217
|
+
continue
|
|
218
|
+
module_name = self.builder._module_for_result(result)
|
|
219
|
+
import_map = self.builder._import_map_for(result, module_name)
|
|
220
|
+
|
|
221
|
+
# Find every function_definition CST node and its caller node_id
|
|
222
|
+
for func_cst, caller_id in self._iter_func_cst(
|
|
223
|
+
parse.tree.root_node, parse.source_bytes, module_name
|
|
224
|
+
):
|
|
225
|
+
if caller_id not in self.builder._nodes:
|
|
226
|
+
continue
|
|
227
|
+
caller_node = self.builder._nodes[caller_id]
|
|
228
|
+
enclosing_class = caller_node.contained_in
|
|
229
|
+
body = func_cst.child_by_field_name("body")
|
|
230
|
+
if body is None:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# P1.13: local-variable type inference for this function body —
|
|
234
|
+
# `x = ClassName(...)` → {x: class_id}, so `x.method()` resolves.
|
|
235
|
+
local_var_types: Dict[str, str] = {}
|
|
236
|
+
for kind, name, cls_id in self._iter_typed_assignments(
|
|
237
|
+
body, parse.source_bytes, module_name,
|
|
238
|
+
import_map, by_module_symbol,
|
|
239
|
+
):
|
|
240
|
+
if kind == "local":
|
|
241
|
+
local_var_types[name] = cls_id
|
|
242
|
+
|
|
243
|
+
for call_node in self._collect_calls(body):
|
|
244
|
+
target_id, confidence, dyn_etype = self._resolve_target(
|
|
245
|
+
call_node,
|
|
246
|
+
parse.source_bytes,
|
|
247
|
+
module_name,
|
|
248
|
+
enclosing_class,
|
|
249
|
+
import_map,
|
|
250
|
+
by_simple_name,
|
|
251
|
+
by_module_symbol,
|
|
252
|
+
param_types_by_id.get(caller_id, {}),
|
|
253
|
+
_getattr_class_ids,
|
|
254
|
+
local_var_types,
|
|
255
|
+
self_attr_types,
|
|
256
|
+
)
|
|
257
|
+
if not target_id or target_id == caller_id:
|
|
258
|
+
continue
|
|
259
|
+
is_awaited = (
|
|
260
|
+
dyn_etype is None
|
|
261
|
+
and call_node.parent is not None
|
|
262
|
+
and call_node.parent.type == "await"
|
|
263
|
+
)
|
|
264
|
+
for tid in target_id if isinstance(target_id, list) else [target_id]:
|
|
265
|
+
if tid not in self.builder._nodes:
|
|
266
|
+
continue
|
|
267
|
+
if dyn_etype is not None:
|
|
268
|
+
# P2.14: DYNAMIC_ATTR — use override edge type directly
|
|
269
|
+
etype = dyn_etype
|
|
270
|
+
else:
|
|
271
|
+
# P2.8: Foo() on a class node is construction, not a
|
|
272
|
+
# plain call — constructor-signature changes must be
|
|
273
|
+
# distinguishable in impact reports.
|
|
274
|
+
etype = (
|
|
275
|
+
EdgeType.INSTANTIATES
|
|
276
|
+
if self.builder._nodes[tid].node_type == NodeType.CLASS
|
|
277
|
+
else EdgeType.CALLS
|
|
278
|
+
)
|
|
279
|
+
edge = GraphEdge(
|
|
280
|
+
source=caller_id,
|
|
281
|
+
target=tid,
|
|
282
|
+
edge_type=etype,
|
|
283
|
+
file_path=str(file_path),
|
|
284
|
+
line=call_node.start_point[0] + 1,
|
|
285
|
+
column=call_node.start_point[1],
|
|
286
|
+
start_byte=call_node.start_byte,
|
|
287
|
+
end_byte=call_node.end_byte,
|
|
288
|
+
confidence=float(confidence),
|
|
289
|
+
strategy="python_resolver",
|
|
290
|
+
)
|
|
291
|
+
self.builder._record_edge_occurrence(edge)
|
|
292
|
+
if (caller_id, tid, etype) not in self.builder._edge_keys:
|
|
293
|
+
self.builder._add_edge(edge)
|
|
294
|
+
caller_node.calls.add(tid)
|
|
295
|
+
self.builder._nodes[tid].called_by.add(caller_id)
|
|
296
|
+
edges_added += 1
|
|
297
|
+
if is_awaited:
|
|
298
|
+
await_edge = GraphEdge(
|
|
299
|
+
source=caller_id,
|
|
300
|
+
target=tid,
|
|
301
|
+
edge_type=EdgeType.AWAITS,
|
|
302
|
+
file_path=str(file_path),
|
|
303
|
+
line=call_node.start_point[0] + 1,
|
|
304
|
+
column=call_node.start_point[1],
|
|
305
|
+
start_byte=call_node.start_byte,
|
|
306
|
+
end_byte=call_node.end_byte,
|
|
307
|
+
confidence=float(confidence),
|
|
308
|
+
strategy="python_resolver",
|
|
309
|
+
)
|
|
310
|
+
self.builder._record_edge_occurrence(await_edge)
|
|
311
|
+
if not self.builder._has_edge_of_type(
|
|
312
|
+
caller_id, tid, EdgeType.AWAITS
|
|
313
|
+
):
|
|
314
|
+
self.builder._add_edge(await_edge)
|
|
315
|
+
|
|
316
|
+
# P1.8: REFERENCES edges — variable/constant reads in the body
|
|
317
|
+
name_reads, attr_reads = self._collect_variable_reads(
|
|
318
|
+
body, parse.source_bytes
|
|
319
|
+
)
|
|
320
|
+
for name, line in name_reads:
|
|
321
|
+
if self.builder._is_denied(name):
|
|
322
|
+
continue
|
|
323
|
+
var_id = var_by_module.get(module_name, {}).get(name)
|
|
324
|
+
conf = 0.9
|
|
325
|
+
if var_id is None and name in import_map:
|
|
326
|
+
var_id = self._lookup_variable(import_map[name], var_by_module)
|
|
327
|
+
conf = 0.85
|
|
328
|
+
if not var_id or var_id == caller_id:
|
|
329
|
+
continue
|
|
330
|
+
self.builder._add_edge(GraphEdge(
|
|
331
|
+
source=caller_id,
|
|
332
|
+
target=var_id,
|
|
333
|
+
edge_type=EdgeType.REFERENCES,
|
|
334
|
+
file_path=str(file_path),
|
|
335
|
+
line=line,
|
|
336
|
+
confidence=conf,
|
|
337
|
+
))
|
|
338
|
+
if enclosing_class:
|
|
339
|
+
for attr, line in attr_reads:
|
|
340
|
+
var_id = f"{module_name}:{enclosing_class}.{attr}"
|
|
341
|
+
var_node = self.builder._nodes.get(var_id)
|
|
342
|
+
if var_node is None or var_node.node_type != NodeType.VARIABLE:
|
|
343
|
+
continue
|
|
344
|
+
self.builder._add_edge(GraphEdge(
|
|
345
|
+
source=caller_id,
|
|
346
|
+
target=var_id,
|
|
347
|
+
edge_type=EdgeType.REFERENCES,
|
|
348
|
+
file_path=str(file_path),
|
|
349
|
+
line=line,
|
|
350
|
+
confidence=0.95,
|
|
351
|
+
))
|
|
352
|
+
return edges_added
|
|
353
|
+
|
|
354
|
+
def _collect_variable_reads(self, body, source: bytes):
|
|
355
|
+
"""P1.8: collect identifier reads and ``self.X`` attribute reads.
|
|
356
|
+
|
|
357
|
+
Returns (name_reads, attr_reads), each a list of (name, line).
|
|
358
|
+
Definition sites are filtered out: assignment LHS targets, for-loop
|
|
359
|
+
targets, keyword-argument names, and the attribute position of dotted
|
|
360
|
+
access (only the leftmost identifier is a name lookup).
|
|
361
|
+
"""
|
|
362
|
+
name_reads: List[Tuple[str, int]] = []
|
|
363
|
+
attr_reads: List[Tuple[str, int]] = []
|
|
364
|
+
self_names = self.config.receiver_self_names
|
|
365
|
+
|
|
366
|
+
def _is_assignment_lhs(node) -> bool:
|
|
367
|
+
p = node.parent
|
|
368
|
+
if p is None:
|
|
369
|
+
return False
|
|
370
|
+
if p.type == "assignment" and node == p.child_by_field_name("left"):
|
|
371
|
+
return True
|
|
372
|
+
# tuple/list unpack target: x, y = ...
|
|
373
|
+
if p.type in ("pattern_list", "tuple_pattern", "list_pattern"):
|
|
374
|
+
gp = p.parent
|
|
375
|
+
return gp is not None and gp.type == "assignment" \
|
|
376
|
+
and p == gp.child_by_field_name("left")
|
|
377
|
+
return False
|
|
378
|
+
|
|
379
|
+
stack = [body]
|
|
380
|
+
while stack:
|
|
381
|
+
node = stack.pop()
|
|
382
|
+
if node.type == "function_definition" and node is not body:
|
|
383
|
+
continue # inner function's reads belong to the inner function
|
|
384
|
+
if node.type == "identifier":
|
|
385
|
+
p = node.parent
|
|
386
|
+
text = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
387
|
+
if text in self_names:
|
|
388
|
+
continue
|
|
389
|
+
if _is_assignment_lhs(node):
|
|
390
|
+
continue
|
|
391
|
+
if p is not None:
|
|
392
|
+
if p.type == "keyword_argument" and node == p.child_by_field_name("name"):
|
|
393
|
+
continue
|
|
394
|
+
if p.type == "attribute" and node == p.child_by_field_name("attribute"):
|
|
395
|
+
continue
|
|
396
|
+
if p.type == "for_statement" and node == p.child_by_field_name("left"):
|
|
397
|
+
continue
|
|
398
|
+
name_reads.append((text, node.start_point[0] + 1))
|
|
399
|
+
continue
|
|
400
|
+
if node.type == "attribute":
|
|
401
|
+
obj = node.child_by_field_name("object")
|
|
402
|
+
attr = node.child_by_field_name("attribute")
|
|
403
|
+
if obj is not None and attr is not None and obj.type == "identifier":
|
|
404
|
+
obj_text = source[obj.start_byte:obj.end_byte].decode("utf-8", errors="replace")
|
|
405
|
+
if obj_text in self_names and not _is_assignment_lhs(node):
|
|
406
|
+
attr_reads.append((
|
|
407
|
+
source[attr.start_byte:attr.end_byte].decode("utf-8", errors="replace"),
|
|
408
|
+
node.start_point[0] + 1,
|
|
409
|
+
))
|
|
410
|
+
stack.extend(node.children)
|
|
411
|
+
return name_reads, attr_reads
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _lookup_variable(
|
|
415
|
+
qualified: str, var_by_module: Dict[str, Dict[str, str]]
|
|
416
|
+
) -> Optional[str]:
|
|
417
|
+
"""Resolve 'pkg.mod.CONST' to a VARIABLE node id (suffix-matched,
|
|
418
|
+
same convention as _lookup_qualified)."""
|
|
419
|
+
parts = qualified.split(".")
|
|
420
|
+
if len(parts) < 2:
|
|
421
|
+
return None
|
|
422
|
+
sym = parts[-1]
|
|
423
|
+
target_mod = parts[:-1]
|
|
424
|
+
for mod, var_names in var_by_module.items():
|
|
425
|
+
if sym not in var_names:
|
|
426
|
+
continue
|
|
427
|
+
mod_parts = mod.split(".")
|
|
428
|
+
if mod_parts[-len(target_mod):] == target_mod \
|
|
429
|
+
or target_mod[-len(mod_parts):] == mod_parts:
|
|
430
|
+
return var_names[sym]
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------ P2.15
|
|
434
|
+
|
|
435
|
+
def resolve_decorator_types(
|
|
436
|
+
self,
|
|
437
|
+
parses: Dict[Path, ParseResult],
|
|
438
|
+
results: Dict[Path, AnalysisResult],
|
|
439
|
+
) -> None:
|
|
440
|
+
"""P2.15: build decorator-rebind and stub maps for _resolve_target.
|
|
441
|
+
|
|
442
|
+
For each decorated function, records either a set of stub attribute
|
|
443
|
+
names (from `self.config.annotation_stub_table`) or a rebound class
|
|
444
|
+
node id (from narrow own-inference on local decorator bodies).
|
|
445
|
+
Populated once after resolve_relationships() so that resolve_calls()
|
|
446
|
+
can consult it.
|
|
447
|
+
"""
|
|
448
|
+
self.builder._decorator_rebirds.clear()
|
|
449
|
+
|
|
450
|
+
by_module_symbol: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
451
|
+
by_simple_name: Dict[str, List[str]] = defaultdict(list)
|
|
452
|
+
for nid, node in self.builder._nodes.items():
|
|
453
|
+
if node.language != "python":
|
|
454
|
+
continue
|
|
455
|
+
if node.node_type in (NodeType.FUNCTION, NodeType.CLASS):
|
|
456
|
+
by_simple_name[node.name].append(nid)
|
|
457
|
+
by_module_symbol[node.module][node.name] = nid
|
|
458
|
+
|
|
459
|
+
import_maps: Dict[str, Dict[str, str]] = {}
|
|
460
|
+
for ar in results.values():
|
|
461
|
+
mod = self.builder._module_for_result(ar)
|
|
462
|
+
import_maps[str(ar.file_path)] = self.builder._import_map_for(ar, mod)
|
|
463
|
+
|
|
464
|
+
for sym_id, (decorators, module_name, file_path, _ln) in \
|
|
465
|
+
self.builder._pending_decorators.items():
|
|
466
|
+
if sym_id not in self.builder._nodes:
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
stub_attrs: Set[str] = set()
|
|
470
|
+
rebound: Optional[str] = None
|
|
471
|
+
|
|
472
|
+
for deco_text in decorators:
|
|
473
|
+
dname = self.builder._decorator_name(deco_text)
|
|
474
|
+
if not dname:
|
|
475
|
+
continue
|
|
476
|
+
simple = dname.split(".")[-1]
|
|
477
|
+
|
|
478
|
+
# Stub table lookup — simple decorator name only
|
|
479
|
+
if simple in self.config.annotation_stub_table:
|
|
480
|
+
stub_attrs.update(self.config.annotation_stub_table[simple])
|
|
481
|
+
|
|
482
|
+
# Own inference: resolve decorator to a function node and
|
|
483
|
+
# check for a single `return Cls(fn)` body.
|
|
484
|
+
if rebound is None:
|
|
485
|
+
imap = import_maps.get(file_path, {})
|
|
486
|
+
deco_fn_id = self.builder._resolve_name(
|
|
487
|
+
dname, module_name, imap,
|
|
488
|
+
by_simple_name, by_module_symbol,
|
|
489
|
+
strict=True,
|
|
490
|
+
)
|
|
491
|
+
if (
|
|
492
|
+
deco_fn_id
|
|
493
|
+
and deco_fn_id in self.builder._nodes
|
|
494
|
+
and self.builder._nodes[deco_fn_id].node_type == NodeType.FUNCTION
|
|
495
|
+
):
|
|
496
|
+
cls_id = self._infer_decorator_return_class(
|
|
497
|
+
deco_fn_id, parses, results,
|
|
498
|
+
by_module_symbol, import_maps,
|
|
499
|
+
)
|
|
500
|
+
if cls_id:
|
|
501
|
+
rebound = cls_id
|
|
502
|
+
|
|
503
|
+
if stub_attrs or rebound:
|
|
504
|
+
self.builder._decorator_rebirds[sym_id] = (
|
|
505
|
+
stub_attrs if stub_attrs else None,
|
|
506
|
+
rebound,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def _infer_decorator_return_class(
|
|
510
|
+
self,
|
|
511
|
+
deco_fn_id: str,
|
|
512
|
+
parses: Dict[Path, ParseResult],
|
|
513
|
+
results: Dict[Path, AnalysisResult],
|
|
514
|
+
by_module_symbol: Dict[str, Dict[str, str]],
|
|
515
|
+
import_maps: Dict[str, Dict[str, str]],
|
|
516
|
+
) -> Optional[str]:
|
|
517
|
+
"""P2.15: return CLASS node id iff decorator body has a single
|
|
518
|
+
``return Cls(...)`` where Cls resolves to a known class.
|
|
519
|
+
|
|
520
|
+
Narrow own inference — multiple returns or non-class returns yield
|
|
521
|
+
None so we never mis-bind a complex decorator.
|
|
522
|
+
"""
|
|
523
|
+
deco_node = self.builder._nodes.get(deco_fn_id)
|
|
524
|
+
if deco_node is None or not deco_node.file_path:
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
parse = None
|
|
528
|
+
fp_key: Optional[Path] = None
|
|
529
|
+
for fp, pr in parses.items():
|
|
530
|
+
if str(fp) == deco_node.file_path:
|
|
531
|
+
parse = pr
|
|
532
|
+
fp_key = fp
|
|
533
|
+
break
|
|
534
|
+
if parse is None:
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
result = results.get(fp_key)
|
|
538
|
+
module_name = (
|
|
539
|
+
self.builder._module_for_result(result)
|
|
540
|
+
if result is not None
|
|
541
|
+
else self.builder._path_to_module(fp_key)
|
|
542
|
+
)
|
|
543
|
+
imap = import_maps.get(deco_node.file_path, {})
|
|
544
|
+
|
|
545
|
+
# Locate the function_definition CST node by line (0-indexed)
|
|
546
|
+
func_cst = self._find_func_cst_at_line(
|
|
547
|
+
parse.tree.root_node, deco_node.line - 1
|
|
548
|
+
)
|
|
549
|
+
if func_cst is None:
|
|
550
|
+
return None
|
|
551
|
+
|
|
552
|
+
body = func_cst.child_by_field_name("body")
|
|
553
|
+
if body is None:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
# Collect top-level return_statement nodes (skip nested functions)
|
|
557
|
+
returns = []
|
|
558
|
+
stack = list(body.children)
|
|
559
|
+
while stack:
|
|
560
|
+
node = stack.pop()
|
|
561
|
+
if node.type == "function_definition":
|
|
562
|
+
continue
|
|
563
|
+
if node.type == "return_statement":
|
|
564
|
+
returns.append(node)
|
|
565
|
+
continue
|
|
566
|
+
stack.extend(node.children)
|
|
567
|
+
|
|
568
|
+
if len(returns) != 1:
|
|
569
|
+
return None
|
|
570
|
+
|
|
571
|
+
# The return value must be a call: `return Cls(fn)` or `return Cls()`
|
|
572
|
+
named = returns[0].named_children
|
|
573
|
+
if not named:
|
|
574
|
+
return None
|
|
575
|
+
value = named[0]
|
|
576
|
+
if value.type != "call":
|
|
577
|
+
return None
|
|
578
|
+
fn_part = value.child_by_field_name("function")
|
|
579
|
+
if fn_part is None or fn_part.type != "identifier":
|
|
580
|
+
return None
|
|
581
|
+
|
|
582
|
+
cls_name = parse.source_bytes[
|
|
583
|
+
fn_part.start_byte:fn_part.end_byte
|
|
584
|
+
].decode("utf-8", errors="replace")
|
|
585
|
+
|
|
586
|
+
# Resolve cls_name → CLASS node (same-module first, then imports)
|
|
587
|
+
cls_id = by_module_symbol.get(module_name, {}).get(cls_name)
|
|
588
|
+
if cls_id is None and cls_name in imap:
|
|
589
|
+
resolved = imap[cls_name]
|
|
590
|
+
parts = resolved.split(".")
|
|
591
|
+
sym_name = parts[-1]
|
|
592
|
+
for mod, syms in by_module_symbol.items():
|
|
593
|
+
if sym_name in syms:
|
|
594
|
+
cand = syms[sym_name]
|
|
595
|
+
if (
|
|
596
|
+
cand in self.builder._nodes
|
|
597
|
+
and self.builder._nodes[cand].node_type == NodeType.CLASS
|
|
598
|
+
):
|
|
599
|
+
cls_id = cand
|
|
600
|
+
break
|
|
601
|
+
|
|
602
|
+
if (
|
|
603
|
+
cls_id
|
|
604
|
+
and cls_id in self.builder._nodes
|
|
605
|
+
and self.builder._nodes[cls_id].node_type == NodeType.CLASS
|
|
606
|
+
):
|
|
607
|
+
return cls_id
|
|
608
|
+
return None
|
|
609
|
+
|
|
610
|
+
@staticmethod
|
|
611
|
+
def _find_func_cst_at_line(root, target_line: int):
|
|
612
|
+
"""Find a function_definition CST node starting at target_line (0-indexed)."""
|
|
613
|
+
stack = [root]
|
|
614
|
+
while stack:
|
|
615
|
+
node = stack.pop()
|
|
616
|
+
if (
|
|
617
|
+
node.type == "function_definition"
|
|
618
|
+
and node.start_point[0] == target_line
|
|
619
|
+
):
|
|
620
|
+
return node
|
|
621
|
+
stack.extend(node.children)
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
# ------------------------------------------------------------------ target resolution
|
|
625
|
+
|
|
626
|
+
def _resolve_target(
|
|
627
|
+
self,
|
|
628
|
+
call_node,
|
|
629
|
+
source: bytes,
|
|
630
|
+
module_name: str,
|
|
631
|
+
enclosing_class: Optional[str],
|
|
632
|
+
import_map: Dict[str, str],
|
|
633
|
+
by_simple_name: Dict[str, List[str]],
|
|
634
|
+
by_module_symbol: Dict[str, Dict[str, str]],
|
|
635
|
+
param_types: Optional[Dict[str, str]] = None,
|
|
636
|
+
getattr_class_ids: Optional[Set[str]] = None,
|
|
637
|
+
local_var_types: Optional[Dict[str, str]] = None,
|
|
638
|
+
self_attr_types: Optional[Dict[str, Dict[str, str]]] = None,
|
|
639
|
+
):
|
|
640
|
+
"""Resolve a `call` node's callable to (target_id_or_list, confidence, edge_type_override).
|
|
641
|
+
|
|
642
|
+
`param_types` is the enclosing function's {param: annotation} map —
|
|
643
|
+
used by the P2.2 conformance strategy to type annotated receivers.
|
|
644
|
+
|
|
645
|
+
`local_var_types` (P1.13) is the enclosing function's {var: class_id}
|
|
646
|
+
map from `x = ClassName(...)` assignments; `self_attr_types` (P1.13) is
|
|
647
|
+
the global {class_id: {attr: class_id}} map from `self.x = ClassName(...)`.
|
|
648
|
+
Both let the resolver type otherwise-unannotated receivers, emitting a
|
|
649
|
+
real edge to the correct method instead of dropping or over-approximating.
|
|
650
|
+
|
|
651
|
+
`edge_type_override` is None for normal CALLS/INSTANTIATES/AWAITS
|
|
652
|
+
resolution; EdgeType.DYNAMIC_ATTR when the target is a __getattr__
|
|
653
|
+
method reached via the P2.14 open-attribute-universe fallback.
|
|
654
|
+
"""
|
|
655
|
+
param_types = param_types or {}
|
|
656
|
+
getattr_class_ids = getattr_class_ids or set()
|
|
657
|
+
local_var_types = local_var_types or {}
|
|
658
|
+
self_attr_types = self_attr_types or {}
|
|
659
|
+
fn_node = call_node.child_by_field_name("function")
|
|
660
|
+
if fn_node is None:
|
|
661
|
+
return None, 0.0, None
|
|
662
|
+
|
|
663
|
+
# P2.2: super().method() → search INHERITS ancestors of the
|
|
664
|
+
# enclosing class (receiver is a call node, so the chain flattener
|
|
665
|
+
# would otherwise skip it).
|
|
666
|
+
if fn_node.type == "attribute":
|
|
667
|
+
obj_node = fn_node.child_by_field_name("object")
|
|
668
|
+
attr_node = fn_node.child_by_field_name("attribute")
|
|
669
|
+
if (
|
|
670
|
+
obj_node is not None and obj_node.type == "call"
|
|
671
|
+
and attr_node is not None and enclosing_class
|
|
672
|
+
):
|
|
673
|
+
inner = obj_node.child_by_field_name("function")
|
|
674
|
+
if inner is not None and inner.type == "identifier":
|
|
675
|
+
inner_text = source[inner.start_byte:inner.end_byte].decode(
|
|
676
|
+
"utf-8", errors="replace"
|
|
677
|
+
)
|
|
678
|
+
if inner_text in self.config.receiver_super_names:
|
|
679
|
+
attr = source[attr_node.start_byte:attr_node.end_byte].decode(
|
|
680
|
+
"utf-8", errors="replace"
|
|
681
|
+
)
|
|
682
|
+
tid = self.builder._find_method_on_ancestors(
|
|
683
|
+
f"{module_name}:{enclosing_class}", attr
|
|
684
|
+
)
|
|
685
|
+
if tid:
|
|
686
|
+
return tid, 0.85, None
|
|
687
|
+
return None, 0.0, None
|
|
688
|
+
|
|
689
|
+
if fn_node.type == "identifier":
|
|
690
|
+
name = source[fn_node.start_byte:fn_node.end_byte].decode("utf-8", errors="replace")
|
|
691
|
+
# 1. same module
|
|
692
|
+
if name in by_module_symbol.get(module_name, {}):
|
|
693
|
+
return by_module_symbol[module_name][name], 0.9, None
|
|
694
|
+
# 2. imported symbol
|
|
695
|
+
if name in import_map:
|
|
696
|
+
resolved = import_map[name]
|
|
697
|
+
tid = self.builder._lookup_qualified(resolved, by_module_symbol)
|
|
698
|
+
if tid:
|
|
699
|
+
return tid, 0.85, None
|
|
700
|
+
# 3. any function with this name (ambiguous) — never for
|
|
701
|
+
# built-ins (P2.7): a user `def filter(...)` elsewhere must
|
|
702
|
+
# not capture every bare `filter(...)` call.
|
|
703
|
+
if self.builder._is_denied(name):
|
|
704
|
+
return None, 0.0, None
|
|
705
|
+
cands = by_simple_name.get(name, [])
|
|
706
|
+
if len(cands) == 1:
|
|
707
|
+
return cands[0], 0.6, None
|
|
708
|
+
return None, 0.0, None
|
|
709
|
+
|
|
710
|
+
if fn_node.type == "attribute":
|
|
711
|
+
chain = self._flatten_attr_chain(fn_node, source)
|
|
712
|
+
if chain is None:
|
|
713
|
+
# P1.14: receiver is a call — `f(...).method()`. If `f` has a
|
|
714
|
+
# declared return type (RETURNS edge) or is a constructor, the
|
|
715
|
+
# receiver class is known, so resolve the method on it. A known
|
|
716
|
+
# type decides resolution (found or unresolved) — never
|
|
717
|
+
# over-approximated (P1.7). Otherwise skip.
|
|
718
|
+
return self._resolve_return_type_chain(
|
|
719
|
+
fn_node, source, module_name, enclosing_class, import_map,
|
|
720
|
+
by_simple_name, by_module_symbol, param_types,
|
|
721
|
+
getattr_class_ids, local_var_types, self_attr_types,
|
|
722
|
+
)
|
|
723
|
+
*receiver, attr = chain
|
|
724
|
+
|
|
725
|
+
if len(receiver) == 1:
|
|
726
|
+
obj = receiver[0]
|
|
727
|
+
# self.attr / cls.attr → method on enclosing class
|
|
728
|
+
if obj in self.config.receiver_self_names and enclosing_class:
|
|
729
|
+
nid = f"{module_name}:{enclosing_class}.{attr}"
|
|
730
|
+
if nid in self.builder._nodes:
|
|
731
|
+
return nid, 0.95, None
|
|
732
|
+
# P2.2: not defined here → walk the INHERITS chain
|
|
733
|
+
tid = self.builder._find_method_on_ancestors(
|
|
734
|
+
f"{module_name}:{enclosing_class}", attr
|
|
735
|
+
)
|
|
736
|
+
if tid:
|
|
737
|
+
return tid, 0.85, None
|
|
738
|
+
# P2.14: class has open attribute universe (__getattr__)
|
|
739
|
+
# and the attr has no structural match — route through
|
|
740
|
+
# __getattr__ so impact analysis covers all dynamic reads.
|
|
741
|
+
class_id = f"{module_name}:{enclosing_class}"
|
|
742
|
+
if class_id in getattr_class_ids:
|
|
743
|
+
ga_id = f"{class_id}.__getattr__"
|
|
744
|
+
if ga_id in self.builder._nodes:
|
|
745
|
+
return ga_id, 0.4, EdgeType.DYNAMIC_ATTR
|
|
746
|
+
return None, 0.0, None
|
|
747
|
+
# P2.2 conformance: annotated parameter receiver —
|
|
748
|
+
# def f(svc: UserService): svc.notify() → UserService.notify.
|
|
749
|
+
# When the receiver's declared type is known, resolution is
|
|
750
|
+
# decided here: either we find the method (on the class or
|
|
751
|
+
# its INHERITS ancestors) or the call stays unresolved —
|
|
752
|
+
# never over-approximated.
|
|
753
|
+
if obj in param_types:
|
|
754
|
+
cls_id = self.builder._resolve_annotation(
|
|
755
|
+
param_types[obj], module_name, import_map,
|
|
756
|
+
by_simple_name, by_module_symbol,
|
|
757
|
+
)
|
|
758
|
+
if cls_id:
|
|
759
|
+
tid = self.builder._contains_child(cls_id, attr) or \
|
|
760
|
+
self.builder._find_method_on_ancestors(cls_id, attr)
|
|
761
|
+
if tid:
|
|
762
|
+
return tid, 0.85, None
|
|
763
|
+
# P2.14: declared type has __getattr__ → DYNAMIC_ATTR
|
|
764
|
+
if cls_id in getattr_class_ids:
|
|
765
|
+
ga_id = f"{cls_id}.__getattr__"
|
|
766
|
+
if ga_id in self.builder._nodes:
|
|
767
|
+
return ga_id, 0.4, EdgeType.DYNAMIC_ATTR
|
|
768
|
+
return None, 0.0, None
|
|
769
|
+
# P1.13: local-variable type — x = Service(); x.run() →
|
|
770
|
+
# Service.run. A known inferred type decides resolution here
|
|
771
|
+
# (method found, inherited, or __getattr__) — never falls back
|
|
772
|
+
# to global-name over-approximation.
|
|
773
|
+
if obj in local_var_types:
|
|
774
|
+
cls_id = local_var_types[obj]
|
|
775
|
+
tid = self.builder._contains_child(cls_id, attr) or \
|
|
776
|
+
self.builder._find_method_on_ancestors(cls_id, attr)
|
|
777
|
+
if tid:
|
|
778
|
+
return tid, 0.8, None
|
|
779
|
+
if cls_id in getattr_class_ids:
|
|
780
|
+
ga_id = f"{cls_id}.__getattr__"
|
|
781
|
+
if ga_id in self.builder._nodes:
|
|
782
|
+
return ga_id, 0.4, EdgeType.DYNAMIC_ATTR
|
|
783
|
+
return None, 0.0, None
|
|
784
|
+
# module.attr via import
|
|
785
|
+
if obj in import_map:
|
|
786
|
+
resolved_mod = import_map[obj]
|
|
787
|
+
tid = self.builder._lookup_qualified(
|
|
788
|
+
f"{resolved_mod}.{attr}", by_module_symbol
|
|
789
|
+
)
|
|
790
|
+
if tid:
|
|
791
|
+
return tid, 0.85, None
|
|
792
|
+
# ClassName.attr / alias.attr → resolve receiver, walk one
|
|
793
|
+
# CONTAINS link (P1.7 chained-call strategy, single link)
|
|
794
|
+
root_id = self.builder._resolve_chain_root(
|
|
795
|
+
obj, module_name, import_map, by_module_symbol
|
|
796
|
+
)
|
|
797
|
+
if root_id:
|
|
798
|
+
child = self.builder._contains_child(root_id, attr)
|
|
799
|
+
if child:
|
|
800
|
+
return child, 0.75, None
|
|
801
|
+
# P2.15: decorated function — check stub table or own-
|
|
802
|
+
# inference rebind before falling to over-approximation.
|
|
803
|
+
if self.builder._nodes[root_id].node_type == NodeType.FUNCTION:
|
|
804
|
+
rebind = self.builder._decorator_rebirds.get(root_id)
|
|
805
|
+
if rebind is not None:
|
|
806
|
+
stub_attrs, rebound_class_id = rebind
|
|
807
|
+
if stub_attrs and attr in stub_attrs:
|
|
808
|
+
# Wrapper attr delegates to decorated fn;
|
|
809
|
+
# emit CALLS to the function itself so its
|
|
810
|
+
# callers appear in blast-radius (conf 0.8).
|
|
811
|
+
return root_id, 0.8, None
|
|
812
|
+
if rebound_class_id:
|
|
813
|
+
method_id = self.builder._contains_child(
|
|
814
|
+
rebound_class_id, attr
|
|
815
|
+
)
|
|
816
|
+
if method_id:
|
|
817
|
+
return method_id, 0.7, None
|
|
818
|
+
# fallback: over-approximate by attribute name — never for
|
|
819
|
+
# built-in / common stdlib method names (P2.7)
|
|
820
|
+
if self.builder._is_denied(attr):
|
|
821
|
+
return None, 0.0, None
|
|
822
|
+
cands = by_simple_name.get(attr, [])
|
|
823
|
+
if len(cands) == 1:
|
|
824
|
+
return cands[0], 0.5, None
|
|
825
|
+
if 1 < len(cands) <= 5:
|
|
826
|
+
return cands, 0.3, None
|
|
827
|
+
return None, 0.0, None
|
|
828
|
+
|
|
829
|
+
# Multi-link chain (P1.7 + P1.13): e.g. lib.Db.query() →
|
|
830
|
+
# receiver=["lib", "Db"], attr="query". Resolve chain[0], then walk
|
|
831
|
+
# one link at a time — jumping through *typed* instance attributes
|
|
832
|
+
# (self.db = Session()) instead of dead-ending on the attribute
|
|
833
|
+
# VARIABLE node, which is what made self.x.y() unresolvable.
|
|
834
|
+
if receiver[0] in self.config.receiver_self_names and enclosing_class:
|
|
835
|
+
current: Optional[str] = f"{module_name}:{enclosing_class}"
|
|
836
|
+
elif receiver[0] in local_var_types:
|
|
837
|
+
current = local_var_types[receiver[0]]
|
|
838
|
+
else:
|
|
839
|
+
current = self.builder._resolve_chain_root(
|
|
840
|
+
receiver[0], module_name, import_map, by_module_symbol,
|
|
841
|
+
param_types,
|
|
842
|
+
)
|
|
843
|
+
links = receiver[1:]
|
|
844
|
+
if current is None or current not in self.builder._nodes:
|
|
845
|
+
return None, 0.0, None
|
|
846
|
+
chained_conf = 0.75
|
|
847
|
+
for link in links:
|
|
848
|
+
typed = self_attr_types.get(current, {}).get(link)
|
|
849
|
+
if typed is not None and typed in self.builder._nodes:
|
|
850
|
+
current = typed # P1.13 typed-attribute hop
|
|
851
|
+
chained_conf = 0.8
|
|
852
|
+
continue
|
|
853
|
+
nxt = self.builder._contains_child(current, link)
|
|
854
|
+
if nxt is None:
|
|
855
|
+
# Chain broken mid-walk: do NOT fall back to global
|
|
856
|
+
# name over-approximation — a missing edge beats 30
|
|
857
|
+
# phantom ones (P1.7).
|
|
858
|
+
return None, 0.0, None
|
|
859
|
+
current = nxt
|
|
860
|
+
# final link is the method/attribute being called — also try the
|
|
861
|
+
# INHERITS chain (P2.2) so inherited methods on a typed receiver
|
|
862
|
+
# resolve.
|
|
863
|
+
tid = self.builder._contains_child(current, attr) or \
|
|
864
|
+
self.builder._find_method_on_ancestors(current, attr)
|
|
865
|
+
if tid:
|
|
866
|
+
return tid, chained_conf, None
|
|
867
|
+
return None, 0.0, None
|
|
868
|
+
|
|
869
|
+
return None, 0.0, None
|
|
870
|
+
|
|
871
|
+
def _iter_typed_assignments(
|
|
872
|
+
self, body, source: bytes, module_name: str,
|
|
873
|
+
import_map: Dict[str, str],
|
|
874
|
+
by_module_symbol: Dict[str, Dict[str, str]],
|
|
875
|
+
):
|
|
876
|
+
"""P1.13: yield ``(kind, name, class_id)`` for every assignment whose
|
|
877
|
+
right-hand side constructs a known in-repo class.
|
|
878
|
+
|
|
879
|
+
``kind`` is ``"local"`` for ``x = ClassName(...)`` and ``"self"`` for
|
|
880
|
+
``self.x = ClassName(...)`` / ``cls.x = ...``. Nested function bodies
|
|
881
|
+
are not descended (their locals belong to the inner scope).
|
|
882
|
+
"""
|
|
883
|
+
stack = [body]
|
|
884
|
+
while stack:
|
|
885
|
+
n = stack.pop()
|
|
886
|
+
if n.type == "function_definition" and n is not body:
|
|
887
|
+
continue
|
|
888
|
+
if n.type == "assignment":
|
|
889
|
+
left = n.child_by_field_name("left")
|
|
890
|
+
right = n.child_by_field_name("right")
|
|
891
|
+
if left is not None and right is not None:
|
|
892
|
+
cls_id = self._infer_value_class(
|
|
893
|
+
right, source, module_name, import_map, by_module_symbol
|
|
894
|
+
)
|
|
895
|
+
if cls_id is not None:
|
|
896
|
+
if left.type == "identifier":
|
|
897
|
+
yield "local", source[
|
|
898
|
+
left.start_byte:left.end_byte
|
|
899
|
+
].decode("utf-8", errors="replace"), cls_id
|
|
900
|
+
elif left.type == "attribute":
|
|
901
|
+
obj = left.child_by_field_name("object")
|
|
902
|
+
attr = left.child_by_field_name("attribute")
|
|
903
|
+
if (obj is not None and attr is not None
|
|
904
|
+
and obj.type == "identifier"
|
|
905
|
+
and source[
|
|
906
|
+
obj.start_byte:obj.end_byte
|
|
907
|
+
].decode("utf-8", errors="replace")
|
|
908
|
+
in self.config.receiver_self_names):
|
|
909
|
+
yield "self", source[
|
|
910
|
+
attr.start_byte:attr.end_byte
|
|
911
|
+
].decode("utf-8", errors="replace"), cls_id
|
|
912
|
+
stack.extend(n.children)
|
|
913
|
+
|
|
914
|
+
def _infer_value_class(
|
|
915
|
+
self, value_node, source: bytes, module_name: str,
|
|
916
|
+
import_map: Dict[str, str],
|
|
917
|
+
by_module_symbol: Dict[str, Dict[str, str]],
|
|
918
|
+
) -> Optional[str]:
|
|
919
|
+
"""P1.13: if ``value_node`` is a constructor call ``ClassName(...)`` /
|
|
920
|
+
``module.ClassName(...)`` resolving to a CLASS node, return its id.
|
|
921
|
+
|
|
922
|
+
Returns None for any non-constructor RHS (calls to functions, literals,
|
|
923
|
+
names, subscripts) — so a wrongly-typed receiver is never invented.
|
|
924
|
+
"""
|
|
925
|
+
if value_node is None or value_node.type != "call":
|
|
926
|
+
return None
|
|
927
|
+
fn = value_node.child_by_field_name("function")
|
|
928
|
+
if fn is None:
|
|
929
|
+
return None
|
|
930
|
+
cid: Optional[str] = None
|
|
931
|
+
if fn.type == "identifier":
|
|
932
|
+
name = source[fn.start_byte:fn.end_byte].decode("utf-8", errors="replace")
|
|
933
|
+
cid = by_module_symbol.get(module_name, {}).get(name)
|
|
934
|
+
if cid is None and name in import_map:
|
|
935
|
+
cid = (self.builder._lookup_qualified(import_map[name], by_module_symbol)
|
|
936
|
+
or self.builder._node_for_dotted(import_map[name]))
|
|
937
|
+
elif fn.type == "attribute":
|
|
938
|
+
chain = self._flatten_attr_chain(fn, source)
|
|
939
|
+
if not chain:
|
|
940
|
+
return None
|
|
941
|
+
cid = self.builder._resolve_chain_root(
|
|
942
|
+
chain[0], module_name, import_map, by_module_symbol
|
|
943
|
+
)
|
|
944
|
+
for link in chain[1:]:
|
|
945
|
+
if cid is None:
|
|
946
|
+
break
|
|
947
|
+
cid = self.builder._contains_child(cid, link)
|
|
948
|
+
if (cid is not None and cid in self.builder._nodes
|
|
949
|
+
and self.builder._nodes[cid].node_type == NodeType.CLASS):
|
|
950
|
+
return cid
|
|
951
|
+
return None
|
|
952
|
+
|
|
953
|
+
@staticmethod
|
|
954
|
+
def _flatten_attr_chain(fn_node, source: bytes) -> Optional[List[str]]:
|
|
955
|
+
"""Flatten an `attribute` CST node into its dotted identifier chain.
|
|
956
|
+
|
|
957
|
+
``User.objects.get`` → ``["User", "objects", "get"]``.
|
|
958
|
+
Returns None when any link is not a plain identifier (calls,
|
|
959
|
+
subscripts, awaits in the receiver) — those receivers are dynamic
|
|
960
|
+
and must not be resolved by name.
|
|
961
|
+
"""
|
|
962
|
+
parts: List[str] = []
|
|
963
|
+
node = fn_node
|
|
964
|
+
while node is not None and node.type == "attribute":
|
|
965
|
+
attr_node = node.child_by_field_name("attribute")
|
|
966
|
+
if attr_node is None:
|
|
967
|
+
return None
|
|
968
|
+
parts.append(
|
|
969
|
+
source[attr_node.start_byte:attr_node.end_byte].decode("utf-8", errors="replace")
|
|
970
|
+
)
|
|
971
|
+
node = node.child_by_field_name("object")
|
|
972
|
+
if node is None or node.type != "identifier":
|
|
973
|
+
return None
|
|
974
|
+
parts.append(
|
|
975
|
+
source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
976
|
+
)
|
|
977
|
+
parts.reverse()
|
|
978
|
+
return parts
|
|
979
|
+
|
|
980
|
+
def _return_type_of(self, func_id: str) -> Optional[str]:
|
|
981
|
+
"""P1.14: follow a FUNCTION node's RETURNS edge to its CLASS node id.
|
|
982
|
+
|
|
983
|
+
RETURNS edges (P1.8) are declared annotations resolved to a class at
|
|
984
|
+
confidence 1.0, emitted by ``resolve_relationships()`` before
|
|
985
|
+
``resolve_calls()`` runs. Returns None when the function has no
|
|
986
|
+
class-typed return annotation.
|
|
987
|
+
"""
|
|
988
|
+
if func_id not in self.builder.graph:
|
|
989
|
+
return None
|
|
990
|
+
for _, tgt, d in self.builder.graph.out_edges(func_id, data=True):
|
|
991
|
+
if d.get("type") != EdgeType.RETURNS.name:
|
|
992
|
+
continue
|
|
993
|
+
n = self.builder._nodes.get(tgt)
|
|
994
|
+
if n is not None and n.node_type == NodeType.CLASS:
|
|
995
|
+
return tgt
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
def _resolve_return_type_chain(
|
|
999
|
+
self, fn_node, source: bytes, module_name: str,
|
|
1000
|
+
enclosing_class: Optional[str], import_map: Dict[str, str],
|
|
1001
|
+
by_simple_name: Dict[str, List[str]],
|
|
1002
|
+
by_module_symbol: Dict[str, Dict[str, str]],
|
|
1003
|
+
param_types: Dict[str, str], getattr_class_ids: Set[str],
|
|
1004
|
+
local_var_types: Dict[str, str],
|
|
1005
|
+
self_attr_types: Dict[str, Dict[str, str]],
|
|
1006
|
+
):
|
|
1007
|
+
"""P1.14: resolve `f(...).a.b()` where the receiver root is a *call*.
|
|
1008
|
+
|
|
1009
|
+
Flatten the trailing attribute links, resolve the leading call's
|
|
1010
|
+
callable, derive its receiver class (declared return type via
|
|
1011
|
+
``_return_type_of``, or the class itself when the leading call is a
|
|
1012
|
+
constructor), then walk the remaining links and resolve the final
|
|
1013
|
+
method. Annotation/constructor-gated — never over-approximates a
|
|
1014
|
+
dynamic receiver (P1.7). Returns the standard
|
|
1015
|
+
``(target, confidence, edge_override)`` triple.
|
|
1016
|
+
"""
|
|
1017
|
+
parts: List[str] = []
|
|
1018
|
+
node = fn_node
|
|
1019
|
+
while node is not None and node.type == "attribute":
|
|
1020
|
+
attr_node = node.child_by_field_name("attribute")
|
|
1021
|
+
if attr_node is None:
|
|
1022
|
+
return None, 0.0, None
|
|
1023
|
+
parts.append(
|
|
1024
|
+
source[attr_node.start_byte:attr_node.end_byte].decode(
|
|
1025
|
+
"utf-8", errors="replace"
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
node = node.child_by_field_name("object")
|
|
1029
|
+
if node is None or node.type != "call":
|
|
1030
|
+
return None, 0.0, None
|
|
1031
|
+
parts.reverse()
|
|
1032
|
+
if not parts:
|
|
1033
|
+
return None, 0.0, None
|
|
1034
|
+
|
|
1035
|
+
# Resolve the leading call's callable (handles self.f(), f(), mod.f(),
|
|
1036
|
+
# and nested f().g() via recursion).
|
|
1037
|
+
func_id, _conf, _edge = self._resolve_target(
|
|
1038
|
+
node, source, module_name, enclosing_class, import_map,
|
|
1039
|
+
by_simple_name, by_module_symbol, param_types,
|
|
1040
|
+
getattr_class_ids, local_var_types, self_attr_types,
|
|
1041
|
+
)
|
|
1042
|
+
if not isinstance(func_id, str):
|
|
1043
|
+
return None, 0.0, None # ambiguous list or unresolved
|
|
1044
|
+
target = self.builder._nodes.get(func_id)
|
|
1045
|
+
if target is None:
|
|
1046
|
+
return None, 0.0, None
|
|
1047
|
+
if target.node_type == NodeType.CLASS:
|
|
1048
|
+
cls_id: Optional[str] = func_id # Foo().bar() — receiver = Foo
|
|
1049
|
+
else:
|
|
1050
|
+
cls_id = self._return_type_of(func_id)
|
|
1051
|
+
if cls_id is None or cls_id not in self.builder._nodes:
|
|
1052
|
+
return None, 0.0, None
|
|
1053
|
+
|
|
1054
|
+
*links, attr = parts
|
|
1055
|
+
current = cls_id
|
|
1056
|
+
for link in links:
|
|
1057
|
+
typed = self_attr_types.get(current, {}).get(link)
|
|
1058
|
+
if typed is not None and typed in self.builder._nodes:
|
|
1059
|
+
current = typed
|
|
1060
|
+
continue
|
|
1061
|
+
nxt = self.builder._contains_child(current, link)
|
|
1062
|
+
if nxt is None:
|
|
1063
|
+
return None, 0.0, None
|
|
1064
|
+
current = nxt
|
|
1065
|
+
tid = self.builder._contains_child(current, attr) or \
|
|
1066
|
+
self.builder._find_method_on_ancestors(current, attr)
|
|
1067
|
+
if tid:
|
|
1068
|
+
return tid, 0.85, None
|
|
1069
|
+
return None, 0.0, None
|
|
1070
|
+
|
|
1071
|
+
# ------------------------------------------------------------------ CST utilities
|
|
1072
|
+
|
|
1073
|
+
@staticmethod
|
|
1074
|
+
def _cst_scope_chain(node, source: bytes) -> List[str]:
|
|
1075
|
+
"""P2.17/P1.10: walk CST ancestors collecting class/function def names,
|
|
1076
|
+
outermost first, with ``LOCALS_MARKER`` after every function segment.
|
|
1077
|
+
"""
|
|
1078
|
+
chain: List[str] = []
|
|
1079
|
+
p = node.parent
|
|
1080
|
+
while p is not None:
|
|
1081
|
+
if p.type in ("class_definition", "function_definition"):
|
|
1082
|
+
nn = p.child_by_field_name("name")
|
|
1083
|
+
if nn is not None:
|
|
1084
|
+
if p.type == "function_definition":
|
|
1085
|
+
# reversed below: marker must follow the function name
|
|
1086
|
+
chain.append(LOCALS_MARKER)
|
|
1087
|
+
chain.append(
|
|
1088
|
+
source[nn.start_byte:nn.end_byte].decode("utf-8", errors="replace")
|
|
1089
|
+
)
|
|
1090
|
+
p = p.parent
|
|
1091
|
+
chain.reverse()
|
|
1092
|
+
return chain
|
|
1093
|
+
|
|
1094
|
+
def _iter_func_cst(self, root, source: bytes, module_name: str):
|
|
1095
|
+
"""Yield (function_definition CST node, node_id) for every function in file.
|
|
1096
|
+
|
|
1097
|
+
P2.17: uses the full scope chain (class + function ancestors) so that
|
|
1098
|
+
two closures with the same name in different outer functions resolve to
|
|
1099
|
+
the correct distinct graph node IDs.
|
|
1100
|
+
"""
|
|
1101
|
+
stack = [root]
|
|
1102
|
+
while stack:
|
|
1103
|
+
node = stack.pop()
|
|
1104
|
+
if node.type == "function_definition":
|
|
1105
|
+
name_node = node.child_by_field_name("name")
|
|
1106
|
+
if name_node:
|
|
1107
|
+
fname = source[name_node.start_byte:name_node.end_byte].decode(
|
|
1108
|
+
"utf-8", errors="replace"
|
|
1109
|
+
)
|
|
1110
|
+
scope_chain = self._cst_scope_chain(node, source)
|
|
1111
|
+
if scope_chain:
|
|
1112
|
+
nid = f"{module_name}:{'.'.join(scope_chain)}.{fname}"
|
|
1113
|
+
else:
|
|
1114
|
+
nid = f"{module_name}:{fname}"
|
|
1115
|
+
yield node, nid
|
|
1116
|
+
stack.extend(node.children)
|
|
1117
|
+
|
|
1118
|
+
@staticmethod
|
|
1119
|
+
def _collect_calls(body) -> List:
|
|
1120
|
+
"""Collect every `call` CST node under `body`."""
|
|
1121
|
+
out = []
|
|
1122
|
+
stack = [body]
|
|
1123
|
+
while stack:
|
|
1124
|
+
node = stack.pop()
|
|
1125
|
+
if node.type == "call":
|
|
1126
|
+
out.append(node)
|
|
1127
|
+
# Don't descend into nested function bodies — those calls belong to the inner func
|
|
1128
|
+
if node.type == "function_definition" and node is not body:
|
|
1129
|
+
continue
|
|
1130
|
+
stack.extend(node.children)
|
|
1131
|
+
return out
|
|
1132
|
+
|
|
1133
|
+
# ------------------------------------------------------------------ P2.13
|
|
1134
|
+
# Function-local callable-dict dispatch with variable aliasing.
|
|
1135
|
+
# Handles the idiomatic router/command-table pattern that module-level
|
|
1136
|
+
# mechanism 1 misses:
|
|
1137
|
+
# dispatch = {"a": self.h.handle_a, ...} # local dict, bound-method vals
|
|
1138
|
+
# fn = dispatch.get(name) # var alias of a dict value
|
|
1139
|
+
# fn(args) # call through the alias
|
|
1140
|
+
# incl. the values being typed-attribute chains (`self.h.handle_a`) and the
|
|
1141
|
+
# call living in a nested closure (cross-scope via node-id prefix).
|
|
1142
|
+
|
|
1143
|
+
@staticmethod
|
|
1144
|
+
def _collect_module_literal_containers(root_node, source: bytes):
|
|
1145
|
+
"""Walk module-level assignments for literal dict/list/tuple containers.
|
|
1146
|
+
|
|
1147
|
+
Returns ``(dicts, refs)`` where:
|
|
1148
|
+
- ``dicts``: ``{var_name: {str_key: identifier_name}}`` — dict literals
|
|
1149
|
+
with string keys and bare-identifier values.
|
|
1150
|
+
- ``refs``: ``{var_name: [identifier_names]}`` — ALL identifier values
|
|
1151
|
+
inside any literal container (dict/list/tuple), for REFERENCES widening.
|
|
1152
|
+
"""
|
|
1153
|
+
dicts: Dict[str, Dict[str, str]] = {}
|
|
1154
|
+
refs: Dict[str, List[str]] = {}
|
|
1155
|
+
|
|
1156
|
+
def _text(n) -> str:
|
|
1157
|
+
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
|
|
1158
|
+
|
|
1159
|
+
def _str_val(n) -> Optional[str]:
|
|
1160
|
+
if n.type != "string":
|
|
1161
|
+
return None
|
|
1162
|
+
t = _text(n)
|
|
1163
|
+
for q in ('"""', "'''", '"', "'"):
|
|
1164
|
+
if t.startswith(q) and t.endswith(q) and len(t) > 2 * len(q):
|
|
1165
|
+
return t[len(q):-len(q)]
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
def _ids_in(container) -> List[str]:
|
|
1169
|
+
out: List[str] = []
|
|
1170
|
+
stack = list(container.children)
|
|
1171
|
+
while stack:
|
|
1172
|
+
n = stack.pop()
|
|
1173
|
+
if n.type == "identifier":
|
|
1174
|
+
out.append(_text(n))
|
|
1175
|
+
elif n.type in ("dictionary", "list", "tuple", "pair"):
|
|
1176
|
+
stack.extend(n.children)
|
|
1177
|
+
return out
|
|
1178
|
+
|
|
1179
|
+
for child in root_node.children:
|
|
1180
|
+
node = child
|
|
1181
|
+
if node.type == "expression_statement":
|
|
1182
|
+
assigns = [c for c in node.children if c.type == "assignment"]
|
|
1183
|
+
node = assigns[0] if assigns else None
|
|
1184
|
+
if node is None or node.type != "assignment":
|
|
1185
|
+
continue
|
|
1186
|
+
lhs = node.child_by_field_name("left")
|
|
1187
|
+
rhs = node.child_by_field_name("right")
|
|
1188
|
+
if lhs is None or rhs is None or lhs.type != "identifier":
|
|
1189
|
+
continue
|
|
1190
|
+
var = _text(lhs)
|
|
1191
|
+
if rhs.type == "dictionary":
|
|
1192
|
+
pairs: Dict[str, str] = {}
|
|
1193
|
+
all_ids: List[str] = []
|
|
1194
|
+
for pair in rhs.children:
|
|
1195
|
+
if pair.type != "pair":
|
|
1196
|
+
continue
|
|
1197
|
+
kn = pair.child_by_field_name("key")
|
|
1198
|
+
vn = pair.child_by_field_name("value")
|
|
1199
|
+
if kn is None or vn is None:
|
|
1200
|
+
continue
|
|
1201
|
+
key_str = _str_val(kn)
|
|
1202
|
+
if key_str is not None and vn.type == "identifier":
|
|
1203
|
+
pairs[key_str] = _text(vn)
|
|
1204
|
+
if vn.type == "identifier":
|
|
1205
|
+
all_ids.append(_text(vn))
|
|
1206
|
+
if pairs:
|
|
1207
|
+
dicts[var] = pairs
|
|
1208
|
+
if all_ids:
|
|
1209
|
+
refs[var] = all_ids
|
|
1210
|
+
elif rhs.type in ("list", "tuple"):
|
|
1211
|
+
ids = [_text(c) for c in rhs.children if c.type == "identifier"]
|
|
1212
|
+
if ids:
|
|
1213
|
+
refs[var] = ids
|
|
1214
|
+
return dicts, refs
|
|
1215
|
+
|
|
1216
|
+
@staticmethod
|
|
1217
|
+
def _iter_body_assignments(body):
|
|
1218
|
+
"""Yield every ``assignment`` CST node under ``body`` without descending
|
|
1219
|
+
into nested function bodies (their assignments belong to the inner scope)."""
|
|
1220
|
+
stack = [body]
|
|
1221
|
+
while stack:
|
|
1222
|
+
n = stack.pop()
|
|
1223
|
+
if n.type == "function_definition" and n is not body:
|
|
1224
|
+
continue
|
|
1225
|
+
if n.type == "assignment":
|
|
1226
|
+
yield n
|
|
1227
|
+
stack.extend(n.children)
|
|
1228
|
+
|
|
1229
|
+
@staticmethod
|
|
1230
|
+
def _attr_chain_parts(node, source: bytes) -> Optional[List[str]]:
|
|
1231
|
+
"""Flatten a pure identifier/attribute chain into dotted parts.
|
|
1232
|
+
|
|
1233
|
+
``self.handlers.handle_scan`` → ``["self", "handlers", "handle_scan"]``.
|
|
1234
|
+
Returns ``None`` for anything that is not a plain attribute/name chain
|
|
1235
|
+
(calls, subscripts, etc.)."""
|
|
1236
|
+
parts: List[str] = []
|
|
1237
|
+
cur = node
|
|
1238
|
+
while cur is not None:
|
|
1239
|
+
if cur.type == "identifier":
|
|
1240
|
+
parts.append(source[cur.start_byte:cur.end_byte].decode(
|
|
1241
|
+
"utf-8", errors="replace"))
|
|
1242
|
+
break
|
|
1243
|
+
if cur.type == "attribute":
|
|
1244
|
+
attr = cur.child_by_field_name("attribute")
|
|
1245
|
+
obj = cur.child_by_field_name("object")
|
|
1246
|
+
if attr is None or obj is None:
|
|
1247
|
+
return None
|
|
1248
|
+
parts.append(source[attr.start_byte:attr.end_byte].decode(
|
|
1249
|
+
"utf-8", errors="replace"))
|
|
1250
|
+
cur = obj
|
|
1251
|
+
else:
|
|
1252
|
+
return None
|
|
1253
|
+
parts.reverse()
|
|
1254
|
+
return parts or None
|
|
1255
|
+
|
|
1256
|
+
def _resolve_callable_value(
|
|
1257
|
+
self, value_node, source: bytes, module_name: str,
|
|
1258
|
+
enclosing_class: Optional[str], import_map: Dict[str, str],
|
|
1259
|
+
by_simple: Dict[str, List[str]], by_mod_sym: Dict[str, Dict[str, str]],
|
|
1260
|
+
strict: bool = False,
|
|
1261
|
+
) -> Optional[str]:
|
|
1262
|
+
"""Resolve a dict *value* / alias-RHS expression to a callable node id.
|
|
1263
|
+
|
|
1264
|
+
Mirrors the typed-attribute hop in ``_resolve_target`` so bound-method
|
|
1265
|
+
references (``self.handlers.handle_scan``) resolve via P1.13's
|
|
1266
|
+
``self_attr_types`` — not just bare/dotted names.
|
|
1267
|
+
|
|
1268
|
+
``strict=True`` (P2.23) resolves a bare identifier RHS *only* against the
|
|
1269
|
+
same-module top-level symbol table — never the fuzzy import-map /
|
|
1270
|
+
unique-name fallback in ``_resolve_name``. The fallback mis-resolves
|
|
1271
|
+
function-local ``import ... as x`` rebindings: e.g. click's
|
|
1272
|
+
``_getchar = f`` where two ``from … import … as f`` lines collide in the
|
|
1273
|
+
module import map. Multi-link chains are already structural either way."""
|
|
1274
|
+
parts = self._attr_chain_parts(value_node, source)
|
|
1275
|
+
if not parts:
|
|
1276
|
+
return None
|
|
1277
|
+
if len(parts) == 1:
|
|
1278
|
+
tid = by_mod_sym.get(module_name, {}).get(parts[0])
|
|
1279
|
+
if tid is None and not strict:
|
|
1280
|
+
tid = self.builder._resolve_name(
|
|
1281
|
+
parts[0], module_name, import_map, by_simple, by_mod_sym,
|
|
1282
|
+
strict=False,
|
|
1283
|
+
)
|
|
1284
|
+
return tid
|
|
1285
|
+
attr = parts[-1]
|
|
1286
|
+
links = parts[1:-1]
|
|
1287
|
+
self_attr_types = self._self_attr_types
|
|
1288
|
+
if parts[0] in self.config.receiver_self_names and enclosing_class:
|
|
1289
|
+
current: Optional[str] = f"{module_name}:{enclosing_class}"
|
|
1290
|
+
else:
|
|
1291
|
+
current = self.builder._resolve_chain_root(
|
|
1292
|
+
parts[0], module_name, import_map, by_mod_sym,
|
|
1293
|
+
)
|
|
1294
|
+
if current is None or current not in self.builder._nodes:
|
|
1295
|
+
return None
|
|
1296
|
+
for link in links:
|
|
1297
|
+
typed = self_attr_types.get(current, {}).get(link)
|
|
1298
|
+
if typed is not None and typed in self.builder._nodes:
|
|
1299
|
+
current = typed
|
|
1300
|
+
continue
|
|
1301
|
+
nxt = self.builder._contains_child(current, link)
|
|
1302
|
+
if nxt is None:
|
|
1303
|
+
return None
|
|
1304
|
+
current = nxt
|
|
1305
|
+
return self.builder._contains_child(current, attr) or \
|
|
1306
|
+
self.builder._find_method_on_ancestors(current, attr)
|
|
1307
|
+
|
|
1308
|
+
def _collect_local_callable_dicts(
|
|
1309
|
+
self, body, source: bytes, module_name: str,
|
|
1310
|
+
enclosing_class: Optional[str], import_map: Dict[str, str],
|
|
1311
|
+
by_simple: Dict[str, List[str]], by_mod_sym: Dict[str, Dict[str, str]],
|
|
1312
|
+
) -> Dict[str, List[str]]:
|
|
1313
|
+
"""``{var_name: [callable_node_id, ...]}`` for dict literals in ``body``
|
|
1314
|
+
whose values resolve to in-repo callables."""
|
|
1315
|
+
out: Dict[str, List[str]] = {}
|
|
1316
|
+
for n in self._iter_body_assignments(body):
|
|
1317
|
+
left = n.child_by_field_name("left")
|
|
1318
|
+
right = n.child_by_field_name("right")
|
|
1319
|
+
if (left is None or right is None
|
|
1320
|
+
or left.type != "identifier" or right.type != "dictionary"):
|
|
1321
|
+
continue
|
|
1322
|
+
targets: List[str] = []
|
|
1323
|
+
for pair in right.children:
|
|
1324
|
+
if pair.type != "pair":
|
|
1325
|
+
continue
|
|
1326
|
+
vn = pair.child_by_field_name("value")
|
|
1327
|
+
if vn is None:
|
|
1328
|
+
continue
|
|
1329
|
+
tid = self._resolve_callable_value(
|
|
1330
|
+
vn, source, module_name, enclosing_class,
|
|
1331
|
+
import_map, by_simple, by_mod_sym,
|
|
1332
|
+
)
|
|
1333
|
+
if (tid and tid in self.builder._nodes
|
|
1334
|
+
and not self.builder._is_denied(self.builder._nodes[tid].name)
|
|
1335
|
+
and tid not in targets):
|
|
1336
|
+
targets.append(tid)
|
|
1337
|
+
if targets:
|
|
1338
|
+
var = source[left.start_byte:left.end_byte].decode(
|
|
1339
|
+
"utf-8", errors="replace")
|
|
1340
|
+
out.setdefault(var, [])
|
|
1341
|
+
for t in targets:
|
|
1342
|
+
if t not in out[var]:
|
|
1343
|
+
out[var].append(t)
|
|
1344
|
+
return out
|
|
1345
|
+
|
|
1346
|
+
def _collect_local_callable_aliases(
|
|
1347
|
+
self, body, source: bytes, module_name: str,
|
|
1348
|
+
enclosing_class: Optional[str], import_map: Dict[str, str],
|
|
1349
|
+
by_simple: Dict[str, List[str]], by_mod_sym: Dict[str, Dict[str, str]],
|
|
1350
|
+
) -> Dict[str, List[str]]:
|
|
1351
|
+
"""P2.23: ``{var_name: [callable_node_id, ...]}`` for plain callable
|
|
1352
|
+
re-bindings ``fn = <name>`` / ``fn = <attr.chain>`` in ``body`` whose RHS
|
|
1353
|
+
resolves *structurally* to an in-repo function/class. A later
|
|
1354
|
+
``fn(args)`` is then dispatched to each (the conditional-rebind idiom
|
|
1355
|
+
``Cls = A if c else B; Cls()`` — values from every branch are kept).
|
|
1356
|
+
|
|
1357
|
+
Strict resolution is mandatory (see ``_resolve_callable_value`` strict):
|
|
1358
|
+
the fuzzy fallback mis-resolves function-local ``import … as x``
|
|
1359
|
+
rebindings and produces wrong edges. A missing edge beats a wrong one
|
|
1360
|
+
(P1.7). RHS shapes that are calls/subscripts (``D.get(k)`` / ``D[k]`` /
|
|
1361
|
+
``factory()``) are left to mechanism 6 / the dynamic marker."""
|
|
1362
|
+
out: Dict[str, List[str]] = {}
|
|
1363
|
+
for n in self._iter_body_assignments(body):
|
|
1364
|
+
left = n.child_by_field_name("left")
|
|
1365
|
+
right = n.child_by_field_name("right")
|
|
1366
|
+
if (left is None or right is None
|
|
1367
|
+
or left.type != "identifier"
|
|
1368
|
+
or right.type not in ("identifier", "attribute")):
|
|
1369
|
+
continue
|
|
1370
|
+
tid = self._resolve_callable_value(
|
|
1371
|
+
right, source, module_name, enclosing_class,
|
|
1372
|
+
import_map, by_simple, by_mod_sym, strict=True,
|
|
1373
|
+
)
|
|
1374
|
+
if (tid and tid in self.builder._nodes
|
|
1375
|
+
and self.builder._nodes[tid].node_type in (
|
|
1376
|
+
NodeType.FUNCTION, NodeType.CLASS)
|
|
1377
|
+
and not self.builder._is_denied(self.builder._nodes[tid].name)):
|
|
1378
|
+
var = source[left.start_byte:left.end_byte].decode(
|
|
1379
|
+
"utf-8", errors="replace")
|
|
1380
|
+
out.setdefault(var, [])
|
|
1381
|
+
if tid not in out[var]:
|
|
1382
|
+
out[var].append(tid)
|
|
1383
|
+
return out
|
|
1384
|
+
|
|
1385
|
+
def _collect_dict_aliases(
|
|
1386
|
+
self, body, source: bytes, visible_names: Set[str],
|
|
1387
|
+
) -> Dict[str, str]:
|
|
1388
|
+
"""``{alias_var: dict_var}`` for ``h = D.get(k)`` / ``h = D[k]`` where
|
|
1389
|
+
``D`` is a known callable-dict."""
|
|
1390
|
+
out: Dict[str, str] = {}
|
|
1391
|
+
for n in self._iter_body_assignments(body):
|
|
1392
|
+
left = n.child_by_field_name("left")
|
|
1393
|
+
right = n.child_by_field_name("right")
|
|
1394
|
+
if left is None or right is None or left.type != "identifier":
|
|
1395
|
+
continue
|
|
1396
|
+
dict_var: Optional[str] = None
|
|
1397
|
+
if right.type == "call":
|
|
1398
|
+
fn = right.child_by_field_name("function")
|
|
1399
|
+
if fn is not None and fn.type == "attribute":
|
|
1400
|
+
obj = fn.child_by_field_name("object")
|
|
1401
|
+
meth = fn.child_by_field_name("attribute")
|
|
1402
|
+
if (obj is not None and obj.type == "identifier"
|
|
1403
|
+
and meth is not None
|
|
1404
|
+
and source[meth.start_byte:meth.end_byte].decode(
|
|
1405
|
+
"utf-8", errors="replace") == "get"):
|
|
1406
|
+
name = source[obj.start_byte:obj.end_byte].decode(
|
|
1407
|
+
"utf-8", errors="replace")
|
|
1408
|
+
if name in visible_names:
|
|
1409
|
+
dict_var = name
|
|
1410
|
+
elif right.type == "subscript":
|
|
1411
|
+
val = right.child_by_field_name("value")
|
|
1412
|
+
if val is not None and val.type == "identifier":
|
|
1413
|
+
name = source[val.start_byte:val.end_byte].decode(
|
|
1414
|
+
"utf-8", errors="replace")
|
|
1415
|
+
if name in visible_names:
|
|
1416
|
+
dict_var = name
|
|
1417
|
+
if dict_var:
|
|
1418
|
+
alias_var = source[left.start_byte:left.end_byte].decode(
|
|
1419
|
+
"utf-8", errors="replace")
|
|
1420
|
+
out[alias_var] = dict_var
|
|
1421
|
+
return out
|
|
1422
|
+
|
|
1423
|
+
@staticmethod
|
|
1424
|
+
def _extract_fstring_prefix(node, source: bytes) -> Optional[str]:
|
|
1425
|
+
"""Return the literal prefix from an f-string, e.g. ``f"handle_{x}"`` → ``"handle_"``.
|
|
1426
|
+
|
|
1427
|
+
Returns ``None`` when node is not an f-string or has no literal content
|
|
1428
|
+
before the first interpolation.
|
|
1429
|
+
"""
|
|
1430
|
+
if node.type not in ("string", "concatenated_string"):
|
|
1431
|
+
return None
|
|
1432
|
+
text = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
1433
|
+
lower = text.lower()
|
|
1434
|
+
for marker in ('f"""', "f'''", 'f"', "f'"):
|
|
1435
|
+
if lower.startswith(marker):
|
|
1436
|
+
inner = text[len(marker):]
|
|
1437
|
+
brace = inner.find("{")
|
|
1438
|
+
if brace > 0:
|
|
1439
|
+
return inner[:brace]
|
|
1440
|
+
break
|
|
1441
|
+
return None
|
|
1442
|
+
|
|
1443
|
+
@staticmethod
|
|
1444
|
+
def _nth_pos_arg(args_node, n: int):
|
|
1445
|
+
"""Return the n-th positional argument CST node (0-indexed)."""
|
|
1446
|
+
if args_node is None:
|
|
1447
|
+
return None
|
|
1448
|
+
pos = 0
|
|
1449
|
+
for child in args_node.children:
|
|
1450
|
+
if child.type in (",", "(", ")"):
|
|
1451
|
+
continue
|
|
1452
|
+
if child.type in ("keyword_argument", "dictionary_splat_argument",
|
|
1453
|
+
"list_splat_argument"):
|
|
1454
|
+
continue
|
|
1455
|
+
if pos == n:
|
|
1456
|
+
return child
|
|
1457
|
+
pos += 1
|
|
1458
|
+
return None
|
|
1459
|
+
|
|
1460
|
+
def harvest_literal_candidates(
|
|
1461
|
+
self,
|
|
1462
|
+
parses: Dict[Path, ParseResult],
|
|
1463
|
+
results: Dict[Path, AnalysisResult],
|
|
1464
|
+
) -> int:
|
|
1465
|
+
"""P2.13: emit low-confidence candidate edges recovered from literal strings.
|
|
1466
|
+
|
|
1467
|
+
Mechanisms (all use ``metadata={"dispatch": "..."}`` on the edge):
|
|
1468
|
+
1. **Dict dispatch** — ``_D = {k: fn}; _D[x]()`` → CALLS to each literal
|
|
1469
|
+
value fn, confidence 0.6.
|
|
1470
|
+
2. **getattr prefix** — ``getattr(self, f"pfx_{v}")`` → CALLS to each
|
|
1471
|
+
class method whose name starts with the literal prefix, confidence 0.5.
|
|
1472
|
+
3. **__init_subclass__ registry** — ``Base._reg[k]()`` where ``Base``
|
|
1473
|
+
defines ``__init_subclass__`` → CALLS (INSTANTIATES for classes) to
|
|
1474
|
+
each INHERITS subclass, confidence 0.6.
|
|
1475
|
+
4. **importlib prefix** — ``import_module(f"pkg.{v}")`` → IMPORTS to
|
|
1476
|
+
each matching module node, confidence 0.5.
|
|
1477
|
+
5. **REFERENCES widening** — module-level ``{k: fn}``/``[fn]`` literals
|
|
1478
|
+
→ REFERENCES from the container VARIABLE to fn, confidence 0.9
|
|
1479
|
+
(blast radius of fn includes the dispatch container).
|
|
1480
|
+
6. **Callable-dict dispatch with aliasing (P2.22)** — a *function-local*
|
|
1481
|
+
dict of callables, looked up into a variable, then called through it:
|
|
1482
|
+
``D = {k: self.h.handle}; fn = D.get(x); fn()`` → CALLS to each
|
|
1483
|
+
resolved value, confidence 0.5. Values may be typed-attribute chains
|
|
1484
|
+
(``self.h.handle`` via P1.13) and the call may live in a nested
|
|
1485
|
+
closure (cross-scope via node-id prefix). This is what connects an
|
|
1486
|
+
entry-point route to the handlers it dispatches through a table.
|
|
1487
|
+
7. **Direct callable alias (P2.23)** — a plain re-binding of a callable,
|
|
1488
|
+
then a call through it: ``fn = handler; fn()`` or the conditional
|
|
1489
|
+
idiom ``Cls = A if c else B; Cls()`` → CALLS/INSTANTIATES to each
|
|
1490
|
+
structurally-resolved target, confidence 0.5. RHS resolved *strictly*
|
|
1491
|
+
(same-module top-level / typed self.attr / import-rooted chain only)
|
|
1492
|
+
so function-local ``import … as x`` rebindings never mis-resolve.
|
|
1493
|
+
|
|
1494
|
+
Hard rule: any non-literal input to the patterns above → no candidate,
|
|
1495
|
+
fall through to the P2.12 low-confidence marker. A missing edge is
|
|
1496
|
+
always better than a wrong one.
|
|
1497
|
+
"""
|
|
1498
|
+
added = 0
|
|
1499
|
+
|
|
1500
|
+
# Shared name→node indices (same as resolve_calls uses)
|
|
1501
|
+
by_simple: Dict[str, List[str]] = defaultdict(list)
|
|
1502
|
+
by_mod_sym: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
1503
|
+
var_by_mod: Dict[str, Dict[str, str]] = defaultdict(dict)
|
|
1504
|
+
for nid, nd in self.builder._nodes.items():
|
|
1505
|
+
if nd.language != "python":
|
|
1506
|
+
continue
|
|
1507
|
+
if nd.node_type in (NodeType.FUNCTION, NodeType.CLASS):
|
|
1508
|
+
by_simple[nd.name].append(nid)
|
|
1509
|
+
by_mod_sym[nd.module][nd.name] = nid
|
|
1510
|
+
elif nd.node_type == NodeType.VARIABLE and not nd.contained_in:
|
|
1511
|
+
var_by_mod[nd.module][nd.name] = nid
|
|
1512
|
+
|
|
1513
|
+
# Build __init_subclass__ base index:
|
|
1514
|
+
# {base_class_id: [subclass_id, ...]} from existing INHERITS edges,
|
|
1515
|
+
# filtered to only bases that actually define __init_subclass__.
|
|
1516
|
+
init_subclass_bases: Set[str] = set()
|
|
1517
|
+
for nid, nd in self.builder._nodes.items():
|
|
1518
|
+
if nd.language != "python":
|
|
1519
|
+
continue
|
|
1520
|
+
if nd.node_type == NodeType.FUNCTION and nd.name == "__init_subclass__" \
|
|
1521
|
+
and nd.contained_in:
|
|
1522
|
+
parent_id = f"{nd.module}:{nd.contained_in}"
|
|
1523
|
+
if parent_id in self.builder._nodes:
|
|
1524
|
+
init_subclass_bases.add(parent_id)
|
|
1525
|
+
|
|
1526
|
+
subclass_of: Dict[str, List[str]] = defaultdict(list)
|
|
1527
|
+
for edge in self.builder._edges:
|
|
1528
|
+
if edge.edge_type == EdgeType.INHERITS:
|
|
1529
|
+
subclass_of[edge.target].append(edge.source)
|
|
1530
|
+
|
|
1531
|
+
def _edge(src: str, tgt: str, etype: EdgeType, fp: str, line: int, conf: float) -> None:
|
|
1532
|
+
nonlocal added
|
|
1533
|
+
if src == tgt:
|
|
1534
|
+
return
|
|
1535
|
+
if (src, tgt, etype) in self.builder._edge_keys:
|
|
1536
|
+
return
|
|
1537
|
+
if src not in self.builder._nodes or tgt not in self.builder._nodes:
|
|
1538
|
+
return
|
|
1539
|
+
self.builder._add_edge(GraphEdge(
|
|
1540
|
+
source=src, target=tgt, edge_type=etype,
|
|
1541
|
+
file_path=fp, line=line, confidence=conf,
|
|
1542
|
+
))
|
|
1543
|
+
added += 1
|
|
1544
|
+
|
|
1545
|
+
def _node_text(node, src: bytes) -> str:
|
|
1546
|
+
return src[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
|
1547
|
+
|
|
1548
|
+
for file_path, parse in parses.items():
|
|
1549
|
+
result = results.get(file_path)
|
|
1550
|
+
if result is None:
|
|
1551
|
+
continue
|
|
1552
|
+
module_name = self.builder._module_for_result(result)
|
|
1553
|
+
import_map = self.builder._import_map_for(result, module_name)
|
|
1554
|
+
source = parse.source_bytes
|
|
1555
|
+
fp_str = str(file_path)
|
|
1556
|
+
|
|
1557
|
+
# Collect module-level literal containers
|
|
1558
|
+
mod_dicts, mod_refs = self._collect_module_literal_containers(
|
|
1559
|
+
parse.tree.root_node, source
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
# P2.22/P2.23: function-local callable-dict + direct-alias maps
|
|
1563
|
+
# (pre-pass so a binding in an outer function is visible to calls in
|
|
1564
|
+
# its nested closures).
|
|
1565
|
+
func_dicts: Dict[str, Dict[str, List[str]]] = {}
|
|
1566
|
+
func_aliases: Dict[str, Dict[str, List[str]]] = {}
|
|
1567
|
+
for func_cst, caller_id in self._iter_func_cst(
|
|
1568
|
+
parse.tree.root_node, source, module_name
|
|
1569
|
+
):
|
|
1570
|
+
if caller_id not in self.builder._nodes:
|
|
1571
|
+
continue
|
|
1572
|
+
ec = self.builder._nodes[caller_id].contained_in
|
|
1573
|
+
fbody = func_cst.child_by_field_name("body")
|
|
1574
|
+
if fbody is None:
|
|
1575
|
+
continue
|
|
1576
|
+
dmap = self._collect_local_callable_dicts(
|
|
1577
|
+
fbody, source, module_name, ec, import_map,
|
|
1578
|
+
by_simple, by_mod_sym,
|
|
1579
|
+
)
|
|
1580
|
+
if dmap:
|
|
1581
|
+
func_dicts[caller_id] = dmap
|
|
1582
|
+
amap = self._collect_local_callable_aliases(
|
|
1583
|
+
fbody, source, module_name, ec, import_map,
|
|
1584
|
+
by_simple, by_mod_sym,
|
|
1585
|
+
)
|
|
1586
|
+
if amap:
|
|
1587
|
+
func_aliases[caller_id] = amap
|
|
1588
|
+
|
|
1589
|
+
# 5. REFERENCES widening — container VARIABLE → FUNCTION/CLASS targets
|
|
1590
|
+
for var_name, ref_names in mod_refs.items():
|
|
1591
|
+
# container may be a VARIABLE node (e.g. _OPS) or a CLASS/FUNCTION
|
|
1592
|
+
var_id = (
|
|
1593
|
+
var_by_mod.get(module_name, {}).get(var_name)
|
|
1594
|
+
or by_mod_sym.get(module_name, {}).get(var_name)
|
|
1595
|
+
)
|
|
1596
|
+
if var_id is None:
|
|
1597
|
+
continue
|
|
1598
|
+
for ref_name in ref_names:
|
|
1599
|
+
if ref_name in self.config.receiver_self_names or self.builder._is_denied(ref_name):
|
|
1600
|
+
continue
|
|
1601
|
+
fn_id = by_mod_sym.get(module_name, {}).get(ref_name)
|
|
1602
|
+
if fn_id is None:
|
|
1603
|
+
continue
|
|
1604
|
+
if self.builder._nodes[fn_id].node_type not in (NodeType.FUNCTION, NodeType.CLASS):
|
|
1605
|
+
continue
|
|
1606
|
+
_edge(var_id, fn_id, EdgeType.REFERENCES, fp_str, 0, 0.9)
|
|
1607
|
+
|
|
1608
|
+
# Walk each function body for call-site harvesting
|
|
1609
|
+
for func_cst, caller_id in self._iter_func_cst(
|
|
1610
|
+
parse.tree.root_node, source, module_name
|
|
1611
|
+
):
|
|
1612
|
+
if caller_id not in self.builder._nodes:
|
|
1613
|
+
continue
|
|
1614
|
+
enclosing_class = self.builder._nodes[caller_id].contained_in
|
|
1615
|
+
body = func_cst.child_by_field_name("body")
|
|
1616
|
+
if body is None:
|
|
1617
|
+
continue
|
|
1618
|
+
|
|
1619
|
+
# P2.22: callable-dicts visible here = own + ancestor functions
|
|
1620
|
+
# (a closure's id is prefixed by its enclosing function's id).
|
|
1621
|
+
visible: Dict[str, List[str]] = {}
|
|
1622
|
+
for cid, dmap in func_dicts.items():
|
|
1623
|
+
if cid == caller_id or caller_id.startswith(cid + "."):
|
|
1624
|
+
visible.update(dmap)
|
|
1625
|
+
aliases = (
|
|
1626
|
+
self._collect_dict_aliases(body, source, set(visible))
|
|
1627
|
+
if visible else {}
|
|
1628
|
+
)
|
|
1629
|
+
# P2.23: direct callable aliases, same cross-scope visibility.
|
|
1630
|
+
visible_aliases: Dict[str, List[str]] = {}
|
|
1631
|
+
for cid, amap in func_aliases.items():
|
|
1632
|
+
if cid == caller_id or caller_id.startswith(cid + "."):
|
|
1633
|
+
visible_aliases.update(amap)
|
|
1634
|
+
|
|
1635
|
+
for call_node in self._collect_calls(body):
|
|
1636
|
+
fn_node = call_node.child_by_field_name("function")
|
|
1637
|
+
if fn_node is None:
|
|
1638
|
+
continue
|
|
1639
|
+
line = call_node.start_point[0] + 1
|
|
1640
|
+
|
|
1641
|
+
# 6. Callable-dict dispatch (P2.22): `D[k]()` on a local dict
|
|
1642
|
+
# or `fn(args)` where `fn = D.get(k)` / `fn = D[k]`.
|
|
1643
|
+
if visible:
|
|
1644
|
+
disp_targets: Optional[List[str]] = None
|
|
1645
|
+
if fn_node.type == "subscript":
|
|
1646
|
+
sv = fn_node.child_by_field_name("value")
|
|
1647
|
+
if sv is not None and sv.type == "identifier":
|
|
1648
|
+
nm = _node_text(sv, source)
|
|
1649
|
+
if nm in visible:
|
|
1650
|
+
disp_targets = visible[nm]
|
|
1651
|
+
elif fn_node.type == "identifier":
|
|
1652
|
+
nm = _node_text(fn_node, source)
|
|
1653
|
+
if nm in aliases:
|
|
1654
|
+
disp_targets = visible.get(aliases[nm])
|
|
1655
|
+
if disp_targets:
|
|
1656
|
+
for tid in disp_targets:
|
|
1657
|
+
nd = self.builder._nodes.get(tid)
|
|
1658
|
+
if nd is None:
|
|
1659
|
+
continue
|
|
1660
|
+
etype = (
|
|
1661
|
+
EdgeType.INSTANTIATES
|
|
1662
|
+
if nd.node_type == NodeType.CLASS
|
|
1663
|
+
else EdgeType.CALLS
|
|
1664
|
+
)
|
|
1665
|
+
_edge(caller_id, tid, etype, fp_str, line, 0.5)
|
|
1666
|
+
if etype == EdgeType.CALLS:
|
|
1667
|
+
self.builder._nodes[caller_id].calls.add(tid)
|
|
1668
|
+
self.builder._nodes[tid].called_by.add(caller_id)
|
|
1669
|
+
|
|
1670
|
+
# 7. Direct callable alias (P2.23): `fn = callable; fn()`.
|
|
1671
|
+
# RHS resolved structurally (strict) in the pre-pass, so this
|
|
1672
|
+
# only fires on in-repo functions/classes — incl. the
|
|
1673
|
+
# conditional-rebind idiom `Cls = A if c else B; Cls()`.
|
|
1674
|
+
if visible_aliases and fn_node.type == "identifier":
|
|
1675
|
+
nm = _node_text(fn_node, source)
|
|
1676
|
+
for tid in visible_aliases.get(nm, []):
|
|
1677
|
+
nd = self.builder._nodes.get(tid)
|
|
1678
|
+
if nd is None:
|
|
1679
|
+
continue
|
|
1680
|
+
etype = (
|
|
1681
|
+
EdgeType.INSTANTIATES
|
|
1682
|
+
if nd.node_type == NodeType.CLASS
|
|
1683
|
+
else EdgeType.CALLS
|
|
1684
|
+
)
|
|
1685
|
+
_edge(caller_id, tid, etype, fp_str, line, 0.5)
|
|
1686
|
+
if etype == EdgeType.CALLS:
|
|
1687
|
+
self.builder._nodes[caller_id].calls.add(tid)
|
|
1688
|
+
self.builder._nodes[tid].called_by.add(caller_id)
|
|
1689
|
+
|
|
1690
|
+
# 1. Dict dispatch: _D[k]()
|
|
1691
|
+
if fn_node.type == "subscript":
|
|
1692
|
+
sub_val = fn_node.child_by_field_name("value")
|
|
1693
|
+
if sub_val is None:
|
|
1694
|
+
continue
|
|
1695
|
+
|
|
1696
|
+
if sub_val.type == "identifier":
|
|
1697
|
+
# Local dict literal lookup
|
|
1698
|
+
var_name = _node_text(sub_val, source)
|
|
1699
|
+
for val_name in mod_dicts.get(var_name, {}).values():
|
|
1700
|
+
fn_id = by_mod_sym.get(module_name, {}).get(val_name)
|
|
1701
|
+
if fn_id is None:
|
|
1702
|
+
fn_id = self.builder._resolve_name(
|
|
1703
|
+
val_name, module_name, import_map,
|
|
1704
|
+
by_simple, by_mod_sym, strict=False,
|
|
1705
|
+
)
|
|
1706
|
+
if not fn_id:
|
|
1707
|
+
continue
|
|
1708
|
+
nd = self.builder._nodes.get(fn_id)
|
|
1709
|
+
if nd is None:
|
|
1710
|
+
continue
|
|
1711
|
+
etype = (
|
|
1712
|
+
EdgeType.INSTANTIATES
|
|
1713
|
+
if nd.node_type == NodeType.CLASS
|
|
1714
|
+
else EdgeType.CALLS
|
|
1715
|
+
)
|
|
1716
|
+
_edge(caller_id, fn_id, etype, fp_str, line, 0.6)
|
|
1717
|
+
if etype == EdgeType.CALLS:
|
|
1718
|
+
self.builder._nodes[caller_id].calls.add(fn_id)
|
|
1719
|
+
self.builder._nodes[fn_id].called_by.add(caller_id)
|
|
1720
|
+
|
|
1721
|
+
elif sub_val.type == "attribute":
|
|
1722
|
+
# 3. __init_subclass__: Base._registry[k]()
|
|
1723
|
+
attr_obj = sub_val.child_by_field_name("object")
|
|
1724
|
+
if attr_obj is not None and attr_obj.type == "identifier":
|
|
1725
|
+
base_name = _node_text(attr_obj, source)
|
|
1726
|
+
base_id = by_mod_sym.get(module_name, {}).get(base_name)
|
|
1727
|
+
if base_id is None:
|
|
1728
|
+
base_id = self.builder._resolve_name(
|
|
1729
|
+
base_name, module_name, import_map,
|
|
1730
|
+
by_simple, by_mod_sym, strict=True,
|
|
1731
|
+
)
|
|
1732
|
+
if base_id and base_id in init_subclass_bases:
|
|
1733
|
+
for sub_id in subclass_of.get(base_id, []):
|
|
1734
|
+
nd = self.builder._nodes.get(sub_id)
|
|
1735
|
+
if nd is None:
|
|
1736
|
+
continue
|
|
1737
|
+
etype = (
|
|
1738
|
+
EdgeType.INSTANTIATES
|
|
1739
|
+
if nd.node_type == NodeType.CLASS
|
|
1740
|
+
else EdgeType.CALLS
|
|
1741
|
+
)
|
|
1742
|
+
_edge(caller_id, sub_id, etype, fp_str, line, 0.6)
|
|
1743
|
+
|
|
1744
|
+
else:
|
|
1745
|
+
fn_text = _node_text(fn_node, source)
|
|
1746
|
+
|
|
1747
|
+
# 2. getattr prefix: getattr(self, f"pfx_{v}")
|
|
1748
|
+
if fn_text == "getattr" and enclosing_class:
|
|
1749
|
+
args = call_node.child_by_field_name("arguments")
|
|
1750
|
+
first = self._nth_pos_arg(args, 0)
|
|
1751
|
+
second = self._nth_pos_arg(args, 1)
|
|
1752
|
+
if first is not None and second is not None:
|
|
1753
|
+
first_text = _node_text(first, source)
|
|
1754
|
+
if first_text in self.config.receiver_self_names:
|
|
1755
|
+
prefix = self._extract_fstring_prefix(second, source)
|
|
1756
|
+
if prefix:
|
|
1757
|
+
class_id = f"{module_name}:{enclosing_class}"
|
|
1758
|
+
for _, child_id, d in self.builder.graph.out_edges(
|
|
1759
|
+
class_id, data=True
|
|
1760
|
+
):
|
|
1761
|
+
if d.get("type") != EdgeType.CONTAINS.name:
|
|
1762
|
+
continue
|
|
1763
|
+
child = self.builder._nodes.get(child_id)
|
|
1764
|
+
if child is None or child.node_type != NodeType.FUNCTION:
|
|
1765
|
+
continue
|
|
1766
|
+
if child.name.startswith(prefix):
|
|
1767
|
+
_edge(caller_id, child_id, EdgeType.CALLS, fp_str, line, 0.5)
|
|
1768
|
+
self.builder._nodes[caller_id].calls.add(child_id)
|
|
1769
|
+
self.builder._nodes[child_id].called_by.add(caller_id)
|
|
1770
|
+
|
|
1771
|
+
# 4. importlib prefix: import_module(f"pkg.{v}")
|
|
1772
|
+
elif fn_text in ("importlib.import_module", "__import__"):
|
|
1773
|
+
args = call_node.child_by_field_name("arguments")
|
|
1774
|
+
mod_arg = self._nth_pos_arg(args, 0)
|
|
1775
|
+
if mod_arg is not None:
|
|
1776
|
+
prefix = self._extract_fstring_prefix(mod_arg, source)
|
|
1777
|
+
if prefix:
|
|
1778
|
+
pkg_prefix = prefix.rstrip(".")
|
|
1779
|
+
source_mod_id = module_name
|
|
1780
|
+
for nid, nnd in self.builder._nodes.items():
|
|
1781
|
+
if nnd.language != "python":
|
|
1782
|
+
continue
|
|
1783
|
+
if nnd.node_type != NodeType.MODULE:
|
|
1784
|
+
continue
|
|
1785
|
+
if nid == pkg_prefix or nid.startswith(pkg_prefix + "."):
|
|
1786
|
+
_edge(source_mod_id, nid, EdgeType.IMPORTS,
|
|
1787
|
+
fp_str, line, 0.5)
|
|
1788
|
+
|
|
1789
|
+
return added
|