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