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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""13-class connected triad census and total-variation distance (spec ss3.1-3.3).
|
|
2
|
+
|
|
3
|
+
Classification uses the Batagelj-Mrvar tricode: the 6 possible directed edges
|
|
4
|
+
among an ordered triple form a 6-bit code; a 64-entry table maps each code to
|
|
5
|
+
one of the 16 MAN triad types. We count only the 13 *connected* types -- the
|
|
6
|
+
unit tests pin one hand-built graph per class, which is the correctness anchor
|
|
7
|
+
for the table.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
from cgis.core.models import Edge, EdgeType
|
|
13
|
+
|
|
14
|
+
#: The 13 connected triad classes, canonical vector order for fingerprints.
|
|
15
|
+
TRIAD_ORDER: tuple[str, ...] = (
|
|
16
|
+
"021D",
|
|
17
|
+
"021U",
|
|
18
|
+
"021C",
|
|
19
|
+
"111D",
|
|
20
|
+
"111U",
|
|
21
|
+
"030T",
|
|
22
|
+
"030C",
|
|
23
|
+
"201",
|
|
24
|
+
"120D",
|
|
25
|
+
"120U",
|
|
26
|
+
"120C",
|
|
27
|
+
"210",
|
|
28
|
+
"300",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
#: All 16 MAN types in Batagelj-Mrvar order; the first three are disconnected.
|
|
32
|
+
_TRIAD_NAMES: tuple[str, ...] = (
|
|
33
|
+
"003",
|
|
34
|
+
"012",
|
|
35
|
+
"102",
|
|
36
|
+
"021D",
|
|
37
|
+
"021U",
|
|
38
|
+
"021C",
|
|
39
|
+
"111D",
|
|
40
|
+
"111U",
|
|
41
|
+
"030T",
|
|
42
|
+
"030C",
|
|
43
|
+
"201",
|
|
44
|
+
"120D",
|
|
45
|
+
"120U",
|
|
46
|
+
"120C",
|
|
47
|
+
"210",
|
|
48
|
+
"300",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
#: Batagelj-Mrvar TRICODES: maps each 6-bit edge code to a 1-based MAN index.
|
|
52
|
+
_TRICODES: tuple[int, ...] = (
|
|
53
|
+
1,
|
|
54
|
+
2,
|
|
55
|
+
2,
|
|
56
|
+
3,
|
|
57
|
+
2,
|
|
58
|
+
4,
|
|
59
|
+
6,
|
|
60
|
+
8,
|
|
61
|
+
2,
|
|
62
|
+
6,
|
|
63
|
+
5,
|
|
64
|
+
7,
|
|
65
|
+
3,
|
|
66
|
+
8,
|
|
67
|
+
7,
|
|
68
|
+
11,
|
|
69
|
+
2,
|
|
70
|
+
6,
|
|
71
|
+
4,
|
|
72
|
+
8,
|
|
73
|
+
5,
|
|
74
|
+
9,
|
|
75
|
+
9,
|
|
76
|
+
13,
|
|
77
|
+
6,
|
|
78
|
+
10,
|
|
79
|
+
9,
|
|
80
|
+
14,
|
|
81
|
+
7,
|
|
82
|
+
14,
|
|
83
|
+
12,
|
|
84
|
+
15,
|
|
85
|
+
2,
|
|
86
|
+
5,
|
|
87
|
+
6,
|
|
88
|
+
7,
|
|
89
|
+
6,
|
|
90
|
+
9,
|
|
91
|
+
10,
|
|
92
|
+
14,
|
|
93
|
+
4,
|
|
94
|
+
9,
|
|
95
|
+
9,
|
|
96
|
+
12,
|
|
97
|
+
8,
|
|
98
|
+
13,
|
|
99
|
+
14,
|
|
100
|
+
15,
|
|
101
|
+
3,
|
|
102
|
+
7,
|
|
103
|
+
8,
|
|
104
|
+
11,
|
|
105
|
+
7,
|
|
106
|
+
12,
|
|
107
|
+
14,
|
|
108
|
+
15,
|
|
109
|
+
8,
|
|
110
|
+
14,
|
|
111
|
+
13,
|
|
112
|
+
15,
|
|
113
|
+
11,
|
|
114
|
+
15,
|
|
115
|
+
15,
|
|
116
|
+
16,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
#: Zero vector, the default for fingerprints of empty domains.
|
|
120
|
+
ZERO_TRIADS: tuple[float, ...] = (0.0,) * len(TRIAD_ORDER)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _classify(succ: dict[str, set[str]], v: str, u: str, w: str) -> str:
|
|
124
|
+
"""Return the MAN class name for the ordered triple (v, u, w)."""
|
|
125
|
+
code = (
|
|
126
|
+
(1 if u in succ[v] else 0)
|
|
127
|
+
+ (2 if v in succ[u] else 0)
|
|
128
|
+
+ (4 if w in succ[v] else 0)
|
|
129
|
+
+ (8 if v in succ[w] else 0)
|
|
130
|
+
+ (16 if w in succ[u] else 0)
|
|
131
|
+
+ (32 if u in succ[w] else 0)
|
|
132
|
+
)
|
|
133
|
+
return _TRIAD_NAMES[_TRICODES[code] - 1]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _adjacency(
|
|
137
|
+
node_ids: set[str], edges: list[Edge], edge_type: EdgeType
|
|
138
|
+
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
|
|
139
|
+
"""Build (successor, undirected-neighbor) maps over intra-set edges of edge_type.
|
|
140
|
+
|
|
141
|
+
Self-loops and edges touching nodes outside node_ids are ignored.
|
|
142
|
+
"""
|
|
143
|
+
succ: dict[str, set[str]] = {n: set() for n in node_ids}
|
|
144
|
+
nbrs: dict[str, set[str]] = {n: set() for n in node_ids}
|
|
145
|
+
for e in edges:
|
|
146
|
+
if e.type != edge_type or e.source == e.target:
|
|
147
|
+
continue
|
|
148
|
+
if e.source in succ and e.target in succ:
|
|
149
|
+
succ[e.source].add(e.target)
|
|
150
|
+
nbrs[e.source].add(e.target)
|
|
151
|
+
nbrs[e.target].add(e.source)
|
|
152
|
+
return succ, nbrs
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _connected_triples(
|
|
156
|
+
node_ids: set[str], nbrs: dict[str, set[str]]
|
|
157
|
+
) -> Iterator[tuple[str, str, str]]:
|
|
158
|
+
"""Yield each connected unordered triple exactly once, as a sorted 3-tuple."""
|
|
159
|
+
seen: set[tuple[str, str, str]] = set()
|
|
160
|
+
for v in node_ids:
|
|
161
|
+
for u in nbrs[v]:
|
|
162
|
+
for w in nbrs[v] | nbrs[u]:
|
|
163
|
+
if w in (v, u):
|
|
164
|
+
continue
|
|
165
|
+
a, b, c = sorted((v, u, w))
|
|
166
|
+
if (a, b, c) not in seen:
|
|
167
|
+
seen.add((a, b, c))
|
|
168
|
+
yield a, b, c
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def triad_census(node_ids: set[str], edges: list[Edge], edge_type: EdgeType) -> dict[str, int]:
|
|
172
|
+
"""Count connected triads over intra-set edges of edge_type.
|
|
173
|
+
|
|
174
|
+
Each unordered triple is counted exactly once. O(Σ deg(v)²) — fine at
|
|
175
|
+
our scale (spec §3.6.3).
|
|
176
|
+
"""
|
|
177
|
+
succ, nbrs = _adjacency(node_ids, edges, edge_type)
|
|
178
|
+
counts: dict[str, int] = dict.fromkeys(TRIAD_ORDER, 0)
|
|
179
|
+
# Every yielded triple has v-u adjacent and w adjacent to one of them,
|
|
180
|
+
# so it is connected and the class is always one of the 13.
|
|
181
|
+
for a, b, c in _connected_triples(node_ids, nbrs):
|
|
182
|
+
counts[_classify(succ, a, b, c)] += 1
|
|
183
|
+
return counts
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def normalized_census(counts: dict[str, int]) -> tuple[float, ...]:
|
|
187
|
+
"""Return the census as a 13-tuple in TRIAD_ORDER, normalized to sum 1.
|
|
188
|
+
|
|
189
|
+
An empty census normalizes to the all-zero vector ("no data", not NaN).
|
|
190
|
+
"""
|
|
191
|
+
total = sum(counts.values())
|
|
192
|
+
if total == 0:
|
|
193
|
+
return ZERO_TRIADS
|
|
194
|
+
return tuple(counts[name] / total for name in TRIAD_ORDER)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def tv_distance(
|
|
198
|
+
t: tuple[float, ...],
|
|
199
|
+
ideal: tuple[float, ...],
|
|
200
|
+
weights: tuple[float, ...],
|
|
201
|
+
) -> tuple[float, list[tuple[str, float]]]:
|
|
202
|
+
"""Weighted total variation 0.5*sum(wi*|ti-ideali|) with per-triad decomposition.
|
|
203
|
+
|
|
204
|
+
Returns (tv, contributions) where contributions pairs each TRIAD_ORDER
|
|
205
|
+
name with its 0.5*wi*|ti-ideali| term (spec ss3.3: violations fall out of
|
|
206
|
+
the metric instead of being approximated from it).
|
|
207
|
+
"""
|
|
208
|
+
contribs = [
|
|
209
|
+
(name, 0.5 * w * abs(a - b))
|
|
210
|
+
for name, a, b, w in zip(TRIAD_ORDER, t, ideal, weights, strict=True)
|
|
211
|
+
]
|
|
212
|
+
return sum((c for _, c in contribs), 0.0), contribs
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
#: MAN mutual-dyad count (M, the first digit of the MAN code) for each class,
|
|
216
|
+
#: aligned index-for-index with TRIAD_ORDER. Transpose-fixed mutual mass is the
|
|
217
|
+
#: anti-pattern signal (spec: health = acyclicity + antisymmetry).
|
|
218
|
+
_TANGLE_WEIGHTS: tuple[int, ...] = (0, 0, 0, 1, 1, 0, 0, 2, 1, 1, 1, 2, 3)
|
|
219
|
+
|
|
220
|
+
#: Max single-class weight (pure 300), used to normalize tangle into [0, 1].
|
|
221
|
+
_TANGLE_MAX_M = 3.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def tangle_mass(census: tuple[float, ...]) -> float:
|
|
225
|
+
"""Normalized transpose-fixed (mutual) motif mass of a census, in [0, 1].
|
|
226
|
+
|
|
227
|
+
Weights each normalized triad fraction by its MAN mutual-dyad count M and
|
|
228
|
+
divides by the maximal M (3, pure 300). An antisymmetric graph — no mutual
|
|
229
|
+
dyads, i.e. only 021*/030T/030C (cycles included; acyclicity is cycle_ratio's
|
|
230
|
+
job, orthogonal to tangle) — scores 0; a pure mutual mesh (300) scores 1.
|
|
231
|
+
The empty census scores 0.
|
|
232
|
+
"""
|
|
233
|
+
weighted = sum(w * t for w, t in zip(_TANGLE_WEIGHTS, census, strict=True))
|
|
234
|
+
return weighted / _TANGLE_MAX_M
|
cgis/query/engine.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Implement query engine for code graph."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from cgis.core.models import Edge, EdgeType, Node, NodeNamespace
|
|
6
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
7
|
+
|
|
8
|
+
STRUCTURAL_EDGE_TYPES: frozenset[EdgeType] = frozenset({EdgeType.CONTAINS, EdgeType.DECLARES})
|
|
9
|
+
BEHAVIORAL_EDGE_TYPES: frozenset[EdgeType] = frozenset(
|
|
10
|
+
t for t in EdgeType if t not in STRUCTURAL_EDGE_TYPES
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _reachable_from(
|
|
15
|
+
start_id: str,
|
|
16
|
+
edges: list[Edge],
|
|
17
|
+
get_neighbor_id: Callable[[Edge], str],
|
|
18
|
+
) -> set[str]:
|
|
19
|
+
"""DFS reachability from start_id through filtered edges."""
|
|
20
|
+
adj: dict[str, list[str]] = {}
|
|
21
|
+
for e in edges:
|
|
22
|
+
nbr = get_neighbor_id(e)
|
|
23
|
+
frm = e.source if nbr == e.target else e.target
|
|
24
|
+
adj.setdefault(frm, []).append(nbr)
|
|
25
|
+
reachable: set[str] = {start_id}
|
|
26
|
+
queue: list[str] = [start_id]
|
|
27
|
+
while queue:
|
|
28
|
+
curr = queue.pop()
|
|
29
|
+
for nbr in adj.get(curr, []):
|
|
30
|
+
if nbr not in reachable:
|
|
31
|
+
reachable.add(nbr)
|
|
32
|
+
queue.append(nbr)
|
|
33
|
+
return reachable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _prune_external(
|
|
37
|
+
start_id: str,
|
|
38
|
+
nodes: list[Node],
|
|
39
|
+
visited_edges: dict[str, Edge],
|
|
40
|
+
get_neighbor_id: Callable[[Edge], str],
|
|
41
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
42
|
+
"""Remove non-INTERNAL nodes and prune internal nodes disconnected after that removal."""
|
|
43
|
+
nodes = [n for n in nodes if n.namespace == NodeNamespace.INTERNAL or n.id == start_id]
|
|
44
|
+
internal_ids = {n.id for n in nodes} | {start_id}
|
|
45
|
+
filtered_edges = [
|
|
46
|
+
e for e in visited_edges.values() if e.source in internal_ids and e.target in internal_ids
|
|
47
|
+
]
|
|
48
|
+
reachable = _reachable_from(start_id, filtered_edges, get_neighbor_id)
|
|
49
|
+
nodes = [n for n in nodes if n.id in reachable]
|
|
50
|
+
return nodes, [e for e in filtered_edges if e.source in reachable and e.target in reachable]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _edge_accepted(
|
|
54
|
+
edge: Edge,
|
|
55
|
+
allowed_edge_types: frozenset[EdgeType] | None,
|
|
56
|
+
min_confidence: float | None,
|
|
57
|
+
) -> bool:
|
|
58
|
+
"""True when an edge passes the traversal filters: type allow-list and confidence floor."""
|
|
59
|
+
if allowed_edge_types is not None and edge.type not in allowed_edge_types:
|
|
60
|
+
return False
|
|
61
|
+
return not (min_confidence is not None and edge.confidence < min_confidence)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QueryEngine:
|
|
65
|
+
"""
|
|
66
|
+
Performs graph traversals over the SQLite Code Graph.
|
|
67
|
+
Enables Impact Analysis (upstream) and Flow Tracing (downstream).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, store: SQLiteStore) -> None:
|
|
71
|
+
"""Bind the query engine to an open SQLiteStore instance."""
|
|
72
|
+
self.store = store
|
|
73
|
+
|
|
74
|
+
def get_impact_graph(
|
|
75
|
+
self,
|
|
76
|
+
target_node_id: str,
|
|
77
|
+
max_depth: int = 5,
|
|
78
|
+
allowed_edge_types: frozenset[EdgeType] | None = None,
|
|
79
|
+
show_external: bool = True,
|
|
80
|
+
min_confidence: float | None = None,
|
|
81
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
82
|
+
"""
|
|
83
|
+
Transitive upstream traversal (who calls me?).
|
|
84
|
+
If target_node_id changes, what else is impacted?
|
|
85
|
+
"""
|
|
86
|
+
return self._bfs_traverse(
|
|
87
|
+
target_node_id,
|
|
88
|
+
self.store.get_incoming_edges_batch,
|
|
89
|
+
lambda e: e.source,
|
|
90
|
+
max_depth,
|
|
91
|
+
allowed_edge_types=allowed_edge_types,
|
|
92
|
+
show_external=show_external,
|
|
93
|
+
min_confidence=min_confidence,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def get_flow_graph(
|
|
97
|
+
self,
|
|
98
|
+
start_node_id: str,
|
|
99
|
+
max_depth: int = 5,
|
|
100
|
+
allowed_edge_types: frozenset[EdgeType] | None = None,
|
|
101
|
+
show_external: bool = True,
|
|
102
|
+
min_confidence: float | None = None,
|
|
103
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
104
|
+
"""
|
|
105
|
+
Transitive downstream traversal (who do I call?).
|
|
106
|
+
Traces execution path starting from start_node_id.
|
|
107
|
+
"""
|
|
108
|
+
return self._bfs_traverse(
|
|
109
|
+
start_node_id,
|
|
110
|
+
self.store.get_outgoing_edges_batch,
|
|
111
|
+
lambda e: e.target,
|
|
112
|
+
max_depth,
|
|
113
|
+
allowed_edge_types=allowed_edge_types,
|
|
114
|
+
show_external=show_external,
|
|
115
|
+
min_confidence=min_confidence,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def get_structural_graph(
|
|
119
|
+
self, target_id: str, max_depth: int = 5
|
|
120
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
121
|
+
"""
|
|
122
|
+
Structural hierarchy rooted at target_id (FILE → CLASS → METHOD).
|
|
123
|
+
Traverses only CONTAINS and DECLARES edges — no call-graph noise.
|
|
124
|
+
Delegates to a single recursive CTE query in the store.
|
|
125
|
+
"""
|
|
126
|
+
return self.store.get_structural_subgraph(target_id, max_depth)
|
|
127
|
+
|
|
128
|
+
def _bfs_traverse(
|
|
129
|
+
self,
|
|
130
|
+
start_id: str,
|
|
131
|
+
get_edges_batch: Callable[[list[str]], list[Edge]],
|
|
132
|
+
get_neighbor_id: Callable[[Edge], str],
|
|
133
|
+
max_depth: int,
|
|
134
|
+
allowed_edge_types: frozenset[EdgeType] | None = None,
|
|
135
|
+
show_external: bool = True,
|
|
136
|
+
min_confidence: float | None = None,
|
|
137
|
+
) -> tuple[list[Node], list[Edge]]:
|
|
138
|
+
"""
|
|
139
|
+
Level-by-level BFS. Fetches edges for the entire frontier in one
|
|
140
|
+
batch query per level — O(depth) DB roundtrips instead of O(nodes).
|
|
141
|
+
|
|
142
|
+
``min_confidence`` prunes edges below the given confidence (e.g.
|
|
143
|
+
unresolved ``raw_call:`` edges at 0.1) so the traversal never crosses
|
|
144
|
+
them — low-confidence neighbours stay out of the result.
|
|
145
|
+
"""
|
|
146
|
+
discovered_ids: set[str] = {start_id}
|
|
147
|
+
visited_edges: dict[str, Edge] = {}
|
|
148
|
+
current_frontier = [start_id]
|
|
149
|
+
depth = 0
|
|
150
|
+
|
|
151
|
+
while current_frontier and depth < max_depth:
|
|
152
|
+
edges = get_edges_batch(current_frontier)
|
|
153
|
+
next_frontier: list[str] = []
|
|
154
|
+
for edge in edges:
|
|
155
|
+
if not _edge_accepted(edge, allowed_edge_types, min_confidence):
|
|
156
|
+
continue
|
|
157
|
+
visited_edges[edge.id] = edge
|
|
158
|
+
neighbor_id = get_neighbor_id(edge)
|
|
159
|
+
if neighbor_id not in discovered_ids:
|
|
160
|
+
discovered_ids.add(neighbor_id)
|
|
161
|
+
next_frontier.append(neighbor_id)
|
|
162
|
+
current_frontier = next_frontier
|
|
163
|
+
depth += 1
|
|
164
|
+
|
|
165
|
+
nodes = self.store.get_nodes(list(discovered_ids))
|
|
166
|
+
|
|
167
|
+
if not show_external:
|
|
168
|
+
return _prune_external(start_id, nodes, visited_edges, get_neighbor_id)
|
|
169
|
+
|
|
170
|
+
return nodes, list(visited_edges.values())
|
cgis/query/fqn.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Suffix-based FQN resolution shared by CLI commands and MCP tools.
|
|
2
|
+
|
|
3
|
+
Fixes the prefix-mismatch UX wart (#145): a graph ingested from ``src``
|
|
4
|
+
holds ``cgis.*`` FQNs while users (and agents) often pass ``src.cgis.*``
|
|
5
|
+
or bare names. A unique dot-boundary suffix match resolves silently;
|
|
6
|
+
ambiguity surfaces the candidates instead of a bare "not found".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from cgis.storage.sqlite_store import SQLiteStore
|
|
12
|
+
|
|
13
|
+
_CANDIDATE_LIMIT = 10
|
|
14
|
+
_PROBE_LIMIT = _CANDIDATE_LIMIT + 1 # one extra to detect truncation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class FqnResolution:
|
|
19
|
+
"""Outcome of resolving a possibly-partial FQN against the graph.
|
|
20
|
+
|
|
21
|
+
``resolved`` is set when there is exactly one match (exact or suffix).
|
|
22
|
+
``candidates`` lists up to ``_CANDIDATE_LIMIT`` alternatives when the
|
|
23
|
+
suffix is ambiguous. ``via_suffix`` is True when the caller passed a
|
|
24
|
+
partial name that was matched by dot-boundary suffix search.
|
|
25
|
+
``truncated`` is True when the candidate list was capped because more
|
|
26
|
+
than ``_CANDIDATE_LIMIT`` nodes matched; in that case the caller should
|
|
27
|
+
prompt the user to refine the name.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
resolved: str | None
|
|
31
|
+
candidates: list[str] = field(default_factory=list)
|
|
32
|
+
via_suffix: bool = False
|
|
33
|
+
truncated: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_fqn(store: SQLiteStore, fqn: str) -> FqnResolution:
|
|
37
|
+
"""Resolve ``fqn`` exactly, or by unique dot-boundary suffix match.
|
|
38
|
+
|
|
39
|
+
Exact hit resolves as-is (``via_suffix=False``); a single suffix hit
|
|
40
|
+
resolves to the full FQN with ``via_suffix=True``; several hits return
|
|
41
|
+
``candidates`` (capped at ``_CANDIDATE_LIMIT``); no hit returns an empty
|
|
42
|
+
resolution. When the result set is larger than ``_CANDIDATE_LIMIT`` the
|
|
43
|
+
``truncated`` flag is set to signal that the listing is incomplete.
|
|
44
|
+
|
|
45
|
+
Empty or whitespace-only input returns an unresolved ``FqnResolution``
|
|
46
|
+
immediately without touching the store.
|
|
47
|
+
|
|
48
|
+
Exact-match policy lives here, not in ``SQLiteStore.find_nodes_by_suffix``,
|
|
49
|
+
so the storage layer stays a pure suffix search with no intra-domain call
|
|
50
|
+
chain (enforced by the pure_utility self-drift guardrail, #145).
|
|
51
|
+
"""
|
|
52
|
+
fqn = fqn.strip()
|
|
53
|
+
if not fqn:
|
|
54
|
+
return FqnResolution(resolved=None)
|
|
55
|
+
exact = store.get_node(fqn)
|
|
56
|
+
if exact:
|
|
57
|
+
return FqnResolution(resolved=fqn)
|
|
58
|
+
matches = store.find_nodes_by_suffix(fqn, limit=_PROBE_LIMIT)
|
|
59
|
+
if not matches:
|
|
60
|
+
return FqnResolution(resolved=None)
|
|
61
|
+
if len(matches) == 1:
|
|
62
|
+
return FqnResolution(resolved=matches[0].id, via_suffix=True)
|
|
63
|
+
truncated = len(matches) > _CANDIDATE_LIMIT
|
|
64
|
+
candidates = [n.id for n in matches[:_CANDIDATE_LIMIT]]
|
|
65
|
+
return FqnResolution(resolved=None, candidates=candidates, truncated=truncated)
|
|
File without changes
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Serialize a (nodes, edges) subgraph into a machine-readable JSON payload.
|
|
2
|
+
|
|
3
|
+
Mermaid output (``mermaid.py``) is for human eyes; this is the joinable,
|
|
4
|
+
agent/CI-facing view of the same subgraph. FQNs are emitted verbatim — no
|
|
5
|
+
display hashes — so results from separate queries can be combined with plain
|
|
6
|
+
set operations (e.g. authz-coverage or dead-code sweeps). See issue #171.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from cgis.core.models import Edge, Node
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def graph_to_json(root: str, nodes: list[Node], edges: list[Edge]) -> dict[str, Any]:
|
|
15
|
+
"""Build the JSON shape ``{root, nodes, edges}`` for a traversal result.
|
|
16
|
+
|
|
17
|
+
``root`` is the resolved FQN the traversal started from. Node entries carry
|
|
18
|
+
``fqn``/``type``/``file``/``line``; edge entries carry ``src``/``dst``/
|
|
19
|
+
``type``/``confidence``. Unresolved targets keep their ``raw_call:`` prefix
|
|
20
|
+
so consumers can tell a resolved edge from a dangling one.
|
|
21
|
+
"""
|
|
22
|
+
return {
|
|
23
|
+
"root": root,
|
|
24
|
+
"nodes": [
|
|
25
|
+
{
|
|
26
|
+
"fqn": node.id,
|
|
27
|
+
"type": node.type.value,
|
|
28
|
+
"file": node.file_path,
|
|
29
|
+
"line": node.start_line,
|
|
30
|
+
}
|
|
31
|
+
for node in nodes
|
|
32
|
+
],
|
|
33
|
+
"edges": [
|
|
34
|
+
{
|
|
35
|
+
"src": edge.source,
|
|
36
|
+
"dst": edge.target,
|
|
37
|
+
"type": edge.type.value,
|
|
38
|
+
"confidence": edge.confidence,
|
|
39
|
+
}
|
|
40
|
+
for edge in edges
|
|
41
|
+
],
|
|
42
|
+
}
|