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,980 @@
1
+ """
2
+ Analysis Router (Task 9.2)
3
+
4
+ REST API endpoints for impact analysis operations.
5
+ """
6
+ from fastapi import APIRouter, Depends, status, HTTPException, Query
7
+ from pydantic import BaseModel, Field
8
+ import structlog
9
+
10
+ from src.application.dtos.impact_query_request import ImpactQueryRequest
11
+ from src.application.dtos.impact_analysis_response import ImpactAnalysisResponse
12
+ from src.application.dtos.affected_component import AffectedComponent
13
+ from src.infrastructure.persistence.neo4j_dependency_repository import Neo4jDependencyGraphRepository
14
+ from src.infrastructure.persistence.qdrant_vector_repository import QdrantVectorRepository
15
+ from src.infrastructure.external_apis.openai_embedding_provider import OpenAIEmbeddingProvider
16
+ from src.config.container import Container
17
+ from dependency_injector.wiring import inject, Provide
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+ router = APIRouter(prefix="/api/analysis/impact", tags=["analysis"])
22
+
23
+
24
+ @router.post(
25
+ "/repo-dependents",
26
+ response_model=ImpactAnalysisResponse,
27
+ status_code=status.HTTP_200_OK,
28
+ responses={
29
+ 200: {"description": "Impact analysis completed"},
30
+ 400: {"description": "Invalid target"},
31
+ 422: {"description": "Validation error"},
32
+ 500: {"description": "Internal server error"}
33
+ }
34
+ )
35
+ @inject
36
+ async def analyze_repo_dependents(
37
+ request: ImpactQueryRequest,
38
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository])
39
+ ) -> ImpactAnalysisResponse:
40
+ """
41
+ Analyze which components depend on a repository.
42
+
43
+ Performs impact analysis to identify all classes, methods, and other
44
+ repositories that depend on the specified target repository.
45
+
46
+ Args:
47
+ request: Impact query with target repository name and max depth
48
+
49
+ Returns:
50
+ ImpactAnalysisResponse with affected components
51
+
52
+ Raises:
53
+ HTTP 400: Invalid or unknown repository
54
+ HTTP 422: Request validation failed
55
+ HTTP 500: Internal error during analysis
56
+ """
57
+ logger.info(
58
+ "repo_dependents_analysis_requested",
59
+ target=request.target,
60
+ max_depth=request.max_depth
61
+ )
62
+
63
+ try:
64
+ # Query Neo4j for components that depend on the target repository
65
+ query = f"""
66
+ MATCH (dependent)-[r*1..{request.max_depth}]->(component)
67
+ WHERE component.repository = $target_repo
68
+ AND dependent.repository <> $target_repo
69
+ AND dependent.repository <> 'external'
70
+ AND dependent.repository <> 'unknown'
71
+ RETURN DISTINCT
72
+ dependent.repository AS repo,
73
+ labels(dependent)[0] AS type,
74
+ dependent.qualified_name AS qualified_name,
75
+ dependent.file_path AS file_path
76
+ ORDER BY repo, qualified_name
77
+ LIMIT 100
78
+ """
79
+
80
+ async with neo4j_repo.driver.session() as session:
81
+ result = await session.run(query, target_repo=request.target)
82
+ records = await result.data()
83
+
84
+ # Convert to AffectedComponent DTOs
85
+ affected_components = []
86
+ for record in records:
87
+ component_type = "class" # Default
88
+ if record["type"] == "METHOD":
89
+ component_type = "method"
90
+ elif record["type"] in ("REPOSITORY", "NPM_PACKAGE"):
91
+ component_type = "repository"
92
+
93
+ # Extract simple name from qualified_name
94
+ qualified_name = record.get("qualified_name") or ""
95
+ name = qualified_name.split(".")[-1] if qualified_name else "Unknown"
96
+
97
+ affected_components.append(
98
+ AffectedComponent(
99
+ type=component_type,
100
+ name=name,
101
+ qualified_name=qualified_name,
102
+ file_path=record.get("file_path")
103
+ )
104
+ )
105
+
106
+ response = ImpactAnalysisResponse(
107
+ target=request.target,
108
+ query_type="repo-dependents",
109
+ affected_components=affected_components
110
+ )
111
+
112
+ logger.info(
113
+ "repo_dependents_analysis_completed",
114
+ target=request.target,
115
+ affected_count=len(response.affected_components)
116
+ )
117
+
118
+ return response
119
+
120
+ except Exception as e:
121
+ logger.error(
122
+ "repo_dependents_analysis_failed",
123
+ target=request.target,
124
+ error=str(e),
125
+ error_type=type(e).__name__
126
+ )
127
+ raise HTTPException(
128
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
129
+ detail=f"Impact analysis failed: {str(e)}"
130
+ )
131
+
132
+
133
+ @router.post(
134
+ "/component-impact",
135
+ response_model=ImpactAnalysisResponse,
136
+ status_code=status.HTTP_200_OK,
137
+ responses={
138
+ 200: {"description": "Impact analysis completed"},
139
+ 400: {"description": "Invalid target component"},
140
+ 422: {"description": "Validation error"},
141
+ 500: {"description": "Internal server error"}
142
+ }
143
+ )
144
+ @inject
145
+ async def analyze_component_impact(
146
+ request: ImpactQueryRequest,
147
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository])
148
+ ) -> ImpactAnalysisResponse:
149
+ """
150
+ Analyze impact of changes to a component (class, React component, function, etc).
151
+
152
+ Performs impact analysis to identify all components that would be
153
+ affected by changes to the specified component. Traverses dependencies
154
+ up to the specified max depth.
155
+
156
+ Supports both Java classes and TypeScript/React components.
157
+
158
+ Args:
159
+ request: Impact query with target component name and max depth
160
+
161
+ Returns:
162
+ ImpactAnalysisResponse with affected components
163
+
164
+ Raises:
165
+ HTTP 400: Invalid or unknown component
166
+ HTTP 422: Request validation failed
167
+ HTTP 500: Internal error during analysis
168
+ """
169
+ logger.info(
170
+ "component_impact_analysis_requested",
171
+ target=request.target,
172
+ max_depth=request.max_depth
173
+ )
174
+
175
+ try:
176
+ # Query Neo4j for components that import/depend on the target component
177
+ # Support both exact match and partial match (for simple names like "Header")
178
+ query = f"""
179
+ MATCH path = (dependent)-[r:IMPORTS*1..{request.max_depth}]->(target)
180
+ WHERE target.qualified_name CONTAINS $target_name
181
+ OR target.name = $target_name
182
+ WITH dependent, target, length(path) AS depth
183
+ WHERE dependent.repository <> 'external'
184
+ AND dependent.repository <> 'unknown'
185
+ RETURN DISTINCT
186
+ dependent.repository AS repo,
187
+ labels(dependent)[0] AS type,
188
+ dependent.qualified_name AS qualified_name,
189
+ dependent.name AS name,
190
+ dependent.file_path AS file_path,
191
+ depth,
192
+ target.qualified_name AS target_qualified_name
193
+ ORDER BY depth, repo, qualified_name
194
+ LIMIT 100
195
+ """
196
+
197
+ async with neo4j_repo.driver.session() as session:
198
+ result = await session.run(query, target_name=request.target)
199
+ records = await result.data()
200
+
201
+ if not records:
202
+ logger.warning(
203
+ "component_not_found_or_no_dependents",
204
+ target=request.target
205
+ )
206
+
207
+ # Convert to AffectedComponent DTOs
208
+ affected_components = []
209
+ for record in records:
210
+ component_type = "class" # Default
211
+ if record["type"] == "METHOD":
212
+ component_type = "method"
213
+ elif record["type"] == "Component":
214
+ component_type = "class" # React components as class
215
+
216
+ name = record.get("name") or record.get("qualified_name", "Unknown").split(".")[-1]
217
+
218
+ affected_components.append(
219
+ AffectedComponent(
220
+ type=component_type,
221
+ name=name,
222
+ qualified_name=record.get("qualified_name"),
223
+ file_path=record.get("file_path")
224
+ )
225
+ )
226
+
227
+ response = ImpactAnalysisResponse(
228
+ target=request.target,
229
+ query_type="component-impact",
230
+ affected_components=affected_components
231
+ )
232
+
233
+ logger.info(
234
+ "component_impact_analysis_completed",
235
+ target=request.target,
236
+ affected_count=len(response.affected_components)
237
+ )
238
+
239
+ return response
240
+
241
+ except Exception as e:
242
+ logger.error(
243
+ "component_impact_analysis_failed",
244
+ target=request.target,
245
+ error=str(e),
246
+ error_type=type(e).__name__
247
+ )
248
+ raise HTTPException(
249
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
250
+ detail=f"Component impact analysis failed: {str(e)}"
251
+ )
252
+
253
+
254
+ @router.get(
255
+ "/most-imported/{repository}",
256
+ response_model=ImpactAnalysisResponse,
257
+ status_code=status.HTTP_200_OK,
258
+ responses={
259
+ 200: {"description": "Analysis completed"},
260
+ 404: {"description": "Repository not found"},
261
+ 500: {"description": "Internal server error"}
262
+ }
263
+ )
264
+ @inject
265
+ async def get_most_imported_components(
266
+ repository: str,
267
+ limit: int = 20,
268
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository])
269
+ ) -> ImpactAnalysisResponse:
270
+ """
271
+ Get the most imported components in a repository.
272
+
273
+ Returns a ranking of components by import count, helping identify
274
+ critical components that have high impact if changed.
275
+
276
+ Args:
277
+ repository: Repository name (e.g., "wdc-ui")
278
+ limit: Maximum number of components to return (default: 20)
279
+
280
+ Returns:
281
+ ImpactAnalysisResponse with most imported components
282
+ """
283
+ logger.info(
284
+ "most_imported_analysis_requested",
285
+ repository=repository,
286
+ limit=limit
287
+ )
288
+
289
+ try:
290
+ # Query Neo4j for most imported components
291
+ query = """
292
+ MATCH (component)<-[r:IMPORTS]-(importer)
293
+ WHERE component.repository = $repository
294
+ AND importer.repository = $repository
295
+ WITH component, count(r) AS import_count
296
+ ORDER BY import_count DESC
297
+ LIMIT $limit
298
+ RETURN labels(component)[0] AS type,
299
+ component.name AS name,
300
+ component.qualified_name AS qualified_name,
301
+ component.file_path AS file_path,
302
+ import_count
303
+ """
304
+
305
+ async with neo4j_repo.driver.session() as session:
306
+ result = await session.run(query, repository=repository, limit=limit)
307
+ records = await result.data()
308
+
309
+ if not records:
310
+ raise HTTPException(
311
+ status_code=status.HTTP_404_NOT_FOUND,
312
+ detail=f"Repository '{repository}' not found or has no import relationships"
313
+ )
314
+
315
+ # Convert to AffectedComponent DTOs
316
+ affected_components = []
317
+ for record in records:
318
+ component_type = "class"
319
+ if record["type"] == "METHOD":
320
+ component_type = "method"
321
+
322
+ name = record.get("name") or record.get("qualified_name", "Unknown").split(".")[-1]
323
+
324
+ affected_components.append(
325
+ AffectedComponent(
326
+ type=component_type,
327
+ name=f"{name} (imported {record['import_count']}x)",
328
+ qualified_name=record.get("qualified_name"),
329
+ file_path=record.get("file_path")
330
+ )
331
+ )
332
+
333
+ response = ImpactAnalysisResponse(
334
+ target=repository,
335
+ query_type="most-imported",
336
+ affected_components=affected_components
337
+ )
338
+
339
+ logger.info(
340
+ "most_imported_analysis_completed",
341
+ repository=repository,
342
+ components_found=len(affected_components)
343
+ )
344
+
345
+ return response
346
+
347
+ except HTTPException:
348
+ raise
349
+ except Exception as e:
350
+ logger.error(
351
+ "most_imported_analysis_failed",
352
+ repository=repository,
353
+ error=str(e),
354
+ error_type=type(e).__name__
355
+ )
356
+ raise HTTPException(
357
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
358
+ detail=f"Most imported analysis failed: {str(e)}"
359
+ )
360
+
361
+
362
+ @router.get(
363
+ "/cross-repo-dependencies/{repository}",
364
+ response_model=ImpactAnalysisResponse,
365
+ status_code=status.HTTP_200_OK,
366
+ responses={
367
+ 200: {"description": "Analysis completed"},
368
+ 404: {"description": "Repository not found"},
369
+ 500: {"description": "Internal server error"}
370
+ }
371
+ )
372
+ @inject
373
+ async def get_cross_repo_dependencies(
374
+ repository: str,
375
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository])
376
+ ) -> ImpactAnalysisResponse:
377
+ """
378
+ Get cross-repository dependencies for a repository.
379
+
380
+ Shows which other repositories import components from the target repository,
381
+ with detailed information about what is being imported.
382
+
383
+ Args:
384
+ repository: Target repository name (e.g., "wdc-ui")
385
+
386
+ Returns:
387
+ ImpactAnalysisResponse with importing repositories and components
388
+ """
389
+ logger.info(
390
+ "cross_repo_dependencies_requested",
391
+ repository=repository
392
+ )
393
+
394
+ try:
395
+ # Query Neo4j for cross-repository imports
396
+ query = """
397
+ MATCH (source)-[r:IMPORTS]->(target)
398
+ WHERE target.repository = $repository
399
+ AND source.repository <> $repository
400
+ AND source.repository <> 'external'
401
+ AND source.repository <> 'unknown'
402
+ RETURN DISTINCT
403
+ source.repository AS source_repo,
404
+ source.qualified_name AS source_component,
405
+ source.name AS source_name,
406
+ source.file_path AS source_file,
407
+ target.qualified_name AS target_component,
408
+ target.name AS target_name,
409
+ count(r) AS import_count
410
+ ORDER BY source_repo, import_count DESC
411
+ LIMIT 100
412
+ """
413
+
414
+ async with neo4j_repo.driver.session() as session:
415
+ result = await session.run(query, repository=repository)
416
+ records = await result.data()
417
+
418
+ if not records:
419
+ raise HTTPException(
420
+ status_code=status.HTTP_404_NOT_FOUND,
421
+ detail=f"No cross-repository dependencies found for '{repository}'"
422
+ )
423
+
424
+ # Convert to AffectedComponent DTOs
425
+ affected_components = []
426
+ for record in records:
427
+ source_name = record.get("source_name") or record.get("source_component", "").split(".")[-1]
428
+ target_name = record.get("target_name") or record.get("target_component", "").split(".")[-1]
429
+
430
+ affected_components.append(
431
+ AffectedComponent(
432
+ type="class",
433
+ name=f"{source_name} → {target_name} ({record['import_count']}x from {record['source_repo']})",
434
+ qualified_name=record.get("source_component"),
435
+ file_path=record.get("source_file")
436
+ )
437
+ )
438
+
439
+ response = ImpactAnalysisResponse(
440
+ target=repository,
441
+ query_type="cross-repo-dependencies",
442
+ affected_components=affected_components
443
+ )
444
+
445
+ logger.info(
446
+ "cross_repo_dependencies_completed",
447
+ repository=repository,
448
+ dependencies_found=len(affected_components)
449
+ )
450
+
451
+ return response
452
+
453
+ except HTTPException:
454
+ raise
455
+ except Exception as e:
456
+ logger.error(
457
+ "cross_repo_dependencies_failed",
458
+ repository=repository,
459
+ error=str(e),
460
+ error_type=type(e).__name__
461
+ )
462
+ raise HTTPException(
463
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
464
+ detail=f"Cross-repo dependency analysis failed: {str(e)}"
465
+ )
466
+
467
+
468
+ @router.get(
469
+ "/api-calls/{repository}",
470
+ response_model=ImpactAnalysisResponse,
471
+ status_code=status.HTTP_200_OK,
472
+ responses={
473
+ 200: {"description": "API calls found"},
474
+ 404: {"description": "Repository not found or no API calls detected"},
475
+ 500: {"description": "Internal server error"}
476
+ }
477
+ )
478
+ @inject
479
+ async def get_api_calls(
480
+ repository: str,
481
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository])
482
+ ) -> ImpactAnalysisResponse:
483
+ """
484
+ Get all API calls made by components in a repository.
485
+
486
+ Shows which components are calling which HTTP APIs (fetch, axios, etc.),
487
+ helping identify frontend-backend connections.
488
+
489
+ Args:
490
+ repository: Source repository name (e.g., "wdc-ui")
491
+
492
+ Returns:
493
+ ImpactAnalysisResponse with API calls (component → API endpoint)
494
+ """
495
+ logger.info(
496
+ "api_calls_analysis_requested",
497
+ repository=repository
498
+ )
499
+
500
+ try:
501
+ # Query Neo4j for API calls from this repository
502
+ query = """
503
+ MATCH (component)-[r:CALLS_API]->(api)
504
+ WHERE component.repository = $repository
505
+ RETURN DISTINCT
506
+ component.qualified_name AS component,
507
+ component.name AS component_name,
508
+ component.file_path AS file_path,
509
+ api.url AS api_endpoint,
510
+ r.line AS line_number
511
+ ORDER BY component, api_endpoint
512
+ LIMIT 100
513
+ """
514
+
515
+ async with neo4j_repo.driver.session() as session:
516
+ result = await session.run(query, repository=repository)
517
+ records = await result.data()
518
+
519
+ if not records:
520
+ raise HTTPException(
521
+ status_code=status.HTTP_404_NOT_FOUND,
522
+ detail=f"No API calls detected in repository '{repository}'. Make sure components use fetch(), axios, or similar HTTP clients with string literal URLs."
523
+ )
524
+
525
+ # Convert to AffectedComponent DTOs
526
+ affected_components = []
527
+ for record in records:
528
+ component_name = record.get("component_name") or record.get("component", "").split(".")[-1]
529
+ api_url = record.get("api_endpoint", "")
530
+ line = record.get("line_number", "")
531
+
532
+ affected_components.append(
533
+ AffectedComponent(
534
+ type="method", # Use "method" to indicate it's a function/component calling API
535
+ name=f"{component_name} → {api_url} (line {line})",
536
+ qualified_name=record.get("component"),
537
+ file_path=record.get("file_path")
538
+ )
539
+ )
540
+
541
+ response = ImpactAnalysisResponse(
542
+ target=repository,
543
+ query_type="api-calls",
544
+ affected_components=affected_components
545
+ )
546
+
547
+ logger.info(
548
+ "api_calls_analysis_completed",
549
+ repository=repository,
550
+ api_calls_found=len(affected_components)
551
+ )
552
+
553
+ return response
554
+
555
+ except HTTPException:
556
+ raise
557
+ except Exception as e:
558
+ logger.error(
559
+ "api_calls_analysis_failed",
560
+ repository=repository,
561
+ error=str(e),
562
+ error_type=type(e).__name__
563
+ )
564
+ raise HTTPException(
565
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
566
+ detail=f"API calls analysis failed: {str(e)}"
567
+ )
568
+
569
+
570
+ @router.get(
571
+ "/calls-backend/{repository}",
572
+ response_model=ImpactAnalysisResponse,
573
+ status_code=status.HTTP_200_OK,
574
+ summary="Show which backend repos/endpoints this frontend repo calls",
575
+ )
576
+ @inject
577
+ async def get_calls_backend(
578
+ repository: str,
579
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository]),
580
+ ) -> ImpactAnalysisResponse:
581
+ """Returns all CALLS_BACKEND edges originating from this repository."""
582
+ try:
583
+ query = """
584
+ MATCH (src)-[r:CALLS_BACKEND]->(ep)
585
+ WHERE src.repository = $repository
586
+ RETURN DISTINCT
587
+ src.qualified_name AS caller,
588
+ src.file_path AS file_path,
589
+ r.frontend_path AS frontend_path,
590
+ ep.url AS backend_path,
591
+ ep.repository AS backend_repo,
592
+ r.http_method AS http_method
593
+ ORDER BY backend_repo, frontend_path
594
+ LIMIT 200
595
+ """
596
+ async with neo4j_repo.driver.session() as session:
597
+ result = await session.run(query, repository=repository)
598
+ records = await result.data()
599
+
600
+ if not records:
601
+ raise HTTPException(
602
+ status_code=status.HTTP_404_NOT_FOUND,
603
+ detail=f"No backend calls found for '{repository}'. Run POST /cross-language-link first.",
604
+ )
605
+
606
+ components = []
607
+ for r in records:
608
+ method = r.get("http_method") or "?"
609
+ backend = r.get("backend_repo") or "?"
610
+ path = r.get("frontend_path") or ""
611
+ caller = (r.get("caller") or "").split(".")[-1]
612
+ components.append(AffectedComponent(
613
+ type="method",
614
+ name=f"{caller} →[{method}] {path} => {backend}",
615
+ qualified_name=r.get("caller"),
616
+ file_path=r.get("file_path"),
617
+ ))
618
+
619
+ return ImpactAnalysisResponse(
620
+ target=repository,
621
+ query_type="calls-backend",
622
+ affected_components=components,
623
+ )
624
+ except HTTPException:
625
+ raise
626
+ except Exception as e:
627
+ raise HTTPException(status_code=500, detail=str(e))
628
+
629
+
630
+ @router.post(
631
+ "/cross-language-link",
632
+ status_code=status.HTTP_200_OK,
633
+ summary="Link frontend API calls to backend endpoints across all indexed repositories",
634
+ description=(
635
+ "Reads all API nodes (frontend) and ENDPOINT nodes (backend) from Neo4j "
636
+ "and creates CALLS_BACKEND edges using normalised path suffix matching. "
637
+ "Run this after indexing all repositories."
638
+ ),
639
+ )
640
+ @inject
641
+ async def cross_language_link(
642
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository]),
643
+ ) -> dict:
644
+ """Generic cross-language linker — framework agnostic, works for any repos."""
645
+ logger.info("cross_language_link_requested")
646
+
647
+ try:
648
+ async with neo4j_repo.driver.session() as session:
649
+ # Fetch all API nodes (frontend calls)
650
+ api_result = await session.run(
651
+ "MATCH (n:API) RETURN n.id AS id, n.url AS url"
652
+ )
653
+ api_rows = await api_result.data()
654
+
655
+ # Fetch all ENDPOINT nodes (backend endpoints)
656
+ ep_result = await session.run(
657
+ "MATCH (n:ENDPOINT) RETURN n.id AS id, n.url AS url, "
658
+ "n.http_method AS method, n.repository AS repo"
659
+ )
660
+ ep_rows = await ep_result.data()
661
+
662
+ # Fetch existing CALLS_API edges to know who calls each API node
663
+ ca_result = await session.run(
664
+ "MATCH (src)-[r:CALLS_API]->(api) "
665
+ "RETURN src.id AS src_id, api.id AS api_id, src.repository AS src_repo"
666
+ )
667
+ ca_rows = await ca_result.data()
668
+
669
+ if not api_rows or not ep_rows:
670
+ return {"created": 0, "message": "No API or ENDPOINT nodes found — index repositories first."}
671
+
672
+ import re
673
+
674
+ def normalise(path: str) -> str:
675
+ p = path.strip().lstrip("/").lower().split("?")[0]
676
+ p = re.sub(r'(?:\{[^}]+\}|:[^/]+)', '{p}', p)
677
+ return p
678
+
679
+ # Build caller map: api_id -> [src_id]
680
+ caller_map: dict[str, list[tuple[str, str]]] = {}
681
+ for row in ca_rows:
682
+ caller_map.setdefault(row["api_id"], []).append(
683
+ (row["src_id"], row.get("src_repo", ""))
684
+ )
685
+
686
+ # Build endpoint index: normalised_path -> endpoint row
687
+ ep_index: dict[str, dict] = {}
688
+ for ep in ep_rows:
689
+ norm = normalise(ep["url"] or "")
690
+ if norm:
691
+ ep_index[norm] = ep
692
+
693
+ # Match and create edges
694
+ edges_to_create: list[dict] = []
695
+ for api in api_rows:
696
+ api_norm = normalise(api["url"] or "")
697
+ if not api_norm:
698
+ continue
699
+ for ep_norm, ep in ep_index.items():
700
+ if api_norm == ep_norm or ep_norm.endswith("/" + api_norm):
701
+ callers = caller_map.get(api["id"], [(api["id"], "")])
702
+ for src_id, src_repo in callers:
703
+ edges_to_create.append({
704
+ "src": src_id,
705
+ "tgt": ep["id"],
706
+ "frontend_path": api["url"],
707
+ "backend_path": ep["url"],
708
+ "backend_repo": ep["repo"],
709
+ "http_method": ep["method"],
710
+ })
711
+
712
+ if not edges_to_create:
713
+ return {"created": 0, "message": "No path matches found between frontend calls and backend endpoints."}
714
+
715
+ # Write edges in batches
716
+ async with neo4j_repo.driver.session() as session:
717
+ query = """
718
+ UNWIND $edges AS e
719
+ MATCH (src {id: e.src})
720
+ MATCH (tgt {id: e.tgt})
721
+ MERGE (src)-[r:CALLS_BACKEND]->(tgt)
722
+ SET r.frontend_path = e.frontend_path,
723
+ r.backend_path = e.backend_path,
724
+ r.backend_repo = e.backend_repo,
725
+ r.http_method = e.http_method,
726
+ r.match = 'path_suffix'
727
+ """
728
+ await session.run(query, edges=edges_to_create)
729
+
730
+ logger.info("cross_language_link_completed", edges_created=len(edges_to_create))
731
+ return {
732
+ "created": len(edges_to_create),
733
+ "frontend_api_nodes": len(api_rows),
734
+ "backend_endpoint_nodes": len(ep_rows),
735
+ "message": f"Created {len(edges_to_create)} CALLS_BACKEND edges.",
736
+ }
737
+
738
+ except Exception as e:
739
+ logger.error("cross_language_link_failed", error=str(e))
740
+ raise HTTPException(
741
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
742
+ detail=f"Cross-language linking failed: {str(e)}",
743
+ )
744
+
745
+
746
+ @router.post(
747
+ "/resolve-db-refs",
748
+ status_code=status.HTTP_200_OK,
749
+ summary="Resolve DB cross-references for Oracle/SQL repos",
750
+ description=(
751
+ "After indexing Oracle/SQL repos, READS_TABLE edges often point to stub nodes "
752
+ "with warehouse-schema prefixes (e.g. DBB01USR.T_PARCEL_PROCESS) instead of "
753
+ "the real DDL nodes (T_PARCEL_PROCESS). This endpoint:\n"
754
+ "1. Strips warehouse schema prefixes and creates edges to the real DDL nodes\n"
755
+ "2. Resolves case-insensitive mismatches between stub and real node IDs\n"
756
+ "Run this after indexing any Oracle/SQL schema repository."
757
+ ),
758
+ )
759
+ @inject
760
+ async def resolve_db_refs(
761
+ neo4j_repo: Neo4jDependencyGraphRepository = Depends(Provide[Container.neo4j_repository]),
762
+ ) -> dict:
763
+ """
764
+ Resolves 3 types of broken DB ref edges without re-indexing:
765
+ 1. Warehouse prefixes: DBB01USR.TABLE → TABLE
766
+ 2. Cross-schema refs: ZMEDAPP.TABLE → TABLE
767
+ 3. Case mismatches: outordhdr → OUTORDHDR
768
+ All done in batches via Cypher — no regex, no APOC needed.
769
+ """
770
+ logger.info("resolve_db_refs_requested")
771
+
772
+ total_step1 = 0
773
+ total_step2 = 0
774
+ total_step3 = 0
775
+
776
+ try:
777
+ async with neo4j_repo.driver.session() as session:
778
+
779
+ for rel_type in ("READS_TABLE", "WRITES_TABLE", "CALLS_PROCEDURE"):
780
+
781
+ # ── Step 1: Warehouse prefixes (DBBxxUSR. / DBCxxUSR.) ────────
782
+ # Use split + ENDS WITH 'USR' — no regex, fast
783
+ r = await session.run(f"""
784
+ MATCH (src)-[:{rel_type}]->(stub:DB_TABLE)
785
+ WHERE stub.is_stub = 'true'
786
+ AND stub.id CONTAINS '.'
787
+ AND split(stub.id, '.')[0] ENDS WITH 'USR'
788
+ WITH DISTINCT src,
789
+ toUpper(split(stub.id, '.')[1]) AS base_name,
790
+ src.repository AS repo
791
+ MATCH (real:DB_TABLE)
792
+ WHERE toUpper(real.id) = base_name
793
+ AND real.is_stub IS NULL
794
+ MERGE (src)-[nr:{rel_type}]->(real)
795
+ ON CREATE SET nr.resolved = 'warehouse', nr.repository = repo
796
+ RETURN count(nr) AS cnt
797
+ """)
798
+ rows = await r.data()
799
+ total_step1 += rows[0]["cnt"] if rows else 0
800
+
801
+ # ── Step 2: Cross-schema (ZMEDAPP.TABLE, DBO.TABLE, etc.) ─────
802
+ r = await session.run(f"""
803
+ MATCH (src)-[:{rel_type}]->(stub:DB_TABLE)
804
+ WHERE stub.is_stub = 'true'
805
+ AND stub.id CONTAINS '.'
806
+ AND NOT split(stub.id, '.')[0] ENDS WITH 'USR'
807
+ WITH DISTINCT src,
808
+ toUpper(split(stub.id, '.')[1]) AS base_name,
809
+ src.repository AS repo
810
+ MATCH (real:DB_TABLE)
811
+ WHERE toUpper(real.id) = base_name
812
+ AND real.is_stub IS NULL
813
+ MERGE (src)-[nr:{rel_type}]->(real)
814
+ ON CREATE SET nr.resolved = 'schema', nr.repository = repo
815
+ RETURN count(nr) AS cnt
816
+ """)
817
+ rows = await r.data()
818
+ total_step2 += rows[0]["cnt"] if rows else 0
819
+
820
+ # ── Step 3: Unprefixed case mismatch (outordhdr → OUTORDHDR) ──
821
+ r = await session.run(f"""
822
+ MATCH (src)-[:{rel_type}]->(stub:DB_TABLE)
823
+ WHERE stub.is_stub = 'true'
824
+ AND NOT stub.id CONTAINS '.'
825
+ WITH DISTINCT src,
826
+ toUpper(stub.id) AS base_name,
827
+ src.repository AS repo
828
+ MATCH (real:DB_TABLE)
829
+ WHERE toUpper(real.id) = base_name
830
+ AND real.is_stub IS NULL
831
+ MERGE (src)-[nr:{rel_type}]->(real)
832
+ ON CREATE SET nr.resolved = 'case', nr.repository = repo
833
+ RETURN count(nr) AS cnt
834
+ """)
835
+ rows = await r.data()
836
+ total_step3 += rows[0]["cnt"] if rows else 0
837
+
838
+ # Same resolution for DB_VIEW, DB_PROCEDURE targets
839
+ for node_label in ("DB_VIEW", "DB_PROCEDURE", "DB_PACKAGE"):
840
+ r = await session.run(f"""
841
+ MATCH (src)-[:{rel_type}]->(stub:{node_label})
842
+ WHERE stub.is_stub = 'true'
843
+ WITH DISTINCT src,
844
+ toUpper(CASE WHEN stub.id CONTAINS '.'
845
+ THEN split(stub.id, '.')[1]
846
+ ELSE stub.id END) AS base_name,
847
+ src.repository AS repo
848
+ MATCH (real:{node_label})
849
+ WHERE toUpper(real.id) = base_name
850
+ AND real.is_stub IS NULL
851
+ MERGE (src)-[nr:{rel_type}]->(real)
852
+ ON CREATE SET nr.resolved = 'any', nr.repository = repo
853
+ RETURN count(nr) AS cnt
854
+ """)
855
+ rows = await r.data()
856
+ total_step2 += rows[0]["cnt"] if rows else 0
857
+
858
+ # ── Final count ──────────────────────────────────────────────────
859
+ count_r = await session.run("""
860
+ MATCH (src)-[r:READS_TABLE|WRITES_TABLE|CALLS_PROCEDURE]->(tgt)
861
+ WHERE coalesce(tgt.is_stub, 'false') <> 'true'
862
+ RETURN count(r) AS cnt
863
+ """)
864
+ count_rows = await count_r.data()
865
+ resolved_total = count_rows[0]["cnt"] if count_rows else 0
866
+
867
+ total_new = total_step1 + total_step2 + total_step3
868
+ result = {
869
+ "warehouse_prefix_resolved": total_step1,
870
+ "cross_schema_resolved": total_step2,
871
+ "case_mismatch_resolved": total_step3,
872
+ "total_new_edges": total_new,
873
+ "total_resolved_edges_in_graph": resolved_total,
874
+ "message": (
875
+ f"Created {total_new} new edges between real DDL nodes. "
876
+ f"Graph now has {resolved_total} resolved DB edges."
877
+ ),
878
+ }
879
+ logger.info("resolve_db_refs_completed", **result)
880
+ return result
881
+
882
+ except Exception as e:
883
+ logger.error("resolve_db_refs_failed", error=str(e))
884
+ raise HTTPException(
885
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
886
+ detail=f"DB ref resolution failed: {str(e)}",
887
+ )
888
+
889
+
890
+ # ---------------------------------------------------------------------------
891
+ # Semantic search
892
+ # ---------------------------------------------------------------------------
893
+
894
+ class SemanticSearchRequest(BaseModel):
895
+ query: str = Field(..., min_length=3, description="Natural language search query")
896
+ repositories: list[str] | None = Field(
897
+ default=None,
898
+ description="Filter by repository names. None = search all indexed repos."
899
+ )
900
+ max_results: int = Field(default=10, ge=1, le=50, description="Max results to return")
901
+ score_threshold: float = Field(
902
+ default=0.35, ge=0.0, le=1.0,
903
+ description="Minimum similarity score (0-1). Lower = more results, less precise."
904
+ )
905
+
906
+ model_config = {
907
+ "json_schema_extra": {
908
+ "example": {
909
+ "query": "user registration and authentication flow",
910
+ "repositories": ["wdc-packing-list", "wdc-ui"],
911
+ "max_results": 10,
912
+ "score_threshold": 0.35
913
+ }
914
+ }
915
+ }
916
+
917
+
918
+ @router.post(
919
+ "/search",
920
+ status_code=status.HTTP_200_OK,
921
+ summary="Semantic search across indexed code",
922
+ description=(
923
+ "Searches all indexed code chunks using vector similarity (Qdrant + OpenAI embeddings). "
924
+ "Accepts a natural language query and returns the most relevant code chunks "
925
+ "ranked by semantic similarity. Use for business impact analysis to find "
926
+ "which files/methods implement a given concept."
927
+ ),
928
+ )
929
+ @inject
930
+ async def semantic_search(
931
+ request: SemanticSearchRequest,
932
+ qdrant_repo: QdrantVectorRepository = Depends(Provide[Container.qdrant_repository]),
933
+ embedding_provider: OpenAIEmbeddingProvider = Depends(Provide[Container.embedding_provider]),
934
+ ) -> dict:
935
+ logger.info(
936
+ "semantic_search_requested",
937
+ query=request.query[:100],
938
+ repositories=request.repositories,
939
+ max_results=request.max_results,
940
+ )
941
+
942
+ try:
943
+ # 1. Embed the query
944
+ vectors = await embedding_provider.embed_batch([request.query])
945
+ if not vectors:
946
+ raise HTTPException(status_code=500, detail="Failed to generate query embedding")
947
+ query_vector = vectors[0]
948
+
949
+ # 2. Search Qdrant
950
+ hits = await qdrant_repo.search_similar(
951
+ query_vector=query_vector,
952
+ limit=request.max_results,
953
+ repositories=request.repositories,
954
+ score_threshold=request.score_threshold,
955
+ )
956
+
957
+ # 3. Group by repository for readability
958
+ by_repo: dict[str, list] = {}
959
+ for hit in hits:
960
+ repo = hit.get("repository", "unknown")
961
+ by_repo.setdefault(repo, []).append(hit)
962
+
963
+ logger.info("semantic_search_completed", results=len(hits))
964
+
965
+ return {
966
+ "query": request.query,
967
+ "total_results": len(hits),
968
+ "repositories_found": sorted(by_repo.keys()),
969
+ "results": hits,
970
+ "results_by_repo": by_repo,
971
+ }
972
+
973
+ except HTTPException:
974
+ raise
975
+ except Exception as e:
976
+ logger.error("semantic_search_failed", error=str(e))
977
+ raise HTTPException(
978
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
979
+ detail=f"Semantic search failed: {str(e)}",
980
+ )