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,657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Neo4j Dependency Graph Repository Implementation (Task 5.1)
|
|
3
|
+
|
|
4
|
+
Implements persistence layer for dependency graphs in Neo4j.
|
|
5
|
+
Supports 6 node types and 7 relationship types for impact analysis.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class GraphStorageResult:
|
|
16
|
+
"""Result of storing a dependency graph"""
|
|
17
|
+
nodes_created: int
|
|
18
|
+
relationships_created: int
|
|
19
|
+
batch_count: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ImpactAnalysisResult:
|
|
24
|
+
"""Result of impact analysis query"""
|
|
25
|
+
affected_components: list[str | dict]
|
|
26
|
+
warning: str | None = None # Task 10.6: Warning for non-existent entities
|
|
27
|
+
suggestions: list[str] | None = None # Task 10.6: Suggestions for similar entities
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Neo4jDependencyGraphRepository:
|
|
31
|
+
"""
|
|
32
|
+
Neo4j-based repository for storing dependency graphs.
|
|
33
|
+
|
|
34
|
+
Implements:
|
|
35
|
+
- 6 node types: Repository, Class, Method, Library, API, Database
|
|
36
|
+
- 7 relationship types: CONTAINS, HAS_METHOD, CALLS, IMPORTS,
|
|
37
|
+
USES_LIBRARY, CALLS_API, CONNECTS_TO, DEPENDS_ON
|
|
38
|
+
- Batch processing with UNWIND (batch_size=100)
|
|
39
|
+
- MERGE for idempotency
|
|
40
|
+
- Impact analysis queries
|
|
41
|
+
|
|
42
|
+
Requirements: 6.1-6.14, 16.1-16.5
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, driver, batch_size: int = 100):
|
|
46
|
+
"""
|
|
47
|
+
Initialize repository with Neo4j driver.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
driver: Neo4j AsyncGraphDatabase driver
|
|
51
|
+
batch_size: Batch size for UNWIND operations (default 100)
|
|
52
|
+
"""
|
|
53
|
+
self.driver = driver
|
|
54
|
+
self.batch_size = batch_size
|
|
55
|
+
|
|
56
|
+
async def store_dependency_graph(
|
|
57
|
+
self,
|
|
58
|
+
graph,
|
|
59
|
+
repository_name: str
|
|
60
|
+
) -> GraphStorageResult:
|
|
61
|
+
"""
|
|
62
|
+
Store complete dependency graph in Neo4j.
|
|
63
|
+
|
|
64
|
+
Uses MERGE for idempotency and UNWIND for batch processing.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
graph: DependencyGraph with nodes and edges
|
|
68
|
+
repository_name: Name of repository being indexed
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
GraphStorageResult with metrics
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Exception: If Neo4j write fails
|
|
75
|
+
"""
|
|
76
|
+
nodes_created = 0
|
|
77
|
+
relationships_created = 0
|
|
78
|
+
batch_count = 0
|
|
79
|
+
|
|
80
|
+
async with self.driver.session() as session:
|
|
81
|
+
# Store nodes in batches using UNWIND and MERGE
|
|
82
|
+
batch_count = await self._store_nodes_in_batches(session, graph.nodes)
|
|
83
|
+
nodes_created = len(graph.nodes)
|
|
84
|
+
|
|
85
|
+
# Store relationships in batches using UNWIND and MERGE
|
|
86
|
+
await self._store_relationships_in_batches(session, graph.edges)
|
|
87
|
+
relationships_created = len(graph.edges)
|
|
88
|
+
|
|
89
|
+
logger.info(
|
|
90
|
+
"dependency_graph_stored",
|
|
91
|
+
repository=repository_name,
|
|
92
|
+
nodes_created=nodes_created,
|
|
93
|
+
relationships_created=relationships_created,
|
|
94
|
+
batch_count=batch_count
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return GraphStorageResult(
|
|
98
|
+
nodes_created=nodes_created,
|
|
99
|
+
relationships_created=relationships_created,
|
|
100
|
+
batch_count=batch_count
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
async def _store_nodes_in_batches(self, session, nodes: list) -> int:
|
|
104
|
+
"""
|
|
105
|
+
Store nodes in batches of batch_size using UNWIND.
|
|
106
|
+
|
|
107
|
+
Uses MERGE to ensure idempotency (no duplicates).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
session: Neo4j session
|
|
111
|
+
nodes: List of DependencyNode
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Number of batches processed
|
|
115
|
+
"""
|
|
116
|
+
batch_count = 0
|
|
117
|
+
|
|
118
|
+
for i in range(0, len(nodes), self.batch_size):
|
|
119
|
+
batch = nodes[i:i + self.batch_size]
|
|
120
|
+
batch_count += 1
|
|
121
|
+
|
|
122
|
+
# Convert nodes to dicts for UNWIND
|
|
123
|
+
node_dicts = []
|
|
124
|
+
for node in batch:
|
|
125
|
+
node_dict = {
|
|
126
|
+
'id': node.id,
|
|
127
|
+
'node_type': node.node_type,
|
|
128
|
+
'properties': node.properties
|
|
129
|
+
}
|
|
130
|
+
node_dicts.append(node_dict)
|
|
131
|
+
|
|
132
|
+
# Use UNWIND + MERGE for batch insert
|
|
133
|
+
query = """
|
|
134
|
+
UNWIND $nodes AS node
|
|
135
|
+
CALL apoc.merge.node(
|
|
136
|
+
[node.node_type],
|
|
137
|
+
{id: node.id},
|
|
138
|
+
node.properties,
|
|
139
|
+
{}
|
|
140
|
+
) YIELD node AS n
|
|
141
|
+
RETURN count(n) AS created
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
# Fallback if APOC not available - use standard MERGE per node type
|
|
145
|
+
if batch:
|
|
146
|
+
await self._merge_nodes_by_type(session, batch)
|
|
147
|
+
|
|
148
|
+
return batch_count
|
|
149
|
+
|
|
150
|
+
async def _merge_nodes_by_type(self, session, nodes: list) -> None:
|
|
151
|
+
"""
|
|
152
|
+
MERGE nodes grouped by type (fallback without APOC).
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
session: Neo4j session
|
|
156
|
+
nodes: List of DependencyNode
|
|
157
|
+
"""
|
|
158
|
+
# Group nodes by type
|
|
159
|
+
nodes_by_type = {}
|
|
160
|
+
for node in nodes:
|
|
161
|
+
if node.node_type not in nodes_by_type:
|
|
162
|
+
nodes_by_type[node.node_type] = []
|
|
163
|
+
nodes_by_type[node.node_type].append({
|
|
164
|
+
'id': node.id,
|
|
165
|
+
'properties': node.properties
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
# MERGE each type separately
|
|
169
|
+
for node_type, node_list in nodes_by_type.items():
|
|
170
|
+
query = f"""
|
|
171
|
+
UNWIND $nodes AS node
|
|
172
|
+
MERGE (n:{node_type} {{id: node.id}})
|
|
173
|
+
SET n += node.properties
|
|
174
|
+
RETURN count(n) AS created
|
|
175
|
+
"""
|
|
176
|
+
await session.run(query, nodes=node_list)
|
|
177
|
+
|
|
178
|
+
async def _store_relationships_in_batches(self, session, edges: list) -> None:
|
|
179
|
+
"""
|
|
180
|
+
Store relationships in batches using UNWIND and MERGE.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
session: Neo4j session
|
|
184
|
+
edges: List of DependencyEdge
|
|
185
|
+
"""
|
|
186
|
+
for i in range(0, len(edges), self.batch_size):
|
|
187
|
+
batch = edges[i:i + self.batch_size]
|
|
188
|
+
|
|
189
|
+
# Convert edges to dicts
|
|
190
|
+
edge_dicts = []
|
|
191
|
+
for edge in batch:
|
|
192
|
+
edge_dict = {
|
|
193
|
+
'source': edge.source,
|
|
194
|
+
'target': edge.target,
|
|
195
|
+
'edge_type': edge.edge_type,
|
|
196
|
+
'properties': edge.properties
|
|
197
|
+
}
|
|
198
|
+
edge_dicts.append(edge_dict)
|
|
199
|
+
|
|
200
|
+
# Use UNWIND + MERGE for relationships
|
|
201
|
+
query = """
|
|
202
|
+
UNWIND $edges AS edge
|
|
203
|
+
MATCH (source {id: edge.source})
|
|
204
|
+
MATCH (target {id: edge.target})
|
|
205
|
+
CALL apoc.merge.relationship(
|
|
206
|
+
source,
|
|
207
|
+
edge.edge_type,
|
|
208
|
+
{},
|
|
209
|
+
edge.properties,
|
|
210
|
+
target,
|
|
211
|
+
{}
|
|
212
|
+
) YIELD rel
|
|
213
|
+
RETURN count(rel) AS created
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
# Fallback without APOC
|
|
217
|
+
await self._merge_relationships_by_type(session, batch)
|
|
218
|
+
|
|
219
|
+
async def _merge_relationships_by_type(self, session, edges: list) -> None:
|
|
220
|
+
"""
|
|
221
|
+
MERGE relationships grouped by type (fallback without APOC).
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
session: Neo4j session
|
|
225
|
+
edges: List of DependencyEdge
|
|
226
|
+
"""
|
|
227
|
+
# Group edges by type
|
|
228
|
+
edges_by_type = {}
|
|
229
|
+
for edge in edges:
|
|
230
|
+
if edge.edge_type not in edges_by_type:
|
|
231
|
+
edges_by_type[edge.edge_type] = []
|
|
232
|
+
edges_by_type[edge.edge_type].append({
|
|
233
|
+
'source': edge.source,
|
|
234
|
+
'target': edge.target,
|
|
235
|
+
'properties': edge.properties
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
# MERGE each type separately
|
|
239
|
+
for edge_type, edge_list in edges_by_type.items():
|
|
240
|
+
query = f"""
|
|
241
|
+
UNWIND $edges AS edge
|
|
242
|
+
MATCH (source {{id: edge.source}})
|
|
243
|
+
MATCH (target {{id: edge.target}})
|
|
244
|
+
MERGE (source)-[r:{edge_type}]->(target)
|
|
245
|
+
SET r += edge.properties
|
|
246
|
+
RETURN count(r) AS created
|
|
247
|
+
"""
|
|
248
|
+
await session.run(query, edges=edge_list)
|
|
249
|
+
|
|
250
|
+
async def query_impact_analysis(
|
|
251
|
+
self,
|
|
252
|
+
query_type: str,
|
|
253
|
+
params: dict[str, Any]
|
|
254
|
+
) -> ImpactAnalysisResult:
|
|
255
|
+
"""
|
|
256
|
+
Execute impact analysis query.
|
|
257
|
+
|
|
258
|
+
Supports query types (Requirements 16.1-16.4, Tasks 10.1-10.4):
|
|
259
|
+
- REPO_DEPENDENTS: Find repositories that depend on target
|
|
260
|
+
- CLASS_IMPACT: Find methods affected by class change
|
|
261
|
+
- API_CONSUMERS: Find repositories calling an API
|
|
262
|
+
- DEPENDENCY_PATH: Find shortest path between repos
|
|
263
|
+
- COMPONENT_IMPACT: Find components importing target component (Task 10.1)
|
|
264
|
+
- API_CONSUMER_IMPACT: Find all consumers calling target API (Task 10.2)
|
|
265
|
+
- MICROFRONTEND_IMPACT: Find microfrontends consuming target modules (Task 10.3)
|
|
266
|
+
- PACKAGE_IMPACT: Find components depending on target npm package (Task 10.4)
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
query_type: Type of analysis
|
|
270
|
+
params: Query parameters
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
ImpactAnalysisResult with affected components
|
|
274
|
+
"""
|
|
275
|
+
async with self.driver.session() as session:
|
|
276
|
+
if query_type == "REPO_DEPENDENTS":
|
|
277
|
+
return await self._query_repo_dependents(session, params)
|
|
278
|
+
elif query_type == "CLASS_IMPACT":
|
|
279
|
+
return await self._query_class_impact(session, params)
|
|
280
|
+
elif query_type == "API_CONSUMERS":
|
|
281
|
+
return await self._query_api_consumers(session, params)
|
|
282
|
+
elif query_type == "DEPENDENCY_PATH":
|
|
283
|
+
return await self._query_dependency_path(session, params)
|
|
284
|
+
elif query_type == "COMPONENT_IMPACT":
|
|
285
|
+
return await self._query_component_impact(session, params)
|
|
286
|
+
elif query_type == "API_CONSUMER_IMPACT":
|
|
287
|
+
return await self._query_api_consumer_impact(session, params)
|
|
288
|
+
elif query_type == "MICROFRONTEND_IMPACT":
|
|
289
|
+
return await self._query_microfrontend_impact(session, params)
|
|
290
|
+
elif query_type == "PACKAGE_IMPACT":
|
|
291
|
+
return await self._query_package_impact(session, params)
|
|
292
|
+
else:
|
|
293
|
+
raise ValueError(f"Unknown query type: {query_type}")
|
|
294
|
+
|
|
295
|
+
async def _query_repo_dependents(self, session, params: dict) -> ImpactAnalysisResult:
|
|
296
|
+
"""
|
|
297
|
+
Query: Which repositories depend on target repository? (Requirement 16.1)
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
session: Neo4j session
|
|
301
|
+
params: {"repo_name": str}
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
ImpactAnalysisResult with list of dependent repository names
|
|
305
|
+
"""
|
|
306
|
+
query = """
|
|
307
|
+
MATCH (target:REPOSITORY {name: $repo_name})<-[:DEPENDS_ON*1..3]-(dependent:REPOSITORY)
|
|
308
|
+
RETURN DISTINCT dependent.name AS dependent_repo
|
|
309
|
+
ORDER BY dependent_repo
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
result = await session.run(query, repo_name=params["repo_name"])
|
|
313
|
+
|
|
314
|
+
affected = []
|
|
315
|
+
async for record in result:
|
|
316
|
+
affected.append(record["dependent_repo"])
|
|
317
|
+
|
|
318
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
319
|
+
|
|
320
|
+
async def _query_class_impact(self, session, params: dict) -> ImpactAnalysisResult:
|
|
321
|
+
"""
|
|
322
|
+
Query: Which methods are affected if class changes? (Requirement 16.2)
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
session: Neo4j session
|
|
326
|
+
params: {"class_name": str}
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
ImpactAnalysisResult with affected methods
|
|
330
|
+
"""
|
|
331
|
+
query = """
|
|
332
|
+
MATCH (target:CLASS {qualified_name: $class_name})<-[:IMPORTS]-(dependent:CLASS)-[:HAS_METHOD]->(method:METHOD)
|
|
333
|
+
RETURN DISTINCT
|
|
334
|
+
method.qualified_name AS affected_method,
|
|
335
|
+
method.file_path AS file_path,
|
|
336
|
+
dependent.name AS importing_class
|
|
337
|
+
ORDER BY file_path
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
result = await session.run(query, class_name=params["class_name"])
|
|
341
|
+
|
|
342
|
+
affected = []
|
|
343
|
+
async for record in result:
|
|
344
|
+
affected.append(record["affected_method"])
|
|
345
|
+
|
|
346
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
347
|
+
|
|
348
|
+
async def _query_api_consumers(self, session, params: dict) -> ImpactAnalysisResult:
|
|
349
|
+
"""
|
|
350
|
+
Query: Which repositories call this API? (Requirement 16.3)
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
session: Neo4j session
|
|
354
|
+
params: {"api_url": str}
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
ImpactAnalysisResult with repository names
|
|
358
|
+
"""
|
|
359
|
+
query = """
|
|
360
|
+
MATCH (api:API {url: $api_url})<-[:CALLS_API]-(method:METHOD)<-[:HAS_METHOD]-(class:CLASS)<-[:CONTAINS]-(repo:REPOSITORY)
|
|
361
|
+
RETURN DISTINCT
|
|
362
|
+
repo.name AS repository,
|
|
363
|
+
method.qualified_name AS calling_method,
|
|
364
|
+
class.file_path AS file_path
|
|
365
|
+
ORDER BY repo.name, file_path
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
result = await session.run(query, api_url=params["api_url"])
|
|
369
|
+
|
|
370
|
+
affected = []
|
|
371
|
+
async for record in result:
|
|
372
|
+
affected.append(record["repository"])
|
|
373
|
+
|
|
374
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
375
|
+
|
|
376
|
+
async def _query_dependency_path(self, session, params: dict) -> ImpactAnalysisResult:
|
|
377
|
+
"""
|
|
378
|
+
Query: What's the dependency path between two repos? (Requirement 16.4)
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
session: Neo4j session
|
|
382
|
+
params: {"repo_a": str, "repo_b": str}
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
ImpactAnalysisResult with path chain
|
|
386
|
+
"""
|
|
387
|
+
query = """
|
|
388
|
+
MATCH path = shortestPath(
|
|
389
|
+
(a:REPOSITORY {name: $repo_a})-[:DEPENDS_ON*..10]->(b:REPOSITORY {name: $repo_b})
|
|
390
|
+
)
|
|
391
|
+
RETURN [node IN nodes(path) | node.name] AS dependency_chain
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
result = await session.run(query, repo_a=params["repo_a"], repo_b=params["repo_b"])
|
|
395
|
+
|
|
396
|
+
affected = []
|
|
397
|
+
async for record in result:
|
|
398
|
+
affected.append(record["dependency_chain"])
|
|
399
|
+
|
|
400
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
401
|
+
|
|
402
|
+
async def _query_component_impact(self, session, params: dict) -> ImpactAnalysisResult:
|
|
403
|
+
"""
|
|
404
|
+
Query: Which components import target component (transitive)? (Task 10.1)
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
session: Neo4j session
|
|
408
|
+
params: {"component_name": str, "depth": int (optional, default 5)}
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
ImpactAnalysisResult with list of dependent components
|
|
412
|
+
"""
|
|
413
|
+
depth = params.get("depth", 5)
|
|
414
|
+
component_name = params["component_name"]
|
|
415
|
+
|
|
416
|
+
# Check if component exists (Task 10.6)
|
|
417
|
+
check_query = """
|
|
418
|
+
MATCH (c:Component {qualified_name: $component_name})
|
|
419
|
+
RETURN c.qualified_name AS name
|
|
420
|
+
"""
|
|
421
|
+
check_result = await session.run(check_query, component_name=component_name)
|
|
422
|
+
exists = False
|
|
423
|
+
async for _ in check_result:
|
|
424
|
+
exists = True
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
if not exists:
|
|
428
|
+
# Find similar component names for suggestions (Task 10.6)
|
|
429
|
+
suggestions_query = """
|
|
430
|
+
MATCH (c:Component)
|
|
431
|
+
WHERE c.qualified_name CONTAINS $partial_name
|
|
432
|
+
RETURN c.qualified_name AS name
|
|
433
|
+
LIMIT 5
|
|
434
|
+
"""
|
|
435
|
+
suggestions_result = await session.run(
|
|
436
|
+
suggestions_query,
|
|
437
|
+
partial_name=component_name[:min(len(component_name), 5)]
|
|
438
|
+
)
|
|
439
|
+
suggestions = []
|
|
440
|
+
async for record in suggestions_result:
|
|
441
|
+
suggestions.append(record["name"])
|
|
442
|
+
|
|
443
|
+
logger.warning(
|
|
444
|
+
"component_not_found",
|
|
445
|
+
component_name=component_name,
|
|
446
|
+
suggestions=suggestions
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
result = ImpactAnalysisResult(affected_components=[])
|
|
450
|
+
result.warning = f"Component '{component_name}' not found"
|
|
451
|
+
result.suggestions = suggestions
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
# Find transitive dependencies with circular detection
|
|
455
|
+
query = f"""
|
|
456
|
+
MATCH path = (target:Component {{qualified_name: $component_name}})
|
|
457
|
+
<-[:IMPORTS_COMPONENT*1..{depth}]-(dependent:Component)
|
|
458
|
+
WITH dependent, target, path,
|
|
459
|
+
size([n IN nodes(path) WHERE n = dependent | 1]) > 1 AS is_circular
|
|
460
|
+
RETURN DISTINCT
|
|
461
|
+
dependent.qualified_name AS qualified_name,
|
|
462
|
+
dependent.file_path AS file_path,
|
|
463
|
+
dependent.repository AS repository,
|
|
464
|
+
is_circular AS circular
|
|
465
|
+
ORDER BY dependent.repository, dependent.file_path
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
result = await session.run(query, component_name=component_name)
|
|
469
|
+
|
|
470
|
+
affected = []
|
|
471
|
+
async for record in result:
|
|
472
|
+
affected.append({
|
|
473
|
+
"qualified_name": record["qualified_name"],
|
|
474
|
+
"file_path": record["file_path"],
|
|
475
|
+
"repository": record["repository"],
|
|
476
|
+
"circular": record.get("circular", False)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
480
|
+
|
|
481
|
+
async def _query_api_consumer_impact(self, session, params: dict) -> ImpactAnalysisResult:
|
|
482
|
+
"""
|
|
483
|
+
Query: Which components/services call target API endpoint? (Task 10.2)
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
session: Neo4j session
|
|
487
|
+
params: {"api_url": str, "method": str (optional)}
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
ImpactAnalysisResult with list of consumers (cross-language)
|
|
491
|
+
"""
|
|
492
|
+
api_url = params["api_url"]
|
|
493
|
+
http_method = params.get("method")
|
|
494
|
+
|
|
495
|
+
# Build query with optional HTTP method filter
|
|
496
|
+
method_filter = ""
|
|
497
|
+
if http_method:
|
|
498
|
+
method_filter = "AND r.http_method = $http_method"
|
|
499
|
+
|
|
500
|
+
query = f"""
|
|
501
|
+
MATCH (api:API {{url: $api_url}})<-[r:CALLS_API]-(consumer)
|
|
502
|
+
WHERE (consumer:Class OR consumer:Component)
|
|
503
|
+
{method_filter}
|
|
504
|
+
RETURN DISTINCT
|
|
505
|
+
labels(consumer) AS type,
|
|
506
|
+
consumer.qualified_name AS name,
|
|
507
|
+
consumer.repository AS repository,
|
|
508
|
+
CASE
|
|
509
|
+
WHEN consumer:Class THEN 'java'
|
|
510
|
+
WHEN consumer:Component THEN coalesce(consumer.language, 'typescript')
|
|
511
|
+
ELSE 'unknown'
|
|
512
|
+
END AS language,
|
|
513
|
+
r.http_method AS http_method
|
|
514
|
+
ORDER BY repository, language, name
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
query_params = {"api_url": api_url}
|
|
518
|
+
if http_method:
|
|
519
|
+
query_params["http_method"] = http_method
|
|
520
|
+
|
|
521
|
+
result = await session.run(query, **query_params)
|
|
522
|
+
|
|
523
|
+
affected = []
|
|
524
|
+
async for record in result:
|
|
525
|
+
affected.append({
|
|
526
|
+
"type": record["type"],
|
|
527
|
+
"name": record["name"],
|
|
528
|
+
"repository": record["repository"],
|
|
529
|
+
"language": record["language"],
|
|
530
|
+
"http_method": record.get("http_method")
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
534
|
+
|
|
535
|
+
async def _query_microfrontend_impact(self, session, params: dict) -> ImpactAnalysisResult:
|
|
536
|
+
"""
|
|
537
|
+
Query: Which microfrontends consume modules from target? (Task 10.3)
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
session: Neo4j session
|
|
541
|
+
params: {"microfrontend_name": str}
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
ImpactAnalysisResult with list of consuming microfrontends
|
|
545
|
+
"""
|
|
546
|
+
microfrontend_name = params["microfrontend_name"]
|
|
547
|
+
|
|
548
|
+
query = """
|
|
549
|
+
MATCH (target:Microfrontend {name: $microfrontend_name})
|
|
550
|
+
-[:EXPOSES_MODULE]->()
|
|
551
|
+
<-[:CONSUMES_MODULE]-(consumer:Microfrontend)
|
|
552
|
+
OPTIONAL MATCH (consumer)-[:USES_PACKAGE]->(pkg:NPM_PACKAGE)
|
|
553
|
+
WHERE pkg.name IN ['react', 'vue', 'angular']
|
|
554
|
+
WITH consumer, collect({name: pkg.name, version: pkg.version}) AS shared_deps
|
|
555
|
+
RETURN DISTINCT
|
|
556
|
+
consumer.name AS name,
|
|
557
|
+
consumer.filename AS url,
|
|
558
|
+
shared_deps
|
|
559
|
+
ORDER BY consumer.name
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
result = await session.run(query, microfrontend_name=microfrontend_name)
|
|
563
|
+
|
|
564
|
+
affected = []
|
|
565
|
+
async for record in result:
|
|
566
|
+
# Convert shared_deps list to dict for easier conflict detection
|
|
567
|
+
shared_dict = {}
|
|
568
|
+
for dep in record["shared_deps"]:
|
|
569
|
+
if dep["name"]:
|
|
570
|
+
shared_dict[dep["name"]] = dep["version"]
|
|
571
|
+
|
|
572
|
+
affected.append({
|
|
573
|
+
"name": record["name"],
|
|
574
|
+
"url": record["url"],
|
|
575
|
+
"shared_deps": shared_dict if shared_dict else None
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
579
|
+
|
|
580
|
+
async def _query_package_impact(self, session, params: dict) -> ImpactAnalysisResult:
|
|
581
|
+
"""
|
|
582
|
+
Query: Which components depend on target npm package? (Task 10.4)
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
session: Neo4j session
|
|
586
|
+
params: {"package_name": str, "max_depth": int (optional, default 3), "scope": str (optional)}
|
|
587
|
+
|
|
588
|
+
Returns:
|
|
589
|
+
ImpactAnalysisResult with list of dependent components
|
|
590
|
+
"""
|
|
591
|
+
package_name = params["package_name"]
|
|
592
|
+
max_depth = params.get("max_depth", 3)
|
|
593
|
+
scope = params.get("scope")
|
|
594
|
+
|
|
595
|
+
# Build scope filter
|
|
596
|
+
scope_filter = ""
|
|
597
|
+
if scope:
|
|
598
|
+
scope_filter = "AND pkg.scope = $scope"
|
|
599
|
+
|
|
600
|
+
query = f"""
|
|
601
|
+
MATCH path = (pkg:NPM_PACKAGE {{name: $package_name}})
|
|
602
|
+
<-[:USES_PACKAGE*1..{max_depth}]-(component)
|
|
603
|
+
WHERE (component:Component OR component:Repository)
|
|
604
|
+
{scope_filter}
|
|
605
|
+
WITH component, pkg, length(path) AS depth
|
|
606
|
+
RETURN DISTINCT
|
|
607
|
+
component.qualified_name AS component,
|
|
608
|
+
component.repository AS repository,
|
|
609
|
+
depth,
|
|
610
|
+
pkg.version AS version,
|
|
611
|
+
pkg.scope AS scope
|
|
612
|
+
ORDER BY repository, depth
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
query_params = {"package_name": package_name}
|
|
616
|
+
if scope:
|
|
617
|
+
query_params["scope"] = scope
|
|
618
|
+
|
|
619
|
+
result = await session.run(query, **query_params)
|
|
620
|
+
|
|
621
|
+
affected = []
|
|
622
|
+
async for record in result:
|
|
623
|
+
affected.append({
|
|
624
|
+
"component": record["component"],
|
|
625
|
+
"repository": record["repository"],
|
|
626
|
+
"depth": record["depth"],
|
|
627
|
+
"version": record.get("version"),
|
|
628
|
+
"scope": record.get("scope", "production")
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
return ImpactAnalysisResult(affected_components=affected)
|
|
632
|
+
|
|
633
|
+
async def create_indexes(self) -> None:
|
|
634
|
+
"""
|
|
635
|
+
Create indexes for query performance (Requirement 6.6).
|
|
636
|
+
|
|
637
|
+
Creates indexes on:
|
|
638
|
+
- Class.qualified_name
|
|
639
|
+
- Method.qualified_name
|
|
640
|
+
- Repository.name
|
|
641
|
+
- Library.group_id, artifact_id
|
|
642
|
+
- API.url
|
|
643
|
+
"""
|
|
644
|
+
async with self.driver.session() as session:
|
|
645
|
+
indexes = [
|
|
646
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:CLASS) ON (n.qualified_name)",
|
|
647
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:METHOD) ON (n.qualified_name)",
|
|
648
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:REPOSITORY) ON (n.name)",
|
|
649
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:LIBRARY) ON (n.group_id)",
|
|
650
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:LIBRARY) ON (n.artifact_id)",
|
|
651
|
+
"CREATE INDEX IF NOT EXISTS FOR (n:API) ON (n.url)",
|
|
652
|
+
]
|
|
653
|
+
|
|
654
|
+
for index_query in indexes:
|
|
655
|
+
await session.run(index_query)
|
|
656
|
+
|
|
657
|
+
logger.info("neo4j_indexes_created", count=len(indexes))
|