adl-toolkit 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/adl-drift.md +25 -0
  36. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  37. package/adl_drift/harness/skills/drift-generic.md +19 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. 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