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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
cgis/resolver/engine.py
ADDED
|
@@ -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
|
+
)
|
cgis/resolver/indices.py
ADDED
|
@@ -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
|
+
}
|
cgis/resolver/symbols.py
ADDED
|
@@ -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
|