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,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))