adl-toolkit 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,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}"
|