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