codebeacon 0.1.2__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 (59) hide show
  1. codebeacon/__init__.py +1 -0
  2. codebeacon/__main__.py +3 -0
  3. codebeacon/cache.py +136 -0
  4. codebeacon/cli.py +391 -0
  5. codebeacon/common/__init__.py +0 -0
  6. codebeacon/common/filters.py +170 -0
  7. codebeacon/common/symbols.py +121 -0
  8. codebeacon/common/types.py +98 -0
  9. codebeacon/config.py +144 -0
  10. codebeacon/contextmap/__init__.py +0 -0
  11. codebeacon/contextmap/generator.py +602 -0
  12. codebeacon/discover/__init__.py +0 -0
  13. codebeacon/discover/detector.py +388 -0
  14. codebeacon/discover/scanner.py +192 -0
  15. codebeacon/export/__init__.py +0 -0
  16. codebeacon/export/mcp.py +515 -0
  17. codebeacon/export/obsidian.py +812 -0
  18. codebeacon/extract/__init__.py +22 -0
  19. codebeacon/extract/base.py +372 -0
  20. codebeacon/extract/components.py +357 -0
  21. codebeacon/extract/dependencies.py +140 -0
  22. codebeacon/extract/entities.py +575 -0
  23. codebeacon/extract/queries/README.md +116 -0
  24. codebeacon/extract/queries/actix.scm +115 -0
  25. codebeacon/extract/queries/angular.scm +155 -0
  26. codebeacon/extract/queries/aspnet.scm +159 -0
  27. codebeacon/extract/queries/django.scm +122 -0
  28. codebeacon/extract/queries/express.scm +124 -0
  29. codebeacon/extract/queries/fastapi.scm +152 -0
  30. codebeacon/extract/queries/flask.scm +120 -0
  31. codebeacon/extract/queries/gin.scm +142 -0
  32. codebeacon/extract/queries/ktor.scm +144 -0
  33. codebeacon/extract/queries/laravel.scm +172 -0
  34. codebeacon/extract/queries/nestjs.scm +183 -0
  35. codebeacon/extract/queries/rails.scm +114 -0
  36. codebeacon/extract/queries/react.scm +111 -0
  37. codebeacon/extract/queries/spring_boot.scm +204 -0
  38. codebeacon/extract/queries/svelte.scm +73 -0
  39. codebeacon/extract/queries/vapor.scm +130 -0
  40. codebeacon/extract/queries/vue.scm +123 -0
  41. codebeacon/extract/routes.py +910 -0
  42. codebeacon/extract/semantic.py +280 -0
  43. codebeacon/extract/services.py +597 -0
  44. codebeacon/graph/__init__.py +1 -0
  45. codebeacon/graph/analyze.py +281 -0
  46. codebeacon/graph/build.py +320 -0
  47. codebeacon/graph/cluster.py +160 -0
  48. codebeacon/graph/enrich.py +206 -0
  49. codebeacon/skill/SKILL.md +127 -0
  50. codebeacon/wave.py +292 -0
  51. codebeacon/wiki/__init__.py +0 -0
  52. codebeacon/wiki/generator.py +376 -0
  53. codebeacon/wiki/index.py +95 -0
  54. codebeacon/wiki/templates.py +467 -0
  55. codebeacon-0.1.2.dist-info/METADATA +319 -0
  56. codebeacon-0.1.2.dist-info/RECORD +59 -0
  57. codebeacon-0.1.2.dist-info/WHEEL +4 -0
  58. codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
  59. codebeacon-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,281 @@
1
+ """Graph analysis: god nodes, surprising connections, hub files, cohesion scoring.
2
+
3
+ These metrics help users understand their codebase structure at a glance.
4
+
5
+ Public API:
6
+ god_nodes(G, top_n, min_degree) → list[GodNode]
7
+ surprising_connections(G, communities) → list[SurprisingConnection]
8
+ hub_files(G, top_n) → list[HubFile]
9
+ analyze(G, communities, cohesion_scores) → GraphReport
10
+ report_to_markdown(report) → str
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional
17
+
18
+ import networkx as nx
19
+
20
+
21
+ # ── Data classes ──────────────────────────────────────────────────────────────
22
+
23
+ @dataclass
24
+ class GodNode:
25
+ """A node with unusually high degree (hub / bottleneck)."""
26
+ node_id: str
27
+ label: str
28
+ type: str
29
+ in_degree: int
30
+ out_degree: int
31
+ degree: int
32
+ centrality: float
33
+ source_file: str
34
+
35
+
36
+ @dataclass
37
+ class SurprisingConnection:
38
+ """A cross-community edge that may indicate unexpected coupling."""
39
+ source_id: str
40
+ source_label: str
41
+ target_id: str
42
+ target_label: str
43
+ relation: str
44
+ src_community: int
45
+ tgt_community: int
46
+ source_file: str
47
+
48
+
49
+ @dataclass
50
+ class HubFile:
51
+ """A source file imported by many other files (potential God file)."""
52
+ file_path: str
53
+ import_count: int
54
+ node_count: int
55
+
56
+
57
+ @dataclass
58
+ class GraphReport:
59
+ """Complete analysis report for a built graph."""
60
+ node_count: int = 0
61
+ edge_count: int = 0
62
+ community_count: int = 0
63
+ god_nodes: list[GodNode] = field(default_factory=list)
64
+ surprising_connections: list[SurprisingConnection] = field(default_factory=list)
65
+ hub_files: list[HubFile] = field(default_factory=list)
66
+ cohesion_scores: dict[int, float] = field(default_factory=dict)
67
+ isolated_nodes: int = 0
68
+ density: float = 0.0
69
+
70
+
71
+ # ── Analysis functions ────────────────────────────────────────────────────────
72
+
73
+ def god_nodes(
74
+ G: nx.DiGraph,
75
+ top_n: int = 20,
76
+ min_degree: int = 5,
77
+ ) -> list[GodNode]:
78
+ """Find nodes with the highest degree (potential god classes / bottlenecks).
79
+
80
+ Args:
81
+ G: the knowledge graph
82
+ top_n: return at most this many nodes
83
+ min_degree: minimum total degree to qualify
84
+
85
+ Returns:
86
+ List of GodNode sorted by degree descending.
87
+ """
88
+ centrality = nx.degree_centrality(G)
89
+
90
+ results: list[GodNode] = []
91
+ for node_id, data in G.nodes(data=True):
92
+ deg = G.degree(node_id)
93
+ if deg < min_degree:
94
+ continue
95
+ results.append(GodNode(
96
+ node_id=node_id,
97
+ label=data.get("label", node_id),
98
+ type=data.get("type", "unknown"),
99
+ in_degree=G.in_degree(node_id),
100
+ out_degree=G.out_degree(node_id),
101
+ degree=deg,
102
+ centrality=centrality.get(node_id, 0.0),
103
+ source_file=data.get("source_file", ""),
104
+ ))
105
+
106
+ results.sort(key=lambda n: n.degree, reverse=True)
107
+ return results[:top_n]
108
+
109
+
110
+ def surprising_connections(
111
+ G: nx.DiGraph,
112
+ communities: dict[str, int],
113
+ top_n: int = 20,
114
+ ) -> list[SurprisingConnection]:
115
+ """Find cross-community edges that may indicate unexpected coupling.
116
+
117
+ Expected cross-service relations (calls_api, shares_db_entity) are excluded
118
+ because they are intentional architectural connections.
119
+
120
+ Args:
121
+ G: the knowledge graph
122
+ communities: node_id → community_id mapping from cluster.py
123
+ top_n: return at most this many connections
124
+
125
+ Returns:
126
+ List of SurprisingConnection sorted by relation type (most surprising first).
127
+ """
128
+ # Relations that are expected to cross communities
129
+ expected_relations = frozenset({"calls_api", "shares_db_entity"})
130
+ # Priority: lower = more surprising
131
+ priority = {"injects": 0, "calls": 1, "imports": 2, "imports_from": 3}
132
+
133
+ results: list[SurprisingConnection] = []
134
+
135
+ for src, tgt, edge_data in G.edges(data=True):
136
+ relation = edge_data.get("relation", "")
137
+ if relation in expected_relations:
138
+ continue
139
+
140
+ src_community = communities.get(src, -1)
141
+ tgt_community = communities.get(tgt, -1)
142
+
143
+ if src_community < 0 or tgt_community < 0:
144
+ continue
145
+ if src_community == tgt_community:
146
+ continue
147
+
148
+ src_data = G.nodes.get(src, {})
149
+ tgt_data = G.nodes.get(tgt, {})
150
+
151
+ results.append(SurprisingConnection(
152
+ source_id=src,
153
+ source_label=src_data.get("label", src),
154
+ target_id=tgt,
155
+ target_label=tgt_data.get("label", tgt),
156
+ relation=relation,
157
+ src_community=src_community,
158
+ tgt_community=tgt_community,
159
+ source_file=edge_data.get("source_file", ""),
160
+ ))
161
+
162
+ results.sort(key=lambda c: (priority.get(c.relation, 99), c.source_label))
163
+ return results[:top_n]
164
+
165
+
166
+ def hub_files(
167
+ G: nx.DiGraph,
168
+ top_n: int = 20,
169
+ ) -> list[HubFile]:
170
+ """Find source files imported by many other files.
171
+
172
+ Args:
173
+ G: the knowledge graph
174
+ top_n: return at most this many files
175
+
176
+ Returns:
177
+ List of HubFile sorted by import_count descending.
178
+ """
179
+ file_imports: dict[str, int] = {}
180
+ file_nodes: dict[str, int] = {}
181
+
182
+ for _node_id, data in G.nodes(data=True):
183
+ sf = data.get("source_file", "")
184
+ if sf:
185
+ file_nodes[sf] = file_nodes.get(sf, 0) + 1
186
+
187
+ for _src, _tgt, edge_data in G.edges(data=True):
188
+ if edge_data.get("relation") not in ("imports", "imports_from"):
189
+ continue
190
+ sf = edge_data.get("source_file", "")
191
+ if sf:
192
+ file_imports[sf] = file_imports.get(sf, 0) + 1
193
+
194
+ results = [
195
+ HubFile(
196
+ file_path=fp,
197
+ import_count=cnt,
198
+ node_count=file_nodes.get(fp, 0),
199
+ )
200
+ for fp, cnt in file_imports.items()
201
+ ]
202
+ results.sort(key=lambda h: h.import_count, reverse=True)
203
+ return results[:top_n]
204
+
205
+
206
+ def analyze(
207
+ G: nx.DiGraph,
208
+ communities: Optional[dict[str, int]] = None,
209
+ cohesion_scores: Optional[dict[int, float]] = None,
210
+ ) -> GraphReport:
211
+ """Run all analyses and return a unified GraphReport.
212
+
213
+ Args:
214
+ G: built knowledge graph (output of build.py + optional enrich.py)
215
+ communities: optional community mapping from cluster.py
216
+ cohesion_scores: optional per-community cohesion scores from cluster.score_all()
217
+ """
218
+ report = GraphReport(
219
+ node_count=G.number_of_nodes(),
220
+ edge_count=G.number_of_edges(),
221
+ community_count=len(set(communities.values())) if communities else 0,
222
+ cohesion_scores=cohesion_scores or {},
223
+ density=nx.density(G),
224
+ isolated_nodes=sum(1 for n in G.nodes() if G.degree(n) == 0),
225
+ )
226
+
227
+ report.god_nodes = god_nodes(G)
228
+ report.hub_files = hub_files(G)
229
+
230
+ if communities:
231
+ report.surprising_connections = surprising_connections(G, communities)
232
+
233
+ return report
234
+
235
+
236
+ def report_to_markdown(report: GraphReport) -> str:
237
+ """Render a GraphReport as a Markdown string."""
238
+ lines = [
239
+ "# CodeBeacon Graph Report",
240
+ "",
241
+ "## Statistics",
242
+ f"- Nodes: {report.node_count}",
243
+ f"- Edges: {report.edge_count}",
244
+ f"- Communities: {report.community_count}",
245
+ f"- Graph density: {report.density:.4f}",
246
+ f"- Isolated nodes: {report.isolated_nodes}",
247
+ "",
248
+ ]
249
+
250
+ if report.god_nodes:
251
+ lines += ["## God Nodes (High Coupling)", ""]
252
+ lines.append(f"{'Node':<40} {'Type':<12} {'Degree':>6} {'Centrality':>10}")
253
+ lines.append("-" * 72)
254
+ for gn in report.god_nodes[:10]:
255
+ lines.append(
256
+ f"{gn.label:<40} {gn.type:<12} {gn.degree:>6} {gn.centrality:>10.4f}"
257
+ )
258
+ lines.append("")
259
+
260
+ if report.surprising_connections:
261
+ lines += ["## Surprising Connections (Cross-Community Coupling)", ""]
262
+ for sc in report.surprising_connections[:10]:
263
+ lines.append(
264
+ f"- [{sc.relation}] {sc.source_label} (C{sc.src_community})"
265
+ f" → {sc.target_label} (C{sc.tgt_community})"
266
+ )
267
+ lines.append("")
268
+
269
+ if report.hub_files:
270
+ lines += ["## Hub Files (Most Imported)", ""]
271
+ for hf in report.hub_files[:10]:
272
+ lines.append(f"- {hf.file_path} ({hf.import_count} imports)")
273
+ lines.append("")
274
+
275
+ if report.cohesion_scores:
276
+ lines += ["## Community Cohesion Scores", ""]
277
+ for cid, score in sorted(report.cohesion_scores.items()):
278
+ lines.append(f"- Community {cid}: {score:.3f}")
279
+ lines.append("")
280
+
281
+ return "\n".join(lines)
@@ -0,0 +1,320 @@
1
+ """Graph build: merge WaveResults → symbol resolve → filter → NetworkX DiGraph.
2
+
3
+ This is Pass 2 of the two-pass extraction pipeline.
4
+
5
+ Input: list[WaveResult] from wave.auto_wave()
6
+ Output: networkx.DiGraph with annotated node and edge attributes
7
+
8
+ Pipeline:
9
+ 1. Convert WaveResult data → Node / Edge objects
10
+ 2. Build SymbolTable from all nodes across all projects
11
+ 3. Resolve UnresolvedRefs → Edges (interface→impl, direct class match)
12
+ 4. Apply filters: build artifacts, cross-language imports, cross-service false edges
13
+ 5. Construct NetworkX DiGraph (node attrs as flat key=value, not nested dicts)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import networkx as nx
22
+
23
+ from codebeacon.common.types import Edge, Node, UnresolvedRef
24
+ from codebeacon.common.symbols import SymbolTable
25
+ from codebeacon.common.filters import (
26
+ filter_build_artifacts,
27
+ filter_cross_language,
28
+ filter_cross_service,
29
+ )
30
+ from codebeacon.wave import WaveResult
31
+
32
+
33
+ def build_graph(
34
+ wave_results: list[WaveResult],
35
+ apply_filters: bool = True,
36
+ ) -> nx.DiGraph:
37
+ """Build a NetworkX DiGraph from one or more WaveResults.
38
+
39
+ Args:
40
+ wave_results: list of WaveResult objects (one per project)
41
+ apply_filters: whether to run build-artifact, cross-language,
42
+ and cross-service filters (default: True)
43
+
44
+ Returns:
45
+ Annotated nx.DiGraph ready for enrichment, clustering, and analysis.
46
+ """
47
+ all_nodes: list[Node] = []
48
+ all_edges: list[Edge] = []
49
+ all_unresolved: list[UnresolvedRef] = []
50
+ # node_id → project name, used by cross-service filter
51
+ service_roots: dict[str, str] = {}
52
+
53
+ for wave in wave_results:
54
+ project_name = wave.project.name
55
+ _ingest_wave(wave, project_name, all_nodes, all_edges, all_unresolved, service_roots)
56
+
57
+ # Remap import edges: file_path → raw_import ➜ node_id → node_id
58
+ all_edges = _remap_import_edges(all_nodes, all_edges)
59
+
60
+ # Pass 2: resolve DI references
61
+ symbol_table = SymbolTable()
62
+ symbol_table.build(all_nodes)
63
+
64
+ resolved_edges, _ = symbol_table.resolve_all(all_unresolved)
65
+ all_edges.extend(resolved_edges)
66
+
67
+ # Filter pass
68
+ if apply_filters:
69
+ all_nodes, all_edges = filter_build_artifacts(all_nodes, all_edges)
70
+ node_dict = {n.id: n for n in all_nodes}
71
+ all_edges = filter_cross_language(all_edges, node_dict)
72
+ all_edges = filter_cross_service(all_edges, node_dict, service_roots)
73
+ else:
74
+ node_dict = {n.id: n for n in all_nodes}
75
+
76
+ # Construct NetworkX DiGraph
77
+ return _build_nx_graph(all_nodes, all_edges, node_dict)
78
+
79
+
80
+ # ── Wave ingestion ────────────────────────────────────────────────────────────
81
+
82
+ def _ingest_wave(
83
+ wave: WaveResult,
84
+ project_name: str,
85
+ all_nodes: list[Node],
86
+ all_edges: list[Edge],
87
+ all_unresolved: list[UnresolvedRef],
88
+ service_roots: dict[str, str],
89
+ ) -> None:
90
+ """Convert one WaveResult's extraction data into Node/Edge/UnresolvedRef objects."""
91
+
92
+ # Routes → route nodes
93
+ for route in wave.routes:
94
+ node_id = f"{project_name}::{route.handler}::route::{route.method}::{route.path}"
95
+ node = Node(
96
+ id=node_id,
97
+ label=f"{route.handler} [{route.method} {route.path}]",
98
+ type="route",
99
+ source_file=route.source_file,
100
+ line=route.line,
101
+ metadata={
102
+ "method": route.method,
103
+ "path": route.path,
104
+ "prefix": route.prefix,
105
+ "framework": route.framework,
106
+ "tags": route.tags,
107
+ "project": project_name,
108
+ },
109
+ )
110
+ all_nodes.append(node)
111
+ service_roots[node_id] = project_name
112
+
113
+ # Services → class nodes + unresolved DI refs
114
+ for svc in wave.services:
115
+ node_id = f"{project_name}::{svc.class_name}"
116
+ node = Node(
117
+ id=node_id,
118
+ label=svc.class_name,
119
+ type="class",
120
+ source_file=svc.source_file,
121
+ line=svc.line,
122
+ metadata={
123
+ "methods": svc.methods,
124
+ "dependencies": svc.dependencies,
125
+ "annotations": svc.annotations,
126
+ "framework": svc.framework,
127
+ "project": project_name,
128
+ },
129
+ )
130
+ all_nodes.append(node)
131
+ service_roots[node_id] = project_name
132
+
133
+ # Each declared dependency becomes an UnresolvedRef
134
+ for dep_name in svc.dependencies:
135
+ all_unresolved.append(UnresolvedRef(
136
+ source_node_id=node_id,
137
+ ref_type="depends",
138
+ ref_name=dep_name,
139
+ framework=svc.framework,
140
+ ))
141
+
142
+ # Entities → entity nodes
143
+ for ent in wave.entities:
144
+ node_id = f"{project_name}::{ent.name}"
145
+ node = Node(
146
+ id=node_id,
147
+ label=ent.name,
148
+ type="entity",
149
+ source_file=ent.source_file,
150
+ line=ent.line,
151
+ metadata={
152
+ "table_name": ent.table_name,
153
+ "fields": ent.fields,
154
+ "relations": ent.relations,
155
+ "framework": ent.framework,
156
+ "project": project_name,
157
+ },
158
+ )
159
+ all_nodes.append(node)
160
+ service_roots[node_id] = project_name
161
+
162
+ # Components → component nodes
163
+ for comp in wave.components:
164
+ node_id = f"{project_name}::{comp.name}"
165
+ node = Node(
166
+ id=node_id,
167
+ label=comp.name,
168
+ type="component",
169
+ source_file=comp.source_file,
170
+ line=comp.line,
171
+ metadata={
172
+ "props": comp.props,
173
+ "hooks": comp.hooks,
174
+ "is_page": comp.is_page,
175
+ "route_path": comp.route_path,
176
+ "framework": comp.framework,
177
+ "project": project_name,
178
+ },
179
+ )
180
+ all_nodes.append(node)
181
+ service_roots[node_id] = project_name
182
+
183
+ # Import edges from Pass 1
184
+ all_edges.extend(wave.import_edges)
185
+ # Remaining unresolved refs from Pass 1 (e.g. @Autowired)
186
+ all_unresolved.extend(wave.unresolved)
187
+
188
+
189
+ # ── Import edge remapping ────────────────────────────────────────────────────
190
+
191
+ def _remap_import_edges(all_nodes: list[Node], all_edges: list[Edge]) -> list[Edge]:
192
+ """Remap import edges from file_path → raw_import to node_id → node_id.
193
+
194
+ dependencies.py emits Edge(source=file_path, target=raw_import_string).
195
+ Graph nodes use IDs like "project::ClassName". This function bridges the
196
+ two by building reverse maps and resolving both sides.
197
+ """
198
+ # source_file → [node_id, ...]
199
+ file_to_nodes: dict[str, list[str]] = {}
200
+ # label (class/component name) → [node_id, ...]
201
+ label_to_nodes: dict[str, list[str]] = {}
202
+
203
+ for node in all_nodes:
204
+ file_to_nodes.setdefault(node.source_file, []).append(node.id)
205
+ label_to_nodes.setdefault(node.label, []).append(node.id)
206
+
207
+ remapped: list[Edge] = []
208
+ non_import: list[Edge] = []
209
+
210
+ for edge in all_edges:
211
+ if edge.relation != "imports_from":
212
+ non_import.append(edge)
213
+ continue
214
+
215
+ # Resolve source: file_path → node_ids in that file
216
+ source_ids = file_to_nodes.get(edge.source, [])
217
+ if not source_ids:
218
+ continue
219
+
220
+ # Resolve target: raw import string → node_id via label matching
221
+ target_label = _import_to_label(edge.target)
222
+ target_ids = label_to_nodes.get(target_label, [])
223
+ if not target_ids:
224
+ continue
225
+
226
+ for src_id in source_ids:
227
+ src_project = src_id.split("::")[0] if "::" in src_id else ""
228
+ # Prefer same-project target
229
+ target_id = target_ids[0]
230
+ for tid in target_ids:
231
+ if tid.startswith(src_project + "::"):
232
+ target_id = tid
233
+ break
234
+ if src_id != target_id:
235
+ remapped.append(Edge(
236
+ source=src_id,
237
+ target=target_id,
238
+ relation=edge.relation,
239
+ confidence=edge.confidence,
240
+ confidence_score=edge.confidence_score,
241
+ source_file=edge.source_file,
242
+ ))
243
+
244
+ return non_import + remapped
245
+
246
+
247
+ def _import_to_label(raw_import: str) -> str:
248
+ """Extract a class/component name from a raw import string.
249
+
250
+ Examples:
251
+ "@/components/Button" → "Button"
252
+ "com.example.service.UserSvc" → "UserSvc"
253
+ "../auth/AuthService" → "AuthService"
254
+ "./UserPage" → "UserPage"
255
+ """
256
+ # Java-style package: no slashes, dots as separators
257
+ if "." in raw_import and "/" not in raw_import:
258
+ return raw_import.rsplit(".", 1)[-1]
259
+ # Path-style: take last segment
260
+ name = raw_import.rsplit("/", 1)[-1]
261
+ # Strip file extension
262
+ if "." in name:
263
+ name = name.rsplit(".", 1)[0]
264
+ return name
265
+
266
+
267
+ # ── NetworkX construction ─────────────────────────────────────────────────────
268
+
269
+ def _build_nx_graph(
270
+ nodes: list[Node],
271
+ edges: list[Edge],
272
+ node_dict: dict[str, Node],
273
+ ) -> nx.DiGraph:
274
+ G = nx.DiGraph()
275
+
276
+ for node in nodes:
277
+ attrs = _node_attrs(node)
278
+ G.add_node(node.id, **attrs)
279
+
280
+ for edge in edges:
281
+ if edge.source not in G:
282
+ continue
283
+ if edge.target not in G:
284
+ # Add external stub for unresolved targets
285
+ G.add_node(
286
+ edge.target,
287
+ label=edge.target,
288
+ type="external",
289
+ source_file="",
290
+ line=0,
291
+ project="",
292
+ )
293
+ G.add_edge(
294
+ edge.source,
295
+ edge.target,
296
+ relation=edge.relation,
297
+ confidence=edge.confidence,
298
+ confidence_score=edge.confidence_score,
299
+ source_file=edge.source_file,
300
+ )
301
+
302
+ return G
303
+
304
+
305
+ def _node_attrs(node: Node) -> dict[str, Any]:
306
+ """Flatten a Node into NetworkX attribute dict (no nested dicts)."""
307
+ attrs: dict[str, Any] = {
308
+ "label": node.label,
309
+ "type": node.type,
310
+ "source_file": node.source_file,
311
+ "line": node.line,
312
+ }
313
+ # Flatten metadata as top-level keys
314
+ for k, v in (node.metadata or {}).items():
315
+ # Stringify lists/dicts for simple serialisation
316
+ if isinstance(v, (list, dict)):
317
+ attrs[k] = v # NetworkX handles these fine in memory
318
+ else:
319
+ attrs[k] = v
320
+ return attrs