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,962 @@
1
+ """DependencyExtractor domain service - extracts dependencies from code."""
2
+ import re
3
+ from typing import Optional
4
+
5
+ from src.domain.entities.ast_node import ASTNode
6
+ from src.domain.entities.dependency_graph import DependencyGraph, DependencyNode, DependencyEdge
7
+ from src.domain.value_objects.dependency_type import DependencyType
8
+
9
+
10
+ class DependencyExtractor:
11
+ """
12
+ Domain service for extracting dependencies from multiple sources.
13
+
14
+ Extracts:
15
+ - Internal dependencies from AST (imports, method calls)
16
+ - External libraries from build files (Maven, Gradle)
17
+ - API endpoints from config files and string literals
18
+ - Database connections from config files and JDBC strings
19
+
20
+ Note:
21
+ This is a domain service that orchestrates dependency extraction.
22
+ Actual parsing is delegated to infrastructure parsers (injected).
23
+ """
24
+
25
+ # Regex patterns for string literal detection (fallback)
26
+ URL_PATTERN = re.compile(r'https?://[^\s"\'<>]+')
27
+ JDBC_PATTERN = re.compile(r'jdbc:([^:]+)://([^:/\s"\']+):?(\d+)?/([^\s"\']+)')
28
+
29
+ def __init__(
30
+ self,
31
+ maven_parser=None,
32
+ gradle_parser=None,
33
+ config_parser=None,
34
+ typescript_parser=None,
35
+ npm_parser=None,
36
+ microfrontend_parser=None
37
+ ):
38
+ """
39
+ Initialize DependencyExtractor with optional parsers.
40
+
41
+ Args:
42
+ maven_parser: MavenDependencyParser instance (injected)
43
+ gradle_parser: GradleDependencyParser instance (injected)
44
+ config_parser: ConfigFileParser instance (injected)
45
+ typescript_parser: TreeSitterTypeScriptParser instance (injected) - Task 4.1
46
+ npm_parser: NpmPackageDependencyParser instance (injected) - Task 4.1
47
+ microfrontend_parser: MicrofrontendConfigParser instance (injected) - Task 4.1
48
+
49
+ Note:
50
+ Parsers are optional to maintain backward compatibility and allow
51
+ testing without full infrastructure setup.
52
+ """
53
+ self.maven_parser = maven_parser
54
+ self.gradle_parser = gradle_parser
55
+ self.config_parser = config_parser
56
+ self.typescript_parser = typescript_parser
57
+ self.npm_parser = npm_parser
58
+ self.microfrontend_parser = microfrontend_parser
59
+
60
+ def extract_dependencies(
61
+ self,
62
+ ast_nodes: list[ASTNode],
63
+ build_files: dict[str, str],
64
+ config_files: dict[str, str],
65
+ repository: str = "unknown",
66
+ ) -> DependencyGraph:
67
+ """
68
+ Extract dependencies from all sources.
69
+
70
+ Args:
71
+ ast_nodes: Parsed AST nodes from Tree-sitter
72
+ build_files: Build file contents (pom.xml, build.gradle)
73
+ config_files: Config file contents (application.properties, etc)
74
+ repository: Repository name — stored on DATABASE/API nodes for discovery
75
+
76
+ Returns:
77
+ DependencyGraph with extracted nodes and edges
78
+ """
79
+ graph = DependencyGraph()
80
+ self._current_repository = repository
81
+
82
+ # Extract from AST (internal dependencies)
83
+ self._extract_from_ast(ast_nodes, graph)
84
+
85
+ # Extract from build files (external libraries)
86
+ self._extract_from_build_files(build_files, graph)
87
+
88
+ # Extract from config files (APIs, databases, services)
89
+ self._extract_from_config_files(config_files, graph)
90
+
91
+ return graph
92
+
93
+ # DB object node types from SqlSchemaParser
94
+ _DB_OBJECT_TYPES = frozenset({
95
+ "DB_TABLE", "DB_VIEW", "DB_PROCEDURE", "DB_PACKAGE",
96
+ "DB_TRIGGER", "DB_SEQUENCE", "DB_FUNCTION", "DB_INDEX",
97
+ "DB_TYPE", "DB_COLLECTION",
98
+ })
99
+
100
+ def _extract_from_ast(self, ast_nodes: list[ASTNode], graph: DependencyGraph) -> None:
101
+ """Extract dependencies from AST nodes."""
102
+ for node in ast_nodes:
103
+ if node.node_type == "IMPORT":
104
+ self._handle_import_node(node, graph)
105
+ elif node.node_type == "METHOD_CALL":
106
+ self._handle_method_call_node(node, graph)
107
+ elif node.node_type == "API_CALL":
108
+ self._handle_api_call_node(node, graph)
109
+ elif node.node_type == "BACKEND_ENDPOINT":
110
+ self._handle_backend_endpoint_node(node, graph)
111
+ elif node.node_type in self._DB_OBJECT_TYPES:
112
+ self._handle_db_object_node(node, graph)
113
+ elif node.node_type == "DB_REF":
114
+ self._handle_db_ref_node(node, graph)
115
+ elif node.node_type == "CLASS":
116
+ # Persist CLASS nodes from any language (Groovy, Python, Java, TS)
117
+ # so they appear in the graph even if they have no imports.
118
+ self._handle_class_node(node, graph)
119
+ elif node.node_type == "METHOD":
120
+ # Check for URLs/JDBC in string literals (Task 4.2)
121
+ string_literals = node.metadata.get("string_literals", [])
122
+ self._extract_from_string_literals(string_literals, graph, source_node=node)
123
+
124
+ def _handle_class_node(self, node: ASTNode, graph: DependencyGraph) -> None:
125
+ """
126
+ Persist a CLASS node from any language (Java, Python, Groovy, TS).
127
+ Called when a class has no imports — without this, the node would
128
+ never be created because _handle_import_node is the only place
129
+ that creates source nodes.
130
+ """
131
+ qname = node.qualified_name or node.name
132
+ if not qname or qname in ("Unknown", ""):
133
+ return
134
+
135
+ repo = node.metadata.get("repository",
136
+ getattr(self, "_current_repository", "unknown"))
137
+ lang = node.metadata.get("language", "unknown")
138
+ file_path = node.metadata.get("file_path", "")
139
+
140
+ # Only persist if the node isn't already in the graph
141
+ existing_ids = {n.id for n in graph.nodes}
142
+ if qname in existing_ids:
143
+ return
144
+
145
+ graph.add_node(DependencyNode(
146
+ id=qname,
147
+ node_type="CLASS",
148
+ properties={
149
+ "qualified_name": qname,
150
+ "name": node.name,
151
+ "type": DependencyType.INTERNAL_CLASS.value,
152
+ "repository": repo,
153
+ "language": lang,
154
+ "file_path": file_path,
155
+ }
156
+ ))
157
+
158
+ def _handle_import_node(self, node: ASTNode, graph: DependencyGraph) -> None:
159
+ """Handle import statement node."""
160
+ import_path = node.metadata.get("import_path", node.qualified_name)
161
+ current_package = node.metadata.get("current_package", "")
162
+ file_path = node.metadata.get("file_path", "")
163
+ repository = node.metadata.get("repository", "unknown")
164
+
165
+ source_id = current_package if current_package else "unknown"
166
+ target_id = import_path
167
+
168
+ # Create source node (the class doing the import)
169
+ if source_id != "unknown":
170
+ source_node = DependencyNode(
171
+ id=source_id,
172
+ node_type="CLASS",
173
+ properties={
174
+ "qualified_name": source_id,
175
+ "type": DependencyType.INTERNAL_CLASS.value,
176
+ "repository": repository,
177
+ "file_path": file_path
178
+ }
179
+ )
180
+ graph.add_node(source_node)
181
+
182
+ # Create target node (the imported class)
183
+ target_node = DependencyNode(
184
+ id=target_id,
185
+ node_type="CLASS",
186
+ properties={
187
+ "qualified_name": target_id,
188
+ "type": DependencyType.INTERNAL_CLASS.value if not self._is_external_import(import_path) else DependencyType.LIBRARY.value,
189
+ "repository": repository if not self._is_external_import(import_path) else "external"
190
+ }
191
+ )
192
+ graph.add_node(target_node)
193
+
194
+ # Create edge for import
195
+ edge = DependencyEdge(
196
+ source=source_id,
197
+ target=target_id,
198
+ edge_type="IMPORTS",
199
+ properties={"line": node.line_start, "repository": repository}
200
+ )
201
+ graph.add_edge(edge)
202
+
203
+ def _handle_db_object_node(self, node: ASTNode, graph: DependencyGraph) -> None:
204
+ """Store a DB schema object (table, view, procedure, package, etc.) as a graph node."""
205
+ repo = node.metadata.get("repository", getattr(self, "_current_repository", "unknown"))
206
+ dialect = node.metadata.get("dialect", "sql")
207
+ schema = node.metadata.get("schema", "")
208
+ columns = node.metadata.get("columns", [])
209
+
210
+ db_node = DependencyNode(
211
+ id=node.qualified_name,
212
+ node_type=node.node_type,
213
+ properties={
214
+ "qualified_name": node.qualified_name,
215
+ "name": node.name,
216
+ "schema": schema,
217
+ "dialect": dialect,
218
+ "repository": repo,
219
+ "file_path": node.metadata.get("file_path", ""),
220
+ "columns": ",".join(columns[:50]) if columns else "",
221
+ "type": node.node_type.lower(),
222
+ }
223
+ )
224
+ graph.add_node(db_node)
225
+
226
+ # Link to the repo node
227
+ graph.add_edge(DependencyEdge(
228
+ source=repo,
229
+ target=node.qualified_name,
230
+ edge_type="CONTAINS_DB_OBJECT",
231
+ properties={"object_type": node.node_type, "repository": repo},
232
+ ))
233
+
234
+ def _handle_db_ref_node(self, node: ASTNode, graph: DependencyGraph) -> None:
235
+ """
236
+ Store a cross-object SQL reference (READS_TABLE, WRITES_TABLE, CALLS_PROCEDURE).
237
+
238
+ Source: the DB object that owns this file (procedure/package/trigger).
239
+ We look it up in the graph by file_path; fall back to file stem.
240
+ Target: the referenced table/procedure — always create a stub node so the
241
+ edge endpoint always exists in Neo4j (even for cross-schema refs).
242
+ """
243
+ from pathlib import Path as _Path
244
+ repo = node.metadata.get("repository", getattr(self, "_current_repository", "unknown"))
245
+ ref_type = node.metadata.get("ref_type", "READS_TABLE")
246
+ target = node.metadata.get("target", node.qualified_name)
247
+ file_path = node.metadata.get("file_path", "")
248
+
249
+ # Find the source node: look for a DB object in the same file
250
+ file_path_str = file_path
251
+ src_node = None
252
+ for n in graph.nodes:
253
+ if (n.node_type in self._DB_OBJECT_TYPES and
254
+ n.properties.get("file_path") == file_path_str):
255
+ src_node = n
256
+ break
257
+
258
+ if src_node:
259
+ src_id = src_node.id
260
+ else:
261
+ # Fallback: use file stem as synthetic source node
262
+ src_id = _Path(file_path_str).stem.upper() if file_path_str else repo
263
+ # Create a stub CLASS node so the edge can be stored
264
+ if not any(n.id == src_id for n in graph.nodes):
265
+ graph.add_node(DependencyNode(
266
+ id=src_id,
267
+ node_type="DB_SCRIPT",
268
+ properties={
269
+ "qualified_name": src_id,
270
+ "name": src_id,
271
+ "repository": repo,
272
+ "file_path": file_path_str,
273
+ "type": "db_script",
274
+ }
275
+ ))
276
+
277
+ if not target:
278
+ return
279
+
280
+ # Always create a stub node for the target if it doesn't exist yet.
281
+ # This ensures the Neo4j MATCH always succeeds for both endpoints.
282
+ if not any(n.id == target for n in graph.nodes):
283
+ # Infer type from ref_type
284
+ stub_type = "DB_PROCEDURE" if ref_type == "CALLS_PROCEDURE" else "DB_TABLE"
285
+ # Derive name and schema from qualified target
286
+ parts = target.split(".")
287
+ tgt_name = parts[-1]
288
+ tgt_schema = parts[0] if len(parts) > 1 else ""
289
+ graph.add_node(DependencyNode(
290
+ id=target,
291
+ node_type=stub_type,
292
+ properties={
293
+ "qualified_name": target,
294
+ "name": tgt_name,
295
+ "schema": tgt_schema,
296
+ "repository": repo, # same repo — may be overwritten if owned elsewhere
297
+ "type": stub_type.lower(),
298
+ "is_stub": "true", # marks as stub (inferred, not from DDL)
299
+ }
300
+ ))
301
+
302
+ graph.add_edge(DependencyEdge(
303
+ source=src_id,
304
+ target=target,
305
+ edge_type=ref_type,
306
+ properties={
307
+ "repository": repo,
308
+ "file_path": file_path_str,
309
+ "line": node.line_start,
310
+ },
311
+ ))
312
+
313
+ def _handle_api_call_node(self, node: ASTNode, graph: DependencyGraph) -> None:
314
+ """Handle HTTP wrapper API call node — creates API + CALLS_API edge."""
315
+ api_path: str = node.metadata.get("api_path", "")
316
+ callee: str = node.metadata.get("callee", "")
317
+ repository: str = node.metadata.get("repository", "unknown")
318
+ file_path: str = node.metadata.get("file_path", "")
319
+
320
+ if not api_path:
321
+ return
322
+
323
+ # Normalise path — strip leading slash for consistency
324
+ endpoint = api_path.strip()
325
+
326
+ api_node = DependencyNode(
327
+ id=endpoint,
328
+ node_type="API",
329
+ properties={
330
+ "url": endpoint,
331
+ "qualified_name": endpoint,
332
+ "type": DependencyType.API.value,
333
+ "http_client": callee,
334
+ }
335
+ )
336
+ graph.add_node(api_node)
337
+
338
+ # Source CLASS node (the file that makes the call)
339
+ source_id = node.qualified_name
340
+ source_node = DependencyNode(
341
+ id=source_id,
342
+ node_type="CLASS",
343
+ properties={
344
+ "qualified_name": source_id,
345
+ "type": DependencyType.INTERNAL_CLASS.value,
346
+ "repository": repository,
347
+ "file_path": file_path,
348
+ }
349
+ )
350
+ graph.add_node(source_node)
351
+
352
+ edge = DependencyEdge(
353
+ source=source_id,
354
+ target=endpoint,
355
+ edge_type="CALLS_API",
356
+ properties={
357
+ "line": node.line_start,
358
+ "repository": repository,
359
+ "file_path": file_path,
360
+ "http_client": callee,
361
+ }
362
+ )
363
+ graph.add_edge(edge)
364
+
365
+ # Regex to normalise path params: /{id} and /:id → /{param}
366
+ _PARAM_RE = re.compile(r'/(?:\{[^}]+\}|:[^/]+)')
367
+
368
+ @staticmethod
369
+ def _normalise_path(path: str) -> str:
370
+ """Lowercase, strip leading slash, collapse path params to {p}."""
371
+ p = path.strip().lstrip("/").lower()
372
+ p = re.sub(r'(?:\{[^}]+\}|:[^/]+)', '{p}', p)
373
+ return p
374
+
375
+ def _handle_backend_endpoint_node(self, node: ASTNode, graph: DependencyGraph) -> None:
376
+ """Store a Spring REST endpoint as an ENDPOINT node."""
377
+ path: str = node.metadata.get("path", "")
378
+ http_method: str = node.metadata.get("http_method", "ANY")
379
+ repository: str = node.metadata.get("repository", "unknown")
380
+ file_path: str = node.metadata.get("file_path", "")
381
+ class_name: str = node.metadata.get("class_name", "")
382
+ method_name: str = node.metadata.get("method_name", "")
383
+
384
+ if not path:
385
+ return
386
+
387
+ endpoint_id = f"{repository}:{http_method}:{path}"
388
+ ep_node = DependencyNode(
389
+ id=endpoint_id,
390
+ node_type="ENDPOINT",
391
+ properties={
392
+ "url": path,
393
+ "qualified_name": endpoint_id,
394
+ "http_method": http_method,
395
+ "type": "endpoint",
396
+ "repository": repository,
397
+ "file_path": file_path,
398
+ "class_name": class_name,
399
+ "method_name": method_name,
400
+ "path_normalised": self._normalise_path(path),
401
+ }
402
+ )
403
+ graph.add_node(ep_node)
404
+
405
+ # Link ENDPOINT to the class that declares it
406
+ if class_name:
407
+ class_node = DependencyNode(
408
+ id=class_name,
409
+ node_type="CLASS",
410
+ properties={
411
+ "qualified_name": class_name,
412
+ "type": DependencyType.INTERNAL_CLASS.value,
413
+ "repository": repository,
414
+ "file_path": file_path,
415
+ }
416
+ )
417
+ graph.add_node(class_node)
418
+ graph.add_edge(DependencyEdge(
419
+ source=class_name,
420
+ target=endpoint_id,
421
+ edge_type="EXPOSES",
422
+ properties={"repository": repository}
423
+ ))
424
+
425
+ def link_cross_language(self, graph: DependencyGraph) -> int:
426
+ """
427
+ Generic cross-language linker: match frontend API nodes to backend ENDPOINT nodes.
428
+
429
+ Matching strategy (framework-agnostic):
430
+ - Normalise both paths: lowercase, strip leading '/', collapse {id}/:id → {p}
431
+ - Consider a match when the normalised frontend path is a suffix of (or equal to)
432
+ the normalised backend path — handles base-URL prefix differences.
433
+
434
+ Returns number of CALLS_BACKEND edges created.
435
+ """
436
+ api_nodes = [n for n in graph.nodes if n.node_type == "API"]
437
+ endpoint_nodes = [n for n in graph.nodes if n.node_type == "ENDPOINT"]
438
+
439
+ if not api_nodes or not endpoint_nodes:
440
+ return 0
441
+
442
+ # Build index: normalised_path -> endpoint_id
443
+ ep_index: dict[str, str] = {}
444
+ for ep in endpoint_nodes:
445
+ norm = ep.properties.get("path_normalised") or self._normalise_path(
446
+ ep.properties.get("url", "")
447
+ )
448
+ ep_index[norm] = ep.id
449
+
450
+ # Build edge map: api_node -> [caller_ids]
451
+ caller_map: dict[str, list[str]] = {}
452
+ for edge in graph.edges:
453
+ if edge.edge_type == "CALLS_API":
454
+ caller_map.setdefault(edge.target, []).append(edge.source)
455
+
456
+ created = 0
457
+ for api in api_nodes:
458
+ api_norm = self._normalise_path(api.properties.get("url", ""))
459
+ if not api_norm:
460
+ continue
461
+
462
+ # Find matching endpoints (suffix match)
463
+ for ep_norm, ep_id in ep_index.items():
464
+ if api_norm == ep_norm or ep_norm.endswith("/" + api_norm):
465
+ # Create CALLS_BACKEND for each caller of this API node
466
+ for caller_id in caller_map.get(api.id, [api.id]):
467
+ edge = DependencyEdge(
468
+ source=caller_id,
469
+ target=ep_id,
470
+ edge_type="CALLS_BACKEND",
471
+ properties={
472
+ "frontend_path": api.properties.get("url", ""),
473
+ "backend_path": ep_id.split(":", 2)[-1] if ":" in ep_id else ep_id,
474
+ "match": "path_suffix",
475
+ }
476
+ )
477
+ graph.add_edge(edge)
478
+ created += 1
479
+
480
+ return created
481
+
482
+ def _handle_method_call_node(self, node: ASTNode, graph: DependencyGraph) -> None:
483
+ """Handle method call node."""
484
+ caller = node.metadata.get("caller")
485
+ callee = node.metadata.get("callee")
486
+ repository = node.metadata.get("repository", "unknown")
487
+
488
+ if caller and callee:
489
+ # Create caller node (method doing the call)
490
+ caller_node = DependencyNode(
491
+ id=caller,
492
+ node_type="METHOD",
493
+ properties={
494
+ "qualified_name": caller,
495
+ "type": DependencyType.INTERNAL_METHOD.value,
496
+ "repository": repository
497
+ }
498
+ )
499
+ graph.add_node(caller_node)
500
+
501
+ # Create callee node (method being called)
502
+ callee_node = DependencyNode(
503
+ id=callee,
504
+ node_type="METHOD",
505
+ properties={
506
+ "qualified_name": callee,
507
+ "type": DependencyType.INTERNAL_METHOD.value,
508
+ "repository": repository
509
+ }
510
+ )
511
+ graph.add_node(callee_node)
512
+
513
+ # Create edge for call
514
+ edge = DependencyEdge(
515
+ source=caller,
516
+ target=callee,
517
+ edge_type="CALLS",
518
+ properties={"line": node.line_start, "repository": repository}
519
+ )
520
+ graph.add_edge(edge)
521
+
522
+ def _extract_from_build_files(self, build_files: dict[str, str], graph: DependencyGraph) -> None:
523
+ """Extract library dependencies from build files using specialized parsers."""
524
+ for filename, content in build_files.items():
525
+ if filename.endswith("pom.xml"):
526
+ self._extract_maven_dependencies(content, graph)
527
+ elif filename.endswith((".gradle", ".gradle.kts")):
528
+ self._extract_gradle_dependencies(content, graph)
529
+
530
+ def _extract_maven_dependencies(self, content: str, graph: DependencyGraph) -> None:
531
+ """
532
+ Extract dependencies from Maven pom.xml using MavenDependencyParser.
533
+
534
+ Falls back to simple regex if parser not available.
535
+ """
536
+ if self.maven_parser:
537
+ # Use specialized parser (preferred)
538
+ dependencies = self.maven_parser.parse(content)
539
+
540
+ for dep in dependencies:
541
+ node = DependencyNode(
542
+ id=f"{dep['group_id']}:{dep['artifact_id']}",
543
+ node_type="LIBRARY",
544
+ properties={
545
+ "group_id": dep["group_id"],
546
+ "artifact_id": dep["artifact_id"],
547
+ "version": dep["version"],
548
+ "scope": dep["scope"],
549
+ "type": DependencyType.LIBRARY.value
550
+ }
551
+ )
552
+ graph.add_node(node)
553
+ else:
554
+ # Fallback to regex (legacy compatibility)
555
+ self._extract_maven_dependencies_regex(content, graph)
556
+
557
+ def _extract_maven_dependencies_regex(self, content: str, graph: DependencyGraph) -> None:
558
+ """Fallback regex-based Maven extraction (legacy)."""
559
+ pattern = re.compile(
560
+ r'<groupId>([^<]+)</groupId>.*?<artifactId>([^<]+)</artifactId>',
561
+ re.DOTALL
562
+ )
563
+ matches = pattern.findall(content)
564
+
565
+ for group_id, artifact_id in matches:
566
+ # Skip test dependencies
567
+ if "test" in content[max(0, content.find(group_id)-100):content.find(group_id)+100].lower():
568
+ continue
569
+
570
+ node = DependencyNode(
571
+ id=f"{group_id}:{artifact_id}",
572
+ node_type="LIBRARY",
573
+ properties={
574
+ "group_id": group_id.strip(),
575
+ "artifact_id": artifact_id.strip(),
576
+ "type": DependencyType.LIBRARY.value
577
+ }
578
+ )
579
+ graph.add_node(node)
580
+
581
+ def _extract_gradle_dependencies(self, content: str, graph: DependencyGraph) -> None:
582
+ """
583
+ Extract dependencies from Gradle build files using GradleDependencyParser.
584
+
585
+ Falls back to simple regex if parser not available.
586
+ """
587
+ if self.gradle_parser:
588
+ # Use specialized parser (preferred)
589
+ dependencies = self.gradle_parser.parse(content)
590
+
591
+ for dep in dependencies:
592
+ node = DependencyNode(
593
+ id=f"{dep['group_id']}:{dep['artifact_id']}",
594
+ node_type="LIBRARY",
595
+ properties={
596
+ "group_id": dep["group_id"],
597
+ "artifact_id": dep["artifact_id"],
598
+ "version": dep["version"],
599
+ "scope": dep["scope"],
600
+ "type": DependencyType.LIBRARY.value
601
+ }
602
+ )
603
+ graph.add_node(node)
604
+ else:
605
+ # Fallback to regex (legacy compatibility)
606
+ self._extract_gradle_dependencies_regex(content, graph)
607
+
608
+ def _extract_gradle_dependencies_regex(self, content: str, graph: DependencyGraph) -> None:
609
+ """Fallback regex-based Gradle extraction (legacy)."""
610
+ pattern = re.compile(
611
+ r"(?:implementation|api|compileOnly|runtimeOnly)\s+['\"]([^:'\"]+):([^:'\"]+)(?::([^'\"]+))?['\"]"
612
+ )
613
+ matches = pattern.findall(content)
614
+
615
+ for group_id, artifact_id, version in matches:
616
+ node = DependencyNode(
617
+ id=f"{group_id}:{artifact_id}",
618
+ node_type="LIBRARY",
619
+ properties={
620
+ "group_id": group_id,
621
+ "artifact_id": artifact_id,
622
+ "version": version if version else "unspecified",
623
+ "type": DependencyType.LIBRARY.value
624
+ }
625
+ )
626
+ graph.add_node(node)
627
+
628
+ def _extract_from_config_files(self, config_files: dict[str, str], graph: DependencyGraph) -> None:
629
+ """
630
+ Extract API and DB dependencies from config files using ConfigFileParser.
631
+
632
+ Falls back to regex if parser not available.
633
+ """
634
+ if self.config_parser:
635
+ # Use specialized parser (preferred)
636
+ for filename, content in config_files.items():
637
+ fname_lower = filename.lower()
638
+ if fname_lower.endswith(".properties"):
639
+ result = self.config_parser.parse_properties(content)
640
+ elif fname_lower.endswith((".yml", ".yaml")):
641
+ result = self.config_parser.parse_yaml(content)
642
+ elif fname_lower.endswith(".env") or "/.env" in fname_lower or fname_lower.startswith(".env"):
643
+ result = self.config_parser.parse_env(content)
644
+ else:
645
+ continue
646
+
647
+ # Add databases
648
+ for db in result.databases:
649
+ self._add_database_from_parsed(db, graph)
650
+
651
+ # Add APIs
652
+ for api in result.apis:
653
+ self._add_api_node(api["url"], graph)
654
+
655
+ # Add services
656
+ for service in result.services:
657
+ self._add_service_node(service["name"], graph)
658
+ else:
659
+ # Fallback to regex (legacy compatibility)
660
+ self._extract_from_config_files_regex(config_files, graph)
661
+
662
+ def _extract_from_config_files_regex(self, config_files: dict[str, str], graph: DependencyGraph) -> None:
663
+ """Fallback regex-based config extraction (legacy)."""
664
+ for filename, content in config_files.items():
665
+ # Extract HTTP/HTTPS URLs
666
+ url_matches = self.URL_PATTERN.findall(content)
667
+ for url in url_matches:
668
+ self._add_api_node(url, graph)
669
+
670
+ # Extract JDBC connections
671
+ jdbc_matches = self.JDBC_PATTERN.findall(content)
672
+ repo = getattr(self, "_current_repository", "unknown")
673
+ for db_type, host, port, database in jdbc_matches:
674
+ self._add_database_node(db_type, host, port or "default", database, graph, repo)
675
+
676
+ def _extract_from_string_literals(
677
+ self,
678
+ string_literals: list[str],
679
+ graph: DependencyGraph,
680
+ source_node: Optional[ASTNode] = None
681
+ ) -> None:
682
+ """
683
+ Extract URLs and JDBC strings from string literals (Task 4.2).
684
+
685
+ Args:
686
+ string_literals: List of string literals from code
687
+ graph: Dependency graph to add nodes/edges to
688
+ source_node: Optional source ASTNode (component/method calling the API)
689
+ """
690
+ for literal in string_literals:
691
+ # Check for HTTP/HTTPS URLs
692
+ url_matches = self.URL_PATTERN.findall(literal)
693
+ for url in url_matches:
694
+ self._add_api_node(url, graph, source_node)
695
+
696
+ # Check for JDBC strings
697
+ jdbc_matches = self.JDBC_PATTERN.findall(literal)
698
+ repo = getattr(self, "_current_repository", "unknown")
699
+ for db_type, host, port, database in jdbc_matches:
700
+ self._add_database_node(db_type, host, port or "default", database, graph, repo)
701
+
702
+ def _add_api_node(
703
+ self,
704
+ url: str,
705
+ graph: DependencyGraph,
706
+ source_node: Optional[ASTNode] = None
707
+ ) -> None:
708
+ """
709
+ Add API node to graph and create CALLS_API edge if source provided (Task 4.2).
710
+
711
+ Args:
712
+ url: API URL
713
+ graph: Dependency graph
714
+ source_node: Optional component/method calling this API
715
+ """
716
+ # Extract base URL (without query params)
717
+ base_url = url.split('?')[0]
718
+
719
+ # Create API node
720
+ repo = getattr(self, "_current_repository", "unknown")
721
+ api_node = DependencyNode(
722
+ id=base_url,
723
+ node_type="API",
724
+ properties={
725
+ "url": base_url,
726
+ "qualified_name": base_url,
727
+ "type": DependencyType.API.value,
728
+ "repository": repo,
729
+ }
730
+ )
731
+ graph.add_node(api_node)
732
+
733
+ # Create CALLS_API edge if source is provided
734
+ if source_node:
735
+ repository = source_node.metadata.get("repository", "unknown")
736
+ edge = DependencyEdge(
737
+ source=source_node.qualified_name,
738
+ target=base_url,
739
+ edge_type="CALLS_API",
740
+ properties={
741
+ "line": source_node.line_start,
742
+ "repository": repository,
743
+ "file_path": source_node.metadata.get("file_path", "")
744
+ }
745
+ )
746
+ graph.add_edge(edge)
747
+
748
+ def _add_database_from_parsed(self, db_info: dict, graph: DependencyGraph) -> None:
749
+ """Add database node from parsed config info."""
750
+ url = db_info["url"]
751
+ repo = getattr(self, "_current_repository", "unknown")
752
+
753
+ # Extract components from JDBC URL
754
+ jdbc_match = self.JDBC_PATTERN.match(url)
755
+ if jdbc_match:
756
+ db_type, host, port, database = jdbc_match.groups()
757
+ self._add_database_node(db_type, host, port or "default", database, graph, repo)
758
+ else:
759
+ node = DependencyNode(
760
+ id=url,
761
+ node_type="DATABASE",
762
+ properties={
763
+ "type": DependencyType.DATABASE.value,
764
+ "url": url,
765
+ "repository": repo,
766
+ }
767
+ )
768
+ graph.add_node(node)
769
+
770
+ def _add_database_node(
771
+ self, db_type: str, host: str, port: str, database: str,
772
+ graph: DependencyGraph, repository: str = "unknown"
773
+ ) -> None:
774
+ """Add DATABASE node to graph."""
775
+ node_id = f"jdbc:{db_type}://{host}:{port}/{database}"
776
+
777
+ node = DependencyNode(
778
+ id=node_id,
779
+ node_type="DATABASE",
780
+ properties={
781
+ "type": DependencyType.DATABASE.value,
782
+ "db_type": db_type,
783
+ "host": host,
784
+ "port": port,
785
+ "database": database,
786
+ "repository": repository,
787
+ }
788
+ )
789
+ graph.add_node(node)
790
+
791
+ def _add_service_node(self, service_name: str, graph: DependencyGraph) -> None:
792
+ """Add MICROSERVICE node to graph."""
793
+ node = DependencyNode(
794
+ id=service_name,
795
+ node_type="MICROSERVICE",
796
+ properties={
797
+ "name": service_name,
798
+ "type": DependencyType.MICROSERVICE.value
799
+ }
800
+ )
801
+ graph.add_node(node)
802
+
803
+ def extract_frontend_dependencies(
804
+ self,
805
+ typescript_nodes: list,
806
+ npm_files: dict[str, str] = None
807
+ ) -> DependencyGraph:
808
+ """
809
+ Extract frontend dependencies from TypeScript/JavaScript code (Task 4.1).
810
+
811
+ Args:
812
+ typescript_nodes: Parsed TypeScript/JavaScript AST result dicts
813
+ npm_files: NPM package files (package.json, package-lock.json)
814
+
815
+ Returns:
816
+ DependencyGraph with frontend dependencies
817
+ """
818
+ graph = DependencyGraph()
819
+
820
+ # Extract from TypeScript imports
821
+ if self.typescript_parser and typescript_nodes:
822
+ self._extract_from_typescript_nodes(typescript_nodes, graph)
823
+
824
+ # Extract from NPM files
825
+ if self.npm_parser and npm_files:
826
+ self._extract_from_npm_files(npm_files, graph)
827
+
828
+ return graph
829
+
830
+ def _extract_from_typescript_nodes(self, nodes: list, graph: DependencyGraph) -> None:
831
+ """Extract dependencies from TypeScript parse results."""
832
+ for parse_result in nodes:
833
+ file_path = str(parse_result.file_path) if hasattr(parse_result, 'file_path') else "unknown"
834
+
835
+ # Extract imports
836
+ for imp in (parse_result.imports if hasattr(parse_result, 'imports') else []):
837
+ module_path = imp.get("module_path", "")
838
+ if module_path:
839
+ dep_node = DependencyNode(
840
+ id=module_path,
841
+ node_type="MODULE",
842
+ properties={
843
+ "module_path": module_path,
844
+ "import_type": imp.get("import_type", ""),
845
+ "type": "MODULE"
846
+ }
847
+ )
848
+ graph.add_node(dep_node)
849
+
850
+ def _extract_from_npm_files(self, npm_files: dict[str, str], graph: DependencyGraph) -> None:
851
+ """Extract NPM package dependencies."""
852
+ for file_name, content in npm_files.items():
853
+ if "package.json" in file_name:
854
+ try:
855
+ packages = self.npm_parser.parse_package_json(content)
856
+ for pkg in packages:
857
+ node = DependencyNode(
858
+ id=pkg["name"],
859
+ node_type="NPM_PACKAGE",
860
+ properties={
861
+ "name": pkg["name"],
862
+ "version": pkg["version"],
863
+ "scope": pkg["scope"],
864
+ "type": DependencyType.LIBRARY.value
865
+ }
866
+ )
867
+ graph.add_node(node)
868
+ except Exception:
869
+ pass # Skip malformed files
870
+
871
+ def _is_external_import(self, import_path: str) -> bool:
872
+ """
873
+ Determine if an import is external (library) or internal (project code).
874
+
875
+ Rules (language-agnostic):
876
+ - Relative paths (start with . or /) → always internal
877
+ - npm scoped packages (@scope/pkg) → always external
878
+ - npm bare packages with no dots/slashes that aren't relative → external
879
+ - Java well-known prefixes → external
880
+ """
881
+ if not import_path:
882
+ return False
883
+
884
+ # Relative imports are always internal
885
+ if import_path.startswith(".") or import_path.startswith("/"):
886
+ return False
887
+
888
+ # npm scoped packages: @scope/package
889
+ if import_path.startswith("@"):
890
+ return True
891
+
892
+ # Java external prefixes
893
+ java_external = [
894
+ "java.", "javax.", "jakarta.",
895
+ "org.springframework.", "org.apache.", "org.hibernate.",
896
+ "com.google.", "com.fasterxml.", "com.amazonaws.",
897
+ "io.netty.", "io.grpc.", "io.micrometer.",
898
+ "lombok.", "slf4j.", "logback.",
899
+ ]
900
+ if any(import_path.startswith(p) for p in java_external):
901
+ return True
902
+
903
+ # Python well-known external packages
904
+ python_external_prefixes = [
905
+ # Azure SDK
906
+ "azure.", "msrest.", "msal.",
907
+ # AWS
908
+ "boto3.", "botocore.", "aws_cdk.",
909
+ # Google
910
+ "google.", "googleapiclient.",
911
+ # Web frameworks
912
+ "fastapi.", "flask.", "django.", "starlette.", "uvicorn.",
913
+ # HTTP clients
914
+ "requests.", "httpx.", "aiohttp.", "urllib3.",
915
+ # Data / ML
916
+ "pandas.", "numpy.", "sklearn.", "tensorflow.", "torch.",
917
+ "pydantic.", "sqlalchemy.", "alembic.",
918
+ # Testing
919
+ "pytest.", "unittest.", "mock.",
920
+ # Infra
921
+ "docker.", "kubernetes.", "ansible.",
922
+ # Other common libs
923
+ "yaml.", "dotenv.", "celery.", "redis.", "pymongo.",
924
+ "psycopg2.", "asyncpg.", "motor.",
925
+ ]
926
+ if any(import_path.startswith(p) for p in python_external_prefixes):
927
+ return True
928
+
929
+ # Python stdlib single-word modules → external
930
+ python_stdlib = {
931
+ "os", "sys", "re", "json", "pathlib", "typing", "datetime",
932
+ "collections", "itertools", "functools", "abc", "io", "math",
933
+ "random", "hashlib", "base64", "subprocess", "threading",
934
+ "asyncio", "logging", "argparse", "shutil", "tempfile",
935
+ "urllib", "http", "email", "csv", "xml", "html",
936
+ "time", "copy", "enum", "dataclasses", "contextlib",
937
+ "inspect", "traceback", "warnings", "platform", "socket",
938
+ "struct", "codecs", "unicodedata", "string", "textwrap",
939
+ }
940
+ first_segment = import_path.split(".")[0].split("/")[0]
941
+ if first_segment in python_stdlib:
942
+ return True
943
+
944
+ # npm/JS bare package names (no dots, no slashes) → external
945
+ # BUT only if it's clearly a JS package (lowercase, hyphens allowed)
946
+ # Skip Python module names that happen to have no dots (they're internal)
947
+ if "/" not in import_path and "." not in import_path:
948
+ # Heuristic: JS packages are typically lowercase with hyphens
949
+ # and are imported in JS/TS files. Python internal modules
950
+ # use underscores. We conservatively mark as external only
951
+ # if it doesn't look like a Python internal name.
952
+ # Since we can't know the language here, mark as external
953
+ # only for clearly external names (well-known JS libs or
954
+ # npm-style names with hyphens).
955
+ if "-" in import_path:
956
+ return True # npm-style: react-dom, @scope/pkg
957
+ # Single word without hyphen — could be Python internal or npm
958
+ # For safety: keep existing behavior (mark as external) since
959
+ # it was working correctly for Java/TS before this change.
960
+ return True
961
+
962
+ return False