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,2670 @@
1
+ """Call graph builder.
2
+
3
+ Constructs the call graph from parsed symbols and detected patterns.
4
+ Equivalent to drift_core/src/callgraph.rs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import logging
11
+ import re
12
+ from collections import defaultdict, deque, Counter
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Set, Tuple, Any
15
+ import networkx as nx
16
+
17
+ from adl_drift.utils.types import LOCALS_MARKER, Symbol, AnalysisResult, Pattern
18
+ from adl_drift.utils.config import find_project_root as _find_project_root
19
+ from adl_drift.graph.node import GraphNode, NodeType
20
+ from adl_drift.graph.edges import GraphEdge, EdgeType
21
+ from adl_drift.lang.base import CallResolver, CFGConfig, ResolverConfig
22
+ from adl_drift.parser.core import ParseResult, PythonParser
23
+
24
+
25
+ # Bump this whenever resolver semantics or graph structure change in a way
26
+ # that makes previously persisted graphs wrong (new/removed edge kinds,
27
+ # resolution-strategy changes, node-id scheme changes). CortexMemory stamps
28
+ # it into graph_metadata on save and invalidates any cached graph whose
29
+ # stored version differs — so stale caches self-heal without a manual rescan.
30
+ # History: 1 = pre-P1.7 resolver; 2 = chained calls + builtin deny-list +
31
+ # container-expansion impact (P1.6/P1.7/P2.7); 3 = structured imports
32
+ # (relative-import resolution) + inheritance/super()/annotation-aware
33
+ # resolution (P2.1/P2.2); 4 = REFERENCES / TYPE_OF / RETURNS usage edges
34
+ # (P1.8); 5 = INSTANTIATES / OVERRIDES (P2.8); 6 = literal-candidate
35
+ # CALLS/IMPORTS/REFERENCES edges (P2.13); 7 = DYNAMIC_ATTR edges (P2.14);
36
+ # 8 = decorator stub/rebind resolution (P2.15);
37
+ # 9 = nested same-name defs / scope-chain IDs (P2.17);
38
+ # 10 = <locals> scope markers in node ids + qualified_name decoupling +
39
+ # scope-aware attribute ids (P1.10 steps 2+3, P3.13 fold-in);
40
+ # 11 = ':' module/qualname id split (P1.10 step 4): symbol ids are
41
+ # "module:qualname" so a class ``b`` in ``a/__init__.py`` (``a:b``) can
42
+ # never collide with submodule ``a/b.py`` (``a.b``).
43
+ # 13 = callable-dict dispatch with var aliasing + cross-scope closures (P2.22):
44
+ # `D = {k: self.h.handle}; fn = D.get(x); fn()` now emits CALLS to each
45
+ # resolved value, so entry points reach handlers through dict routers.
46
+ # 16 = Java IMPLEMENTS hierarchy edges (P4.9).
47
+ # 17 = optional erased-signature discriminator in symbol ids (P4.5).
48
+ # 19 = Java compilation-unit imports + stable anonymous/compact-constructor ids (P4.5).
49
+ # 20 = Java static-import call path binds only `static` members — an
50
+ # equally-named instance method is no longer a target (F-02); FUNCTION
51
+ # nodes now carry `java_is_static` metadata.
52
+ # 21 = GraphNode.metadata (java_is_abstract/java_type_kind/java_return_type/
53
+ # java_is_static) is now persisted and restored on save_graph/load_graph
54
+ # (F-01) — older cached graphs have it silently dropped and must rebuild
55
+ # so P4.12 abstract-dispatch fan-out sees the real flags again.
56
+ # 22 = Java generic-bound narrowing (`_collect_type_param_bounds`) now also
57
+ # reads enclosing class/interface/record `<T extends Bound>` type
58
+ # parameters, not just the method's own (F-04) — `class Client<T
59
+ # extends Stripe> { void run(T g) { g.charge(); } }` now resolves;
60
+ # older cached graphs are missing these CALLS edges and must rebuild.
61
+ # 23 = normalize project roots and treat same-file Python rebinding as shadowing.
62
+ # 24 = persist every source occurrence separately from deduplicated dependency
63
+ # edges, using exact CST byte ranges to distinguish nested fluent calls.
64
+ # 25 = Java functional references/lambda target typing and callable-container
65
+ # dispatch edges (P4.13).
66
+ # 26 = stable Java build-unit namespaces for independent nested Maven/Gradle
67
+ # roots, preventing legal duplicate FQNs from overwriting each other.
68
+ # 27 = custom-named Gradle build units (``project.buildFileName``) participate
69
+ # in Java namespaces; cached root-scoped IDs must be rebuilt.
70
+ # 28 = GraphNode gained ``end_line`` (symbol body end, from parse position) so
71
+ # drift_node/drift_search can size a source snippet to the real method
72
+ # length instead of a flat default; cached nodes predate the field.
73
+ GRAPH_SCHEMA_VERSION = 28
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+ class CallGraphBuilder:
78
+ """Builds a NetworkX directed graph from analysis results.
79
+
80
+ The graph captures:
81
+ - Which functions call which other functions
82
+ - Module import relationships
83
+ - Class containment hierarchies
84
+ - Async/await relationships
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ project_root: Optional[Path] = None,
90
+ resolver_config: Optional[ResolverConfig] = None,
91
+ cfg_config: Optional[CFGConfig] = None,
92
+ ):
93
+ # P4.4: the active language's name-resolution policy. None until
94
+ # either passed explicitly (a backend's `resolver_config()`) or
95
+ # lazily defaulted to Python's on first `_get_resolver()` call — every
96
+ # pre-P4.4 call site constructs `CallGraphBuilder()` with no config
97
+ # and must keep getting Python behavior.
98
+ self.resolver_config = resolver_config
99
+ self._resolver: Optional[CallResolver] = None
100
+ # P4.6: the active language's CFG/taint tables + mechanics factories.
101
+ # Same lazy-default story as `resolver_config` above — every pre-P4.6
102
+ # call site constructs `CallGraphBuilder()`/`from_results()` with no
103
+ # config and must keep getting Python behavior via `_get_cfg_config()`.
104
+ self.cfg_config = cfg_config
105
+ self._taint_strategy = None
106
+ # MultiDiGraph: parallel edges of different types between the same
107
+ # node pair must coexist (CALLS+AWAITS, TYPE_OF+RETURNS, ...) — a
108
+ # plain DiGraph silently overwrites the first edge's attributes.
109
+ self.graph = nx.MultiDiGraph()
110
+ self._nodes: Dict[str, GraphNode] = {}
111
+ self._edges: List[GraphEdge] = []
112
+ self._edge_keys: Set[Tuple[str, str, EdgeType]] = set()
113
+ # Dependency edges above intentionally stay unique for traversal and
114
+ # impact analysis. This parallel collection preserves every concrete
115
+ # source occurrence for metrics, taint and IDE positions.
116
+ self._edge_occurrences: List[GraphEdge] = []
117
+ self._occurrence_keys: Set[Tuple[Any, ...]] = set()
118
+ self.project_root = Path(project_root).resolve() if project_root is not None else None
119
+ # Module/package ownership is independent of emitted symbols: valid
120
+ # compilation units such as package-info.java may contain none.
121
+ self._module_owners: Dict[str, Set[str]] = {}
122
+ self.backend_names: Set[str] = set()
123
+ self.indexed_extensions: Set[str] = set()
124
+ self._project_languages: Optional[Set[str]] = None
125
+ self._module_suffix_index: Optional[Dict[Tuple[str, str], str]] = None
126
+ self._qualified_lookup_cache: Dict[Tuple[str, str], Optional[str]] = {}
127
+ self._global_dotted_index: Optional[Dict[str, List[str]]] = None
128
+ # simple CLASS name -> its node id if globally unique, else None.
129
+ # Lazily built/invalidated exactly like `_global_dotted_index` above.
130
+ self._class_by_simple_name_index: Optional[Dict[str, Optional[str]]] = None
131
+ self._classes_by_simple_name: Dict[str, List[Tuple[str, str]]] = {}
132
+ self._java_build_module_roots: Optional[List[Tuple[Path, str]]] = None
133
+ self._java_build_module_cache: Dict[str, Optional[str]] = {}
134
+ self._java_build_module_dir_cache: Dict[str, Path] = {}
135
+ self._java_namespace_visibility: Optional[Dict[str, Set[str]]] = None
136
+ # Pending relationship data collected during add_file_result,
137
+ # resolved later by resolve_relationships().
138
+ # class_id -> (superclass_name_list, file_path, line)
139
+ self._pending_inherits: Dict[str, Tuple[List[str], str, int]] = {}
140
+ self._pending_implements: Dict[str, Tuple[List[str], str, int]] = {}
141
+ # symbol_id -> (decorator_text_list, module_name, file_path, line)
142
+ self._pending_decorators: Dict[str, Tuple[List[str], str, str, int]] = {}
143
+ # file_path -> (module_id, import_map {binding: dotted_path})
144
+ self._pending_imports: Dict[str, Tuple[str, Dict[str, str]]] = {}
145
+ # P2.15: decorated_fn_node_id → (stub_attrs, rebound_class_id)
146
+ # Populated by resolve_decorator_types(); consumed by _resolve_target().
147
+ self._decorator_rebirds: Dict[str, Tuple[Optional[Set[str]], Optional[str]]] = {}
148
+ # P1.10 step 1: collision tripwire side table. When _add_node sees an
149
+ # existing id with a different file or node type, the displaced node is
150
+ # kept here keyed by its location (Drift V3's location-identity
151
+ # pattern) instead of vanishing silently.
152
+ self.node_collisions: Dict[Tuple[str, int], GraphNode] = {}
153
+
154
+ # Dropped from the pickled snapshot `CortexMemory.save_graph` persists:
155
+ # per-language policy objects (they hold lambdas) and every cache that is
156
+ # rebuilt lazily from the nodes/edges, so an unpickled builder starts in
157
+ # the same state as one reloaded row by row.
158
+ _SNAPSHOT_EXCLUDED = frozenset({
159
+ "resolver_config", "_resolver", "cfg_config", "_taint_strategy",
160
+ "_project_languages", "_module_suffix_index", "_qualified_lookup_cache",
161
+ "_global_dotted_index", "_class_by_simple_name_index",
162
+ "_java_build_module_roots", "_java_build_module_cache",
163
+ "_java_build_module_dir_cache", "_java_namespace_visibility",
164
+ })
165
+
166
+ def __getstate__(self) -> Dict[str, Any]:
167
+ return {
168
+ name: value for name, value in self.__dict__.items()
169
+ if name not in self._SNAPSHOT_EXCLUDED
170
+ }
171
+
172
+ def __setstate__(self, state: Dict[str, Any]) -> None:
173
+ self.__dict__.update(CallGraphBuilder().__dict__)
174
+ self.__dict__.update(state)
175
+
176
+ def _get_resolver(self) -> CallResolver:
177
+ """P4.4: lazily build (and cache) this builder's `CallResolver`.
178
+
179
+ `self.resolver_config` is None for every pre-P4.4 call site
180
+ (`CallGraphBuilder()` with no args, still used throughout
181
+ `cli/commands.py`/`server/handlers.py`/`cortex/memory.py` — wiring
182
+ those through `lang.registry` is P4.8/P4.10, out of scope here) — in
183
+ that case default to Python's config, matching the behavior every
184
+ existing caller already depends on. A backend-supplied config
185
+ (`JavaBackend.resolver_config()`) builds its own resolver via
186
+ `ResolverConfig.resolver_factory`.
187
+ """
188
+ if self._resolver is None:
189
+ if self.resolver_config is None:
190
+ from adl_drift.lang.python.resolver import (
191
+ default_python_resolver_config,
192
+ )
193
+ self.resolver_config = default_python_resolver_config()
194
+ if self.resolver_config.resolver_factory is None:
195
+ raise ValueError(
196
+ "ResolverConfig has no resolver_factory — "
197
+ "LanguageBackend.resolver_config() must set one "
198
+ "(see lang/python/resolver.py::default_python_resolver_config)."
199
+ )
200
+ self._resolver = self.resolver_config.resolver_factory(
201
+ self, self.resolver_config
202
+ )
203
+ return self._resolver
204
+
205
+ def _get_cfg_config(self) -> CFGConfig:
206
+ """P4.6: lazily default `self.cfg_config` to Python's, mirroring
207
+ `_get_resolver()`'s fallback — every pre-P4.6 call site (no config
208
+ passed) keeps getting Python's CFG/taint tables."""
209
+ if self.cfg_config is None:
210
+ from adl_drift.lang.python.cfg import default_python_cfg_config
211
+ self.cfg_config = default_python_cfg_config()
212
+ return self.cfg_config
213
+
214
+ def _get_taint_strategy(self):
215
+ """P4.6: lazily build (and cache) this builder's `TaintStrategy`
216
+ (`detectors/taint.py`) via `CFGConfig.taint_strategy_factory`."""
217
+ if self._taint_strategy is None:
218
+ cfg_config = self._get_cfg_config()
219
+ if cfg_config.taint_strategy_factory is None:
220
+ raise ValueError(
221
+ "CFGConfig has no taint_strategy_factory — "
222
+ "LanguageBackend.cfg_config() must set one "
223
+ "(see lang/python/cfg.py::default_python_cfg_config)."
224
+ )
225
+ self._taint_strategy = cfg_config.taint_strategy_factory(cfg_config)
226
+ return self._taint_strategy
227
+
228
+ def add_file_result(self, result: AnalysisResult) -> None:
229
+ """Add symbols and patterns from a single file to the graph."""
230
+ self.backend_names.add(result.language)
231
+ self.indexed_extensions.add(Path(result.file_path).suffix)
232
+ module_name = self._module_for_result(result)
233
+
234
+ # Add module node
235
+ module_id = f"{module_name}"
236
+ self._module_owners.setdefault(module_id, set()).add(str(result.file_path))
237
+ existing_mod = self._nodes.get(module_id)
238
+ if existing_mod is None:
239
+ module_node = GraphNode(
240
+ id=module_id,
241
+ name=self._display_module(module_name),
242
+ node_type=NodeType.MODULE,
243
+ module=module_name,
244
+ file_path=str(result.file_path),
245
+ line=0,
246
+ column=0,
247
+ language=result.language,
248
+ )
249
+ self._add_node(module_node)
250
+ elif existing_mod.node_type != NodeType.MODULE:
251
+ # P1.10 step 1: a non-module node already owns this id (e.g. class
252
+ # ``b`` in ``a/__init__.py`` vs submodule ``a/b.py``). The module
253
+ # node is the loser here (existing node kept) — record it.
254
+ logger.warning(
255
+ "node id collision: %r — MODULE (%s) blocked by existing %s (%s:%d)",
256
+ module_id, result.file_path,
257
+ existing_mod.node_type.name, existing_mod.file_path, existing_mod.line,
258
+ )
259
+ self.node_collisions[(str(result.file_path), 0)] = GraphNode(
260
+ id=module_id,
261
+ name=module_name,
262
+ node_type=NodeType.MODULE,
263
+ module=module_name,
264
+ file_path=str(result.file_path),
265
+ line=0,
266
+ column=0,
267
+ )
268
+
269
+ # Stash the resolved import map for later module-level IMPORTS edges
270
+ if result.imports or result.import_records:
271
+ self._pending_imports[str(result.file_path)] = (
272
+ module_id,
273
+ self._import_map_for(result, module_name),
274
+ )
275
+
276
+ # P1.11: map simple function name -> [(line, node_id)] for the symbols
277
+ # added in THIS call, so nested route handlers (registered inside a
278
+ # factory/setup method, e.g. @self.app.post(...) in _setup_routes) can
279
+ # be resolved by scope-aware id instead of the naive module:handler concat.
280
+ func_by_name: Dict[str, List[Tuple[int, str]]] = {}
281
+ # Java's `java_test` pattern (P4.3's JavaTestDetector) carries no
282
+ # function name in its metadata, only the position of the same
283
+ # `method_declaration` CST node the extractor itself positioned the
284
+ # Symbol on — so it's matched back to a node by exact line instead of
285
+ # by name (see the TESTS branch in the patterns loop below).
286
+ func_by_line: Dict[int, str] = {}
287
+
288
+ # Add symbols as nodes
289
+ for symbol in result.symbols:
290
+ node_id = self._symbol_to_id(symbol, module_name)
291
+ if symbol.kind == "class":
292
+ node_type = NodeType.CLASS
293
+ elif symbol.kind in ("variable", "attribute"):
294
+ node_type = NodeType.VARIABLE
295
+ else:
296
+ node_type = NodeType.FUNCTION
297
+ func_by_name.setdefault(symbol.name, []).append(
298
+ (symbol.position.line, node_id)
299
+ )
300
+ func_by_line.setdefault(symbol.position.line, node_id)
301
+
302
+ graph_node = GraphNode(
303
+ id=node_id,
304
+ name=symbol.name,
305
+ node_type=node_type,
306
+ module=module_name,
307
+ file_path=str(symbol.file_path),
308
+ line=symbol.position.line,
309
+ column=symbol.position.column,
310
+ end_line=symbol.position.end_line,
311
+ is_async=symbol.is_async,
312
+ decorators=symbol.decorators,
313
+ docstring=symbol.docstring,
314
+ signature=symbol.signature,
315
+ contained_in=symbol.parent,
316
+ is_test=symbol.is_test,
317
+ metadata={
318
+ **symbol.metadata,
319
+ **({"modifiers": list(symbol.modifiers)} if symbol.modifiers else {}),
320
+ **({"throws": list(symbol.throws)} if symbol.throws else {}),
321
+ },
322
+ language=result.language,
323
+ )
324
+
325
+ self._add_node(graph_node)
326
+
327
+ # Link to parent module
328
+ self._add_edge(GraphEdge(
329
+ source=module_id,
330
+ target=node_id,
331
+ edge_type=EdgeType.CONTAINS,
332
+ file_path=str(symbol.file_path),
333
+ line=symbol.position.line,
334
+ ))
335
+
336
+ # Link to immediate enclosing scope (class or function).
337
+ # P2.17: scope_chain encodes the full lexical scope outermost-first,
338
+ # so the immediate parent is module + all scope_chain segments.
339
+ # This ensures nested defs appear under their enclosing function
340
+ # (not just the module), enabling correct container expansion.
341
+ if symbol.scope_chain:
342
+ # P1.10: the chain embeds <locals> after function segments;
343
+ # the enclosing definition's own id has no trailing marker.
344
+ chain = symbol.scope_chain
345
+ if chain[-1] == LOCALS_MARKER:
346
+ chain = chain[:-1]
347
+ parent_id = f"{module_name}:{'.'.join(chain)}"
348
+ self._add_edge(GraphEdge(
349
+ source=parent_id,
350
+ target=node_id,
351
+ edge_type=EdgeType.CONTAINS,
352
+ ))
353
+ elif symbol.parent:
354
+ # Pre-P2.17 fallback for cached Symbol objects with no scope_chain
355
+ parent_id = f"{module_name}:{symbol.parent}"
356
+ self._add_edge(GraphEdge(
357
+ source=parent_id,
358
+ target=node_id,
359
+ edge_type=EdgeType.CONTAINS,
360
+ ))
361
+
362
+ # Stash class superclasses for INHERITS resolution
363
+ if symbol.kind == "class" and symbol.superclasses:
364
+ self._pending_inherits[node_id] = (
365
+ list(symbol.superclasses),
366
+ str(symbol.file_path),
367
+ symbol.position.line,
368
+ )
369
+ if symbol.kind == "class" and symbol.implements:
370
+ self._pending_implements[node_id] = (
371
+ list(symbol.implements),
372
+ str(symbol.file_path),
373
+ symbol.position.line,
374
+ )
375
+
376
+ # Stash decorators for DECORATED_BY resolution
377
+ if symbol.decorators:
378
+ self._pending_decorators[node_id] = (
379
+ list(symbol.decorators),
380
+ module_name,
381
+ str(symbol.file_path),
382
+ symbol.position.line,
383
+ )
384
+
385
+ # Add patterns as hints for entry points
386
+ for pattern in result.patterns:
387
+ if pattern.category.name == "ROUTES":
388
+ # Route handlers are entry points
389
+ handler = pattern.metadata.get("handler", "")
390
+ if not handler:
391
+ continue
392
+ # P1.11: resolve the handler against this file's FUNCTION nodes
393
+ # (scope-aware ids), so handlers nested in a factory/setup method
394
+ # are marked. Disambiguate same-named handlers by the candidate
395
+ # whose def line is closest at-or-after the pattern line (the
396
+ # decorator sits directly above the def). Fall back to the naive
397
+ # module:handler concat for cached results with no node match.
398
+ candidates = func_by_name.get(handler)
399
+ node_id = None
400
+ if candidates:
401
+ pat_line = pattern.position.line
402
+ at_or_after = [c for c in candidates if c[0] >= pat_line]
403
+ chosen = min(at_or_after or candidates, key=lambda c: abs(c[0] - pat_line))
404
+ node_id = chosen[1]
405
+ else:
406
+ fallback = f"{module_name}:{handler}"
407
+ if fallback in self._nodes:
408
+ node_id = fallback
409
+ if node_id and node_id in self._nodes:
410
+ self._nodes[node_id].is_entry_point = True
411
+ self.graph.nodes[node_id]["is_entry_point"] = True
412
+ elif pattern.name == "java_test":
413
+ # Java's own `Symbol.is_test` is deliberately left unset by
414
+ # the extractor (PROBLEMS.md P4.2) — JUnit/TestNG detection
415
+ # is P4.3's `JavaTestDetector` job, so the flag is backfilled
416
+ # here from its `java_test` pattern the same way a ROUTES
417
+ # pattern backfills `is_entry_point` above. The pattern and
418
+ # the Symbol are positioned on the very same
419
+ # `method_declaration` node, so an exact line match (not the
420
+ # ROUTES branch's nearest-line heuristic) is enough.
421
+ node_id = func_by_line.get(pattern.position.line)
422
+ if node_id and node_id in self._nodes:
423
+ self._nodes[node_id].is_test = True
424
+ self.graph.nodes[node_id]["is_test"] = True
425
+
426
+ def _add_node(self, node: GraphNode) -> None:
427
+ """Add a node to the graph.
428
+
429
+ P1.10 step 1 (collision tripwire): same id arriving from a different
430
+ file means two distinct program entities share one id (e.g. namespace
431
+ package roots). Same-file type changes are normal Python rebinding, so
432
+ insertion stays last-write-wins without a false collision warning.
433
+ """
434
+ existing = self._nodes.get(node.id)
435
+ self._qualified_lookup_cache.clear()
436
+ self._global_dotted_index = None
437
+ self._class_by_simple_name_index = None
438
+ self._classes_by_simple_name = {}
439
+ if node.node_type == NodeType.MODULE:
440
+ self._module_suffix_index = None
441
+ if existing is not None and existing.file_path != node.file_path:
442
+ logger.warning(
443
+ "node id collision: %r — %s (%s:%d) overwrites %s (%s:%d)",
444
+ node.id,
445
+ node.node_type.name, node.file_path, node.line,
446
+ existing.node_type.name, existing.file_path, existing.line,
447
+ )
448
+ self.node_collisions[(existing.file_path, existing.line)] = existing
449
+ self._nodes[node.id] = node
450
+ self.graph.add_node(
451
+ node.id,
452
+ **node.to_dict()
453
+ )
454
+
455
+ def _add_edge(self, edge: GraphEdge) -> None:
456
+ """Add an edge to the graph.
457
+
458
+ Skip if an edge with same (source, target, edge_type) already exists.
459
+ Python permits duplicate method names within a class (later overrides
460
+ earlier); extractor emits a symbol per definition, so without this
461
+ guard duplicate CONTAINS edges leak through and violate the SQLite
462
+ UNIQUE constraint on save_graph.
463
+ """
464
+ key = (edge.source, edge.target, edge.edge_type)
465
+ if key in self._edge_keys:
466
+ return
467
+ self._edge_keys.add(key)
468
+ self._edges.append(edge)
469
+ self.graph.add_edge(
470
+ edge.source,
471
+ edge.target,
472
+ key=edge.edge_type.name,
473
+ **edge.to_dict()
474
+ )
475
+
476
+ def _record_edge_occurrence(self, edge: GraphEdge) -> None:
477
+ """Record one deterministic source occurrence without changing the
478
+ node-level dependency graph.
479
+
480
+ Exact byte ranges distinguish nested calls sharing a begin position.
481
+ Legacy producers without ranges fall back to line/column; identical
482
+ fallback records are conservatively deduplicated.
483
+ """
484
+ key = (
485
+ edge.source, edge.target, edge.edge_type, edge.file_path,
486
+ edge.start_byte if edge.start_byte is not None else edge.line,
487
+ edge.end_byte if edge.end_byte is not None else edge.column,
488
+ )
489
+ if key in self._occurrence_keys:
490
+ return
491
+ self._occurrence_keys.add(key)
492
+ self._edge_occurrences.append(edge)
493
+
494
+ def reconcile_edges(self) -> None:
495
+ """Drop edges whose endpoints no longer exist and re-sync the NetworkX
496
+ graph. Called after an incremental update to clean up cross-file
497
+ edges whose target node was deleted or renamed in a changed file, and
498
+ to re-insert kept inbound edges whose target node was re-added.
499
+ """
500
+ kept: List[GraphEdge] = []
501
+ self._edge_keys.clear()
502
+ for e in self._edges:
503
+ if e.source not in self._nodes or e.target not in self._nodes:
504
+ continue
505
+ key = (e.source, e.target, e.edge_type)
506
+ if key in self._edge_keys:
507
+ continue
508
+ self._edge_keys.add(key)
509
+ kept.append(e)
510
+ if not self.graph.has_edge(e.source, e.target, key=e.edge_type.name):
511
+ self.graph.add_edge(
512
+ e.source, e.target, key=e.edge_type.name, **e.to_dict()
513
+ )
514
+ self._edges = kept
515
+ self._edge_occurrences = [
516
+ e for e in self._edge_occurrences
517
+ if e.source in self._nodes and e.target in self._nodes
518
+ ]
519
+ self._occurrence_keys = {
520
+ (
521
+ e.source, e.target, e.edge_type, e.file_path,
522
+ e.start_byte if e.start_byte is not None else e.line,
523
+ e.end_byte if e.end_byte is not None else e.column,
524
+ )
525
+ for e in self._edge_occurrences
526
+ }
527
+
528
+ def remove_file(self, file_path: str) -> None:
529
+ """Remove all nodes and edges associated with a given file path.
530
+
531
+ Used during incremental updates when a file has changed or been deleted.
532
+ """
533
+ # P1.10: drop collision records anchored in the removed file — the
534
+ # displaced definition no longer exists in the source.
535
+ for key in list(self.node_collisions):
536
+ if key[0] == file_path:
537
+ del self.node_collisions[key]
538
+
539
+ # A Java package node is shared by every compilation unit in that
540
+ # package, including units that emit no symbols (package-info.java).
541
+ for module_id, owners in list(self._module_owners.items()):
542
+ if file_path not in owners:
543
+ continue
544
+ owners.remove(file_path)
545
+ if not owners:
546
+ del self._module_owners[module_id]
547
+ continue
548
+ module_node = self._nodes.get(module_id)
549
+ if module_node is not None and module_node.file_path == file_path:
550
+ module_node.file_path = min(owners)
551
+ self.graph.nodes[module_id]["file"] = module_node.file_path
552
+
553
+ nodes_to_remove = [nid for nid, node in self._nodes.items() if node.file_path == file_path]
554
+ nodes_set = set(nodes_to_remove)
555
+
556
+ # Clean up calls / called_by references on remaining nodes
557
+ for node in self._nodes.values():
558
+ node.calls -= nodes_set
559
+ node.called_by -= nodes_set
560
+
561
+ # Remove from NetworkX graph (this also removes incident edges)
562
+ for nid in nodes_to_remove:
563
+ if nid in self.graph:
564
+ self.graph.remove_node(nid)
565
+ del self._nodes[nid]
566
+ self._module_suffix_index = None
567
+ self._qualified_lookup_cache.clear()
568
+
569
+ # Drop outbound edges from removed nodes (their bodies are gone, so
570
+ # call resolution will re-emit them). Keep inbound edges in the list
571
+ # so that callers in unchanged files don't lose their CALLS targets
572
+ # when the file is later re-added with the same symbol IDs. These
573
+ # inbound edges get re-validated by ``reconcile_edges`` after the
574
+ # incremental update completes.
575
+ kept: List[GraphEdge] = []
576
+ for e in self._edges:
577
+ if e.source in nodes_set or e.file_path == file_path:
578
+ self._edge_keys.discard((e.source, e.target, e.edge_type))
579
+ else:
580
+ kept.append(e)
581
+ self._edges = kept
582
+ self._edge_occurrences = [
583
+ e for e in self._edge_occurrences
584
+ if e.source not in nodes_set and e.file_path != file_path
585
+ ]
586
+ self._occurrence_keys = {
587
+ (
588
+ e.source, e.target, e.edge_type, e.file_path,
589
+ e.start_byte if e.start_byte is not None else e.line,
590
+ e.end_byte if e.end_byte is not None else e.column,
591
+ )
592
+ for e in self._edge_occurrences
593
+ }
594
+
595
+ # Purge pending relationship data for removed nodes
596
+ for nid in list(self._pending_inherits):
597
+ if nid in nodes_set:
598
+ del self._pending_inherits[nid]
599
+ for nid in list(self._pending_implements):
600
+ if nid in nodes_set:
601
+ del self._pending_implements[nid]
602
+ for nid in list(self._pending_decorators):
603
+ if nid in nodes_set:
604
+ del self._pending_decorators[nid]
605
+ self._pending_imports.pop(file_path, None)
606
+ for nid in nodes_set:
607
+ self._decorator_rebirds.pop(nid, None)
608
+
609
+ def resolve_calls(
610
+ self,
611
+ parses: Dict[Path, ParseResult],
612
+ results: Dict[Path, AnalysisResult],
613
+ ) -> int:
614
+ """Second pass: emit CALLS/INSTANTIATES/AWAITS/REFERENCES edges by
615
+ walking each function body — delegated to the active language's
616
+ `CallResolver` (P4.4). See `lang/python/resolver.py::
617
+ PythonCallResolver.resolve_calls` / `lang/java/resolver.py::
618
+ JavaCallResolver.resolve_calls` for the language-specific walk;
619
+ graph mechanics (edge dedupe, MultiDiGraph, confidence) stay here.
620
+ """
621
+ return self._get_resolver().resolve_calls(parses, results)
622
+
623
+ def resolve_relationships(self, results: Dict[Path, AnalysisResult]) -> int:
624
+ """Emit hierarchy, decorator, type, and module import edges.
625
+
626
+ Run after all files have been added via ``add_file_result``. Uses the
627
+ pending relationship data captured during file insertion.
628
+
629
+ Returns the number of edges added.
630
+ """
631
+ added = 0
632
+
633
+ # Indices over current graph state
634
+ by_simple_name: Dict[str, List[str]] = defaultdict(list)
635
+ by_module_symbol: Dict[str, Dict[str, str]] = defaultdict(dict)
636
+ for nid, node in self._nodes.items():
637
+ if node.node_type in (NodeType.FUNCTION, NodeType.CLASS):
638
+ by_simple_name[node.name].append(nid)
639
+ by_module_symbol[node.module][node.name] = nid
640
+
641
+ # Per-file import maps from the original file results (lets us resolve
642
+ # decorators like @app.route where `app` is an imported module/object).
643
+ import_maps_by_file: Dict[str, Dict[str, str]] = {}
644
+ for ar in results.values():
645
+ mod = self._module_for_result(ar)
646
+ import_maps_by_file[str(ar.file_path)] = self._import_map_for(ar, mod)
647
+
648
+ # ---- INHERITS / IMPLEMENTS ----
649
+ for pending, edge_type in (
650
+ (self._pending_inherits, EdgeType.INHERITS),
651
+ (self._pending_implements, EdgeType.IMPLEMENTS),
652
+ ):
653
+ for class_id, (parents, file_path, line) in pending.items():
654
+ if class_id not in self._nodes:
655
+ continue
656
+ class_module = self._nodes[class_id].module
657
+ imap = import_maps_by_file.get(file_path, {})
658
+ for parent_name in parents:
659
+ tid = self._resolve_name(
660
+ parent_name, class_module, imap, by_simple_name, by_module_symbol,
661
+ strict=True,
662
+ )
663
+ if (
664
+ not tid
665
+ or tid == class_id
666
+ or self._has_edge_of_type(class_id, tid, edge_type)
667
+ ):
668
+ continue
669
+ self._add_edge(GraphEdge(
670
+ source=class_id,
671
+ target=tid,
672
+ edge_type=edge_type,
673
+ file_path=file_path,
674
+ line=line,
675
+ ))
676
+ added += 1
677
+
678
+ # ---- DECORATED_BY ----
679
+ for sym_id, (decorators, module_name, file_path, line) in self._pending_decorators.items():
680
+ if sym_id not in self._nodes:
681
+ continue
682
+ imap = import_maps_by_file.get(file_path, {})
683
+ for deco in decorators:
684
+ dname = self._decorator_name(deco)
685
+ if not dname:
686
+ continue
687
+ head = dname.split(".")[0]
688
+ if head in ("self", "cls"):
689
+ # method-bound decorator (e.g. @self.app.get) — receiver
690
+ # type is unknown, skip rather than guess.
691
+ continue
692
+ # Strict mode for decorators: only resolve via imports or same
693
+ # module. Simple-name fallback is too noisy here.
694
+ tid = self._resolve_name(
695
+ dname, module_name, imap, by_simple_name, by_module_symbol,
696
+ strict=True,
697
+ )
698
+ if not tid or tid == sym_id:
699
+ continue
700
+ if self._has_edge_of_type(sym_id, tid, EdgeType.DECORATED_BY):
701
+ continue
702
+ self._add_edge(GraphEdge(
703
+ source=sym_id,
704
+ target=tid,
705
+ edge_type=EdgeType.DECORATED_BY,
706
+ file_path=file_path,
707
+ line=line,
708
+ ))
709
+ added += 1
710
+
711
+ # ---- TYPE_OF / RETURNS (P1.8) ----
712
+ # def f(svc: UserService) -> Report → f --TYPE_OF--> UserService,
713
+ # f --RETURNS--> Report. Strict annotation resolution only.
714
+ for ar in results.values():
715
+ mod = self._module_for_result(ar)
716
+ imap = import_maps_by_file.get(str(ar.file_path), {})
717
+ for sym in ar.symbols:
718
+ if sym.kind != "function":
719
+ continue
720
+ fid = self._symbol_to_id(sym, mod)
721
+ if fid not in self._nodes:
722
+ continue
723
+ for ann in sym.param_types.values():
724
+ tid = self._resolve_annotation(
725
+ ann, mod, imap, by_simple_name, by_module_symbol
726
+ )
727
+ if not tid or tid == fid:
728
+ continue
729
+ if (fid, tid, EdgeType.TYPE_OF) in self._edge_keys:
730
+ continue
731
+ self._add_edge(GraphEdge(
732
+ source=fid,
733
+ target=tid,
734
+ edge_type=EdgeType.TYPE_OF,
735
+ file_path=str(sym.file_path),
736
+ line=sym.position.line,
737
+ ))
738
+ added += 1
739
+ if sym.return_type:
740
+ tid = self._resolve_annotation(
741
+ sym.return_type, mod, imap, by_simple_name, by_module_symbol
742
+ )
743
+ if tid and tid != fid and \
744
+ (fid, tid, EdgeType.RETURNS) not in self._edge_keys:
745
+ self._add_edge(GraphEdge(
746
+ source=fid,
747
+ target=tid,
748
+ edge_type=EdgeType.RETURNS,
749
+ file_path=str(sym.file_path),
750
+ line=sym.position.line,
751
+ ))
752
+ added += 1
753
+
754
+ # ---- IMPORTS (module -> module) ----
755
+ module_by_suffix = self._module_suffixes()
756
+ for file_path, (module_id, imap) in self._pending_imports.items():
757
+ target_modules: Set[str] = set()
758
+ namespace = self._module_namespace(module_id)
759
+ prefix = f"{namespace}::" if namespace else ""
760
+ for local, dotted in imap.items():
761
+ # dotted is either a module ("a.b") or "module.symbol".
762
+ # Try the whole dotted path as a module first, then drop the
763
+ # last segment.
764
+ candidate = f"{prefix}{dotted}"
765
+ if candidate in self._nodes and self._nodes[candidate].node_type == NodeType.MODULE:
766
+ target_modules.add(candidate)
767
+ continue
768
+ parent = ".".join(dotted.split(".")[:-1])
769
+ parent_candidate = f"{prefix}{parent}"
770
+ if parent and parent_candidate in self._nodes and self._nodes[parent_candidate].node_type == NodeType.MODULE:
771
+ target_modules.add(parent_candidate)
772
+ else:
773
+ matched = module_by_suffix.get((namespace, dotted))
774
+ if matched:
775
+ target_modules.add(matched)
776
+ for tid in target_modules:
777
+ if tid == module_id:
778
+ continue
779
+ if self._has_edge_of_type(module_id, tid, EdgeType.IMPORTS):
780
+ continue
781
+ self._add_edge(GraphEdge(
782
+ source=module_id,
783
+ target=tid,
784
+ edge_type=EdgeType.IMPORTS,
785
+ file_path=file_path,
786
+ ))
787
+ added += 1
788
+
789
+ added += self._resolve_overrides()
790
+
791
+ return added
792
+
793
+ def _resolve_overrides(self) -> int:
794
+ """P2.8: derive OVERRIDES edges from INHERITS/IMPLEMENTS + CONTAINS.
795
+
796
+ For each INHERITS or IMPLEMENTS edge, intersect direct method
797
+ signatures of child and parent type; emit
798
+ ``child.method --OVERRIDES--> parent.method``. Interface contracts
799
+ need the same dependency edge as inherited abstract methods: changing
800
+ a contract must reach callers of its concrete implementation.
801
+ Pure graph derivation (no extraction data), so it is safe to recompute
802
+ on every resolve pass — dedupe via ``_edge_keys``.
803
+
804
+ Only *declared* methods take part. Java's synthetic members (enum
805
+ ``values``/``valueOf``, record component accessors) are materialised
806
+ later by ``resolve_calls`` and persisted with the graph, so a fresh
807
+ build never sees them here while an incremental update on a loaded
808
+ graph would — and would emit OVERRIDES edges a full rebuild does not
809
+ (MCP_INCREMENTAL_REBUILD_PLAN.md Decision #7, Spring Modulith:
810
+ ``record JavaSourceChange(...) implements SourceChange``). Skipping
811
+ them keeps the derivation independent of build history.
812
+ """
813
+ added = 0
814
+
815
+ def _methods_of(class_id: str) -> Dict[str, str]:
816
+ out: Dict[str, str] = {}
817
+ if class_id not in self.graph:
818
+ return out
819
+ for _, child, d in self.graph.out_edges(class_id, data=True):
820
+ if d.get("type") != EdgeType.CONTAINS.name:
821
+ continue
822
+ node = self._nodes.get(child)
823
+ if node is None or node.node_type != NodeType.FUNCTION:
824
+ continue
825
+ if node.metadata.get("java_synthetic_record_accessor") or node.metadata.get(
826
+ "java_synthetic_enum_method"
827
+ ):
828
+ continue
829
+ # Java overloads share `node.name`; the opaque id retains
830
+ # the erased signature and is therefore the stable key.
831
+ out[child.rsplit(".", 1)[-1]] = child
832
+ return out
833
+
834
+ for edge in list(self._edges):
835
+ if edge.edge_type not in (EdgeType.INHERITS, EdgeType.IMPLEMENTS):
836
+ continue
837
+ child_methods = _methods_of(edge.source)
838
+ if not child_methods:
839
+ continue
840
+ parent_methods = _methods_of(edge.target)
841
+ for name in child_methods.keys() & parent_methods.keys():
842
+ src, tgt = child_methods[name], parent_methods[name]
843
+ if (src, tgt, EdgeType.OVERRIDES) in self._edge_keys:
844
+ continue
845
+ src_node = self._nodes[src]
846
+ self._add_edge(GraphEdge(
847
+ source=src,
848
+ target=tgt,
849
+ edge_type=EdgeType.OVERRIDES,
850
+ file_path=src_node.file_path,
851
+ line=src_node.line,
852
+ ))
853
+ added += 1
854
+ return added
855
+
856
+ def _has_edge_of_type(self, src: str, tgt: str, etype: EdgeType) -> bool:
857
+ return (src, tgt, etype) in self._edge_keys
858
+
859
+ # ------------------------------------------------------------------ P2.15
860
+
861
+ def resolve_decorator_types(
862
+ self,
863
+ parses: Dict[Path, ParseResult],
864
+ results: Dict[Path, AnalysisResult],
865
+ ) -> None:
866
+ """Optional decorator/annotation-stub pre-pass — delegated to the
867
+ active language's `CallResolver` (P4.4). Python's P2.15 stub table +
868
+ narrow own-inference lives in `lang/python/resolver.py::
869
+ PythonCallResolver.resolve_decorator_types`; Java's simpler
870
+ annotation-stub lookup resolves inline in `_resolve_target` and
871
+ leaves this a no-op (`CallResolver`'s default)."""
872
+ self._get_resolver().resolve_decorator_types(parses, results)
873
+
874
+ def _resolve_name(
875
+ self,
876
+ name: str,
877
+ module_name: str,
878
+ import_map: Dict[str, str],
879
+ by_simple_name: Dict[str, List[str]],
880
+ by_module_symbol: Dict[str, Dict[str, str]],
881
+ strict: bool = False,
882
+ ) -> Optional[str]:
883
+ """Resolve a (possibly dotted) name to a graph node id.
884
+
885
+ ``strict=True`` skips the simple-name fallback (used by DECORATED_BY
886
+ and INHERITS where over-approximation produces noisy false positives).
887
+ """
888
+ if not name:
889
+ return None
890
+ namespace = self._module_namespace(module_name)
891
+ if "." in name:
892
+ head, *_, tail = name.split(".")
893
+ if head in import_map:
894
+ resolved = import_map[head]
895
+ tid = self._lookup_qualified(
896
+ f"{resolved}.{tail}", by_module_symbol, namespace
897
+ )
898
+ if tid:
899
+ return tid
900
+ # try last segment in same module
901
+ if tail in by_module_symbol.get(module_name, {}):
902
+ return by_module_symbol[module_name][tail]
903
+ if strict:
904
+ return None
905
+ cands = by_simple_name.get(tail, [])
906
+ if len(cands) == 1:
907
+ return cands[0]
908
+ return None
909
+ # bare identifier
910
+ if name in by_module_symbol.get(module_name, {}):
911
+ return by_module_symbol[module_name][name]
912
+ if name in import_map:
913
+ resolved = import_map[name]
914
+ tid = self._lookup_qualified(resolved, by_module_symbol, namespace)
915
+ if tid:
916
+ return tid
917
+ # The whole dotted path may itself be a function/class/module —
918
+ # boundary unknown, try the ':' splits (P1.10 step 4).
919
+ tid = self._node_for_dotted(resolved, namespace)
920
+ if tid:
921
+ return tid
922
+ if strict:
923
+ return None
924
+ cands = by_simple_name.get(name, [])
925
+ if len(cands) == 1:
926
+ return cands[0]
927
+ return None
928
+
929
+ @staticmethod
930
+ def _decorator_name(deco: str) -> Optional[str]:
931
+ """Extract the callable name from a decorator string (e.g. '@app.route(\"/\")' -> 'app.route')."""
932
+ s = deco.strip()
933
+ if s.startswith("@"):
934
+ s = s[1:].lstrip()
935
+ # cut at first '(' to drop arguments
936
+ paren = s.find("(")
937
+ if paren >= 0:
938
+ s = s[:paren]
939
+ s = s.strip()
940
+ # only allow dotted identifier chars
941
+ return s if s and all(c.isalnum() or c in "._" for c in s) else None
942
+
943
+ @classmethod
944
+ def from_results(
945
+ cls,
946
+ parses: Dict[Path, ParseResult],
947
+ results: Dict[Path, AnalysisResult],
948
+ project_root: Optional[Path] = None,
949
+ resolver_config: Optional[ResolverConfig] = None,
950
+ cfg_config: Optional[CFGConfig] = None,
951
+ ) -> "CallGraphBuilder":
952
+ """Convenience: build full graph (CONTAINS + CALLS) in one shot.
953
+
954
+ `resolver_config` is None by default (Python, via `_get_resolver()`'s
955
+ lazy fallback) — pass a Java backend's `resolver_config()` to build a
956
+ Java graph the same way (P4.4). `cfg_config` is the P4.6 analogue,
957
+ consumed by `TaintAnalyzer`/`graph.cfg.analyze_function` rather than
958
+ by anything in this method."""
959
+ builder = cls(
960
+ project_root=project_root,
961
+ resolver_config=resolver_config,
962
+ cfg_config=cfg_config,
963
+ )
964
+ for r in results.values():
965
+ builder.add_file_result(r)
966
+ builder.resolve_relationships(results)
967
+ builder.resolve_decorator_types(parses, results)
968
+ builder.resolve_calls(parses, results)
969
+ builder.harvest_literal_candidates(parses, results)
970
+ return builder
971
+
972
+ # ---- impact analysis ----
973
+
974
+ def get_entry_points(self) -> List[GraphNode]:
975
+ """Get all graph entry points (route handlers, main functions)."""
976
+ return [n for n in self._nodes.values() if n.is_entry_point]
977
+
978
+ def get_dependencies(self, node_id: str) -> List[str]:
979
+ """Transitive callees (what `node_id` depends on) via CALLS and
980
+ INSTANTIATES edges.
981
+
982
+ Container expansion (P2.10, mirror of P1.6's incoming fix): a class
983
+ or module depends on whatever its members call — members are seeded
984
+ at depth 0 and excluded from the result, exactly like
985
+ ``get_transitive_callers`` on the incoming side.
986
+ """
987
+ if node_id not in self.graph:
988
+ return []
989
+ seeds = [node_id] + self.get_container_members(node_id)
990
+ seen: Set[str] = set(seeds)
991
+ out: List[str] = []
992
+ q: deque[str] = deque(seeds)
993
+ while q:
994
+ cur = q.popleft()
995
+ for _, b, d in self.graph.out_edges(cur, data=True):
996
+ if d.get("type") not in (
997
+ EdgeType.CALLS.name, EdgeType.CALLABLE_REF.name,
998
+ EdgeType.INSTANTIATES.name,
999
+ ):
1000
+ continue
1001
+ if b in seen:
1002
+ continue
1003
+ seen.add(b)
1004
+ out.append(b)
1005
+ q.append(b)
1006
+ return out
1007
+
1008
+ # Incoming edge kinds that mean "the source depends on the target" for
1009
+ # blast-radius purposes (P1.8/P2.8): calls, variable reads, type
1010
+ # annotations, instantiation, overriding, interface implementation.
1011
+ # CONTAINS is deliberately excluded (container expansion handles it).
1012
+ IMPACT_EDGE_TYPES = frozenset({
1013
+ EdgeType.CALLS.name,
1014
+ EdgeType.CALLABLE_REF.name,
1015
+ EdgeType.REFERENCES.name,
1016
+ EdgeType.TYPE_OF.name,
1017
+ EdgeType.RETURNS.name,
1018
+ EdgeType.INSTANTIATES.name,
1019
+ EdgeType.OVERRIDES.name,
1020
+ EdgeType.IMPLEMENTS.name,
1021
+ EdgeType.DYNAMIC_ATTR.name, # P2.14: __getattr__ class reads
1022
+ })
1023
+
1024
+ # Bugfix (2026-09-08): subset of IMPACT_EDGE_TYPES that reflects an
1025
+ # actual call/reference site, minus OVERRIDES. An OVERRIDES edge means
1026
+ # "this class re-implements that member's signature" — a structural
1027
+ # fact derived from INHERITS/IMPLEMENTS (_resolve_overrides), not a call
1028
+ # site. Every INHERITS/IMPLEMENTS edge whose two classes share a method
1029
+ # name produces one, so a base method with N real implementors always
1030
+ # has >= N OVERRIDES edges pointing at it, drowning out its actual
1031
+ # callers in get_impact_analysis's `direct_callers` (misread as "who
1032
+ # calls this" by any consumer, human or agent, going only by the field
1033
+ # name/docs) and pushing them one hop further out as
1034
+ # `transitive_callers`. Used only for that "direct" computation —
1035
+ # get_callers()/walk_callers()/get_transitive_callers() keep the full
1036
+ # IMPACT_EDGE_TYPES (OVERRIDES included) since the deeper
1037
+ # polymorphic-dispatch signal ("callers of the base may execute the
1038
+ # override", drift.md) is still correct and intentional there.
1039
+ DIRECT_CALLER_EDGE_TYPES = IMPACT_EDGE_TYPES - {EdgeType.OVERRIDES.name}
1040
+ INITIALIZER_SYNTHETIC_KINDS = frozenset({
1041
+ "class_initializer", "instance_initializer",
1042
+ })
1043
+
1044
+ def _is_initializer_node(self, node_id: str) -> bool:
1045
+ """Whether ``node_id`` represents Java's synthetic init context.
1046
+
1047
+ Initializers remain real graph nodes and dependency-edge sources. The
1048
+ distinction is presentation semantics: they are executable contexts,
1049
+ but not source-declared methods or constructors and therefore should
1050
+ not silently inflate an impact report's caller counts.
1051
+ """
1052
+ node = self._nodes.get(node_id)
1053
+ return bool(
1054
+ node is not None
1055
+ and node.metadata.get("synthetic") in self.INITIALIZER_SYNTHETIC_KINDS
1056
+ )
1057
+
1058
+ def _declaring_class_id(self, member_id: str) -> Optional[str]:
1059
+ """The fully-qualified id of `member_id`'s immediate declaring
1060
+ class, via the reverse CONTAINS edge wired from `Symbol.scope_chain`
1061
+ (`f"{module}:{'.'.join(chain)}"`) when the node was added.
1062
+
1063
+ Unlike `GraphNode.contained_in` — which both language extractors'
1064
+ own `_find_parent_class` deliberately keep as the coarser *nearest
1065
+ enclosing simple name* ("Box"), a separate, coarser field than the
1066
+ scope-chain uniqueness key — this is qualified even for a nested
1067
+ class ("Outer.Box"), matching the id the class's own CONTAINS
1068
+ source actually has. A bare `f"{module}:{contained_in}"` lookup
1069
+ silently misses for any nested class member, in either language."""
1070
+ if member_id not in self.graph:
1071
+ return None
1072
+ for source, _, data in self.graph.in_edges(member_id, data=True):
1073
+ if data.get("type") != EdgeType.CONTAINS.name:
1074
+ continue
1075
+ source_node = self._nodes.get(source)
1076
+ if source_node is not None and source_node.node_type == NodeType.CLASS:
1077
+ return source
1078
+ return None
1079
+
1080
+ def _impact_in_edges(
1081
+ self, node_id: str, edge_types: Optional[frozenset] = None
1082
+ ) -> List[Tuple[str, str, float]]:
1083
+ """Incoming dependents of `node_id` over `edge_types` (default
1084
+ IMPACT_EDGE_TYPES), as ``(source_id, edge_type_name, confidence)``
1085
+ triples.
1086
+
1087
+ P2.8: when `node_id` is a constructor (Python's `__init__`, or a
1088
+ Java constructor — `symbol.metadata["constructor"]`, since Java
1089
+ names a constructor after its class, never `__init__`), INSTANTIATES
1090
+ edges point at its *class* node — fold those sources in so
1091
+ constructor blast radius includes every `Foo()` / `new Foo()` site.
1092
+ """
1093
+ if edge_types is None:
1094
+ edge_types = self.IMPACT_EDGE_TYPES
1095
+ out = [
1096
+ (u, d.get("type"), float(d.get("confidence", 1.0)))
1097
+ for u, _, d in self.graph.in_edges(node_id, data=True)
1098
+ if d.get("type") in edge_types
1099
+ ]
1100
+ node = self._nodes.get(node_id)
1101
+ is_constructor = node is not None and (
1102
+ node.name == "__init__" or node.metadata.get("constructor")
1103
+ )
1104
+ # Bugfix (2026-09-11): only fold in a constructor's INSTANTIATES
1105
+ # sources when the caller actually asked for that edge type. Before
1106
+ # this check, the fold fired unconditionally, so `_override_family`'s
1107
+ # OVERRIDES-only probe (`edge_types={OVERRIDES.name}`) on a
1108
+ # constructor picked up every `new Foo(...)` call site as a bogus
1109
+ # "override family member" — then `_direct_callers` walked *those*
1110
+ # members' own callers one hop further, mislabeling depth-2 callers
1111
+ # (a factory-method forwarder, helper-method callers in tests) as
1112
+ # depth-1 direct callers of the constructor.
1113
+ if is_constructor and EdgeType.INSTANTIATES.name in edge_types:
1114
+ class_id = self._declaring_class_id(node_id)
1115
+ if class_id is not None and class_id in self.graph:
1116
+ # Bugfix (2026-09-12): a class with more than one declared
1117
+ # constructor still has exactly one INSTANTIATES target (the
1118
+ # class node — `_resolve_constructor`'s own docstring: "never
1119
+ # fans out to per-constructor targets"), so every `new Foo(...)`
1120
+ # site's edge lands here regardless of which overload it
1121
+ # actually resolved to. Without this filter, querying *any one*
1122
+ # overload's callers returned the union of every overload's
1123
+ # callers (spring-modulith benchmark: `ShiftedQuarter.of`'s and
1124
+ # `ApplicationModules.of(Class)`'s both-previously-unreconciled
1125
+ # +6 full-depth overcounts — `MomentsProperties`'s and
1126
+ # `Documenter`'s *other* same-arity constructor, which takes an
1127
+ # already-built value instead of deriving one, got folded in
1128
+ # too, then walked one hop further transitively). Each edge's
1129
+ # own `constructor_signature` (set by `_resolve_constructor`
1130
+ # whenever a single overload was actually selected) is the
1131
+ # erased suffix embedded in every constructor node id
1132
+ # (`Foo.(Bar,int)`) — compare the two; a missing/`None` value
1133
+ # means resolution itself was ambiguous between constructors,
1134
+ # so keep including it rather than manufacture a false
1135
+ # exclusion.
1136
+ own_signature = node_id[node_id.rfind("("):] if "(" in node_id else None
1137
+ out.extend(
1138
+ (u, d.get("type"), float(d.get("confidence", 1.0)))
1139
+ for u, _, d in self.graph.in_edges(class_id, data=True)
1140
+ if d.get("type") == EdgeType.INSTANTIATES.name
1141
+ and (
1142
+ own_signature is None
1143
+ or d.get("metadata", {}).get("constructor_signature") in (None, own_signature)
1144
+ )
1145
+ )
1146
+ return out
1147
+
1148
+ def _impact_in_neighbors(self, node_id: str) -> List[str]:
1149
+ """Incoming dependent node ids of `node_id` over IMPACT_EDGE_TYPES."""
1150
+ return [u for u, _, _ in self._impact_in_edges(node_id)]
1151
+
1152
+ def get_callers(self, node_id: str) -> List[str]:
1153
+ """Direct dependents of `node_id`: callers, variable readers,
1154
+ functions typed by it, instantiators, overriders, and implementors."""
1155
+ if node_id not in self.graph:
1156
+ return []
1157
+ return list(dict.fromkeys(self._impact_in_neighbors(node_id)))
1158
+
1159
+ def _override_family(self, node_id: str) -> List[str]:
1160
+ """`node_id` plus every node reachable by following incoming OVERRIDES
1161
+ edges (i.e. `node_id` itself, its direct overriders, their own
1162
+ overriders, ...). All members dispatch the same logical operation —
1163
+ a call resolved to any one of them is a real invocation of the
1164
+ others' contract too."""
1165
+ family = [node_id]
1166
+ seen = {node_id}
1167
+ idx = 0
1168
+ while idx < len(family):
1169
+ cur = family[idx]
1170
+ idx += 1
1171
+ for u, _, _ in self._impact_in_edges(
1172
+ cur, edge_types={EdgeType.OVERRIDES.name}
1173
+ ):
1174
+ if u not in seen:
1175
+ seen.add(u)
1176
+ family.append(u)
1177
+ return family
1178
+
1179
+ def _direct_callers(self, node_id: str) -> List[str]:
1180
+ """Real call/reference sites for `node_id` — `get_callers()` minus
1181
+ OVERRIDES (DIRECT_CALLER_EDGE_TYPES), used for get_impact_analysis's
1182
+ `direct_callers`, where an overrider is not itself a call site.
1183
+
1184
+ Bugfix (2026-09-08) follow-up: benchmarked against ground truth,
1185
+ excluding OVERRIDES alone undercounts an interface/base method's
1186
+ direct callers to 0 whenever every real call site was resolved (by
1187
+ P4.12 dispatch fan-out) straight to a concrete implementation rather
1188
+ than the interface node — e.g. `OwnerRepository.findById` in the
1189
+ Spring PetClinic corpus: GT=2 real callers, this method previously
1190
+ returned 0. Fixed by flattening one degree of dispatch: also fold in
1191
+ the real callers of every node in `node_id`'s OVERRIDES family
1192
+ (`_override_family`) — the overriders themselves stay excluded, only
1193
+ *their* callers count. This is asymmetric by design — querying a
1194
+ concrete override's own direct callers must NOT pull in the
1195
+ interface's callers through *other*, unrelated implementations."""
1196
+ if node_id not in self.graph:
1197
+ return []
1198
+ out: List[str] = []
1199
+ seen: Set[str] = set()
1200
+ for member in self._override_family(node_id):
1201
+ for u, _, _ in self._impact_in_edges(
1202
+ member, edge_types=self.DIRECT_CALLER_EDGE_TYPES
1203
+ ):
1204
+ if u not in seen:
1205
+ seen.add(u)
1206
+ out.append(u)
1207
+ return out
1208
+
1209
+ # Node types whose members are "part of" the node for impact purposes
1210
+ CONTAINER_TYPES = (NodeType.CLASS, NodeType.MODULE)
1211
+
1212
+ def get_container_members(self, node_id: str) -> List[str]:
1213
+ """Transitive CONTAINS descendants of a container node (class/module).
1214
+
1215
+ Returns [] for non-container nodes. Changing a container implicitly
1216
+ changes every member (P1.6 / CODEGRAPH_2.md §5.1).
1217
+ """
1218
+ node = self._nodes.get(node_id)
1219
+ if node is None or node.node_type not in self.CONTAINER_TYPES:
1220
+ return []
1221
+ out: List[str] = []
1222
+ seen: Set[str] = {node_id}
1223
+ q: deque[str] = deque([node_id])
1224
+ while q:
1225
+ cur = q.popleft()
1226
+ for _, child, d in self.graph.out_edges(cur, data=True):
1227
+ if d.get("type") != EdgeType.CONTAINS.name or child in seen:
1228
+ continue
1229
+ seen.add(child)
1230
+ out.append(child)
1231
+ q.append(child)
1232
+ return out
1233
+
1234
+ def get_transitive_callers(self, node_id: str) -> Dict[str, int]:
1235
+ """Reverse BFS over dependency edges (CALLS / REFERENCES / TYPE_OF /
1236
+ RETURNS). Returns {node_id: depth}.
1237
+
1238
+ Container expansion (P1.6): when `node_id` is a class or module,
1239
+ its CONTAINS descendants are seeded at depth 0 — their callers
1240
+ therefore appear at depth 1, exactly as if the member itself had
1241
+ changed. CONTAINS is never followed on the incoming side, so a
1242
+ method's impact does not climb to its class and re-expand siblings.
1243
+ Calls *between* members of the same container are not reported as
1244
+ impact (both endpoints are seeds).
1245
+ """
1246
+ if node_id not in self.graph:
1247
+ return {}
1248
+ seeds = [node_id] + self.get_container_members(node_id)
1249
+ depths: Dict[str, int] = {}
1250
+ visited: Set[str] = set(seeds)
1251
+ q: deque[Tuple[str, int]] = deque((s, 0) for s in seeds)
1252
+ while q:
1253
+ cur, depth = q.popleft()
1254
+ for u in self._impact_in_neighbors(cur):
1255
+ if u in visited:
1256
+ continue
1257
+ visited.add(u)
1258
+ depths[u] = depth + 1
1259
+ q.append((u, depth + 1))
1260
+ return depths
1261
+
1262
+ def get_transitive_instantiators(self, node_id: str) -> Dict[str, int]:
1263
+ """Reverse BFS whose first hop is instantiation-only, then walks the
1264
+ regular dependency graph outward from there. The transitive
1265
+ counterpart of ``instantiator_count`` (get_impact_analysis).
1266
+
1267
+ ``get_transitive_callers(class_or_ctor)`` seeds every hop — including
1268
+ the first, straight off the class/constructor node — with the full
1269
+ IMPACT_EDGE_TYPES union (TYPE_OF/RETURNS/INSTANTIATES/OVERRIDES/...).
1270
+ That answers "who transitively depends on this *type*", which is the
1271
+ right question for "what breaks if I change this class's shape" but
1272
+ not comparable to a ground truth that only counts callers reachable
1273
+ by *constructing* the target (`Target(...)` / `new Target(...)` call
1274
+ chains) — benchmarking transitive_callers against such a GT for a
1275
+ class target silently compares two different questions (e.g. FastAPI
1276
+ benchmark: `Dependant`'s transitive_callers was 60 against a
1277
+ construction-chain GT of 42 — not a 18-node overcount, a category
1278
+ mismatch).
1279
+
1280
+ This narrows hop 1 to the same INSTANTIATES-only sources as
1281
+ ``instantiator_count`` (constructor-fold included, via
1282
+ ``_impact_in_edges``); hop 2+ walk ``_impact_in_neighbors`` exactly
1283
+ like ``get_transitive_callers``, since once you are past the actual
1284
+ instantiation site the question reverts to ordinary "who calls this
1285
+ function" reachability.
1286
+ """
1287
+ if node_id not in self.graph:
1288
+ return {}
1289
+ depths: Dict[str, int] = {}
1290
+ visited: Set[str] = {node_id}
1291
+ q: deque[Tuple[str, int]] = deque()
1292
+ for member in self._override_family(node_id):
1293
+ for u, _, _ in self._impact_in_edges(
1294
+ member, edge_types=frozenset({EdgeType.INSTANTIATES.name})
1295
+ ):
1296
+ if u in visited:
1297
+ continue
1298
+ visited.add(u)
1299
+ depths[u] = 1
1300
+ q.append((u, 1))
1301
+ while q:
1302
+ cur, depth = q.popleft()
1303
+ for u in self._impact_in_neighbors(cur):
1304
+ if u in visited:
1305
+ continue
1306
+ visited.add(u)
1307
+ depths[u] = depth + 1
1308
+ q.append((u, depth + 1))
1309
+ return depths
1310
+
1311
+ # Outgoing edge kinds that mean "the source depends on the target"
1312
+ # (mirror of IMPACT_EDGE_TYPES for the forward direction).
1313
+ DEPENDENCY_EDGE_TYPES = frozenset({
1314
+ EdgeType.CALLS.name,
1315
+ EdgeType.CALLABLE_REF.name,
1316
+ EdgeType.INSTANTIATES.name,
1317
+ })
1318
+
1319
+ def walk_callers(
1320
+ self, node_id: str, max_depth: Optional[int] = 1
1321
+ ) -> List[Dict[str, Any]]:
1322
+ """P2.11: depth-annotated reverse BFS over IMPACT_EDGE_TYPES.
1323
+
1324
+ Returns ``[{id, depth, edge_type, confidence}]`` in BFS order
1325
+ (depth ascending). Container members are seeded at depth 0 (P1.6)
1326
+ and excluded from the result. ``max_depth=None`` is unbounded.
1327
+ ``edge_type``/``confidence`` describe the edge that first reached
1328
+ the node.
1329
+ """
1330
+ if node_id not in self.graph:
1331
+ return []
1332
+ seeds = [node_id] + self.get_container_members(node_id)
1333
+ visited: Set[str] = set(seeds)
1334
+ out: List[Dict[str, Any]] = []
1335
+ q: deque[Tuple[str, int]] = deque((s, 0) for s in seeds)
1336
+ while q:
1337
+ cur, depth = q.popleft()
1338
+ if max_depth is not None and depth >= max_depth:
1339
+ continue
1340
+ for u, etype, conf in self._impact_in_edges(cur):
1341
+ if u in visited:
1342
+ continue
1343
+ visited.add(u)
1344
+ out.append({
1345
+ "id": u, "depth": depth + 1,
1346
+ "edge_type": etype, "confidence": conf,
1347
+ **self._edge_strategy(u, cur, etype),
1348
+ })
1349
+ q.append((u, depth + 1))
1350
+ return out
1351
+
1352
+ def walk_callees(
1353
+ self, node_id: str, max_depth: Optional[int] = 1
1354
+ ) -> List[Dict[str, Any]]:
1355
+ """P2.11: depth-annotated forward BFS over CALLS + INSTANTIATES.
1356
+
1357
+ Same contract as ``walk_callers`` (container members seeded at
1358
+ depth 0 per P2.10, excluded from the result).
1359
+ """
1360
+ if node_id not in self.graph:
1361
+ return []
1362
+ seeds = [node_id] + self.get_container_members(node_id)
1363
+ visited: Set[str] = set(seeds)
1364
+ out: List[Dict[str, Any]] = []
1365
+ q: deque[Tuple[str, int]] = deque((s, 0) for s in seeds)
1366
+ while q:
1367
+ cur, depth = q.popleft()
1368
+ if max_depth is not None and depth >= max_depth:
1369
+ continue
1370
+ for _, b, d in self.graph.out_edges(cur, data=True):
1371
+ if d.get("type") not in self.DEPENDENCY_EDGE_TYPES or b in visited:
1372
+ continue
1373
+ visited.add(b)
1374
+ out.append({
1375
+ "id": b, "depth": depth + 1,
1376
+ "edge_type": d.get("type"),
1377
+ "confidence": float(d.get("confidence", 1.0)),
1378
+ **self._edge_provenance(d),
1379
+ })
1380
+ q.append((b, depth + 1))
1381
+ return out
1382
+
1383
+ def find_path(self, src: str, dst: str) -> Optional[Dict[str, Any]]:
1384
+ """P2.11: shortest path from `src` to `dst`.
1385
+
1386
+ Tries the directed graph first, then falls back to undirected
1387
+ (reporting which succeeded). Each hop carries the connecting
1388
+ edge's type + confidence; ``reversed`` marks hops that only exist
1389
+ against edge direction (undirected fallback). Returns None when
1390
+ no path exists at all.
1391
+ """
1392
+ if src not in self.graph or dst not in self.graph:
1393
+ return None
1394
+ try:
1395
+ nodes = nx.shortest_path(self.graph, src, dst)
1396
+ direction = "directed"
1397
+ except nx.NetworkXNoPath:
1398
+ try:
1399
+ nodes = nx.shortest_path(
1400
+ self.graph.to_undirected(as_view=True), src, dst
1401
+ )
1402
+ direction = "undirected"
1403
+ except nx.NetworkXNoPath:
1404
+ return None
1405
+ steps: List[Dict[str, Any]] = []
1406
+ for a, b in zip(nodes, nodes[1:]):
1407
+ data = self.graph.get_edge_data(a, b)
1408
+ reversed_hop = False
1409
+ if data is None:
1410
+ data = self.graph.get_edge_data(b, a) or {}
1411
+ reversed_hop = True
1412
+ best = max(
1413
+ data.values(),
1414
+ key=lambda d: float(d.get("confidence", 1.0)),
1415
+ ) if data else {}
1416
+ steps.append({
1417
+ "id": a,
1418
+ "edge_to_next": {
1419
+ "type": best.get("type"),
1420
+ "confidence": float(best.get("confidence", 1.0)),
1421
+ "reversed": reversed_hop,
1422
+ **self._edge_provenance(best),
1423
+ },
1424
+ })
1425
+ steps.append({"id": nodes[-1], "edge_to_next": None})
1426
+ return {"direction": direction, "path": steps, "length": len(nodes) - 1}
1427
+
1428
+ def alternate_paths(self, src: str, dst: str, k: int) -> List[Dict[str, Any]]:
1429
+ """MCP_TOKEN_BUDGET_PLAN.md Lớp 3: up to ``k`` directed simple paths
1430
+ after the shortest one, ids only, shortest first. Computed on a
1431
+ DiGraph view (shortest_simple_paths rejects multigraphs) only when
1432
+ a caller asks — the primary path never pays for this."""
1433
+ if k <= 0 or src not in self.graph or dst not in self.graph:
1434
+ return []
1435
+ simple = nx.DiGraph(self.graph)
1436
+ out: List[Dict[str, Any]] = []
1437
+ try:
1438
+ gen = nx.shortest_simple_paths(simple, src, dst)
1439
+ next(gen) # the primary path, already reported
1440
+ for nodes in gen:
1441
+ out.append({"path": list(nodes), "length": len(nodes) - 1})
1442
+ if len(out) >= k:
1443
+ break
1444
+ except (nx.NetworkXNoPath, nx.NodeNotFound, StopIteration):
1445
+ return out
1446
+ return out
1447
+
1448
+ def _edge_strategy(self, source: str, target: str, edge_type: str) -> Dict[str, str]:
1449
+ data = self.graph.get_edge_data(source, target, key=edge_type) or {}
1450
+ return self._edge_provenance(data)
1451
+
1452
+ @staticmethod
1453
+ def _edge_provenance(data: Dict[str, Any]) -> Dict[str, str]:
1454
+ """MCP_TOKEN_BUDGET_PLAN.md Lớp 2: the coarse ``strategy``
1455
+ (``java_resolver``) plus the fine-grained ``metadata["strategy"]``
1456
+ (``interface_fanout``, ``single_implementor``, ``spring_qualifier``,
1457
+ ...) as ``resolution`` — the actual uncertainty signal for abstract
1458
+ dispatch, which never reached MCP responses before."""
1459
+ out: Dict[str, str] = {}
1460
+ if data.get("strategy"):
1461
+ out["strategy"] = data["strategy"]
1462
+ meta = data.get("metadata")
1463
+ if isinstance(meta, dict) and meta.get("strategy"):
1464
+ out["resolution"] = str(meta["strategy"])
1465
+ return out
1466
+
1467
+ def _distinct_impact_targets(self, matches: List[str]) -> List[str]:
1468
+ """Collapse `find_nodes` matches down to distinct top-level impact
1469
+ targets.
1470
+
1471
+ Bugfix: a container's own members (e.g. a Java constructor, whose
1472
+ `GraphNode.name` is always its class's simple name per
1473
+ `extractor.py`'s `metadata["constructor"]` convention) previously
1474
+ counted as a *second*, unrelated ambiguous match alongside their own
1475
+ class — `find_nodes("Box")`'s exact_name tier returns both the class
1476
+ and its constructor node. Worse, when two genuinely distinct classes
1477
+ share a simple name (e.g. two nested `Box` classes in different
1478
+ outers), that inflates to 4 matches for 2 real entities, and
1479
+ `get_impact_analysis` used to silently analyze only `matches[0]` —
1480
+ one arbitrary class — reporting e.g. zero direct callers even when
1481
+ the *other*, unpicked, identically-named class had real callers.
1482
+ Filtering out matches that are already covered by another match's
1483
+ `get_container_members()` collapses class+own-constructor pairs
1484
+ into one target, leaving only genuinely separate entities (if any)
1485
+ for `get_impact_analysis` to merge impact across."""
1486
+ member_sets = {m: set(self.get_container_members(m)) for m in matches}
1487
+ primaries = [
1488
+ m for m in matches
1489
+ if not any(m in member_sets[other] for other in matches if other != m)
1490
+ ]
1491
+ return primaries or list(matches)
1492
+
1493
+ # Characters that continue a dotted identifier segment (Java also uses
1494
+ # `$` for synthetic/nested-class names). Used by `_boundary_contains` to
1495
+ # reject a substring match that lands mid-identifier.
1496
+ _IDENT_CONTINUE = frozenset(
1497
+ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$"
1498
+ )
1499
+
1500
+ @classmethod
1501
+ def _boundary_contains(cls, haystack: str, needle: str) -> bool:
1502
+ """`needle in haystack`, but only accepts a match whose start is a
1503
+ token boundary (string start, or preceded by a non-identifier
1504
+ character like `.`/`:`/`(`/`,`).
1505
+
1506
+ Bugfix: the old plain `in` check let an unrelated, longer identifier
1507
+ that merely ends with the query steal a match — e.g. a query for
1508
+ class `ApplicationModules` also matched `TestApplicationModules`
1509
+ (a distinct class), because `"ApplicationModules"` is a literal
1510
+ substring of `"TestApplicationModules"`. Only the leading edge is
1511
+ checked (not the trailing one) so prefix-style fuzzy search, e.g.
1512
+ `getSpring` finding `getSpringBeans`, still works.
1513
+ """
1514
+ start = 0
1515
+ while True:
1516
+ idx = haystack.find(needle, start)
1517
+ if idx == -1:
1518
+ return False
1519
+ if idx == 0 or haystack[idx - 1] not in cls._IDENT_CONTINUE:
1520
+ return True
1521
+ start = idx + 1
1522
+
1523
+ @staticmethod
1524
+ def _split_signature_suffix(text: str) -> Tuple[str, Optional[str]]:
1525
+ """Split `text` into `(base, raw_params)` if it ends with a `(...)`
1526
+ suffix (e.g. a query written as ``Class.method(Param)`` or
1527
+ ``Class(Param)`` for a constructor), else `(text, None)`."""
1528
+ if not text.endswith(")"):
1529
+ return text, None
1530
+ idx = text.find("(")
1531
+ if idx == -1:
1532
+ return text, None
1533
+ return text[:idx], text[idx + 1:-1]
1534
+
1535
+ @staticmethod
1536
+ def _normalize_signature_param(text: str) -> str:
1537
+ """Erase top-level generic type arguments and whitespace so a
1538
+ query's ``Class<?>`` compares equal to a stored ``Class`` —
1539
+ mirroring the erasure `lang.java.ids.erased_type` already applies
1540
+ when a node id's `signature_key` is built, without this
1541
+ language-agnostic module importing that Java-specific helper."""
1542
+ out: List[str] = []
1543
+ depth = 0
1544
+ for ch in text.strip():
1545
+ if ch == "<":
1546
+ depth += 1
1547
+ elif ch == ">":
1548
+ depth = max(depth - 1, 0)
1549
+ elif depth == 0:
1550
+ out.append(ch)
1551
+ return "".join("".join(out).split())
1552
+
1553
+ @classmethod
1554
+ def _parse_signature_params(cls, raw_params: str) -> List[str]:
1555
+ """Split a `(...)`-interior parameter-type list on top-level commas
1556
+ (respecting nested `<...>` generics) and erase each one."""
1557
+ if not raw_params.strip():
1558
+ return []
1559
+ parts: List[str] = []
1560
+ depth = 0
1561
+ current: List[str] = []
1562
+ for ch in raw_params:
1563
+ if ch == "<":
1564
+ depth += 1
1565
+ current.append(ch)
1566
+ elif ch == ">":
1567
+ depth -= 1
1568
+ current.append(ch)
1569
+ elif ch == "," and depth == 0:
1570
+ parts.append("".join(current))
1571
+ current = []
1572
+ else:
1573
+ current.append(ch)
1574
+ parts.append("".join(current))
1575
+ return [cls._normalize_signature_param(p) for p in parts]
1576
+
1577
+ @classmethod
1578
+ def _node_signature_params(cls, node_id: str) -> Optional[List[str]]:
1579
+ """The erased parameter-type list a Java overload's own node id
1580
+ encodes (its trailing `(...)` `signature_key`), or None for a node
1581
+ id with no such suffix (non-Java backends never append one)."""
1582
+ if not node_id.endswith(")"):
1583
+ return None
1584
+ idx = node_id.rfind("(")
1585
+ if idx == -1:
1586
+ return None
1587
+ return cls._parse_signature_params(node_id[idx + 1:-1])
1588
+
1589
+ def find_nodes(self, query: str) -> List[str]:
1590
+ """Match nodes by id, qualified name, simple name, signature, or
1591
+ substring.
1592
+
1593
+ P1.10 step 3: qualified_name is the search alias for the id — a dotted
1594
+ query like ``m.Foo.bar`` keeps matching even when the underlying id
1595
+ carries <locals> markers (and would survive any future id format
1596
+ change).
1597
+ """
1598
+ direct = [nid for nid in self._nodes if nid == query or nid.endswith(f"::{query}")]
1599
+ if direct:
1600
+ return direct
1601
+ exact_qual = [nid for nid, n in self._nodes.items() if n.qualified_name == query]
1602
+ if exact_qual:
1603
+ return exact_qual
1604
+ exact_name = [nid for nid, n in self._nodes.items() if n.name == query]
1605
+ if exact_name:
1606
+ return exact_name
1607
+
1608
+ # Bugfix: a query written with a signature suffix (e.g.
1609
+ # ``FormattableType.of(Class<?>)``, copied straight from the Java
1610
+ # source/Javadoc) used to only "match" by literal-text accident —
1611
+ # it broke on generics, extra whitespace, or a fully-qualified
1612
+ # param type, since `GraphNode.qualified_name` strips every `(...)`
1613
+ # and no tier above ever parsed the signature at all. Disambiguate
1614
+ # by erased param types when the query has a `(...)` suffix, before
1615
+ # falling back to plain substring.
1616
+ base, raw_params = self._split_signature_suffix(query)
1617
+ if raw_params is not None and base:
1618
+ query_params = self._parse_signature_params(raw_params)
1619
+ sig_matches = [
1620
+ nid for nid, n in self._nodes.items()
1621
+ if (self._boundary_contains(nid, base) or self._boundary_contains(n.qualified_name, base))
1622
+ and self._node_signature_params(nid) == query_params
1623
+ ]
1624
+ if sig_matches:
1625
+ return sig_matches
1626
+
1627
+ return [
1628
+ nid for nid, n in self._nodes.items()
1629
+ if self._boundary_contains(nid, query) or self._boundary_contains(n.qualified_name, query)
1630
+ ]
1631
+
1632
+ def get_impact_analysis(
1633
+ self, query: str, *, include_initializers: bool = False,
1634
+ ) -> Dict[str, Any]:
1635
+ """Blast-radius report for `query` (qualified or simple name).
1636
+
1637
+ Java field/initializer-block call sites are retained as synthetic
1638
+ ``<init>``/``<clinit>`` graph nodes. By default they are reported
1639
+ separately in ``initializer_callers`` instead of being counted as
1640
+ source-declared method/constructor callers. Set
1641
+ ``include_initializers=True`` for the conservative executable-context
1642
+ view used by earlier impact reports.
1643
+ """
1644
+ matches = self.find_nodes(query)
1645
+ if not matches:
1646
+ return {"query": query, "matches": [], "error": "no matching node"}
1647
+ # Bugfix: matches[0] alone used to silently drop the impact of any
1648
+ # other genuinely distinct entity sharing the query's simple name
1649
+ # (see _distinct_impact_targets). Merge across every distinct
1650
+ # target so a caller of the unpicked entity is never lost; report
1651
+ # display fields (target/target_name/...) still reflect the first
1652
+ # one for a stable, single-line summary.
1653
+ targets = self._distinct_impact_targets(matches)
1654
+ target_id = targets[0]
1655
+ target = self._nodes[target_id]
1656
+ # P1.6: changing a container (class/module) implicitly changes all
1657
+ # its members — direct callers of any member are direct impact.
1658
+ members: List[str] = []
1659
+ seed_set: Set[str] = set()
1660
+ all_direct_set: Set[str] = set()
1661
+ for t in targets:
1662
+ t_members = self.get_container_members(t)
1663
+ members.extend(m for m in t_members if m not in seed_set)
1664
+ seed_set.update(t_members)
1665
+ seed_set.add(t)
1666
+ all_direct_set.update(self._direct_callers(t))
1667
+ for m in t_members:
1668
+ all_direct_set.update(self._direct_callers(m))
1669
+ all_direct_set -= seed_set
1670
+ initializer_set = {
1671
+ node_id for node_id in all_direct_set
1672
+ if self._is_initializer_node(node_id)
1673
+ }
1674
+ direct_set = (
1675
+ all_direct_set if include_initializers
1676
+ else all_direct_set - initializer_set
1677
+ )
1678
+ direct = sorted(direct_set - seed_set)
1679
+ initializer_callers = sorted(initializer_set)
1680
+ # PM.3: expose per-caller confidence as a list of objects (each carrying
1681
+ # a "confidence" key) so the monitor's _collect_refs harvests it — a
1682
+ # {id: conf} map would not be, since that harvester only reads floats
1683
+ # stored under a key literally named "confidence". Additive only; the
1684
+ # direct_callers / transitive_callers shapes above are untouched.
1685
+ conf_by_id: Dict[str, Tuple[float, str]] = {}
1686
+ direct_family_seeds: Set[str] = set()
1687
+ for base in (*targets, *members):
1688
+ direct_family_seeds.update(self._override_family(base))
1689
+ for seed in direct_family_seeds:
1690
+ for src, etype, conf in self._impact_in_edges(
1691
+ seed, edge_types=self.DIRECT_CALLER_EDGE_TYPES
1692
+ ):
1693
+ if src not in all_direct_set or src in seed_set:
1694
+ continue
1695
+ prev = conf_by_id.get(src)
1696
+ if prev is None or conf > prev[0]:
1697
+ conf_by_id[src] = (conf, etype)
1698
+ caller_confidence = [
1699
+ {"id": cid, "confidence": conf, "edge_type": etype}
1700
+ for cid, (conf, etype) in sorted(conf_by_id.items())
1701
+ if cid in direct_set
1702
+ ]
1703
+ initializer_caller_confidence = [
1704
+ {"id": cid, "confidence": conf, "edge_type": etype}
1705
+ for cid, (conf, etype) in sorted(conf_by_id.items())
1706
+ if cid in initializer_set
1707
+ ]
1708
+ # direct_callers/direct_caller_count union every IMPACT_EDGE_TYPES kind
1709
+ # (TYPE_OF, RETURNS, INSTANTIATES, ...) — for a class target that means
1710
+ # "who depends on this type", not "who constructs it". instantiators
1711
+ # narrows that down to actual `Target(...)` call sites so a class's
1712
+ # constructor-caller count can be read independently of its broader
1713
+ # type-dependent blast radius. Computed as its own INSTANTIATES-only
1714
+ # pass rather than filtered out of caller_confidence: conf_by_id keeps
1715
+ # only the highest-confidence edge per caller, and a heuristic
1716
+ # INSTANTIATES resolution (e.g. 0.85, dict/alias dispatch) can lose
1717
+ # that slot to a same-caller TYPE_OF/RETURNS edge at 1.0 — a caller
1718
+ # that genuinely constructs the target would then vanish from
1719
+ # instantiators even though the INSTANTIATES edge exists.
1720
+ instantiator_conf_by_id: Dict[str, float] = {}
1721
+ for seed in direct_family_seeds:
1722
+ for src, etype, conf in self._impact_in_edges(
1723
+ seed, edge_types=frozenset({EdgeType.INSTANTIATES.name})
1724
+ ):
1725
+ if src not in direct_set or src in seed_set:
1726
+ continue
1727
+ if conf > instantiator_conf_by_id.get(src, -1.0):
1728
+ instantiator_conf_by_id[src] = conf
1729
+ instantiator_confidence = [
1730
+ {"id": cid, "confidence": conf, "edge_type": EdgeType.INSTANTIATES.name}
1731
+ for cid, conf in sorted(instantiator_conf_by_id.items())
1732
+ ]
1733
+ instantiators = [entry["id"] for entry in instantiator_confidence]
1734
+ all_transitive: Dict[str, int] = {}
1735
+ for t in targets:
1736
+ for nid, depth in self.get_transitive_callers(t).items():
1737
+ if nid not in all_transitive or depth < all_transitive[nid]:
1738
+ all_transitive[nid] = depth
1739
+ transitive_initializer_callers = {
1740
+ nid: depth for nid, depth in all_transitive.items()
1741
+ if self._is_initializer_node(nid)
1742
+ }
1743
+ transitive = (
1744
+ all_transitive if include_initializers
1745
+ else {
1746
+ nid: depth for nid, depth in all_transitive.items()
1747
+ if not self._is_initializer_node(nid)
1748
+ }
1749
+ )
1750
+ entry_points = [c for c in transitive if self._nodes.get(c) and self._nodes[c].is_entry_point]
1751
+ affected_files = sorted({
1752
+ # Initializer nodes are hidden caller *units* by default, not lost
1753
+ # dependencies: their source files remain affected and actionable.
1754
+ self._nodes[c].file_path for c in all_transitive if c in self._nodes
1755
+ } | {self._nodes[t].file_path for t in targets if t in self._nodes})
1756
+ # Transitive counterpart of `instantiators` — see
1757
+ # get_transitive_instantiators for why this can't just be filtered
1758
+ # out of `transitive`/`all_transitive` (those are seeded with the
1759
+ # full IMPACT_EDGE_TYPES union at every hop, not just INSTANTIATES).
1760
+ all_transitive_instantiators: Dict[str, int] = {}
1761
+ for t in targets:
1762
+ for nid, depth in self.get_transitive_instantiators(t).items():
1763
+ if nid not in all_transitive_instantiators or depth < all_transitive_instantiators[nid]:
1764
+ all_transitive_instantiators[nid] = depth
1765
+ transitive_instantiators = (
1766
+ all_transitive_instantiators if include_initializers
1767
+ else {
1768
+ nid: depth for nid, depth in all_transitive_instantiators.items()
1769
+ if not self._is_initializer_node(nid)
1770
+ }
1771
+ )
1772
+ # P4.10 follow-up: node/search/graph payloads already carry `language`
1773
+ # via _node_summary(); impact/plan/verify never did. `node_languages`
1774
+ # covers every id surfaced anywhere above so a polyglot repo's callers
1775
+ # can be attributed to a backend. Filtered to `self._nodes` since ids
1776
+ # sourced from `self.graph` (transitive/members) aren't guaranteed to
1777
+ # have a live node.
1778
+ node_ids = (
1779
+ set(targets) | set(members) | set(direct) | set(initializer_callers)
1780
+ | set(transitive) | set(transitive_initializer_callers)
1781
+ | set(entry_points) | set(transitive_instantiators)
1782
+ )
1783
+ if len(matches) > 1:
1784
+ node_ids |= set(matches)
1785
+ node_languages = {
1786
+ nid: self._nodes[nid].language for nid in node_ids if nid in self._nodes
1787
+ }
1788
+ return {
1789
+ "query": query,
1790
+ "target": target_id,
1791
+ "target_name": target.name,
1792
+ "target_file": target.file_path,
1793
+ "target_language": target.language,
1794
+ "ambiguous_matches": matches if len(matches) > 1 else [],
1795
+ "ambiguous_targets": targets if len(targets) > 1 else [],
1796
+ "affected_members": members,
1797
+ "affected_member_count": len(members),
1798
+ "include_initializers": include_initializers,
1799
+ "initializer_callers": initializer_callers,
1800
+ "initializer_caller_count": len(initializer_callers),
1801
+ "initializer_caller_confidence": initializer_caller_confidence,
1802
+ "transitive_initializer_callers": transitive_initializer_callers,
1803
+ "transitive_initializer_caller_count": len(transitive_initializer_callers),
1804
+ "direct_callers": direct,
1805
+ "direct_caller_count": len(direct),
1806
+ "caller_confidence": caller_confidence,
1807
+ "instantiators": instantiators,
1808
+ "instantiator_count": len(instantiators),
1809
+ "instantiator_confidence": instantiator_confidence,
1810
+ "transitive_callers": transitive,
1811
+ "transitive_caller_count": len(transitive),
1812
+ "transitive_instantiators": transitive_instantiators,
1813
+ "transitive_instantiator_count": len(transitive_instantiators),
1814
+ "affected_entry_points": entry_points,
1815
+ "affected_entry_count": len(entry_points),
1816
+ "affected_files": affected_files,
1817
+ "affected_file_count": len(affected_files),
1818
+ "node_languages": node_languages,
1819
+ "user_facing": bool(entry_points),
1820
+ "max_depth": max(transitive.values()) if transitive else 0,
1821
+ }
1822
+
1823
+ def god_nodes(self, top_n: int = 10) -> List[Dict[str, Any]]:
1824
+ """Top N nodes by total degree (in+out), excluding builtin-denied names.
1825
+
1826
+ These are architectural linchpins: highest blast radius when changed,
1827
+ first place to look when onboarding to an unfamiliar repo.
1828
+ """
1829
+ scores = [
1830
+ (nid, self.graph.in_degree(nid) + self.graph.out_degree(nid))
1831
+ for nid in self.graph.nodes
1832
+ if nid in self._nodes and not self._is_denied(self._nodes[nid].name)
1833
+ ]
1834
+ scores.sort(key=lambda x: x[1], reverse=True)
1835
+ result = []
1836
+ for nid, degree in scores[:top_n]:
1837
+ node = self._nodes.get(nid)
1838
+ result.append({
1839
+ "id": nid,
1840
+ "degree": degree,
1841
+ "in_degree": self.graph.in_degree(nid),
1842
+ "out_degree": self.graph.out_degree(nid),
1843
+ "type": node.node_type.name if node else "UNKNOWN",
1844
+ "file": node.file_path if node else None,
1845
+ "line": node.line if node else None,
1846
+ })
1847
+ return result
1848
+
1849
+ def surprising_connections(self) -> List[Dict[str, Any]]:
1850
+ """CALLS/INSTANTIATES edges that cross Louvain community boundaries.
1851
+
1852
+ Within-community edges are expected coupling. Cross-community edges
1853
+ may signal accidental coupling worth investigating.
1854
+ """
1855
+ from adl_drift.graph.cluster import detect_communities
1856
+ membership = detect_communities(self.graph)
1857
+ result = []
1858
+ seen: Set[Tuple] = set()
1859
+ for src, tgt, data in self.graph.edges(data=True):
1860
+ etype = data.get("type", "")
1861
+ if etype not in ("CALLS", "INSTANTIATES"):
1862
+ continue
1863
+ src_com = membership.get(src)
1864
+ tgt_com = membership.get(tgt)
1865
+ if src_com is None or tgt_com is None or src_com == tgt_com:
1866
+ continue
1867
+ key = (src, tgt, etype)
1868
+ if key in seen:
1869
+ continue
1870
+ seen.add(key)
1871
+ result.append({
1872
+ "from": src,
1873
+ "to": tgt,
1874
+ "edge_type": etype,
1875
+ "confidence": data.get("confidence", 1.0),
1876
+ "from_community": src_com,
1877
+ "to_community": tgt_com,
1878
+ })
1879
+ result.sort(key=lambda x: -x["confidence"])
1880
+ return result
1881
+
1882
+ def stats(self) -> Dict[str, Any]:
1883
+ """Graph statistics: node/edge counts grouped by type."""
1884
+ edge_types: Dict[str, int] = defaultdict(int)
1885
+ for _, _, d in self.graph.edges(data=True):
1886
+ edge_types[d.get("type", "UNKNOWN")] += 1
1887
+ node_types: Dict[str, int] = defaultdict(int)
1888
+ for n in self._nodes.values():
1889
+ node_types[n.node_type.name] += 1
1890
+ return {
1891
+ "total_nodes": len(self._nodes),
1892
+ "total_edges": self.graph.number_of_edges(),
1893
+ "total_edge_occurrences": len(self._edge_occurrences),
1894
+ "node_types": dict(node_types),
1895
+ "edge_types": dict(edge_types),
1896
+ "entry_points": len(self.get_entry_points()),
1897
+ "node_collisions": len(self.node_collisions),
1898
+ }
1899
+
1900
+ def integrity_report(self) -> Dict[str, Any]:
1901
+ """Return deterministic P3.3 graph-integrity violations.
1902
+
1903
+ A graph may be traversable even if its persisted edge list is stale,
1904
+ so validate both representations. The report is deliberately data,
1905
+ rather than an exception, so CI and MCP callers can surface every
1906
+ violation in one run.
1907
+ """
1908
+ node_ids = list(self._nodes)
1909
+ duplicate_ids = sorted(
1910
+ node_id for node_id, n in Counter(node_ids).items() if n > 1
1911
+ )
1912
+ dangling_edges = sorted(
1913
+ (edge.source, edge.target, edge.edge_type.name)
1914
+ for edge in self._edges
1915
+ if edge.source not in self._nodes or edge.target not in self._nodes
1916
+ )
1917
+ missing_graph_edges = sorted(
1918
+ (edge.source, edge.target, edge.edge_type.name)
1919
+ for edge in self._edges
1920
+ if edge.source in self._nodes and edge.target in self._nodes
1921
+ and not self.graph.has_edge(edge.source, edge.target, key=edge.edge_type.name)
1922
+ )
1923
+ collisions = sorted(
1924
+ {node.id for node in self.node_collisions.values()}
1925
+ )
1926
+ return {
1927
+ "duplicate_node_ids": duplicate_ids,
1928
+ "dangling_edges": dangling_edges,
1929
+ "missing_graph_edges": missing_graph_edges,
1930
+ "symbol_collisions": collisions,
1931
+ "ok": not (duplicate_ids or dangling_edges or missing_graph_edges or collisions),
1932
+ }
1933
+
1934
+ # ---- internal helpers ----
1935
+
1936
+ def _import_map_for(self, result: AnalysisResult, module_name: str) -> Dict[str, str]:
1937
+ """Build {local_binding: dotted_path} for a file.
1938
+
1939
+ Prefers structured ImportRecords (P2.1: handles relative imports,
1940
+ multi-line parenthesized lists, aliases, conditional imports).
1941
+ Falls back to the regex parser for cached AnalysisResults that
1942
+ predate import_records.
1943
+ """
1944
+ records = getattr(result, "import_records", None)
1945
+ if records:
1946
+ return self._import_map_from_records(records, module_name)
1947
+ return self._build_import_map(result.imports)
1948
+
1949
+ @staticmethod
1950
+ def _record_module(r, pkg_parts: List[str]) -> str:
1951
+ """Resolve one ImportRecord's ``module`` field against relative-import
1952
+ level (P2.1) — shared by `_import_map_from_records` (bound imports)
1953
+ and `_wildcard_packages_for` (wildcard imports, which have no
1954
+ binding but still name a package)."""
1955
+ if r.level > 0:
1956
+ keep = len(pkg_parts) - (r.level - 1)
1957
+ base = pkg_parts[:keep] if keep > 0 else []
1958
+ mod_parts = base + ([p for p in r.module.split(".") if p] if r.module else [])
1959
+ return ".".join(mod_parts)
1960
+ return r.module
1961
+
1962
+ @staticmethod
1963
+ def _import_map_from_records(records, module_name: str) -> Dict[str, str]:
1964
+ """{binding: dotted_path} from ImportRecords.
1965
+
1966
+ Relative imports resolve against the importing file's package:
1967
+ for module ``pkg.sub.mod``, level 1 → ``pkg.sub``, level 2 → ``pkg``.
1968
+ """
1969
+ out: Dict[str, str] = {}
1970
+ pkg_parts = module_name.split(".")[:-1]
1971
+ for r in records:
1972
+ binding = r.binding
1973
+ if binding is None: # wildcard — nothing to bind; see _wildcard_packages_for
1974
+ continue
1975
+ module = CallGraphBuilder._record_module(r, pkg_parts)
1976
+ if r.name and not r.is_wildcard:
1977
+ out[binding] = f"{module}.{r.name}" if module else r.name
1978
+ else:
1979
+ out[binding] = module
1980
+ return out
1981
+
1982
+ def _wildcard_packages_for(self, result: AnalysisResult, module_name: str) -> List[str]:
1983
+ """Package names named by a wildcard import (``import lib.*;``) for a
1984
+ file — the piece `_import_map_from_records` drops entirely, since a
1985
+ wildcard `ImportRecord.binding` is `None` and carries no entry in the
1986
+ {local_name: dotted_path} map. A language resolver that wants to
1987
+ approximate "search the wildcard-imported package" for an otherwise
1988
+ unresolved type (Java's `_resolve_declared_type`) reads this list
1989
+ instead."""
1990
+ records = getattr(result, "import_records", None)
1991
+ if not records:
1992
+ return []
1993
+ pkg_parts = module_name.split(".")[:-1]
1994
+ return [
1995
+ module
1996
+ for r in records
1997
+ if r.is_wildcard and not r.is_static and (module := self._record_module(r, pkg_parts))
1998
+ ]
1999
+
2000
+ def _static_wildcard_classes_for(
2001
+ self, result: AnalysisResult, module_name: str
2002
+ ) -> List[str]:
2003
+ """Fully-qualified class paths named by a *static* wildcard import
2004
+ (``import static a.b.C.*;``) for a file — the static analogue of
2005
+ `_wildcard_packages_for`. A static wildcard imports every static
2006
+ *member* of class `a.b.C`, so (unlike a plain wildcard, which names a
2007
+ *package* to search for types) this names a *class* whose members a
2008
+ bare, receiver-less call may resolve to. The `ImportRecord.module`
2009
+ for `import static a.b.C.*;` is the whole `a.b.C` path (the class),
2010
+ so it is returned as-is (relative-level-resolved via
2011
+ `_record_module`, matching `_wildcard_packages_for`)."""
2012
+ records = getattr(result, "import_records", None)
2013
+ if not records:
2014
+ return []
2015
+ pkg_parts = module_name.split(".")[:-1]
2016
+ return [
2017
+ cls
2018
+ for r in records
2019
+ if r.is_wildcard and r.is_static and (cls := self._record_module(r, pkg_parts))
2020
+ ]
2021
+
2022
+ @staticmethod
2023
+ def _build_import_map(imports: List[str]) -> Dict[str, str]:
2024
+ """Parse import strings → {local_name: dotted_module_path}.
2025
+
2026
+ Handles:
2027
+ import a.b → {a: a.b} (use leftmost as binding)
2028
+ import a.b as c → {c: a.b}
2029
+ from x.y import z → {z: x.y.z}
2030
+ from x.y import z as q → {q: x.y.z}
2031
+ from x import a, b → {a: x.a, b: x.b}
2032
+ """
2033
+ out: Dict[str, str] = {}
2034
+ for stmt in imports:
2035
+ stmt = stmt.strip().rstrip(";")
2036
+ if stmt.startswith("from "):
2037
+ m = re.match(r"from\s+([.\w]+)\s+import\s+(.+)", stmt)
2038
+ if not m:
2039
+ continue
2040
+ module, names = m.group(1), m.group(2)
2041
+ for part in names.split(","):
2042
+ part = part.strip().rstrip(")").lstrip("(").strip()
2043
+ if not part or part == "*":
2044
+ continue
2045
+ if " as " in part:
2046
+ orig, alias = [x.strip() for x in part.split(" as ", 1)]
2047
+ out[alias] = f"{module}.{orig}"
2048
+ else:
2049
+ out[part] = f"{module}.{part}"
2050
+ elif stmt.startswith("import "):
2051
+ rest = stmt[len("import "):].strip()
2052
+ for part in rest.split(","):
2053
+ part = part.strip()
2054
+ if " as " in part:
2055
+ mod, alias = [x.strip() for x in part.split(" as ", 1)]
2056
+ out[alias] = mod
2057
+ else:
2058
+ # bind leftmost segment
2059
+ out[part.split(".")[0]] = part
2060
+ return out
2061
+
2062
+ def harvest_literal_candidates(
2063
+ self,
2064
+ parses: Dict[Path, ParseResult],
2065
+ results: Dict[Path, AnalysisResult],
2066
+ ) -> int:
2067
+ """Optional low-confidence candidate-edge pass over literal/dynamic
2068
+ dispatch idioms — delegated to the active language's `CallResolver`
2069
+ (P4.4). Python's P2.13/P2.22/P2.23 dict-router/callable-alias
2070
+ harvesting lives in `lang/python/resolver.py::
2071
+ PythonCallResolver.harvest_literal_candidates`; languages without an
2072
+ equivalent pattern (Java, for now) get `CallResolver`'s no-op
2073
+ default. Returns the number of edges added.
2074
+ """
2075
+ return self._get_resolver().harvest_literal_candidates(parses, results)
2076
+
2077
+ def _iter_func_cst(self, root, source: bytes, module_name: str):
2078
+ """Python-CST function-definition iterator — kept as a
2079
+ `CallGraphBuilder` method (rather than only living on
2080
+ `PythonCallResolver`) because three call sites outside the resolver
2081
+ reach into it directly: `cli/commands.py` (literal-eval synthetic-
2082
+ function attribution), `server/handlers.py` (single-file rescan), and
2083
+ `detectors/taint.py` (P4.6's function index). None of those are part
2084
+ of this task's scope (P4.6/P4.8/P4.10), so this stays a thin
2085
+ delegation to the active resolver rather than being redesigned.
2086
+ Python-only for now — Java's grammar has no `function_definition`
2087
+ node, matching the fact that none of those three call sites are
2088
+ wired for a Java builder yet either.
2089
+ """
2090
+ return self._get_resolver()._iter_func_cst(root, source, module_name)
2091
+
2092
+ def _is_denied(self, name: str) -> bool:
2093
+ """P2.7/P4.4: built-ins and dunders are never fuzzy-resolved.
2094
+
2095
+ Language-agnostic since P4.4: the deny-list itself
2096
+ (`PYTHON_BUILTINS` / Java's `java.lang.Object` + collection methods)
2097
+ comes from the active resolver's `ResolverConfig.builtins_deny_list`
2098
+ rather than a hardcoded Python module global. The dunder check is a
2099
+ harmless no-op for Java (no `__x__` naming convention there).
2100
+ """
2101
+ deny = self._get_resolver().config.builtins_deny_list
2102
+ return name in deny or (name.startswith("__") and name.endswith("__"))
2103
+
2104
+ def _resolve_chain_root(
2105
+ self,
2106
+ name: str,
2107
+ module_name: str,
2108
+ import_map: Dict[str, str],
2109
+ by_module_symbol: Dict[str, Dict[str, str]],
2110
+ param_types: Optional[Dict[str, str]] = None,
2111
+ ) -> Optional[str]:
2112
+ """Resolve the first link of a receiver chain to a node id.
2113
+
2114
+ Strategies: annotated parameter (P2.2) → same-module symbol →
2115
+ import map (symbol, then module node, then suffix match against
2116
+ module nodes). No fuzzy fallback.
2117
+ """
2118
+ if param_types and name in param_types:
2119
+ cls_id = self._resolve_annotation(
2120
+ param_types[name], module_name, import_map, {}, by_module_symbol,
2121
+ )
2122
+ if cls_id:
2123
+ return cls_id
2124
+ tid = by_module_symbol.get(module_name, {}).get(name)
2125
+ if tid:
2126
+ return tid
2127
+ if name in import_map:
2128
+ resolved = import_map[name]
2129
+ namespace = self._module_namespace(module_name)
2130
+ tid = self._lookup_qualified(resolved, by_module_symbol, namespace)
2131
+ if tid:
2132
+ return tid
2133
+ tid = self._node_for_dotted(resolved, namespace)
2134
+ if tid:
2135
+ return tid
2136
+ module_id = self._module_suffixes().get((namespace, resolved))
2137
+ if module_id:
2138
+ return module_id
2139
+ return None
2140
+
2141
+ def _contains_child(self, node_id: str, name: str) -> Optional[str]:
2142
+ """Find the CONTAINS child of `node_id` whose simple name is `name`."""
2143
+ if node_id not in self.graph:
2144
+ return None
2145
+ for _, child, d in self.graph.out_edges(node_id, data=True):
2146
+ if d.get("type") != EdgeType.CONTAINS.name:
2147
+ continue
2148
+ n = self._nodes.get(child)
2149
+ if n is not None and n.name == name:
2150
+ return child
2151
+ return None
2152
+
2153
+ def _inherits_parents(self, class_id: str) -> List[str]:
2154
+ """Direct INHERITS targets (base classes) of a class node."""
2155
+ if class_id not in self.graph:
2156
+ return []
2157
+ return [
2158
+ t for _, t, d in self.graph.out_edges(class_id, data=True)
2159
+ if d.get("type") == EdgeType.INHERITS.name
2160
+ ]
2161
+
2162
+ def _find_method_on_ancestors(self, class_id: str, method: str) -> Optional[str]:
2163
+ """P2.2: BFS up the INHERITS chain looking for `method`.
2164
+
2165
+ Starts at the *parents* of `class_id` (the class itself was already
2166
+ checked by the caller). Requires INHERITS edges, which
2167
+ `resolve_relationships()` emits before `resolve_calls()` runs.
2168
+ """
2169
+ q: deque[str] = deque(self._inherits_parents(class_id))
2170
+ seen: Set[str] = {class_id}
2171
+ while q:
2172
+ cur = q.popleft()
2173
+ if cur in seen:
2174
+ continue
2175
+ seen.add(cur)
2176
+ child = self._contains_child(cur, method)
2177
+ if child:
2178
+ return child
2179
+ q.extend(self._inherits_parents(cur))
2180
+ return None
2181
+
2182
+ _GENERIC_WRAPPERS = re.compile(
2183
+ r"^(?:typing\.)?(?:Optional|List|Set|FrozenSet|Tuple|Sequence|"
2184
+ r"Iterable|Iterator|Dict|Mapping|MutableMapping|Type|Annotated|"
2185
+ r"ClassVar|Final|Awaitable|Callable)\s*\[(.+)\]$"
2186
+ )
2187
+ # P4.4: Java generic wrapper — ``java.util.List<User>`` / ``Map<K, V>``.
2188
+ # `[\w.$]+` covers scoped/qualified type names (``java.util.List``).
2189
+ _JAVA_GENERIC_WRAPPER = re.compile(r"^[\w.$]+<(.+)>$")
2190
+ _JAVA_WILDCARD_BOUND = re.compile(r"^\?\s+(?:extends|super)\s+(.+)$")
2191
+
2192
+ @staticmethod
2193
+ def _split_top_level(text: str) -> List[str]:
2194
+ """Split ``text`` on top-level commas only, respecting ``<...>``
2195
+ nesting depth — e.g. ``String, Map<K, V>`` → ``["String", "Map<K, V>"]``
2196
+ rather than splitting inside the nested generic."""
2197
+ parts: List[str] = []
2198
+ depth = 0
2199
+ cur: List[str] = []
2200
+ for ch in text:
2201
+ if ch == "<":
2202
+ depth += 1
2203
+ elif ch == ">":
2204
+ depth -= 1
2205
+ if ch == "," and depth == 0:
2206
+ parts.append("".join(cur))
2207
+ cur = []
2208
+ else:
2209
+ cur.append(ch)
2210
+ parts.append("".join(cur))
2211
+ return parts
2212
+
2213
+ def _resolve_annotation(
2214
+ self,
2215
+ ann: str,
2216
+ module_name: str,
2217
+ import_map: Dict[str, str],
2218
+ by_simple_name: Dict[str, List[str]],
2219
+ by_module_symbol: Dict[str, Dict[str, str]],
2220
+ ) -> Optional[str]:
2221
+ """P2.2/P4.4: resolve annotation text to a CLASS node id.
2222
+
2223
+ Strips quotes (forward refs), generic wrappers — Python's
2224
+ ``Optional[User]``, ``List[User]``, ``Dict[str, User]`` → ``User``,
2225
+ and Java's ``List<User>``, ``Map<String, User>`` → ``User`` (last
2226
+ type argument, depth-aware comma split so ``Map<K, List<V>>`` doesn't
2227
+ split inside the nested generic; a ``? extends T`` / ``? super T``
2228
+ wildcard bound resolves to ``T``) — and ``X | None`` unions, then
2229
+ resolves via same-module / import map (strict — no fuzzy fallback).
2230
+ Returns None for builtins, external types, and bare ``?`` wildcards.
2231
+
2232
+ This mirrors ``List[X]`` → ``X`` for TYPE_OF/RETURNS-style "what does
2233
+ this annotation carry" edges. It is deliberately *not* what
2234
+ `lang/java/resolver.py` uses to resolve a typed *receiver* for method
2235
+ dispatch (`Box<T> b; b.get()` must look up `get` on raw `Box`, not
2236
+ `T`) — that path uses `lang.java.ids.erased_type()` instead, which
2237
+ strips generics to the raw type rather than descending into them.
2238
+ """
2239
+ ann = ann.strip().strip("'\"")
2240
+ # Peel generic wrappers (possibly nested: Optional[List[User]])
2241
+ while True:
2242
+ m = self._GENERIC_WRAPPERS.match(ann)
2243
+ if m:
2244
+ # Dict[str, User] → last type argument carries the value type
2245
+ ann = m.group(1).split(",")[-1].strip().strip("'\"")
2246
+ continue
2247
+ jm = self._JAVA_GENERIC_WRAPPER.match(ann)
2248
+ if jm:
2249
+ last = self._split_top_level(jm.group(1))[-1].strip()
2250
+ wm = self._JAVA_WILDCARD_BOUND.match(last)
2251
+ ann = wm.group(1).strip() if wm else last
2252
+ continue
2253
+ break
2254
+ if "|" in ann: # PEP 604 unions: User | None
2255
+ parts = [p.strip() for p in ann.split("|") if p.strip() not in ("None", "")]
2256
+ ann = parts[0] if parts else ""
2257
+ if not ann or not all(c.isalnum() or c in "._" for c in ann):
2258
+ return None
2259
+ tid = self._resolve_name(
2260
+ ann, module_name, import_map, by_simple_name, by_module_symbol,
2261
+ strict=True,
2262
+ )
2263
+ if tid and self._nodes[tid].node_type == NodeType.CLASS:
2264
+ return tid
2265
+ return None
2266
+
2267
+ def _node_for_dotted(self, dotted: str, namespace: str = "") -> Optional[str]:
2268
+ """P1.10 step 4: resolve a dotted path whose module/qualname boundary
2269
+ is unknown (import-map values) to a node id.
2270
+
2271
+ ``a.b.c`` may be module ``a.b.c``, function ``c`` in module ``a.b``
2272
+ (id ``a.b:c``), or method ``c`` on class ``b`` in ``a/__init__.py``
2273
+ (id ``a:b.c``). Module ids carry no colon, so try the whole path
2274
+ first, then every ``:`` split — longest module prefix wins (matches
2275
+ Python's import semantics: ``from a.b import c`` prefers module
2276
+ ``a.b``).
2277
+ """
2278
+ prefix = f"{namespace}::" if namespace else ""
2279
+ if f"{prefix}{dotted}" in self._nodes:
2280
+ return f"{prefix}{dotted}"
2281
+ parts = dotted.split(".")
2282
+ for i in range(len(parts) - 1, 0, -1):
2283
+ cand = f"{prefix}{'.'.join(parts[:i])}:{'.'.join(parts[i:])}"
2284
+ if cand in self._nodes:
2285
+ return cand
2286
+ if namespace:
2287
+ suffixes = [dotted] + [
2288
+ f"{'.'.join(parts[:i])}:{'.'.join(parts[i:])}"
2289
+ for i in range(len(parts) - 1, 0, -1)
2290
+ ]
2291
+ if self._global_dotted_index is None:
2292
+ index: Dict[str, List[str]] = defaultdict(list)
2293
+ for nid in self._nodes:
2294
+ if "::" not in nid:
2295
+ continue
2296
+ suffix = nid.split("::", 1)[1]
2297
+ index[suffix].append(nid)
2298
+ self._global_dotted_index = index
2299
+ candidates = {
2300
+ candidate
2301
+ for suffix in suffixes
2302
+ for candidate in self._global_dotted_index.get(suffix, [])
2303
+ if self._namespace_visible(
2304
+ namespace, self._module_namespace(candidate)
2305
+ )
2306
+ }
2307
+ if len(candidates) == 1:
2308
+ return next(iter(candidates))
2309
+ return None
2310
+
2311
+ def _unique_class_by_simple_name(
2312
+ self, name: str, namespace: str = ""
2313
+ ) -> Optional[str]:
2314
+ """The sole CLASS node named `name`, or None if zero or multiple.
2315
+
2316
+ Backs the Java resolver's global unique-simple-name fallback (P4.4
2317
+ §5.2/§5.9): a project-wide index built once and lazily invalidated
2318
+ exactly like `_global_dotted_index`, instead of scanning every node
2319
+ on every call — P0 profiling found the previous per-call scan was
2320
+ ~52% of total JUnit5 resolve_calls time (`types.py:719`).
2321
+ """
2322
+ if self._class_by_simple_name_index is None:
2323
+ index: Dict[str, Optional[str]] = {}
2324
+ # Same pass also records every CLASS node per simple name with
2325
+ # its module namespace, so the namespace-scoped variant below
2326
+ # filters a handful of same-named classes instead of re-scanning
2327
+ # all nodes per call — that scan is exactly the P0 root cause
2328
+ # this index exists to remove, and it had crept back in with the
2329
+ # `namespace` parameter (JUnit5 resolve_calls 59s -> 651s).
2330
+ by_name: Dict[str, List[Tuple[str, str]]] = {}
2331
+ for nid, node in self._nodes.items():
2332
+ if node.node_type != NodeType.CLASS:
2333
+ continue
2334
+ if node.name not in index:
2335
+ index[node.name] = nid
2336
+ elif index[node.name] != nid:
2337
+ index[node.name] = None
2338
+ by_name.setdefault(node.name, []).append(
2339
+ (nid, self._module_namespace(node.module))
2340
+ )
2341
+ self._class_by_simple_name_index = index
2342
+ self._classes_by_simple_name = by_name
2343
+ if not namespace:
2344
+ return self._class_by_simple_name_index.get(name)
2345
+ candidates = [
2346
+ nid for nid, node_namespace in self._classes_by_simple_name.get(name, ())
2347
+ if self._namespace_visible(namespace, node_namespace)
2348
+ ]
2349
+ return candidates[0] if len(candidates) == 1 else None
2350
+
2351
+ def _lookup_qualified(
2352
+ self,
2353
+ qualified: str,
2354
+ by_module_symbol: Dict[str, Dict[str, str]],
2355
+ namespace: str = "",
2356
+ ) -> Optional[str]:
2357
+ """Given a dotted name like 'pkg.mod.symbol', find node id by trying
2358
+ progressively shorter module prefixes."""
2359
+ cache_key = (namespace, qualified)
2360
+ if cache_key in self._qualified_lookup_cache:
2361
+ return self._qualified_lookup_cache[cache_key]
2362
+ parts = qualified.split(".")
2363
+ if len(parts) < 2:
2364
+ return None
2365
+ sym = parts[-1]
2366
+ # Try suffix matches against stored module names
2367
+ for mod, syms in by_module_symbol.items():
2368
+ if self._module_namespace(mod) != namespace:
2369
+ continue
2370
+ if sym not in syms:
2371
+ continue
2372
+ mod_parts = self._display_module(mod).split(".")
2373
+ # check if mod ends with parts[:-1] (suffix match — accommodates _path_to_module trimming)
2374
+ target_mod_parts = parts[:-1]
2375
+ if mod_parts[-len(target_mod_parts):] == target_mod_parts \
2376
+ or target_mod_parts[-len(mod_parts):] == mod_parts:
2377
+ self._qualified_lookup_cache[cache_key] = syms[sym]
2378
+ return syms[sym]
2379
+ if namespace:
2380
+ candidates = set()
2381
+ for mod, syms in by_module_symbol.items():
2382
+ if not self._namespace_visible(namespace, self._module_namespace(mod)):
2383
+ continue
2384
+ if sym not in syms:
2385
+ continue
2386
+ mod_parts = self._display_module(mod).split(".")
2387
+ target_mod_parts = parts[:-1]
2388
+ if mod_parts[-len(target_mod_parts):] == target_mod_parts \
2389
+ or target_mod_parts[-len(mod_parts):] == mod_parts:
2390
+ candidates.add(syms[sym])
2391
+ if len(candidates) == 1:
2392
+ result = next(iter(candidates))
2393
+ self._qualified_lookup_cache[cache_key] = result
2394
+ return result
2395
+ self._qualified_lookup_cache[cache_key] = None
2396
+ return None
2397
+
2398
+ def to_json(self) -> Dict:
2399
+ """Serialize graph to JSON-serializable dict."""
2400
+ return {
2401
+ "nodes": [n.to_dict() for n in self._nodes.values()],
2402
+ "edges": [e.to_dict() for e in self._edges],
2403
+ "edge_occurrences": [e.to_dict() for e in self._edge_occurrences],
2404
+ "statistics": {
2405
+ "total_nodes": len(self._nodes),
2406
+ "total_edges": len(self._edges),
2407
+ "total_edge_occurrences": len(self._edge_occurrences),
2408
+ "entry_points": len(self.get_entry_points()),
2409
+ }
2410
+ }
2411
+
2412
+ def _path_to_module(self, path: Path) -> str:
2413
+ """Convert file path to Python module name.
2414
+
2415
+ If project_root is set, computes the module name relative to that root.
2416
+ Otherwise falls back to the last 3 path components.
2417
+ """
2418
+ if self.project_root is not None:
2419
+ try:
2420
+ rel = path.relative_to(self.project_root)
2421
+ except ValueError:
2422
+ rel = path
2423
+ parts = rel.with_suffix("").parts
2424
+ if parts and parts[-1] == "__init__":
2425
+ parts = parts[:-1]
2426
+ return ".".join(parts)
2427
+ parts = path.with_suffix("").parts
2428
+ return ".".join(parts[-3:]) # Last 3 components as module
2429
+
2430
+ def _module_for_result(self, result: AnalysisResult) -> str:
2431
+ """Use declared module identity when a language backend provides it."""
2432
+ module = getattr(result, "module_name", None) or self._path_to_module(
2433
+ result.file_path
2434
+ )
2435
+ if result.language == "java":
2436
+ build_module = self._java_build_module_for(result.file_path)
2437
+ if build_module:
2438
+ return f"{build_module}::{module}"
2439
+ if result.language != "python" and len(self._languages_in_project()) > 1:
2440
+ return f"{result.language}::{module}"
2441
+ return module
2442
+
2443
+ def _java_build_module_for(self, file_path: Path) -> Optional[str]:
2444
+ """Return the deepest owning Java build unit.
2445
+
2446
+ Existing reactor-module labels stay unchanged. Independent nested
2447
+ Maven/Gradle roots use their scan-root-relative path, so equal legal
2448
+ FQNs remain stable, distinct graph nodes instead of colliding.
2449
+ """
2450
+ if self.project_root is None:
2451
+ return None
2452
+ if self._java_build_module_roots is None:
2453
+ # Roots are (re)computed together with the per-file cache: a
2454
+ # caller that resets the roots (new project root) must not keep
2455
+ # ownership answers derived from the previous root set.
2456
+ self._java_build_module_cache = {}
2457
+ self._java_build_module_dir_cache = {}
2458
+ try:
2459
+ from adl_drift.lang.java.config import JavaProjectConfig
2460
+ from adl_drift.lang.java.backend import JavaBackend
2461
+ from adl_drift.scanner.discovery import _project_dirs
2462
+
2463
+ project_root = self.project_root.resolve()
2464
+ def has_build_descriptor(path: Path) -> bool:
2465
+ if any(
2466
+ (path / marker).is_file()
2467
+ for marker in JavaBackend.project_markers
2468
+ ):
2469
+ return True
2470
+ return any(
2471
+ entry.is_file()
2472
+ and entry.name not in {"settings.gradle", "settings.gradle.kts"}
2473
+ and entry.name.endswith((".gradle", ".gradle.kts"))
2474
+ for entry in path.iterdir()
2475
+ )
2476
+
2477
+ build_dirs = {
2478
+ path.resolve()
2479
+ for path in _project_dirs(project_root, JavaBackend.project_markers)
2480
+ if has_build_descriptor(path)
2481
+ }
2482
+ # A custom Gradle build filename is a real build-unit marker,
2483
+ # but ``JavaProjectConfig`` currently accepts directories and
2484
+ # intentionally discovers only conventional marker names.
2485
+ # Parsing a custom unit through it would walk upward and clone
2486
+ # the outer build's coordinates/dependencies under the child
2487
+ # label. Keep those roots for identity while limiting config
2488
+ # parsing to roots with a conventional descriptor.
2489
+ configs = {
2490
+ root: JavaProjectConfig(root)
2491
+ for root in build_dirs
2492
+ if any((root / marker).is_file() for marker in JavaBackend.project_markers)
2493
+ }
2494
+ unit_roots = set(build_dirs)
2495
+ reactor_groups: List[Set[Path]] = []
2496
+ for root, config in configs.items():
2497
+ group = {root}
2498
+ for module in config.modules:
2499
+ candidate = (root / module).resolve()
2500
+ if candidate.is_dir():
2501
+ group.add(candidate)
2502
+ unit_roots.update(group)
2503
+ reactor_groups.append(group)
2504
+
2505
+ # A Gradle settings file may assign arbitrary build filenames
2506
+ # dynamically (for example by walking ``**/*.gradle``). Static
2507
+ # regex extraction cannot enumerate that reactor, but the build
2508
+ # descriptor directories found above are an exact bounded set.
2509
+ # Treat nested Gradle units as visible members of the settings
2510
+ # reactor; same-unit lookup remains preferred by namespace, and
2511
+ # legal duplicate FQNs no longer overwrite each other.
2512
+ for root in configs:
2513
+ if not any(
2514
+ (root / name).is_file()
2515
+ for name in ("settings.gradle", "settings.gradle.kts")
2516
+ ):
2517
+ continue
2518
+ group: Set[Path] = set()
2519
+ for candidate in build_dirs:
2520
+ try:
2521
+ candidate.relative_to(root)
2522
+ except ValueError:
2523
+ continue
2524
+ if any(
2525
+ entry.name.endswith((".gradle", ".gradle.kts"))
2526
+ for entry in candidate.iterdir()
2527
+ if entry.is_file()
2528
+ ):
2529
+ group.add(candidate)
2530
+ if group:
2531
+ reactor_groups.append(group)
2532
+
2533
+ if len(unit_roots) <= 1:
2534
+ self._java_build_module_roots = []
2535
+ self._java_namespace_visibility = {}
2536
+ else:
2537
+ labels = {}
2538
+ for root in unit_roots:
2539
+ relative = root.relative_to(project_root)
2540
+ labels[root] = (
2541
+ "@root" if relative == Path(".") else relative.as_posix()
2542
+ )
2543
+ self._java_build_module_roots = sorted(
2544
+ ((root, label) for root, label in labels.items()),
2545
+ key=lambda item: len(item[0].parts), reverse=True,
2546
+ )
2547
+ visibility = {label: {label} for label in labels.values()}
2548
+ for group in reactor_groups:
2549
+ members = {labels[root] for root in group if root in labels}
2550
+ for member in members:
2551
+ visibility[member].update(members)
2552
+
2553
+ coordinates = {}
2554
+ for root, config in configs.items():
2555
+ if config.group_id and config.artifact_id:
2556
+ coordinates[f"{config.group_id}:{config.artifact_id}"] = labels[root]
2557
+ for root, config in configs.items():
2558
+ source = labels[root]
2559
+ for dependency in config.dependencies:
2560
+ target = coordinates.get(":".join(dependency.split(":")[:2]))
2561
+ if target:
2562
+ visibility[source].add(target)
2563
+ self._java_namespace_visibility = visibility
2564
+ except (OSError, ValueError):
2565
+ self._java_build_module_roots = []
2566
+ self._java_namespace_visibility = {}
2567
+ if not self._java_build_module_roots:
2568
+ return None
2569
+ key = str(file_path)
2570
+ cache = self._java_build_module_cache
2571
+ if key in cache:
2572
+ return cache[key]
2573
+ path = Path(file_path)
2574
+ if os.path.islink(path):
2575
+ resolved = path.resolve()
2576
+ else:
2577
+ # A regular file's real path is its parent's real path plus its
2578
+ # name, so resolve each directory once instead of every file:
2579
+ # Path.resolve() walks the whole chain per call, and on the
2580
+ # fuse-backed working trees this repo is benchmarked on that was
2581
+ # ~1,800 realpath calls (about 4% of a JUnit5 structural build).
2582
+ dir_cache = self._java_build_module_dir_cache
2583
+ parent = str(path.parent)
2584
+ resolved_parent = dir_cache.get(parent)
2585
+ if resolved_parent is None:
2586
+ resolved_parent = dir_cache[parent] = path.parent.resolve()
2587
+ resolved = resolved_parent / path.name
2588
+ owner: Optional[str] = None
2589
+ for root, module in self._java_build_module_roots:
2590
+ try:
2591
+ resolved.relative_to(root)
2592
+ owner = module
2593
+ break
2594
+ except ValueError:
2595
+ continue
2596
+ cache[key] = owner
2597
+ return owner
2598
+
2599
+ def _namespace_visible(self, source: str, candidate: str) -> bool:
2600
+ """Whether source build unit may resolve candidate build unit."""
2601
+ if self._java_namespace_visibility is None:
2602
+ self._java_build_module_for(self.project_root or Path("."))
2603
+ visibility = self._java_namespace_visibility or {}
2604
+ return not source or source not in visibility or candidate in visibility[source]
2605
+
2606
+ def _languages_in_project(self) -> Set[str]:
2607
+ if self._project_languages is None:
2608
+ if self.project_root is None:
2609
+ self._project_languages = set(self.backend_names)
2610
+ else:
2611
+ from adl_drift.scanner.discovery import discover_file_groups
2612
+
2613
+ self._project_languages = set(discover_file_groups(self.project_root))
2614
+ return self._project_languages | self.backend_names
2615
+
2616
+ @staticmethod
2617
+ def _module_namespace(module: str) -> str:
2618
+ return module.split("::", 1)[0] if "::" in module else ""
2619
+
2620
+ @staticmethod
2621
+ def _display_module(module: str) -> str:
2622
+ return module.split("::", 1)[-1]
2623
+
2624
+ def _module_suffixes(self) -> Dict[Tuple[str, str], str]:
2625
+ if self._module_suffix_index is None:
2626
+ index: Dict[Tuple[str, str], str] = {}
2627
+ for nid, node in self._nodes.items():
2628
+ if node.node_type != NodeType.MODULE:
2629
+ continue
2630
+ parts = self._display_module(nid).split(".")
2631
+ namespace = self._module_namespace(nid)
2632
+ for offset in range(len(parts)):
2633
+ index.setdefault((namespace, ".".join(parts[offset:])), nid)
2634
+ self._module_suffix_index = index
2635
+ return self._module_suffix_index
2636
+
2637
+ @staticmethod
2638
+ def find_project_root(start: Path) -> Optional[Path]:
2639
+ """Walk up from `start` looking for project root markers.
2640
+
2641
+ Supports Python, Maven, Gradle, and .git fallback markers.
2642
+ Returns the directory containing the marker, or None if not found.
2643
+ """
2644
+ return _find_project_root(start)
2645
+
2646
+ @staticmethod
2647
+ def _symbol_to_id(symbol: Symbol, module: str) -> str:
2648
+ """Generate unique ID for a symbol.
2649
+
2650
+ P1.10 step 4: ``module:qualname`` — a ``:`` separates the module path
2651
+ from the in-module qualname (prior art: setuptools entry points,
2652
+ pytest node ids). Function ``c`` in ``a/b.py`` → ``a.b:c``; method
2653
+ ``c`` on class ``b`` in ``a/__init__.py`` → ``a:b.c`` — never the same
2654
+ string, so the package/class conflation class of collisions is gone.
2655
+
2656
+ P2.17: the qualname uses the full scope_chain (class + function
2657
+ ancestors, with ``<locals>`` after function segments) so two closures
2658
+ with the same simple name in different outer functions get distinct
2659
+ IDs — mirrors CPython's __qualname__. Falls back to ``symbol.parent``
2660
+ for pre-P2.17 Symbol objects with an empty scope_chain.
2661
+ """
2662
+ if symbol.metadata.get("constructor"):
2663
+ name = symbol.signature_key or "()"
2664
+ else:
2665
+ name = f"{symbol.name}{symbol.signature_key or ''}"
2666
+ if symbol.scope_chain:
2667
+ return f"{module}:{'.'.join(symbol.scope_chain)}.{name}"
2668
+ if symbol.parent:
2669
+ return f"{module}:{symbol.parent}.{name}"
2670
+ return f"{module}:{name}"