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,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
+ }