codegraph-brain 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,145 @@
1
+ """Implements ResolverEngine class."""
2
+
3
+ from cgis.core.models import (
4
+ RAW_CLASS_PREFIX,
5
+ SELF_PREFIX,
6
+ VIRTUAL_FILE_PATH,
7
+ Edge,
8
+ Node,
9
+ NodeNamespace,
10
+ NodeType,
11
+ )
12
+ from cgis.resolver.symbols import SymbolResolver
13
+
14
+ RAW_DEP_PREFIX = "raw_dep:"
15
+ RAW_IMPORT_PREFIX = "raw_import:"
16
+
17
+
18
+ class ResolverEngine:
19
+ """
20
+ The 'Brain' of the CGIS.
21
+ Transforms 'raw' semantic edges into resolved, high-confidence edges.
22
+
23
+ Thin facade: SymbolResolver builds the SymbolIndex internally (via
24
+ IndexBuilder), maps raw names to FQNs, and this class keeps edge
25
+ finalization — confidence policy, edge rewrites, and virtual-node
26
+ creation (spec §2.5).
27
+ """
28
+
29
+ def __init__(self, nodes: list[Node], edges: list[Edge]) -> None:
30
+ """Build the symbol resolver (which builds the index) from the extracted graph."""
31
+ self.edges = edges
32
+ self._resolver = SymbolResolver(nodes, edges)
33
+ self._index = self._resolver.index
34
+
35
+ def resolve(self) -> tuple[list[Edge], list[Node]]:
36
+ """
37
+ Phase 3: The Linking Pass.
38
+ Resolves raw_call targets to FQNs in a single pass. Virtual nodes for
39
+ boundary symbols (STDLIB/EXTERNAL/UNKNOWN) are created on the fly as
40
+ each edge target is finalized.
41
+
42
+ Returns (resolved_edges, virtual_nodes).
43
+ """
44
+ resolved_edges: list[Edge] = []
45
+ virtual_nodes: dict[str, Node] = {}
46
+
47
+ for edge in self.edges:
48
+ if edge.target.startswith(RAW_CLASS_PREFIX):
49
+ class_edge = self._resolved_class_edge(edge)
50
+ resolved_edges.append(class_edge)
51
+ self._ensure_virtual_node(class_edge.target, virtual_nodes)
52
+ elif edge.target.startswith(RAW_DEP_PREFIX):
53
+ dep_edge = self._resolved_dep_edge(edge)
54
+ if dep_edge is not None:
55
+ resolved_edges.append(dep_edge)
56
+ elif edge.target.startswith(RAW_IMPORT_PREFIX):
57
+ import_edge = self._resolved_import_edge(edge)
58
+ if import_edge is not None:
59
+ resolved_edges.append(import_edge)
60
+ # no _ensure_virtual_node: target exists on hit, edge dies on miss
61
+ elif not edge.target.startswith("raw_call:"):
62
+ resolved_edges.append(edge)
63
+ self._ensure_virtual_node(edge.target, virtual_nodes)
64
+ else:
65
+ call_edge = self._resolved_call_edge(edge)
66
+ resolved_edges.append(call_edge)
67
+ self._ensure_virtual_node(call_edge.target, virtual_nodes)
68
+
69
+ return resolved_edges, list(virtual_nodes.values())
70
+
71
+ def _resolved_class_edge(self, edge: Edge) -> Edge:
72
+ """Resolve a raw_class: edge to its final class FQN.
73
+
74
+ Strips the raw_class: prefix, resolves via SymbolResolver, then
75
+ returns a copy of the edge with the resolved target (confidence 1.0) or
76
+ the bare name as fallback (confidence 0.5).
77
+ """
78
+ raw = edge.target.removeprefix(RAW_CLASS_PREFIX)
79
+ resolved = self._resolver.resolve_class_ref(raw, edge.source, edge.file_path)
80
+ final_target = resolved or raw
81
+ confidence = 1.0 if resolved else 0.5
82
+ return edge.model_copy(update={"target": final_target, "confidence": confidence})
83
+
84
+ def _resolved_call_edge(self, edge: Edge) -> Edge:
85
+ """Resolve a raw_call: edge to its final call target FQN.
86
+
87
+ Strips the raw_call: prefix, dispatches to resolve_self_call for
88
+ self.* calls or resolve_global_call otherwise, then returns a copy of
89
+ the edge with the resolved target and adjusted confidence:
90
+ min(edge.confidence + 0.5, 1.0) on success, 0.8 on failure.
91
+ """
92
+ raw_name = edge.target.removeprefix("raw_call:")
93
+ if raw_name.startswith(SELF_PREFIX):
94
+ new_target = self._resolver.resolve_self_call(
95
+ edge.source, raw_name.removeprefix(SELF_PREFIX)
96
+ )
97
+ else:
98
+ new_target = self._resolver.resolve_global_call(raw_name, edge.source, edge.file_path)
99
+ final_target = new_target or raw_name
100
+ confidence = min(edge.confidence + 0.5, 1.0) if new_target else 0.8
101
+ return edge.model_copy(update={"target": final_target, "confidence": confidence})
102
+
103
+ def _resolved_dep_edge(self, edge: Edge) -> Edge | None:
104
+ """Resolve a raw_dep: candidate edge, or None when it must be dropped (spec §3.3)."""
105
+ dep_name = edge.target.removeprefix(RAW_DEP_PREFIX)
106
+ dep_target = self._resolver.resolve_dep_candidate(dep_name, edge.source, edge.file_path)
107
+ if dep_target is None:
108
+ # Speculative candidate that is not a DI alias: drop the edge
109
+ # entirely — raw_dep: must never leak into output (spec §3.3).
110
+ return None
111
+ return edge.model_copy(update={"target": dep_target, "confidence": 1.0})
112
+
113
+ def _resolved_import_edge(self, edge: Edge) -> Edge | None:
114
+ """Resolve a raw_import: symbol edge, or None when it must be dropped.
115
+
116
+ Reuses SymbolIndex.map_to_node_fqn (exact / suffix / strip-prefix). An
117
+ external or unknown symbol drops the edge: the module-level IMPORTS
118
+ edge already captures the coupling — raw_import: never leaks into
119
+ output and never mints a virtual node (spec §2.2/§2.4).
120
+ """
121
+ imported_fqn = edge.target.removeprefix(RAW_IMPORT_PREFIX)
122
+ node_fqn = self._index.map_to_node_fqn(imported_fqn)
123
+ if node_fqn is None:
124
+ return None
125
+ return edge.model_copy(update={"target": node_fqn, "confidence": 1.0})
126
+
127
+ def _ensure_virtual_node(self, target: str, virtual_nodes: dict[str, Node]) -> None:
128
+ """Create a virtual boundary node for target if it is not already in the graph."""
129
+ if target not in self._index.nodes and target not in virtual_nodes:
130
+ virtual_nodes[target] = self._make_virtual_node(
131
+ target, self._index.classify_fqn(target)
132
+ )
133
+
134
+ def _make_virtual_node(self, fqn: str, namespace: NodeNamespace) -> Node:
135
+ """Create a placeholder node for an external/stdlib symbol."""
136
+ return Node(
137
+ id=fqn,
138
+ type=NodeType.FUNCTION,
139
+ name=fqn.rsplit(".", maxsplit=1)[-1],
140
+ file_path=VIRTUAL_FILE_PATH,
141
+ start_line=0,
142
+ end_line=0,
143
+ namespace=namespace,
144
+ confidence_score=0.8,
145
+ )
@@ -0,0 +1,184 @@
1
+ """Symbol indices for FQN resolution: built once by IndexBuilder, read-only after."""
2
+
3
+ import builtins
4
+ import os
5
+ import sys
6
+ from dataclasses import dataclass
7
+
8
+ from cgis.core.models import SELF_PREFIX, Node, NodeNamespace, NodeType
9
+
10
+ _BUILTINS: frozenset[str] = frozenset(dir(builtins))
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class SymbolIndex:
15
+ """Immutable lookup indices over the extracted node set.
16
+
17
+ Built by IndexBuilder, consumed by SymbolResolver and ResolverEngine.
18
+ Frozen by convention (field rebinding prevented); the contained dicts
19
+ are never mutated after construction.
20
+ """
21
+
22
+ # node id (FQN) -> Node
23
+ nodes: dict[str, Node]
24
+ # name -> list of FQNs
25
+ global_symbols: dict[str, list[str]]
26
+ # (file_path, name) -> list of FQNs (list handles conditional redefinitions)
27
+ file_global_symbols: dict[tuple[str, str], list[str]]
28
+ # class_fqn -> {method_name -> method_fqn}
29
+ class_methods: dict[str, dict[str, str]]
30
+ # DI-alias (VARIABLE) indices for raw_dep: resolution; kept separate
31
+ # from global_symbols so call resolution behavior does not change.
32
+ variable_symbols: dict[str, list[str]]
33
+ file_variable_symbols: dict[tuple[str, str], list[str]]
34
+ # normalized file_path -> {local_alias: target_fqn} (from FILE node import_map)
35
+ file_imports: dict[str, dict[str, str]]
36
+ # suffix_fqn -> [full_node_ids] (handles src/ layout prefix mismatch)
37
+ suffix_map: dict[str, list[str]]
38
+ # top-level root segments of all internal nodes (for classify)
39
+ internal_roots: set[str]
40
+ # root segments of absolute imports (anything else is UNKNOWN)
41
+ external_roots: set[str]
42
+
43
+ def map_to_node_fqn(self, imported_fqn: str) -> str | None:
44
+ """Resolve an imported FQN to an actual node in the graph.
45
+
46
+ Handles two common prefix-mismatch patterns:
47
+ - Import has extra package prefix: `cgis.resolver.engine.X` → node `resolver.engine.X`
48
+ (strip leading segments from the imported FQN)
49
+ - Node has extra layout prefix: `cgis.pipeline.X` → node `src.cgis.pipeline.X`
50
+ (look up in suffix_map built from node IDs)
51
+
52
+ Returns None when the target is ambiguous or not in the graph.
53
+ """
54
+ if imported_fqn in self.nodes:
55
+ return imported_fqn
56
+ # Node has extra layout prefix (e.g. src/) — most precise match first
57
+ candidates = self.suffix_map.get(imported_fqn, [])
58
+ if len(candidates) == 1:
59
+ return candidates[0]
60
+ # Strip leading segments from the imported FQN (import has extra prefix)
61
+ parts = imported_fqn.split(".")
62
+ for i in range(1, len(parts)):
63
+ candidate = ".".join(parts[i:])
64
+ if candidate in self.nodes:
65
+ return candidate
66
+ return None
67
+
68
+ def classify_fqn(self, fqn: str) -> NodeNamespace:
69
+ """Classify an FQN as STDLIB, INTERNAL, EXTERNAL, or UNKNOWN.
70
+
71
+ UNKNOWN means the root segment was not found in internal roots,
72
+ stdlib/builtins, or any known import-map external root.
73
+ """
74
+ if fqn.startswith((".", SELF_PREFIX)):
75
+ return NodeNamespace.INTERNAL
76
+ root = fqn.split(".", maxsplit=1)[0]
77
+ if root in self.internal_roots:
78
+ return NodeNamespace.INTERNAL
79
+ if root in sys.stdlib_module_names or root in _BUILTINS:
80
+ return NodeNamespace.STDLIB
81
+ if root in self.external_roots:
82
+ return NodeNamespace.EXTERNAL
83
+ return NodeNamespace.UNKNOWN
84
+
85
+ def is_variable_node(self, fqn: str) -> bool:
86
+ """Return True when fqn names an existing VARIABLE node in the graph."""
87
+ node = self.nodes.get(fqn)
88
+ return node is not None and node.type == NodeType.VARIABLE
89
+
90
+ def normalized_file_path(self, source_fqn: str, edge_file_path: str | None) -> str | None:
91
+ """Return the normalized file path for a source FQN, falling back to edge_file_path."""
92
+ source_node = self.nodes.get(source_fqn)
93
+ if source_node:
94
+ return os.path.normpath(source_node.file_path)
95
+ return os.path.normpath(edge_file_path) if edge_file_path else None
96
+
97
+
98
+ class IndexBuilder:
99
+ """Builds a SymbolIndex from extracted nodes (Phase 1: indexing).
100
+
101
+ Takes only nodes — never edges. The inheritance tree is deliberately
102
+ NOT built here: resolving EXTENDS targets requires symbol resolution,
103
+ so it belongs to SymbolResolver (spec §2.4).
104
+ """
105
+
106
+ def build(self, nodes: list[Node]) -> SymbolIndex:
107
+ """Index all nodes for fast resolution and return the frozen index."""
108
+ nodes_by_id = {n.id: n for n in nodes}
109
+ global_symbols: dict[str, list[str]] = {}
110
+ file_global_symbols: dict[tuple[str, str], list[str]] = {}
111
+ class_methods: dict[str, dict[str, str]] = {}
112
+ variable_symbols: dict[str, list[str]] = {}
113
+ file_variable_symbols: dict[tuple[str, str], list[str]] = {}
114
+ file_imports: dict[str, dict[str, str]] = {}
115
+ suffix_map: dict[str, list[str]] = {}
116
+ internal_roots: set[str] = set()
117
+
118
+ for node in nodes_by_id.values():
119
+ # Index FILE-level import maps
120
+ if node.type == NodeType.FILE:
121
+ normalized = os.path.normpath(node.file_path)
122
+ file_imports[normalized] = node.metadata.get("import_map") or {}
123
+
124
+ # Index global functions/symbols
125
+ if node.type in (NodeType.FUNCTION, NodeType.CLASS):
126
+ global_symbols.setdefault(node.name, []).append(node.id)
127
+ file_global_symbols.setdefault(
128
+ (os.path.normpath(node.file_path), node.name), []
129
+ ).append(node.id)
130
+
131
+ # Index methods within classes
132
+ if node.type == NodeType.METHOD:
133
+ class_fqn, sep, _ = node.id.rpartition(".")
134
+ if sep:
135
+ class_methods.setdefault(class_fqn, {})[node.name] = node.id
136
+
137
+ # Index DI aliases for raw_dep: candidate resolution
138
+ if node.type == NodeType.VARIABLE:
139
+ variable_symbols.setdefault(node.name, []).append(node.id)
140
+ file_variable_symbols.setdefault(
141
+ (os.path.normpath(node.file_path), node.name), []
142
+ ).append(node.id)
143
+
144
+ # Build suffix map for src/-layout prefix normalization:
145
+ # "src.cgis.pipeline.X" → suffix "cgis.pipeline.X" also points to the node
146
+ self._add_node_to_suffix_map(node.id, suffix_map, internal_roots)
147
+
148
+ return SymbolIndex(
149
+ nodes=nodes_by_id,
150
+ global_symbols=global_symbols,
151
+ file_global_symbols=file_global_symbols,
152
+ class_methods=class_methods,
153
+ variable_symbols=variable_symbols,
154
+ file_variable_symbols=file_variable_symbols,
155
+ file_imports=file_imports,
156
+ suffix_map=suffix_map,
157
+ internal_roots=internal_roots,
158
+ external_roots=self._build_external_roots(file_imports),
159
+ )
160
+
161
+ def _add_node_to_suffix_map(
162
+ self, node_id: str, suffix_map: dict[str, list[str]], internal_roots: set[str]
163
+ ) -> None:
164
+ """Add node to suffix map and internal roots based on its ID."""
165
+ parts = node_id.split(".")
166
+ internal_roots.add(parts[0])
167
+ if len(parts) > 1 and parts[0] in {"src", "lib"}:
168
+ internal_roots.add(parts[1])
169
+ for i in range(1, len(parts)):
170
+ suffix = ".".join(parts[i:])
171
+ suffix_map.setdefault(suffix, []).append(node_id)
172
+
173
+ def _build_external_roots(self, file_imports: dict[str, dict[str, str]]) -> set[str]:
174
+ """Build set of known external root modules from file import maps.
175
+
176
+ Any root not in internal_roots, stdlib, or external_roots
177
+ is classified as UNKNOWN by classify_fqn.
178
+ """
179
+ return {
180
+ val.split(".", maxsplit=1)[0]
181
+ for import_map in file_imports.values()
182
+ for val in import_map.values()
183
+ if val and not val.startswith(".")
184
+ }
@@ -0,0 +1,179 @@
1
+ """Symbol resolution strategies over a SymbolIndex."""
2
+
3
+ from cgis.core.models import RAW_CLASS_PREFIX, Edge, EdgeType, Node, NodeNamespace
4
+ from cgis.resolver.indices import IndexBuilder, SymbolIndex
5
+
6
+
7
+ class SymbolResolver:
8
+ """Maps raw symbol names to graph FQNs.
9
+
10
+ Strategy chain per call site: local variable types, the consuming file's
11
+ import map, then the global symbol index with same-file preference.
12
+ Holds the inheritance tree (a resolution product built from EXTENDS
13
+ edges — not an index, see spec §2.4).
14
+
15
+ The constructor accepts nodes and edges directly; it builds the
16
+ SymbolIndex internally via IndexBuilder and exposes it as the public
17
+ ``index`` attribute so callers (e.g. ResolverEngine) can read the index
18
+ without building it themselves.
19
+ """
20
+
21
+ def __init__(self, nodes: list[Node], edges: list[Edge]) -> None:
22
+ """Build the symbol index from nodes, then the inheritance tree from EXTENDS edges."""
23
+ self.index: SymbolIndex = IndexBuilder().build(nodes)
24
+ # class_fqn -> [resolved parent FQNs] built from EXTENDS edges
25
+ self._inheritance_tree: dict[str, list[str]] = {}
26
+ for edge in edges:
27
+ if edge.type == EdgeType.EXTENDS:
28
+ raw = edge.target.removeprefix(RAW_CLASS_PREFIX)
29
+ resolved = self.resolve_class_ref(raw, edge.source, edge.file_path)
30
+ # Only resolved (FQN) parents are useful for method-hierarchy lookup.
31
+ # Storing the bare `raw` name would false-match an unrelated class
32
+ # that happens to share that bare name (#183).
33
+ if resolved:
34
+ self._inheritance_tree.setdefault(edge.source, []).append(resolved)
35
+
36
+ def resolve_class_ref(
37
+ self, name: str, source_fqn: str, edge_file_path: str | None
38
+ ) -> str | None:
39
+ """Resolve a class name from an EXTENDS edge target to a graph FQN."""
40
+ file_path = self.index.normalized_file_path(source_fqn, edge_file_path)
41
+ if file_path:
42
+ result = self._resolve_via_import_map(name, file_path)
43
+ if result:
44
+ return result
45
+ # Global symbol index is keyed by short name; for dotted refs like `models.BaseModel`
46
+ # strip the module prefix and verify the resolved FQN ends with the full dotted name.
47
+ query_name = name.rsplit(".", maxsplit=1)[-1]
48
+ resolved = self._resolve_via_global_symbols(query_name, file_path)
49
+ if resolved and ("." not in name or resolved == name or resolved.endswith(f".{name}")):
50
+ return resolved
51
+ return None
52
+
53
+ def resolve_self_call(self, source_fqn: str, method_name: str) -> str | None:
54
+ """Attempts to find a method on the class that owns source, traversing inheritance.
55
+
56
+ Walks up the FQN segments to handle nested functions (e.g. mod.Cls.method.inner).
57
+ """
58
+ parts = source_fqn.split(".")
59
+ for i in range(len(parts) - 1, 0, -1):
60
+ candidate = ".".join(parts[:i])
61
+ if candidate in self.index.class_methods:
62
+ return self._resolve_method_on_class_hierarchy(candidate, method_name, set())
63
+ return None
64
+
65
+ def resolve_global_call(
66
+ self, name: str, source_fqn: str, edge_file_path: str | None = None
67
+ ) -> str | None:
68
+ """Resolve a global call using local types, import map, then global symbol index."""
69
+ source_node = self.index.nodes.get(source_fqn)
70
+ file_path = self.index.normalized_file_path(source_fqn, edge_file_path)
71
+
72
+ if source_node:
73
+ result = self._resolve_local_type_call(name, source_node)
74
+ if result:
75
+ return result
76
+
77
+ if file_path:
78
+ result = self._resolve_via_import_map(name, file_path)
79
+ if result:
80
+ return result
81
+
82
+ return self._resolve_via_global_symbols(name, file_path)
83
+
84
+ def resolve_dep_candidate(
85
+ self, name: str, source_fqn: str, edge_file_path: str | None
86
+ ) -> str | None:
87
+ """Resolve a raw_dep: candidate to a VARIABLE (DI alias) node, or None.
88
+
89
+ Order: the consuming file's import map first, then the VARIABLE-only
90
+ symbol index with same-file preference. Returns None for anything that
91
+ is not an existing VARIABLE node — the caller drops the edge.
92
+ A globally-unique match is accepted even when the name is not importable
93
+ in the consuming file — matching resolve_global_call's behavior.
94
+
95
+ When ``name`` is present in the file's import map, the import is
96
+ authoritative: return the hit only if it is a VARIABLE node, otherwise
97
+ return ``None`` immediately — never fall through to the global
98
+ ``variable_symbols`` index. This prevents an explicitly imported
99
+ class from being shadowed by a same-named DI alias in another module.
100
+ """
101
+ file_path = self.index.normalized_file_path(source_fqn, edge_file_path)
102
+ if file_path:
103
+ via_import = self._resolve_via_import_map(name, file_path)
104
+ if via_import:
105
+ return via_import if self.index.is_variable_node(via_import) else None
106
+ candidates = self.index.variable_symbols.get(name, [])
107
+ if len(candidates) == 1:
108
+ return candidates[0]
109
+ if candidates and file_path:
110
+ same_file = self.index.file_variable_symbols.get((file_path, name), [])
111
+ if len(same_file) == 1:
112
+ return same_file[0]
113
+ return None
114
+
115
+ def _resolve_method_on_class_hierarchy(
116
+ self, class_fqn: str, method_name: str, visited: set[str]
117
+ ) -> str | None:
118
+ """DFS over EXTENDS edges to find method_name defined in class_fqn or any ancestor."""
119
+ if class_fqn in visited:
120
+ return None
121
+ visited.add(class_fqn)
122
+ direct = self.index.class_methods.get(class_fqn, {}).get(method_name)
123
+ if direct:
124
+ return direct
125
+ for parent_fqn in self._inheritance_tree.get(class_fqn, []):
126
+ result = self._resolve_method_on_class_hierarchy(parent_fqn, method_name, visited)
127
+ if result:
128
+ return result
129
+ return None
130
+
131
+ def _resolve_via_import_map(self, name: str, file_path: str) -> str | None:
132
+ """Look up name in the file's import map (direct and module-prefixed calls)."""
133
+ file_import_map = self.index.file_imports.get(file_path, {})
134
+
135
+ if name in file_import_map:
136
+ target_fqn = file_import_map[name]
137
+ return self.index.map_to_node_fqn(target_fqn) or target_fqn
138
+
139
+ first_part = name.split(".", maxsplit=1)[0]
140
+ if first_part in file_import_map and "." in name:
141
+ rest = name[len(first_part) + 1 :]
142
+ target_fqn = f"{file_import_map[first_part]}.{rest}"
143
+ return self.index.map_to_node_fqn(target_fqn) or target_fqn
144
+
145
+ return None
146
+
147
+ def _resolve_local_type_call(self, name: str, source_node: Node) -> str | None:
148
+ """Resolve `var.method` using local_types metadata on the source node."""
149
+ if "." not in name:
150
+ return None
151
+ var_name, method_name = name.split(".", maxsplit=1)
152
+ local_types: dict[str, str] = source_node.metadata.get("local_types") or {}
153
+ class_fqn = local_types.get(var_name)
154
+ if not class_fqn:
155
+ return None
156
+ candidate = f"{class_fqn}.{method_name}"
157
+ resolved = self.index.map_to_node_fqn(candidate)
158
+ if resolved:
159
+ return resolved
160
+ # No node for `Class.method`. Keep it only when the type is external/stdlib
161
+ # — that is a real call into a library (it becomes an EXTERNAL/STDLIB virtual
162
+ # node). For an internal/unknown type it is a phantom method: drop it rather
163
+ # than fabricate a node that does not exist (#183).
164
+ if self.index.classify_fqn(candidate) in (NodeNamespace.EXTERNAL, NodeNamespace.STDLIB):
165
+ return candidate
166
+ return None
167
+
168
+ def _resolve_via_global_symbols(self, name: str, file_path: str | None) -> str | None:
169
+ """Look up name in the global symbol index, preferring same-file candidates."""
170
+ candidates = self.index.global_symbols.get(name, [])
171
+ if not candidates:
172
+ return None
173
+ if len(candidates) == 1:
174
+ return candidates[0]
175
+ if file_path:
176
+ same_file = self.index.file_global_symbols.get((file_path, name), [])
177
+ if len(same_file) == 1:
178
+ return same_file[0]
179
+ return None