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.
Files changed (86) hide show
  1. graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
  2. graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
  3. graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
  4. graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
  5. graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/application/__init__.py +0 -0
  8. src/application/dtos/__init__.py +0 -0
  9. src/application/dtos/affected_component.py +59 -0
  10. src/application/dtos/batch_processing_result.py +17 -0
  11. src/application/dtos/impact_analysis_response.py +56 -0
  12. src/application/dtos/impact_query_request.py +38 -0
  13. src/application/dtos/index_batch_request.py +53 -0
  14. src/application/dtos/index_batch_response.py +72 -0
  15. src/application/dtos/index_result.py +20 -0
  16. src/application/dtos/repository_index_result.py +79 -0
  17. src/application/exceptions.py +41 -0
  18. src/application/services/__init__.py +0 -0
  19. src/application/services/file_scanner_service.py +258 -0
  20. src/application/services/progress_tracker.py +132 -0
  21. src/application/use_cases/__init__.py +0 -0
  22. src/application/use_cases/clear_all_data_use_case.py +180 -0
  23. src/application/use_cases/index_batch_use_case.py +153 -0
  24. src/application/use_cases/index_repository_use_case.py +683 -0
  25. src/application/use_cases/indexing_orchestrator.py +181 -0
  26. src/application/use_cases/list_repositories_use_case.py +84 -0
  27. src/cli.py +512 -0
  28. src/config/__init__.py +5 -0
  29. src/config/container.py +211 -0
  30. src/config/logging_config.py +123 -0
  31. src/config/settings.py +165 -0
  32. src/domain/__init__.py +0 -0
  33. src/domain/entities/__init__.py +20 -0
  34. src/domain/entities/ast_node.py +32 -0
  35. src/domain/entities/code_chunk.py +45 -0
  36. src/domain/entities/code_file.py +44 -0
  37. src/domain/entities/dependency_graph.py +110 -0
  38. src/domain/entities/repository.py +46 -0
  39. src/domain/exceptions.py +36 -0
  40. src/domain/repositories/__init__.py +22 -0
  41. src/domain/repositories/i_code_parser.py +50 -0
  42. src/domain/repositories/i_dependency_extractor.py +35 -0
  43. src/domain/repositories/i_embedding_provider.py +31 -0
  44. src/domain/repositories/i_graph_repository.py +78 -0
  45. src/domain/repositories/i_vector_repository.py +55 -0
  46. src/domain/services/__init__.py +8 -0
  47. src/domain/services/dependency_extractor.py +962 -0
  48. src/domain/services/semantic_chunk_builder.py +719 -0
  49. src/domain/value_objects/__init__.py +12 -0
  50. src/domain/value_objects/chunk_type.py +24 -0
  51. src/domain/value_objects/dependency_type.py +22 -0
  52. src/domain/value_objects/node_type.py +22 -0
  53. src/domain/value_objects/qualified_name.py +78 -0
  54. src/infrastructure/__init__.py +0 -0
  55. src/infrastructure/exceptions.py +41 -0
  56. src/infrastructure/external_apis/__init__.py +0 -0
  57. src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
  58. src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
  59. src/infrastructure/parsing/__init__.py +20 -0
  60. src/infrastructure/parsing/config_file_parser.py +347 -0
  61. src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
  62. src/infrastructure/parsing/groovy_parser.py +198 -0
  63. src/infrastructure/parsing/maven_dependency_parser.py +147 -0
  64. src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
  65. src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
  66. src/infrastructure/parsing/python_parser.py +211 -0
  67. src/infrastructure/parsing/sql_schema_parser.py +658 -0
  68. src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
  69. src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
  70. src/infrastructure/persistence/__init__.py +0 -0
  71. src/infrastructure/persistence/graph_export_repository.py +443 -0
  72. src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
  73. src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
  74. src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
  75. src/presentation/__init__.py +0 -0
  76. src/presentation/api/__init__.py +0 -0
  77. src/presentation/api/rest/__init__.py +0 -0
  78. src/presentation/api/rest/exception_handlers.py +174 -0
  79. src/presentation/api/rest/main.py +80 -0
  80. src/presentation/api/rest/routers/__init__.py +5 -0
  81. src/presentation/api/rest/routers/admin.py +103 -0
  82. src/presentation/api/rest/routers/analysis.py +980 -0
  83. src/presentation/api/rest/routers/export.py +442 -0
  84. src/presentation/api/rest/routers/health.py +117 -0
  85. src/presentation/api/rest/routers/indexing.py +148 -0
  86. 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
+ }