graph-dependency-analyzer 0.2.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.
- graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
- graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
- graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
- graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
- graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
- src/__init__.py +0 -0
- src/application/__init__.py +0 -0
- src/application/dtos/__init__.py +0 -0
- src/application/dtos/affected_component.py +59 -0
- src/application/dtos/batch_processing_result.py +17 -0
- src/application/dtos/impact_analysis_response.py +56 -0
- src/application/dtos/impact_query_request.py +38 -0
- src/application/dtos/index_batch_request.py +53 -0
- src/application/dtos/index_batch_response.py +72 -0
- src/application/dtos/index_result.py +20 -0
- src/application/dtos/repository_index_result.py +79 -0
- src/application/exceptions.py +41 -0
- src/application/services/__init__.py +0 -0
- src/application/services/file_scanner_service.py +258 -0
- src/application/services/progress_tracker.py +132 -0
- src/application/use_cases/__init__.py +0 -0
- src/application/use_cases/clear_all_data_use_case.py +180 -0
- src/application/use_cases/index_batch_use_case.py +153 -0
- src/application/use_cases/index_repository_use_case.py +683 -0
- src/application/use_cases/indexing_orchestrator.py +181 -0
- src/application/use_cases/list_repositories_use_case.py +84 -0
- src/cli.py +512 -0
- src/config/__init__.py +5 -0
- src/config/container.py +211 -0
- src/config/logging_config.py +123 -0
- src/config/settings.py +165 -0
- src/domain/__init__.py +0 -0
- src/domain/entities/__init__.py +20 -0
- src/domain/entities/ast_node.py +32 -0
- src/domain/entities/code_chunk.py +45 -0
- src/domain/entities/code_file.py +44 -0
- src/domain/entities/dependency_graph.py +110 -0
- src/domain/entities/repository.py +46 -0
- src/domain/exceptions.py +36 -0
- src/domain/repositories/__init__.py +22 -0
- src/domain/repositories/i_code_parser.py +50 -0
- src/domain/repositories/i_dependency_extractor.py +35 -0
- src/domain/repositories/i_embedding_provider.py +31 -0
- src/domain/repositories/i_graph_repository.py +78 -0
- src/domain/repositories/i_vector_repository.py +55 -0
- src/domain/services/__init__.py +8 -0
- src/domain/services/dependency_extractor.py +962 -0
- src/domain/services/semantic_chunk_builder.py +719 -0
- src/domain/value_objects/__init__.py +12 -0
- src/domain/value_objects/chunk_type.py +24 -0
- src/domain/value_objects/dependency_type.py +22 -0
- src/domain/value_objects/node_type.py +22 -0
- src/domain/value_objects/qualified_name.py +78 -0
- src/infrastructure/__init__.py +0 -0
- src/infrastructure/exceptions.py +41 -0
- src/infrastructure/external_apis/__init__.py +0 -0
- src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
- src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
- src/infrastructure/parsing/__init__.py +20 -0
- src/infrastructure/parsing/config_file_parser.py +347 -0
- src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
- src/infrastructure/parsing/groovy_parser.py +198 -0
- src/infrastructure/parsing/maven_dependency_parser.py +147 -0
- src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
- src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
- src/infrastructure/parsing/python_parser.py +211 -0
- src/infrastructure/parsing/sql_schema_parser.py +658 -0
- src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
- src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
- src/infrastructure/persistence/__init__.py +0 -0
- src/infrastructure/persistence/graph_export_repository.py +443 -0
- src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
- src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
- src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
- src/presentation/__init__.py +0 -0
- src/presentation/api/__init__.py +0 -0
- src/presentation/api/rest/__init__.py +0 -0
- src/presentation/api/rest/exception_handlers.py +174 -0
- src/presentation/api/rest/main.py +80 -0
- src/presentation/api/rest/routers/__init__.py +5 -0
- src/presentation/api/rest/routers/admin.py +103 -0
- src/presentation/api/rest/routers/analysis.py +980 -0
- src/presentation/api/rest/routers/export.py +442 -0
- src/presentation/api/rest/routers/health.py +117 -0
- src/presentation/api/rest/routers/indexing.py +148 -0
- src/presentation/api/rest/routers/relationships.py +1089 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Graph Export Repository — reads Neo4j and converts to Graphify-compatible schema."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
logger = structlog.get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
# Map Neo4j node labels to Graphify file_type values
|
|
12
|
+
_NODE_TYPE_MAP: dict[str, str] = {
|
|
13
|
+
"CLASS": "code",
|
|
14
|
+
"METHOD": "code",
|
|
15
|
+
"REPOSITORY": "code",
|
|
16
|
+
"LIBRARY": "module",
|
|
17
|
+
"API": "concept",
|
|
18
|
+
"DATABASE": "concept",
|
|
19
|
+
"Component": "code",
|
|
20
|
+
"NPM_PACKAGE": "module",
|
|
21
|
+
"Microfrontend": "code",
|
|
22
|
+
"WORKSPACE": "concept",
|
|
23
|
+
"PROJECT": "code",
|
|
24
|
+
# DB schema objects
|
|
25
|
+
"DB_TABLE": "database",
|
|
26
|
+
"DB_VIEW": "database",
|
|
27
|
+
"DB_PROCEDURE": "database",
|
|
28
|
+
"DB_PACKAGE": "database",
|
|
29
|
+
"DB_TRIGGER": "database",
|
|
30
|
+
"DB_SEQUENCE": "database",
|
|
31
|
+
"DB_FUNCTION": "database",
|
|
32
|
+
"DB_INDEX": "database",
|
|
33
|
+
"DB_TYPE": "database",
|
|
34
|
+
"DB_COLLECTION": "database",
|
|
35
|
+
"ENDPOINT": "concept",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Map Neo4j relationship types to Graphify relation names
|
|
39
|
+
_EDGE_RELATION_MAP: dict[str, str] = {
|
|
40
|
+
"CONTAINS": "contains",
|
|
41
|
+
"HAS_METHOD": "method",
|
|
42
|
+
"CALLS": "calls",
|
|
43
|
+
"IMPORTS": "imports",
|
|
44
|
+
"IMPORTS_COMPONENT": "imports",
|
|
45
|
+
"USES_LIBRARY": "references",
|
|
46
|
+
"USES_PACKAGE": "references",
|
|
47
|
+
"CALLS_API": "calls",
|
|
48
|
+
"CONNECTS_TO": "references",
|
|
49
|
+
"DEPENDS_ON": "references",
|
|
50
|
+
"EXPOSES_MODULE": "references",
|
|
51
|
+
"CONSUMES_MODULE": "references",
|
|
52
|
+
# DB schema edges
|
|
53
|
+
"CONTAINS_DB_OBJECT": "contains",
|
|
54
|
+
"READS_TABLE": "reads",
|
|
55
|
+
"WRITES_TABLE": "writes",
|
|
56
|
+
"CALLS_PROCEDURE": "calls",
|
|
57
|
+
"CALLS_BACKEND": "calls",
|
|
58
|
+
"EXPOSES": "references",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class GraphifyNode:
|
|
64
|
+
id: str
|
|
65
|
+
label: str
|
|
66
|
+
file_type: str
|
|
67
|
+
file: str | None = None
|
|
68
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class GraphifyEdge:
|
|
73
|
+
source: str
|
|
74
|
+
target: str
|
|
75
|
+
relation: str
|
|
76
|
+
confidence: str = "EXTRACTED"
|
|
77
|
+
weight: float = 1.0
|
|
78
|
+
source_file: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class GraphifyGraph:
|
|
83
|
+
nodes: list[dict[str, Any]]
|
|
84
|
+
edges: list[dict[str, Any]]
|
|
85
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GraphExportRepository:
|
|
89
|
+
"""Reads Neo4j graph and exports it in Graphify-compatible JSON schema."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, driver) -> None:
|
|
92
|
+
self.driver = driver
|
|
93
|
+
|
|
94
|
+
async def export_graph(
|
|
95
|
+
self,
|
|
96
|
+
repository: str | None = None,
|
|
97
|
+
group_packages: bool = True,
|
|
98
|
+
node_types: list[str] | None = None,
|
|
99
|
+
max_nodes: int = 500,
|
|
100
|
+
) -> GraphifyGraph:
|
|
101
|
+
"""
|
|
102
|
+
Export dependency graph with optional package grouping and type filtering.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repository: Filter by repository name.
|
|
106
|
+
group_packages: If True, collapse Java/Python class nodes into
|
|
107
|
+
their top-level package groups (default True).
|
|
108
|
+
node_types: Which node types to include. None = all.
|
|
109
|
+
Examples: ["CLASS","DB_TABLE","API","DATABASE"]
|
|
110
|
+
max_nodes: Hard cap on nodes returned (default 500 for performance).
|
|
111
|
+
"""
|
|
112
|
+
async with self.driver.session() as session:
|
|
113
|
+
nodes = await self._fetch_nodes(session, repository)
|
|
114
|
+
edges = await self._fetch_edges(session, repository)
|
|
115
|
+
|
|
116
|
+
graphify_nodes = [self._convert_node(n) for n in nodes]
|
|
117
|
+
graphify_edges = [self._convert_edge(e) for e in edges if e]
|
|
118
|
+
|
|
119
|
+
# ── Filter by node type ──────────────────────────────────────────
|
|
120
|
+
if node_types:
|
|
121
|
+
allowed = {t.upper() for t in node_types}
|
|
122
|
+
graphify_nodes = [
|
|
123
|
+
n for n in graphify_nodes
|
|
124
|
+
if (n.get("metadata", {}).get("neo4j_label", "").upper() in allowed
|
|
125
|
+
or n.get("type", "").upper() in allowed)
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
# ── Deduplicate nodes ────────────────────────────────────────────
|
|
129
|
+
seen_ids: set[str] = set()
|
|
130
|
+
unique_nodes: list[dict] = []
|
|
131
|
+
for node in graphify_nodes:
|
|
132
|
+
if node["id"] not in seen_ids:
|
|
133
|
+
seen_ids.add(node["id"])
|
|
134
|
+
unique_nodes.append(node)
|
|
135
|
+
|
|
136
|
+
# ── Package grouping ─────────────────────────────────────────────
|
|
137
|
+
if group_packages:
|
|
138
|
+
unique_nodes, edges_remap = self._group_by_package(unique_nodes)
|
|
139
|
+
else:
|
|
140
|
+
edges_remap = {}
|
|
141
|
+
|
|
142
|
+
# ── Apply edge remapping + filter to existing nodes ───────────────
|
|
143
|
+
node_id_set = {n["id"] for n in unique_nodes}
|
|
144
|
+
valid_edges = []
|
|
145
|
+
for e in graphify_edges:
|
|
146
|
+
if e is None:
|
|
147
|
+
continue
|
|
148
|
+
src = edges_remap.get(e["source"], e["source"])
|
|
149
|
+
tgt = edges_remap.get(e["target"], e["target"])
|
|
150
|
+
if src == tgt:
|
|
151
|
+
continue
|
|
152
|
+
if src in node_id_set and tgt in node_id_set:
|
|
153
|
+
valid_edges.append({**e, "source": src, "target": tgt})
|
|
154
|
+
|
|
155
|
+
# Deduplicate edges after remapping
|
|
156
|
+
seen_edges: set[tuple] = set()
|
|
157
|
+
deduped_edges = []
|
|
158
|
+
for e in valid_edges:
|
|
159
|
+
key = (e["source"], e["target"], e["relation"])
|
|
160
|
+
if key not in seen_edges:
|
|
161
|
+
seen_edges.add(key)
|
|
162
|
+
deduped_edges.append(e)
|
|
163
|
+
|
|
164
|
+
# ── Cap nodes for performance ─────────────────────────────────────
|
|
165
|
+
# Prioritise: DB objects > API/ENDPOINT > packages with most edges > rest
|
|
166
|
+
# Also cap edges per node to avoid vis.js freezing on hub nodes.
|
|
167
|
+
edge_degree: dict[str, int] = {}
|
|
168
|
+
for e in deduped_edges:
|
|
169
|
+
edge_degree[e["source"]] = edge_degree.get(e["source"], 0) + 1
|
|
170
|
+
edge_degree[e["target"]] = edge_degree.get(e["target"], 0) + 1
|
|
171
|
+
|
|
172
|
+
if len(unique_nodes) > max_nodes:
|
|
173
|
+
def priority(n: dict) -> tuple:
|
|
174
|
+
lbl = n.get("metadata", {}).get("neo4j_label", "")
|
|
175
|
+
deg = edge_degree.get(n["id"], 0)
|
|
176
|
+
is_db = 1 if lbl.startswith("DB_") else 0
|
|
177
|
+
is_api = 1 if lbl in ("API", "ENDPOINT", "DATABASE") else 0
|
|
178
|
+
return (-is_db, -is_api, -deg)
|
|
179
|
+
|
|
180
|
+
unique_nodes = sorted(unique_nodes, key=priority)[:max_nodes]
|
|
181
|
+
node_id_set = {n["id"] for n in unique_nodes}
|
|
182
|
+
deduped_edges = [
|
|
183
|
+
e for e in deduped_edges
|
|
184
|
+
if e["source"] in node_id_set and e["target"] in node_id_set
|
|
185
|
+
]
|
|
186
|
+
# Recalculate degree after node cap
|
|
187
|
+
edge_degree = {}
|
|
188
|
+
for e in deduped_edges:
|
|
189
|
+
edge_degree[e["source"]] = edge_degree.get(e["source"], 0) + 1
|
|
190
|
+
edge_degree[e["target"]] = edge_degree.get(e["target"], 0) + 1
|
|
191
|
+
|
|
192
|
+
# Cap edges per node to MAX_DEGREE to prevent vis.js hub freeze.
|
|
193
|
+
# Nodes with >50 connections get their edges trimmed to 50 (keep highest-degree neighbours).
|
|
194
|
+
MAX_DEGREE = 50
|
|
195
|
+
hub_ids = {nid for nid, deg in edge_degree.items() if deg > MAX_DEGREE}
|
|
196
|
+
if hub_ids:
|
|
197
|
+
# For each hub, keep only its MAX_DEGREE edges (by neighbour degree, descending)
|
|
198
|
+
kept_edges = []
|
|
199
|
+
hub_edge_count: dict[str, int] = {}
|
|
200
|
+
# Sort edges so high-degree neighbours come first
|
|
201
|
+
def edge_priority(e: dict) -> int:
|
|
202
|
+
return -(edge_degree.get(e["source"], 0) + edge_degree.get(e["target"], 0))
|
|
203
|
+
for e in sorted(deduped_edges, key=edge_priority):
|
|
204
|
+
src_hub = e["source"] in hub_ids
|
|
205
|
+
tgt_hub = e["target"] in hub_ids
|
|
206
|
+
if src_hub and hub_edge_count.get(e["source"], 0) >= MAX_DEGREE:
|
|
207
|
+
continue
|
|
208
|
+
if tgt_hub and hub_edge_count.get(e["target"], 0) >= MAX_DEGREE:
|
|
209
|
+
continue
|
|
210
|
+
kept_edges.append(e)
|
|
211
|
+
if src_hub:
|
|
212
|
+
hub_edge_count[e["source"]] = hub_edge_count.get(e["source"], 0) + 1
|
|
213
|
+
if tgt_hub:
|
|
214
|
+
hub_edge_count[e["target"]] = hub_edge_count.get(e["target"], 0) + 1
|
|
215
|
+
deduped_edges = kept_edges
|
|
216
|
+
|
|
217
|
+
# ── Sanitise node IDs for vis.js compatibility ──────────────────────
|
|
218
|
+
# vis.js treats '.' as namespace separator and chokes on './', '../', '.'
|
|
219
|
+
# Replace relative path fragments used as TypeScript import IDs.
|
|
220
|
+
import re as _re
|
|
221
|
+
def _safe_id(nid: str) -> str:
|
|
222
|
+
if not nid:
|
|
223
|
+
return "__empty__"
|
|
224
|
+
s = str(nid)
|
|
225
|
+
s = _re.sub(r'^\.\.\/', '__parent__/', s)
|
|
226
|
+
s = _re.sub(r'^\.\/', '__rel__/', s)
|
|
227
|
+
if s in (".", ".."):
|
|
228
|
+
s = "__dot__" if s == "." else "__dotdot__"
|
|
229
|
+
return s
|
|
230
|
+
|
|
231
|
+
id_remap: dict[str, str] = {}
|
|
232
|
+
for n in unique_nodes:
|
|
233
|
+
orig = n["id"]
|
|
234
|
+
safe = _safe_id(orig)
|
|
235
|
+
if safe != orig:
|
|
236
|
+
id_remap[orig] = safe
|
|
237
|
+
n["id"] = safe
|
|
238
|
+
|
|
239
|
+
if id_remap:
|
|
240
|
+
for e in deduped_edges:
|
|
241
|
+
if e["source"] in id_remap:
|
|
242
|
+
e["source"] = id_remap[e["source"]]
|
|
243
|
+
if e["target"] in id_remap:
|
|
244
|
+
e["target"] = id_remap[e["target"]]
|
|
245
|
+
|
|
246
|
+
meta = {
|
|
247
|
+
"source": "neo4j",
|
|
248
|
+
"repository_filter": repository or "all",
|
|
249
|
+
"node_count": len(unique_nodes),
|
|
250
|
+
"edge_count": len(deduped_edges),
|
|
251
|
+
"grouped": group_packages,
|
|
252
|
+
}
|
|
253
|
+
logger.info("graph_exported", repository=repository or "all",
|
|
254
|
+
nodes=len(unique_nodes), edges=len(deduped_edges))
|
|
255
|
+
return GraphifyGraph(nodes=unique_nodes, edges=deduped_edges, meta=meta)
|
|
256
|
+
|
|
257
|
+
# ── Package grouping ──────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
@staticmethod
|
|
260
|
+
def _pkg_group(node_id: str, neo4j_label: str) -> str | None:
|
|
261
|
+
"""
|
|
262
|
+
Return a package group id for CLASS nodes.
|
|
263
|
+
Groups com.medline.wms.WebServiceEJB.Foo → com.medline.wms.WebServiceEJB
|
|
264
|
+
Non-CLASS nodes return None (keep as-is).
|
|
265
|
+
"""
|
|
266
|
+
if neo4j_label not in ("CLASS", ""):
|
|
267
|
+
return None
|
|
268
|
+
# Only group internal classes (not external/java.*)
|
|
269
|
+
if not node_id or node_id.startswith("java.") or node_id.startswith("javax."):
|
|
270
|
+
return None
|
|
271
|
+
parts = node_id.split(".")
|
|
272
|
+
if len(parts) <= 2:
|
|
273
|
+
return None
|
|
274
|
+
# Use up to 4 segments as group key
|
|
275
|
+
return ".".join(parts[:min(4, len(parts) - 1)])
|
|
276
|
+
|
|
277
|
+
def _group_by_package(
|
|
278
|
+
self,
|
|
279
|
+
nodes: list[dict],
|
|
280
|
+
) -> tuple[list[dict], dict[str, str]]:
|
|
281
|
+
"""
|
|
282
|
+
Collapse CLASS nodes into package groups.
|
|
283
|
+
Returns (new_nodes, remap_dict) where remap_dict maps
|
|
284
|
+
old_node_id → group_node_id for edge rewriting.
|
|
285
|
+
"""
|
|
286
|
+
remap: dict[str, str] = {} # original_id → group_id
|
|
287
|
+
groups: dict[str, dict] = {} # group_id → group_node
|
|
288
|
+
kept: list[dict] = [] # non-CLASS nodes kept as-is
|
|
289
|
+
|
|
290
|
+
# Pre-build set of IDs that are kept as-is (non-CLASS nodes)
|
|
291
|
+
# so we can detect collisions before creating group nodes.
|
|
292
|
+
kept_ids: set[str] = set()
|
|
293
|
+
groupable: list[dict] = []
|
|
294
|
+
for n in nodes:
|
|
295
|
+
lbl = n.get("metadata", {}).get("neo4j_label", "")
|
|
296
|
+
grp = GraphExportRepository._pkg_group(n["id"], lbl)
|
|
297
|
+
if grp is None:
|
|
298
|
+
kept.append(n)
|
|
299
|
+
kept_ids.add(n["id"])
|
|
300
|
+
else:
|
|
301
|
+
groupable.append(n)
|
|
302
|
+
|
|
303
|
+
for n in groupable:
|
|
304
|
+
lbl = n.get("metadata", {}).get("neo4j_label", "")
|
|
305
|
+
grp = GraphExportRepository._pkg_group(n["id"], lbl)
|
|
306
|
+
|
|
307
|
+
# If the group ID collides with an existing kept node,
|
|
308
|
+
# append __pkg suffix to avoid duplicate IDs in vis.js.
|
|
309
|
+
safe_grp = grp
|
|
310
|
+
if grp in kept_ids:
|
|
311
|
+
safe_grp = grp + "__pkg"
|
|
312
|
+
|
|
313
|
+
remap[n["id"]] = safe_grp
|
|
314
|
+
|
|
315
|
+
if safe_grp not in groups:
|
|
316
|
+
# Derive short label from last segment of original group
|
|
317
|
+
short = grp.split(".")[-1]
|
|
318
|
+
repo = n.get("metadata", {}).get("repository", "")
|
|
319
|
+
groups[safe_grp] = {
|
|
320
|
+
"id": safe_grp,
|
|
321
|
+
"label": short,
|
|
322
|
+
"type": "code",
|
|
323
|
+
"file": None,
|
|
324
|
+
"metadata": {
|
|
325
|
+
"neo4j_label": "PACKAGE",
|
|
326
|
+
"repository": repo,
|
|
327
|
+
"qualified_name": grp,
|
|
328
|
+
"_class_count": 0,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
groups[safe_grp]["metadata"]["_class_count"] = \
|
|
332
|
+
groups[safe_grp]["metadata"].get("_class_count", 0) + 1
|
|
333
|
+
|
|
334
|
+
# Add class count to group label
|
|
335
|
+
for grp_node in groups.values():
|
|
336
|
+
cnt = grp_node["metadata"].get("_class_count", 0)
|
|
337
|
+
grp_node["label"] = f"{grp_node['label']} ({cnt})"
|
|
338
|
+
|
|
339
|
+
return kept + list(groups.values()), remap
|
|
340
|
+
|
|
341
|
+
async def _fetch_nodes(self, session, repository: str | None) -> list[dict]:
|
|
342
|
+
if repository:
|
|
343
|
+
# For DB schema repos (many stub nodes), only fetch real DDL objects
|
|
344
|
+
# (is_stub IS NULL = real DDL-backed node) plus their connected neighbours.
|
|
345
|
+
# This prevents 16k+ stub tables from flooding the graph.
|
|
346
|
+
query = """
|
|
347
|
+
MATCH (n)
|
|
348
|
+
WHERE n.repository = $repository
|
|
349
|
+
AND coalesce(n.is_stub, 'false') <> 'true'
|
|
350
|
+
RETURN labels(n) AS labels, properties(n) AS props
|
|
351
|
+
LIMIT 8000
|
|
352
|
+
"""
|
|
353
|
+
params = {"repository": repository}
|
|
354
|
+
else:
|
|
355
|
+
query = """
|
|
356
|
+
MATCH (n)
|
|
357
|
+
WHERE coalesce(n.is_stub, 'false') <> 'true'
|
|
358
|
+
RETURN labels(n) AS labels, properties(n) AS props
|
|
359
|
+
LIMIT 8000
|
|
360
|
+
"""
|
|
361
|
+
params = {}
|
|
362
|
+
result = await session.run(query, **params)
|
|
363
|
+
return await result.data()
|
|
364
|
+
|
|
365
|
+
async def _fetch_edges(self, session, repository: str | None) -> list[dict]:
|
|
366
|
+
if repository:
|
|
367
|
+
query = """
|
|
368
|
+
MATCH (source)-[r]->(target)
|
|
369
|
+
WHERE (source.repository = $repository OR target.repository = $repository)
|
|
370
|
+
AND coalesce(source.is_stub, 'false') <> 'true'
|
|
371
|
+
AND coalesce(target.is_stub, 'false') <> 'true'
|
|
372
|
+
RETURN
|
|
373
|
+
source.id AS source_id,
|
|
374
|
+
target.id AS target_id,
|
|
375
|
+
type(r) AS relation_type,
|
|
376
|
+
source.file_path AS source_file
|
|
377
|
+
LIMIT 20000
|
|
378
|
+
"""
|
|
379
|
+
params = {"repository": repository}
|
|
380
|
+
else:
|
|
381
|
+
query = """
|
|
382
|
+
MATCH (source)-[r]->(target)
|
|
383
|
+
WHERE coalesce(source.is_stub, 'false') <> 'true'
|
|
384
|
+
AND coalesce(target.is_stub, 'false') <> 'true'
|
|
385
|
+
RETURN
|
|
386
|
+
source.id AS source_id,
|
|
387
|
+
target.id AS target_id,
|
|
388
|
+
type(r) AS relation_type,
|
|
389
|
+
source.file_path AS source_file
|
|
390
|
+
LIMIT 20000
|
|
391
|
+
"""
|
|
392
|
+
params = {}
|
|
393
|
+
result = await session.run(query, **params)
|
|
394
|
+
return await result.data()
|
|
395
|
+
|
|
396
|
+
def _convert_node(self, record: dict) -> dict[str, Any]:
|
|
397
|
+
node_labels: list[str] = record.get("labels") or []
|
|
398
|
+
primary_label = node_labels[0] if node_labels else "UNKNOWN"
|
|
399
|
+
file_type = _NODE_TYPE_MAP.get(primary_label, "concept")
|
|
400
|
+
|
|
401
|
+
props: dict = record.get("props") or {}
|
|
402
|
+
qname: str = props.get("qualified_name") or ""
|
|
403
|
+
node_id: str = str(props.get("id") or qname or props.get("url") or "unknown")
|
|
404
|
+
|
|
405
|
+
label = (
|
|
406
|
+
props.get("name")
|
|
407
|
+
or (qname.split(".")[-1] if qname else None)
|
|
408
|
+
or props.get("url")
|
|
409
|
+
or node_id
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
metadata: dict[str, Any] = {"neo4j_label": primary_label}
|
|
413
|
+
for key in ("repository", "version", "framework", "language", "qualified_name",
|
|
414
|
+
"url", "db_type", "host", "database", "type"):
|
|
415
|
+
val = props.get(key)
|
|
416
|
+
if val is not None:
|
|
417
|
+
metadata[key] = str(val)
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
"id": node_id,
|
|
421
|
+
"label": str(label),
|
|
422
|
+
"type": file_type,
|
|
423
|
+
"file": props.get("file_path"),
|
|
424
|
+
"metadata": metadata,
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
def _convert_edge(self, record: dict) -> dict[str, Any] | None:
|
|
428
|
+
source_id = record.get("source_id")
|
|
429
|
+
target_id = record.get("target_id")
|
|
430
|
+
if not source_id or not target_id:
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
relation_type: str = record.get("relation_type") or "references"
|
|
434
|
+
relation = _EDGE_RELATION_MAP.get(relation_type, "references")
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
"source": str(source_id),
|
|
438
|
+
"target": str(target_id),
|
|
439
|
+
"relation": relation,
|
|
440
|
+
"confidence": "EXTRACTED",
|
|
441
|
+
"weight": 1.0,
|
|
442
|
+
"source_file": record.get("source_file"),
|
|
443
|
+
}
|