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.
- codebeacon/__init__.py +1 -0
- codebeacon/__main__.py +3 -0
- codebeacon/cache.py +136 -0
- codebeacon/cli.py +391 -0
- codebeacon/common/__init__.py +0 -0
- codebeacon/common/filters.py +170 -0
- codebeacon/common/symbols.py +121 -0
- codebeacon/common/types.py +98 -0
- codebeacon/config.py +144 -0
- codebeacon/contextmap/__init__.py +0 -0
- codebeacon/contextmap/generator.py +602 -0
- codebeacon/discover/__init__.py +0 -0
- codebeacon/discover/detector.py +388 -0
- codebeacon/discover/scanner.py +192 -0
- codebeacon/export/__init__.py +0 -0
- codebeacon/export/mcp.py +515 -0
- codebeacon/export/obsidian.py +812 -0
- codebeacon/extract/__init__.py +22 -0
- codebeacon/extract/base.py +372 -0
- codebeacon/extract/components.py +357 -0
- codebeacon/extract/dependencies.py +140 -0
- codebeacon/extract/entities.py +575 -0
- codebeacon/extract/queries/README.md +116 -0
- codebeacon/extract/queries/actix.scm +115 -0
- codebeacon/extract/queries/angular.scm +155 -0
- codebeacon/extract/queries/aspnet.scm +159 -0
- codebeacon/extract/queries/django.scm +122 -0
- codebeacon/extract/queries/express.scm +124 -0
- codebeacon/extract/queries/fastapi.scm +152 -0
- codebeacon/extract/queries/flask.scm +120 -0
- codebeacon/extract/queries/gin.scm +142 -0
- codebeacon/extract/queries/ktor.scm +144 -0
- codebeacon/extract/queries/laravel.scm +172 -0
- codebeacon/extract/queries/nestjs.scm +183 -0
- codebeacon/extract/queries/rails.scm +114 -0
- codebeacon/extract/queries/react.scm +111 -0
- codebeacon/extract/queries/spring_boot.scm +204 -0
- codebeacon/extract/queries/svelte.scm +73 -0
- codebeacon/extract/queries/vapor.scm +130 -0
- codebeacon/extract/queries/vue.scm +123 -0
- codebeacon/extract/routes.py +910 -0
- codebeacon/extract/semantic.py +280 -0
- codebeacon/extract/services.py +597 -0
- codebeacon/graph/__init__.py +1 -0
- codebeacon/graph/analyze.py +281 -0
- codebeacon/graph/build.py +320 -0
- codebeacon/graph/cluster.py +160 -0
- codebeacon/graph/enrich.py +206 -0
- codebeacon/skill/SKILL.md +127 -0
- codebeacon/wave.py +292 -0
- codebeacon/wiki/__init__.py +0 -0
- codebeacon/wiki/generator.py +376 -0
- codebeacon/wiki/index.py +95 -0
- codebeacon/wiki/templates.py +467 -0
- codebeacon-0.1.2.dist-info/METADATA +319 -0
- codebeacon-0.1.2.dist-info/RECORD +59 -0
- codebeacon-0.1.2.dist-info/WHEEL +4 -0
- codebeacon-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|