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,1312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Repository Relationship Discovery — 4-pass cross-repo dependency analysis.
|
|
3
|
+
|
|
4
|
+
Pass 1: Explicit relationships (HIGH confidence) — CALLS_BACKEND, USES_LIBRARY, DEPENDS_ON
|
|
5
|
+
Pass 2: Inferred by path matching (MEDIUM confidence) — unmatched API ↔ ENDPOINT paths
|
|
6
|
+
Pass 3: Inferred by semantic name similarity (LOW/MEDIUM) — UserService ↔ UserController
|
|
7
|
+
Pass 4: Shared database/event inference (MEDIUM) — same table name or event name across repos
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Semantic synonym groups: names in the same group are treated as equivalent
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
_SYNONYM_GROUPS: list[set[str]] = [
|
|
23
|
+
{"user", "account", "member", "profile", "customer", "client"},
|
|
24
|
+
{"order", "purchase", "transaction", "checkout", "cart"},
|
|
25
|
+
{"product", "item", "sku", "good", "catalog"},
|
|
26
|
+
{"shipment", "delivery", "shipping", "dispatch", "fulfillment"},
|
|
27
|
+
{"inventory", "stock", "warehouse", "storage"},
|
|
28
|
+
{"payment", "billing", "invoice", "charge", "fee"},
|
|
29
|
+
{"notification", "alert", "message", "email", "sms"},
|
|
30
|
+
{"report", "analytics", "metric", "stat", "dashboard"},
|
|
31
|
+
{"auth", "authentication", "authorization", "permission", "role", "access"},
|
|
32
|
+
{"event", "topic", "message", "queue", "stream"},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
# Build reverse map: word -> canonical synonym set index
|
|
36
|
+
_WORD_TO_GROUP: dict[str, int] = {}
|
|
37
|
+
for _i, _group in enumerate(_SYNONYM_GROUPS):
|
|
38
|
+
for _w in _group:
|
|
39
|
+
_WORD_TO_GROUP[_w] = _i
|
|
40
|
+
|
|
41
|
+
# Common class-name suffixes that suggest same service
|
|
42
|
+
_SERVICE_SUFFIXES = [
|
|
43
|
+
"Service", "Controller", "ApplicationService", "Facade",
|
|
44
|
+
"Api", "Client", "Repository", "Gateway", "Adapter",
|
|
45
|
+
"Handler", "Listener", "Consumer", "Producer",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Data classes
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class RepositoryRelationship:
|
|
55
|
+
from_repo: str
|
|
56
|
+
to_repo: str
|
|
57
|
+
relation_type: str # CALLS_BACKEND | USES_LIBRARY | POSSIBLE_MATCH | SHARED_DB | SHARED_EVENT
|
|
58
|
+
confidence: str # HIGH | MEDIUM | LOW
|
|
59
|
+
evidence: list[str]
|
|
60
|
+
details: list[dict[str, Any]] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class RepositoryRelationshipGraph:
|
|
65
|
+
repositories: list[str]
|
|
66
|
+
relationships: list[dict[str, Any]]
|
|
67
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
68
|
+
external_services: list[dict[str, Any]] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Repository
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
class RepositoryRelationshipRepository:
|
|
76
|
+
"""Discovers relationships between indexed repositories using 4-pass strategy."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, driver) -> None:
|
|
79
|
+
self.driver = driver
|
|
80
|
+
|
|
81
|
+
async def discover_all(self) -> RepositoryRelationshipGraph:
|
|
82
|
+
"""Run all 5 passes and return the merged relationship graph."""
|
|
83
|
+
async with self.driver.session() as session:
|
|
84
|
+
repos = await self._fetch_repositories(session)
|
|
85
|
+
p1 = await self._pass1_explicit(session)
|
|
86
|
+
p2 = await self._pass2_path_matching(session, p1)
|
|
87
|
+
p3 = await self._pass3_semantic_names(session, repos)
|
|
88
|
+
p4 = await self._pass4_shared_artifacts(session)
|
|
89
|
+
p5, ext_services = await self._pass5_external_services(session)
|
|
90
|
+
|
|
91
|
+
relationships = p1 + p2 + p3 + p4 + p5
|
|
92
|
+
|
|
93
|
+
# Deduplicate: keep highest confidence for same (from, to, type) triple
|
|
94
|
+
deduped = _dedup_relationships(relationships)
|
|
95
|
+
|
|
96
|
+
meta = {
|
|
97
|
+
"total_repos": len(repos),
|
|
98
|
+
"explicit": len(p1),
|
|
99
|
+
"path_inferred": len(p2),
|
|
100
|
+
"semantic_inferred": len(p3),
|
|
101
|
+
"shared_artifact": len(p4),
|
|
102
|
+
"external_services": len(p5),
|
|
103
|
+
"total_after_dedup": len(deduped),
|
|
104
|
+
"ext_service_nodes": ext_services,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.info("repository_relationships_discovered", **meta)
|
|
108
|
+
|
|
109
|
+
return RepositoryRelationshipGraph(
|
|
110
|
+
repositories=repos,
|
|
111
|
+
relationships=[_rel_to_dict(r) for r in deduped],
|
|
112
|
+
meta=meta,
|
|
113
|
+
external_services=meta.pop("ext_service_nodes", []),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------------
|
|
117
|
+
# Helpers
|
|
118
|
+
# ------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async def _fetch_repositories(self, session) -> list[str]:
|
|
121
|
+
result = await session.run(
|
|
122
|
+
"MATCH (n) WHERE n.repository IS NOT NULL "
|
|
123
|
+
"AND n.repository <> 'external' AND n.repository <> 'unknown' "
|
|
124
|
+
"RETURN DISTINCT n.repository AS repo ORDER BY repo"
|
|
125
|
+
)
|
|
126
|
+
rows = await result.data()
|
|
127
|
+
return [r["repo"] for r in rows if r["repo"]]
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Pass 1 — Explicit relationships (HIGH)
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
async def _pass1_explicit(self, session) -> list[RepositoryRelationship]:
|
|
134
|
+
results: list[RepositoryRelationship] = []
|
|
135
|
+
results.extend(await self._explicit_calls_backend(session))
|
|
136
|
+
results.extend(await self._explicit_shared_library(session))
|
|
137
|
+
results.extend(await self._explicit_depends_on(session))
|
|
138
|
+
results.extend(await self._explicit_calls_api(session))
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
async def _explicit_calls_backend(self, session) -> list[RepositoryRelationship]:
|
|
142
|
+
query = """
|
|
143
|
+
MATCH (src)-[r:CALLS_BACKEND]->(ep)
|
|
144
|
+
WHERE src.repository IS NOT NULL AND ep.repository IS NOT NULL
|
|
145
|
+
AND src.repository <> ep.repository
|
|
146
|
+
RETURN DISTINCT
|
|
147
|
+
src.repository AS from_repo,
|
|
148
|
+
ep.repository AS to_repo,
|
|
149
|
+
r.frontend_path AS frontend_path,
|
|
150
|
+
ep.url AS backend_path,
|
|
151
|
+
ep.http_method AS http_method,
|
|
152
|
+
src.file_path AS src_file
|
|
153
|
+
LIMIT 500
|
|
154
|
+
"""
|
|
155
|
+
rows = (await (await session.run(query)).data())
|
|
156
|
+
grouped: dict[tuple, list[dict]] = {}
|
|
157
|
+
for r in rows:
|
|
158
|
+
key = (r["from_repo"], r["to_repo"])
|
|
159
|
+
grouped.setdefault(key, []).append(r)
|
|
160
|
+
|
|
161
|
+
out = []
|
|
162
|
+
for (from_repo, to_repo), items in grouped.items():
|
|
163
|
+
evidence = [
|
|
164
|
+
f"CALLS_BACKEND: {i.get('http_method','?')} {i.get('frontend_path','')} → {i.get('backend_path','')}"
|
|
165
|
+
for i in items[:5]
|
|
166
|
+
]
|
|
167
|
+
out.append(RepositoryRelationship(
|
|
168
|
+
from_repo=from_repo,
|
|
169
|
+
to_repo=to_repo,
|
|
170
|
+
relation_type="CALLS_BACKEND",
|
|
171
|
+
confidence="HIGH",
|
|
172
|
+
evidence=evidence,
|
|
173
|
+
details=items[:20],
|
|
174
|
+
))
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
async def _explicit_shared_library(self, session) -> list[RepositoryRelationship]:
|
|
178
|
+
"""Repos that share the same external library may be in the same domain."""
|
|
179
|
+
query = """
|
|
180
|
+
MATCH (a)-[:USES_LIBRARY]->(lib:LIBRARY)<-[:USES_LIBRARY]-(b)
|
|
181
|
+
WHERE a.repository IS NOT NULL AND b.repository IS NOT NULL
|
|
182
|
+
AND a.repository < b.repository
|
|
183
|
+
RETURN DISTINCT
|
|
184
|
+
a.repository AS from_repo,
|
|
185
|
+
b.repository AS to_repo,
|
|
186
|
+
lib.artifact_id AS library,
|
|
187
|
+
lib.group_id AS group_id
|
|
188
|
+
LIMIT 300
|
|
189
|
+
"""
|
|
190
|
+
rows = await (await session.run(query)).data()
|
|
191
|
+
grouped: dict[tuple, list[str]] = {}
|
|
192
|
+
for r in rows:
|
|
193
|
+
key = (r["from_repo"], r["to_repo"])
|
|
194
|
+
lib = f"{r.get('group_id','')}:{r.get('library','')}"
|
|
195
|
+
grouped.setdefault(key, []).append(lib)
|
|
196
|
+
|
|
197
|
+
return [
|
|
198
|
+
RepositoryRelationship(
|
|
199
|
+
from_repo=k[0],
|
|
200
|
+
to_repo=k[1],
|
|
201
|
+
relation_type="USES_LIBRARY",
|
|
202
|
+
confidence="HIGH",
|
|
203
|
+
evidence=[f"Shared library: {lib}" for lib in libs[:5]],
|
|
204
|
+
details=[{"library": lib} for lib in libs],
|
|
205
|
+
)
|
|
206
|
+
for k, libs in grouped.items()
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
async def _explicit_depends_on(self, session) -> list[RepositoryRelationship]:
|
|
210
|
+
query = """
|
|
211
|
+
MATCH (a:REPOSITORY)-[:DEPENDS_ON]->(b:REPOSITORY)
|
|
212
|
+
WHERE a.name IS NOT NULL AND b.name IS NOT NULL AND a.name <> b.name
|
|
213
|
+
RETURN DISTINCT a.name AS from_repo, b.name AS to_repo
|
|
214
|
+
LIMIT 200
|
|
215
|
+
"""
|
|
216
|
+
rows = await (await session.run(query)).data()
|
|
217
|
+
return [
|
|
218
|
+
RepositoryRelationship(
|
|
219
|
+
from_repo=r["from_repo"],
|
|
220
|
+
to_repo=r["to_repo"],
|
|
221
|
+
relation_type="DEPENDS_ON",
|
|
222
|
+
confidence="HIGH",
|
|
223
|
+
evidence=["Direct DEPENDS_ON edge in graph"],
|
|
224
|
+
)
|
|
225
|
+
for r in rows
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
async def _explicit_calls_api(self, session) -> list[RepositoryRelationship]:
|
|
229
|
+
"""Frontend CALLS_API where the API url is explicitly attributed to a backend repo."""
|
|
230
|
+
query = """
|
|
231
|
+
MATCH (src)-[:CALLS_API]->(api:API)
|
|
232
|
+
WHERE src.repository IS NOT NULL AND api.repository IS NOT NULL
|
|
233
|
+
AND src.repository <> api.repository
|
|
234
|
+
RETURN DISTINCT
|
|
235
|
+
src.repository AS from_repo,
|
|
236
|
+
api.repository AS to_repo,
|
|
237
|
+
api.url AS url
|
|
238
|
+
LIMIT 300
|
|
239
|
+
"""
|
|
240
|
+
rows = await (await session.run(query)).data()
|
|
241
|
+
grouped: dict[tuple, list[str]] = {}
|
|
242
|
+
for r in rows:
|
|
243
|
+
key = (r["from_repo"], r["to_repo"])
|
|
244
|
+
grouped.setdefault(key, []).append(r.get("url", ""))
|
|
245
|
+
|
|
246
|
+
return [
|
|
247
|
+
RepositoryRelationship(
|
|
248
|
+
from_repo=k[0],
|
|
249
|
+
to_repo=k[1],
|
|
250
|
+
relation_type="CALLS_API",
|
|
251
|
+
confidence="HIGH",
|
|
252
|
+
evidence=[f"CALLS_API: {u}" for u in urls[:5]],
|
|
253
|
+
details=[{"url": u} for u in urls],
|
|
254
|
+
)
|
|
255
|
+
for k, urls in grouped.items()
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
# Pass 2 — Path matching inference (MEDIUM)
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
async def _pass2_path_matching(
|
|
263
|
+
self,
|
|
264
|
+
session,
|
|
265
|
+
existing: list[RepositoryRelationship],
|
|
266
|
+
) -> list[RepositoryRelationship]:
|
|
267
|
+
"""
|
|
268
|
+
Match unlinked API nodes (frontend calls) to ENDPOINT nodes (backend) by
|
|
269
|
+
normalised path suffix. Skip pairs already covered by Pass 1.
|
|
270
|
+
"""
|
|
271
|
+
already_linked = {(r.from_repo, r.to_repo) for r in existing if r.relation_type == "CALLS_BACKEND"}
|
|
272
|
+
|
|
273
|
+
# Fetch unmatched API nodes (no CALLS_BACKEND yet)
|
|
274
|
+
api_result = await session.run(
|
|
275
|
+
"MATCH (n:API) WHERE n.repository IS NOT NULL "
|
|
276
|
+
"AND NOT (n)-[:CALLS_BACKEND]->() "
|
|
277
|
+
"RETURN n.url AS url, n.repository AS repo, n.id AS id"
|
|
278
|
+
)
|
|
279
|
+
api_rows = await api_result.data()
|
|
280
|
+
|
|
281
|
+
ep_result = await session.run(
|
|
282
|
+
"MATCH (n:ENDPOINT) WHERE n.repository IS NOT NULL "
|
|
283
|
+
"RETURN n.url AS url, n.repository AS repo, n.http_method AS method, n.id AS id"
|
|
284
|
+
)
|
|
285
|
+
ep_rows = await ep_result.data()
|
|
286
|
+
|
|
287
|
+
if not api_rows or not ep_rows:
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
# Build normalised index for endpoints
|
|
291
|
+
ep_index: list[tuple[str, dict]] = [
|
|
292
|
+
(_normalise_path(ep["url"] or ""), ep) for ep in ep_rows if ep.get("url")
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
out: list[RepositoryRelationship] = []
|
|
296
|
+
grouped: dict[tuple, list[dict]] = {}
|
|
297
|
+
|
|
298
|
+
for api in api_rows:
|
|
299
|
+
api_norm = _normalise_path(api["url"] or "")
|
|
300
|
+
if not api_norm:
|
|
301
|
+
continue
|
|
302
|
+
for ep_norm, ep in ep_index:
|
|
303
|
+
if not ep_norm:
|
|
304
|
+
continue
|
|
305
|
+
from_repo = api["repo"]
|
|
306
|
+
to_repo = ep["repo"]
|
|
307
|
+
if from_repo == to_repo:
|
|
308
|
+
continue
|
|
309
|
+
if (from_repo, to_repo) in already_linked:
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
if api_norm == ep_norm or ep_norm.endswith("/" + api_norm) or api_norm.endswith("/" + ep_norm):
|
|
313
|
+
key = (from_repo, to_repo)
|
|
314
|
+
grouped.setdefault(key, []).append({
|
|
315
|
+
"frontend_path": api["url"],
|
|
316
|
+
"backend_path": ep["url"],
|
|
317
|
+
"method": ep.get("method", "?"),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
for (from_repo, to_repo), matches in grouped.items():
|
|
321
|
+
evidence = [
|
|
322
|
+
f"Path match: {m['method']} {m['frontend_path']} ~ {m['backend_path']}"
|
|
323
|
+
for m in matches[:5]
|
|
324
|
+
]
|
|
325
|
+
out.append(RepositoryRelationship(
|
|
326
|
+
from_repo=from_repo,
|
|
327
|
+
to_repo=to_repo,
|
|
328
|
+
relation_type="POSSIBLE_MATCH",
|
|
329
|
+
confidence="MEDIUM",
|
|
330
|
+
evidence=evidence,
|
|
331
|
+
details=matches[:20],
|
|
332
|
+
))
|
|
333
|
+
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
# ------------------------------------------------------------------
|
|
337
|
+
# Pass 3 — Semantic name matching (LOW/MEDIUM)
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
async def _pass3_semantic_names(
|
|
341
|
+
self,
|
|
342
|
+
session,
|
|
343
|
+
repos: list[str],
|
|
344
|
+
) -> list[RepositoryRelationship]:
|
|
345
|
+
"""
|
|
346
|
+
Compare class/service names across repositories using synonym groups and
|
|
347
|
+
suffix stripping. Returns LOW confidence unless multiple signals agree.
|
|
348
|
+
"""
|
|
349
|
+
if len(repos) < 2:
|
|
350
|
+
return []
|
|
351
|
+
|
|
352
|
+
# Fetch all classes / components grouped by repo
|
|
353
|
+
result = await session.run(
|
|
354
|
+
"MATCH (n) WHERE n.repository IS NOT NULL AND n.name IS NOT NULL "
|
|
355
|
+
"AND (n:CLASS OR n:Component OR n:METHOD) "
|
|
356
|
+
"RETURN n.repository AS repo, n.name AS name, labels(n)[0] AS label "
|
|
357
|
+
"LIMIT 5000"
|
|
358
|
+
)
|
|
359
|
+
rows = await result.data()
|
|
360
|
+
|
|
361
|
+
# Build repo → set of normalised base names
|
|
362
|
+
repo_names: dict[str, set[str]] = {}
|
|
363
|
+
for r in rows:
|
|
364
|
+
base = _strip_suffix(r["name"] or "")
|
|
365
|
+
norm = _normalise_name(base)
|
|
366
|
+
if norm and len(norm) > 3:
|
|
367
|
+
repo_names.setdefault(r["repo"], set()).add(norm)
|
|
368
|
+
|
|
369
|
+
out: list[RepositoryRelationship] = []
|
|
370
|
+
repo_list = list(repo_names.keys())
|
|
371
|
+
|
|
372
|
+
for i, repo_a in enumerate(repo_list):
|
|
373
|
+
for repo_b in repo_list[i + 1:]:
|
|
374
|
+
shared = _find_semantic_matches(repo_names[repo_a], repo_names[repo_b])
|
|
375
|
+
if not shared:
|
|
376
|
+
continue
|
|
377
|
+
confidence = "MEDIUM" if len(shared) >= 3 else "LOW"
|
|
378
|
+
evidence = [f"Shared concept: '{s}'" for s in list(shared)[:5]]
|
|
379
|
+
out.append(RepositoryRelationship(
|
|
380
|
+
from_repo=repo_a,
|
|
381
|
+
to_repo=repo_b,
|
|
382
|
+
relation_type="POSSIBLE_MATCH",
|
|
383
|
+
confidence=confidence,
|
|
384
|
+
evidence=evidence,
|
|
385
|
+
details=[{"shared_concept": s} for s in shared],
|
|
386
|
+
))
|
|
387
|
+
|
|
388
|
+
return out
|
|
389
|
+
|
|
390
|
+
# ------------------------------------------------------------------
|
|
391
|
+
# Pass 4 — Shared database tables / events (MEDIUM)
|
|
392
|
+
# ------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
async def _pass4_shared_artifacts(self, session) -> list[RepositoryRelationship]:
|
|
395
|
+
"""
|
|
396
|
+
Detect repos that reference the same database table names or event/topic names.
|
|
397
|
+
Table names come from SQL string literals; events from class names ending in Event/Message.
|
|
398
|
+
"""
|
|
399
|
+
# Tables: string literals matching SQL patterns stored in metadata
|
|
400
|
+
table_result = await session.run(
|
|
401
|
+
"MATCH (n) WHERE n.repository IS NOT NULL AND n.db_table IS NOT NULL "
|
|
402
|
+
"RETURN n.repository AS repo, n.db_table AS table_name "
|
|
403
|
+
"UNION "
|
|
404
|
+
"MATCH (n:DATABASE) WHERE n.repository IS NOT NULL AND n.database IS NOT NULL "
|
|
405
|
+
"RETURN n.repository AS repo, n.database AS table_name "
|
|
406
|
+
"LIMIT 1000"
|
|
407
|
+
)
|
|
408
|
+
table_rows = await table_result.data()
|
|
409
|
+
|
|
410
|
+
# Events / topics: nodes or string literals that look like events
|
|
411
|
+
event_result = await session.run(
|
|
412
|
+
"MATCH (n) WHERE n.repository IS NOT NULL AND n.name IS NOT NULL "
|
|
413
|
+
"AND (n.name ENDS WITH 'Event' OR n.name ENDS WITH 'Message' "
|
|
414
|
+
" OR n.name ENDS WITH 'Topic' OR n.name ENDS WITH 'Command') "
|
|
415
|
+
"RETURN n.repository AS repo, n.name AS event_name "
|
|
416
|
+
"LIMIT 1000"
|
|
417
|
+
)
|
|
418
|
+
event_rows = await event_result.data()
|
|
419
|
+
|
|
420
|
+
out = []
|
|
421
|
+
out.extend(_group_shared_artifact(table_rows, "table_name", "SHARED_DB", "Shared DB table"))
|
|
422
|
+
out.extend(_group_shared_artifact(event_rows, "event_name", "SHARED_EVENT", "Shared event/topic"))
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
# Pass 5a — DB schema cross-repo linking (HIGH confidence)
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
async def _pass5a_db_schema_links(
|
|
431
|
+
self, session
|
|
432
|
+
) -> list[RepositoryRelationship]:
|
|
433
|
+
"""
|
|
434
|
+
Link application repos to database schema repos by matching table/collection names.
|
|
435
|
+
|
|
436
|
+
Strategy:
|
|
437
|
+
1. Find all DB_TABLE / DB_COLLECTION nodes and which repo defined them (schema repos)
|
|
438
|
+
2. Find all string literals and READS_TABLE/WRITES_TABLE refs from application repos
|
|
439
|
+
3. When an app repo references a table name owned by a schema repo → HIGH confidence link
|
|
440
|
+
4. Also match JPA @Table names and SQL query embedded in .properties files
|
|
441
|
+
"""
|
|
442
|
+
out: list[RepositoryRelationship] = []
|
|
443
|
+
|
|
444
|
+
# Step 1: all DB schema objects with their owning repo
|
|
445
|
+
tbl_result = await session.run(
|
|
446
|
+
"""
|
|
447
|
+
MATCH (n)
|
|
448
|
+
WHERE n.repository IS NOT NULL
|
|
449
|
+
AND n.repository <> 'unknown' AND n.repository <> 'external'
|
|
450
|
+
AND labels(n)[0] IN ['DB_TABLE','DB_VIEW','DB_COLLECTION','DB_PROCEDURE','DB_PACKAGE']
|
|
451
|
+
RETURN DISTINCT
|
|
452
|
+
n.repository AS schema_repo,
|
|
453
|
+
n.name AS obj_name,
|
|
454
|
+
coalesce(n.qualified_name, n.name) AS qname,
|
|
455
|
+
labels(n)[0] AS obj_type
|
|
456
|
+
LIMIT 5000
|
|
457
|
+
"""
|
|
458
|
+
)
|
|
459
|
+
tbl_rows = await tbl_result.data()
|
|
460
|
+
|
|
461
|
+
if not tbl_rows:
|
|
462
|
+
return []
|
|
463
|
+
|
|
464
|
+
# Build index: normalised_name -> {schema_repo, qname, obj_type}
|
|
465
|
+
schema_index: dict[str, dict] = {}
|
|
466
|
+
for row in tbl_rows:
|
|
467
|
+
norm = (row.get("obj_name") or "").upper().strip()
|
|
468
|
+
qnorm = (row.get("qname") or "").upper().strip()
|
|
469
|
+
entry = {
|
|
470
|
+
"schema_repo": row["schema_repo"],
|
|
471
|
+
"qname": row["qname"],
|
|
472
|
+
"obj_type": row["obj_type"],
|
|
473
|
+
}
|
|
474
|
+
for key in [norm, qnorm]:
|
|
475
|
+
if key and key not in schema_index:
|
|
476
|
+
schema_index[key] = entry
|
|
477
|
+
|
|
478
|
+
# Step 2: Find all READS_TABLE/WRITES_TABLE edges from app repos
|
|
479
|
+
ref_result = await session.run(
|
|
480
|
+
"""
|
|
481
|
+
MATCH (src)-[r:READS_TABLE|WRITES_TABLE|CALLS_PROCEDURE]->(tgt)
|
|
482
|
+
WHERE src.repository IS NOT NULL
|
|
483
|
+
AND src.repository <> 'unknown'
|
|
484
|
+
RETURN DISTINCT
|
|
485
|
+
src.repository AS app_repo,
|
|
486
|
+
tgt.id AS target_id,
|
|
487
|
+
type(r) AS ref_type
|
|
488
|
+
LIMIT 5000
|
|
489
|
+
"""
|
|
490
|
+
)
|
|
491
|
+
ref_rows = await ref_result.data()
|
|
492
|
+
|
|
493
|
+
# Also gather CLASS nodes that have table names via JPA
|
|
494
|
+
jpa_result = await session.run(
|
|
495
|
+
"""
|
|
496
|
+
MATCH (n:DB_TABLE)
|
|
497
|
+
WHERE n.repository IS NOT NULL AND n.repository <> 'unknown'
|
|
498
|
+
RETURN DISTINCT n.repository AS app_repo, n.name AS tbl_name
|
|
499
|
+
LIMIT 2000
|
|
500
|
+
"""
|
|
501
|
+
)
|
|
502
|
+
jpa_rows = await jpa_result.data()
|
|
503
|
+
|
|
504
|
+
# Step 3: match refs to schema objects
|
|
505
|
+
grouped: dict[tuple, list[str]] = {}
|
|
506
|
+
|
|
507
|
+
def add_match(app_repo: str, schema_repo: str, evidence: str):
|
|
508
|
+
if app_repo == schema_repo:
|
|
509
|
+
return
|
|
510
|
+
key = (app_repo, schema_repo)
|
|
511
|
+
grouped.setdefault(key, []).append(evidence)
|
|
512
|
+
|
|
513
|
+
for row in ref_rows:
|
|
514
|
+
target = (row.get("target_id") or "").upper().strip()
|
|
515
|
+
app_repo = row.get("app_repo", "")
|
|
516
|
+
if not target or not app_repo:
|
|
517
|
+
continue
|
|
518
|
+
entry = schema_index.get(target)
|
|
519
|
+
if not entry:
|
|
520
|
+
# try last part after dot
|
|
521
|
+
short = target.split(".")[-1]
|
|
522
|
+
entry = schema_index.get(short)
|
|
523
|
+
if entry:
|
|
524
|
+
add_match(
|
|
525
|
+
app_repo, entry["schema_repo"],
|
|
526
|
+
f"{row['ref_type']}: {target} ({entry['obj_type']})"
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# JPA @Table cross-repo
|
|
530
|
+
for row in jpa_rows:
|
|
531
|
+
tbl = (row.get("tbl_name") or "").upper().strip()
|
|
532
|
+
app_repo = row.get("app_repo", "")
|
|
533
|
+
entry = schema_index.get(tbl)
|
|
534
|
+
if entry and entry["schema_repo"] != app_repo:
|
|
535
|
+
add_match(app_repo, entry["schema_repo"],
|
|
536
|
+
f"JPA @Table: {tbl}")
|
|
537
|
+
|
|
538
|
+
for (app_repo, schema_repo), evidences in grouped.items():
|
|
539
|
+
conf = "HIGH" if len(evidences) >= 3 else "MEDIUM"
|
|
540
|
+
out.append(RepositoryRelationship(
|
|
541
|
+
from_repo=app_repo,
|
|
542
|
+
to_repo=schema_repo,
|
|
543
|
+
relation_type="USES_DB_SCHEMA",
|
|
544
|
+
confidence=conf,
|
|
545
|
+
evidence=list(dict.fromkeys(evidences))[:6],
|
|
546
|
+
details=[{"table": e} for e in evidences[:10]],
|
|
547
|
+
))
|
|
548
|
+
|
|
549
|
+
return out
|
|
550
|
+
|
|
551
|
+
# ------------------------------------------------------------------
|
|
552
|
+
# Pass 5 — External service detection (HIGH/MEDIUM confidence)
|
|
553
|
+
# ------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
async def _pass5_external_services(
|
|
556
|
+
self, session
|
|
557
|
+
) -> tuple[list[RepositoryRelationship], list[dict]]:
|
|
558
|
+
"""
|
|
559
|
+
Detect connections between repositories and external services:
|
|
560
|
+
- AI providers (Claude/Anthropic, OpenAI, Azure OpenAI, Gemini)
|
|
561
|
+
- Azure services (Cosmos DB, Synapse, Service Bus, Blob, SQL, Event Hub)
|
|
562
|
+
- Databases (PostgreSQL, MySQL, Oracle, MongoDB, Redis, Cassandra)
|
|
563
|
+
- Analytics (Databricks, BigQuery, Snowflake, Redshift)
|
|
564
|
+
- Messaging (Kafka, RabbitMQ, SQS)
|
|
565
|
+
- Observability (Datadog, New Relic, Elastic)
|
|
566
|
+
- Other SaaS (Stripe, Twilio, SendGrid, Auth0, Okta)
|
|
567
|
+
|
|
568
|
+
Detection sources (in priority):
|
|
569
|
+
1. Maven/Gradle LIBRARY nodes — artifact_id / group_id patterns
|
|
570
|
+
2. NPM_PACKAGE nodes — package name patterns
|
|
571
|
+
3. DATABASE nodes — JDBC URL / connection string patterns
|
|
572
|
+
4. API nodes — URL hostname patterns
|
|
573
|
+
5. String literals — SDK initialisation patterns in code
|
|
574
|
+
"""
|
|
575
|
+
relationships: list[RepositoryRelationship] = []
|
|
576
|
+
ext_nodes: list[dict] = []
|
|
577
|
+
|
|
578
|
+
# ── Pass 5a: DB schema cross-repo linking ──────────────────────────
|
|
579
|
+
# Match app repos that reference table/collection names that belong to
|
|
580
|
+
# a dedicated schema repo (wms-oracle, etc.)
|
|
581
|
+
db_rels = await self._pass5a_db_schema_links(session)
|
|
582
|
+
relationships.extend(db_rels)
|
|
583
|
+
|
|
584
|
+
# Fetch all libraries with their owning repo
|
|
585
|
+
lib_result = await session.run(
|
|
586
|
+
"MATCH (n)-[:USES_LIBRARY]->(lib:LIBRARY) "
|
|
587
|
+
"WHERE n.repository IS NOT NULL "
|
|
588
|
+
"RETURN DISTINCT n.repository AS repo, lib.group_id AS group_id, "
|
|
589
|
+
"lib.artifact_id AS artifact_id "
|
|
590
|
+
"LIMIT 2000"
|
|
591
|
+
)
|
|
592
|
+
lib_rows = await lib_result.data()
|
|
593
|
+
|
|
594
|
+
npm_result = await session.run(
|
|
595
|
+
"MATCH (n)-[:USES_PACKAGE]->(pkg:NPM_PACKAGE) "
|
|
596
|
+
"WHERE n.repository IS NOT NULL "
|
|
597
|
+
"RETURN DISTINCT n.repository AS repo, pkg.name AS pkg_name "
|
|
598
|
+
"LIMIT 2000"
|
|
599
|
+
)
|
|
600
|
+
npm_rows = await npm_result.data()
|
|
601
|
+
|
|
602
|
+
# DATABASE nodes — find which repos connect to each DB node via any edge,
|
|
603
|
+
# or via the repository property if it was correctly populated.
|
|
604
|
+
# Exclude 'unknown' which is a placeholder from pre-fix indexing runs.
|
|
605
|
+
db_result = await session.run(
|
|
606
|
+
"""
|
|
607
|
+
MATCH (owner)-[]->(n:DATABASE)
|
|
608
|
+
WHERE owner.repository IS NOT NULL
|
|
609
|
+
AND owner.repository <> 'unknown'
|
|
610
|
+
AND owner.repository <> 'external'
|
|
611
|
+
RETURN DISTINCT owner.repository AS repo, n.url AS url,
|
|
612
|
+
n.host AS host, n.db_type AS db_type, n.database AS database
|
|
613
|
+
UNION
|
|
614
|
+
MATCH (n:DATABASE)
|
|
615
|
+
WHERE n.repository IS NOT NULL
|
|
616
|
+
AND n.repository <> 'unknown'
|
|
617
|
+
AND n.repository <> 'external'
|
|
618
|
+
RETURN DISTINCT n.repository AS repo, n.url AS url,
|
|
619
|
+
n.host AS host, n.db_type AS db_type, n.database AS database
|
|
620
|
+
LIMIT 500
|
|
621
|
+
"""
|
|
622
|
+
)
|
|
623
|
+
db_rows = await db_result.data()
|
|
624
|
+
|
|
625
|
+
api_result = await session.run(
|
|
626
|
+
"""
|
|
627
|
+
MATCH (n:API)
|
|
628
|
+
WHERE n.repository IS NOT NULL
|
|
629
|
+
RETURN DISTINCT n.repository AS repo, n.url AS url
|
|
630
|
+
UNION
|
|
631
|
+
MATCH (owner)-[]->(n:API)
|
|
632
|
+
WHERE owner.repository IS NOT NULL AND n.repository IS NULL
|
|
633
|
+
RETURN DISTINCT owner.repository AS repo, n.url AS url
|
|
634
|
+
LIMIT 2000
|
|
635
|
+
"""
|
|
636
|
+
)
|
|
637
|
+
api_rows = await api_result.data()
|
|
638
|
+
|
|
639
|
+
# Infer services from Java package names (e.g. config.entra → Azure AD)
|
|
640
|
+
pkg_result = await session.run(
|
|
641
|
+
"""
|
|
642
|
+
MATCH (n:CLASS)
|
|
643
|
+
WHERE n.repository IS NOT NULL AND n.qualified_name IS NOT NULL
|
|
644
|
+
RETURN DISTINCT n.repository AS repo, n.qualified_name AS qname
|
|
645
|
+
LIMIT 5000
|
|
646
|
+
"""
|
|
647
|
+
)
|
|
648
|
+
pkg_rows = await pkg_result.data()
|
|
649
|
+
|
|
650
|
+
str_rows: list[dict] = [] # string_literals not stored as node property
|
|
651
|
+
|
|
652
|
+
# Collect all signal per repo
|
|
653
|
+
repo_signals: dict[str, list[tuple[str, str, str]]] = {}
|
|
654
|
+
# Each entry: (service_id, service_label, evidence_str)
|
|
655
|
+
|
|
656
|
+
def add_signal(repo: str, svc_id: str, svc_label: str, evidence: str) -> None:
|
|
657
|
+
repo_signals.setdefault(repo, []).append((svc_id, svc_label, evidence))
|
|
658
|
+
|
|
659
|
+
# ── Maven/Gradle libraries ──────────────────────────────────────────
|
|
660
|
+
for row in lib_rows:
|
|
661
|
+
repo = row.get("repo") or ""
|
|
662
|
+
gid = (row.get("group_id") or "").lower()
|
|
663
|
+
aid = (row.get("artifact_id") or "").lower()
|
|
664
|
+
combined = f"{gid}:{aid}"
|
|
665
|
+
|
|
666
|
+
svc = _classify_library(gid, aid)
|
|
667
|
+
if svc:
|
|
668
|
+
add_signal(repo, svc["id"], svc["label"],
|
|
669
|
+
f"Maven/Gradle lib: {combined}")
|
|
670
|
+
|
|
671
|
+
# ── NPM packages ────────────────────────────────────────────────────
|
|
672
|
+
for row in npm_rows:
|
|
673
|
+
repo = row.get("repo") or ""
|
|
674
|
+
pkg = (row.get("pkg_name") or "").lower()
|
|
675
|
+
svc = _classify_npm_package(pkg)
|
|
676
|
+
if svc:
|
|
677
|
+
add_signal(repo, svc["id"], svc["label"],
|
|
678
|
+
f"NPM package: {pkg}")
|
|
679
|
+
|
|
680
|
+
# ── Database nodes ──────────────────────────────────────────────────
|
|
681
|
+
for row in db_rows:
|
|
682
|
+
repo = row.get("repo") or ""
|
|
683
|
+
url = (row.get("url") or "").lower()
|
|
684
|
+
host = (row.get("host") or "").lower()
|
|
685
|
+
db_type = (row.get("db_type") or "").lower()
|
|
686
|
+
svc = _classify_db_url(url, host, db_type)
|
|
687
|
+
if svc:
|
|
688
|
+
add_signal(repo, svc["id"], svc["label"],
|
|
689
|
+
f"DB connection: {host or url[:60]}")
|
|
690
|
+
|
|
691
|
+
# ── API URL nodes ───────────────────────────────────────────────────
|
|
692
|
+
for row in api_rows:
|
|
693
|
+
repo = row.get("repo") or ""
|
|
694
|
+
url = (row.get("url") or "").lower()
|
|
695
|
+
svc = _classify_api_url(url)
|
|
696
|
+
if svc:
|
|
697
|
+
add_signal(repo, svc["id"], svc["label"],
|
|
698
|
+
f"API call: {url[:80]}")
|
|
699
|
+
|
|
700
|
+
# ── String literals (not stored; skipped) ──────────────────────────
|
|
701
|
+
_ = str_rows
|
|
702
|
+
|
|
703
|
+
# ── Java package / qualified-name inference ─────────────────────────
|
|
704
|
+
# e.g. "config.entra" → Azure AD, "repositories" → database, "kafka" → Kafka
|
|
705
|
+
_PKG_RULES: list[tuple[str, dict]] = [
|
|
706
|
+
# AI
|
|
707
|
+
("anthropic", {"id": "anthropic-claude", "label": "Anthropic Claude"}),
|
|
708
|
+
("openai", {"id": "openai", "label": "OpenAI"}),
|
|
709
|
+
("gemini", {"id": "google-gemini", "label": "Google Gemini"}),
|
|
710
|
+
("vertexai", {"id": "google-vertex-ai", "label": "Google Vertex AI"}),
|
|
711
|
+
# Azure (specific first)
|
|
712
|
+
("entra", {"id": "azure-ad", "label": "Azure AD (Entra)"}),
|
|
713
|
+
("keyvault", {"id": "azure-keyvault", "label": "Azure Key Vault"}),
|
|
714
|
+
("cosmos", {"id": "azure-cosmos-db", "label": "Azure Cosmos DB"}),
|
|
715
|
+
("servicebus", {"id": "azure-service-bus","label": "Azure Service Bus"}),
|
|
716
|
+
("eventhub", {"id": "azure-event-hubs", "label": "Azure Event Hubs"}),
|
|
717
|
+
("blobstorage", {"id": "azure-blob", "label": "Azure Blob Storage"}),
|
|
718
|
+
("synapse", {"id": "azure-synapse", "label": "Azure Synapse"}),
|
|
719
|
+
("azure", {"id": "azure", "label": "Azure SDK"}),
|
|
720
|
+
# AWS
|
|
721
|
+
("dynamodb", {"id": "aws-dynamodb", "label": "AWS DynamoDB"}),
|
|
722
|
+
("awss3", {"id": "aws-s3", "label": "AWS S3"}),
|
|
723
|
+
("awslambda", {"id": "aws-lambda", "label": "AWS Lambda"}),
|
|
724
|
+
("cloudwatch", {"id": "aws-cloudwatch", "label": "AWS CloudWatch"}),
|
|
725
|
+
("secretsmanager",{"id": "aws-secrets", "label": "AWS Secrets Manager"}),
|
|
726
|
+
# Google Cloud
|
|
727
|
+
("pubsub", {"id": "google-pubsub", "label": "Google Pub/Sub"}),
|
|
728
|
+
("firestore", {"id": "firestore", "label": "Google Firestore"}),
|
|
729
|
+
("bigquery", {"id": "bigquery", "label": "BigQuery"}),
|
|
730
|
+
# Databases
|
|
731
|
+
("postgres", {"id": "postgresql", "label": "PostgreSQL"}),
|
|
732
|
+
("mysql", {"id": "mysql", "label": "MySQL"}),
|
|
733
|
+
("mariadb", {"id": "mariadb", "label": "MariaDB"}),
|
|
734
|
+
("oracle", {"id": "oracle-db", "label": "Oracle DB"}),
|
|
735
|
+
("db2", {"id": "ibm-db2", "label": "IBM DB2"}),
|
|
736
|
+
("hana", {"id": "sap-hana", "label": "SAP HANA"}),
|
|
737
|
+
("mongo", {"id": "mongodb", "label": "MongoDB"}),
|
|
738
|
+
("redis", {"id": "redis", "label": "Redis"}),
|
|
739
|
+
("cassandra", {"id": "cassandra", "label": "Cassandra"}),
|
|
740
|
+
("couchbase", {"id": "couchbase", "label": "Couchbase"}),
|
|
741
|
+
# Messaging
|
|
742
|
+
("kafka", {"id": "kafka", "label": "Apache Kafka"}),
|
|
743
|
+
("rabbitmq", {"id": "rabbitmq", "label": "RabbitMQ"}),
|
|
744
|
+
("activemq", {"id": "activemq", "label": "Apache ActiveMQ"}),
|
|
745
|
+
("ibmmq", {"id": "ibm-mq", "label": "IBM MQ"}),
|
|
746
|
+
# Observability
|
|
747
|
+
("elasticsearch", {"id": "elasticsearch", "label": "Elasticsearch"}),
|
|
748
|
+
("datadog", {"id": "datadog", "label": "Datadog"}),
|
|
749
|
+
("newrelic", {"id": "newrelic", "label": "New Relic"}),
|
|
750
|
+
("prometheus", {"id": "prometheus", "label": "Prometheus"}),
|
|
751
|
+
("splunk", {"id": "splunk", "label": "Splunk"}),
|
|
752
|
+
("grafana", {"id": "grafana", "label": "Grafana"}),
|
|
753
|
+
# Secrets
|
|
754
|
+
("hashicorp", {"id": "hashicorp-vault", "label": "HashiCorp Vault"}),
|
|
755
|
+
# Auth
|
|
756
|
+
("okta", {"id": "okta", "label": "Okta"}),
|
|
757
|
+
("auth0", {"id": "auth0", "label": "Auth0"}),
|
|
758
|
+
# Analytics
|
|
759
|
+
("databricks", {"id": "databricks", "label": "Databricks"}),
|
|
760
|
+
("snowflake", {"id": "snowflake", "label": "Snowflake"}),
|
|
761
|
+
# SaaS
|
|
762
|
+
("stripe", {"id": "stripe", "label": "Stripe"}),
|
|
763
|
+
("twilio", {"id": "twilio", "label": "Twilio"}),
|
|
764
|
+
("sendgrid", {"id": "sendgrid", "label": "SendGrid"}),
|
|
765
|
+
("salesforce", {"id": "salesforce", "label": "Salesforce"}),
|
|
766
|
+
("servicenow", {"id": "servicenow", "label": "ServiceNow"}),
|
|
767
|
+
# IBM
|
|
768
|
+
("websphere", {"id": "ibm", "label": "IBM WebSphere"}),
|
|
769
|
+
]
|
|
770
|
+
for row in pkg_rows:
|
|
771
|
+
repo = row.get("repo") or ""
|
|
772
|
+
qname = (row.get("qname") or "").lower()
|
|
773
|
+
for keyword, svc in _PKG_RULES:
|
|
774
|
+
if keyword in qname:
|
|
775
|
+
add_signal(repo, svc["id"], svc["label"],
|
|
776
|
+
f"Java package: {row.get('qname','')[:80]}")
|
|
777
|
+
break # one match per class is enough
|
|
778
|
+
|
|
779
|
+
# ── Build relationships & ext_nodes ─────────────────────────────────
|
|
780
|
+
seen_ext: dict[str, dict] = {}
|
|
781
|
+
for repo, signals in repo_signals.items():
|
|
782
|
+
# Deduplicate services for this repo
|
|
783
|
+
seen_for_repo: dict[str, list[str]] = {}
|
|
784
|
+
for svc_id, svc_label, evidence in signals:
|
|
785
|
+
seen_for_repo.setdefault(svc_id, []).append(evidence)
|
|
786
|
+
if svc_id not in seen_ext:
|
|
787
|
+
cat = _service_category(svc_id)
|
|
788
|
+
seen_ext[svc_id] = {
|
|
789
|
+
"id": svc_id,
|
|
790
|
+
"label": svc_label,
|
|
791
|
+
"category": cat,
|
|
792
|
+
"node_type": "EXTERNAL_SERVICE",
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
for svc_id, evidences in seen_for_repo.items():
|
|
796
|
+
conf = "HIGH" if len(evidences) >= 2 else "MEDIUM"
|
|
797
|
+
relationships.append(RepositoryRelationship(
|
|
798
|
+
from_repo=repo,
|
|
799
|
+
to_repo=svc_id,
|
|
800
|
+
relation_type="USES_SERVICE",
|
|
801
|
+
confidence=conf,
|
|
802
|
+
evidence=list(dict.fromkeys(evidences))[:5],
|
|
803
|
+
details=[{"service": svc_id}],
|
|
804
|
+
))
|
|
805
|
+
|
|
806
|
+
ext_nodes = list(seen_ext.values())
|
|
807
|
+
return relationships, ext_nodes
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
# ---------------------------------------------------------------------------
|
|
811
|
+
# External service classification tables
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
# Each entry: (group_id_pattern, artifact_id_pattern) → service descriptor
|
|
815
|
+
_MAVEN_SERVICE_RULES: list[tuple[str, str, dict]] = [
|
|
816
|
+
# ── AI providers ──────────────────────────────────────────────────────────
|
|
817
|
+
("com.anthropic", "", {"id": "anthropic-claude", "label": "Anthropic Claude"}),
|
|
818
|
+
("com.openai", "", {"id": "openai", "label": "OpenAI"}),
|
|
819
|
+
("", "azure-ai-openai", {"id": "azure-openai", "label": "Azure OpenAI"}),
|
|
820
|
+
("com.azure.ai", "", {"id": "azure-ai", "label": "Azure AI"}),
|
|
821
|
+
("com.google.cloud", "google-cloud-aiplatform", {"id": "google-vertex-ai", "label": "Google Vertex AI"}),
|
|
822
|
+
("com.google.ai.client", "", {"id": "google-gemini", "label": "Google Gemini"}),
|
|
823
|
+
# ── Azure ─────────────────────────────────────────────────────────────────
|
|
824
|
+
("com.azure", "azure-cosmos", {"id": "azure-cosmos-db", "label": "Azure Cosmos DB"}),
|
|
825
|
+
("com.azure", "azure-storage-blob", {"id": "azure-blob", "label": "Azure Blob Storage"}),
|
|
826
|
+
("com.azure", "azure-messaging-servicebus", {"id": "azure-service-bus", "label": "Azure Service Bus"}),
|
|
827
|
+
("com.azure", "azure-messaging-eventhubs", {"id": "azure-event-hubs", "label": "Azure Event Hubs"}),
|
|
828
|
+
("com.azure", "azure-messaging-eventgrid", {"id": "azure-event-grid", "label": "Azure Event Grid"}),
|
|
829
|
+
("com.azure", "azure-data-tables", {"id": "azure-table", "label": "Azure Table Storage"}),
|
|
830
|
+
("com.azure", "azure-security-keyvault", {"id": "azure-keyvault", "label": "Azure Key Vault"}),
|
|
831
|
+
("com.azure", "azure-spring-cloud", {"id": "azure-appconfig", "label": "Azure App Config"}),
|
|
832
|
+
("com.microsoft.azure", "azure-sqldb-spark", {"id": "azure-synapse", "label": "Azure Synapse"}),
|
|
833
|
+
("com.microsoft.sqlserver","mssql-jdbc", {"id": "azure-sql", "label": "Azure SQL / MSSQL"}),
|
|
834
|
+
# ── Databases — on-premise & cloud ───────────────────────────────────────
|
|
835
|
+
("org.postgresql", "", {"id": "postgresql", "label": "PostgreSQL"}),
|
|
836
|
+
("mysql", "mysql-connector", {"id": "mysql", "label": "MySQL"}),
|
|
837
|
+
("org.mariadb.jdbc", "", {"id": "mariadb", "label": "MariaDB"}),
|
|
838
|
+
("com.oracle", "", {"id": "oracle-db", "label": "Oracle DB"}),
|
|
839
|
+
("com.ibm.db2", "", {"id": "ibm-db2", "label": "IBM DB2"}),
|
|
840
|
+
("com.h2database", "", {"id": "h2", "label": "H2 Database"}),
|
|
841
|
+
("com.sap.cloud.db.jdbc", "", {"id": "sap-hana", "label": "SAP HANA"}),
|
|
842
|
+
("org.mongodb", "", {"id": "mongodb", "label": "MongoDB"}),
|
|
843
|
+
("io.lettuce", "", {"id": "redis", "label": "Redis"}),
|
|
844
|
+
("redis.clients", "jedis", {"id": "redis", "label": "Redis"}),
|
|
845
|
+
("com.datastax", "", {"id": "cassandra", "label": "Cassandra"}),
|
|
846
|
+
# ── AWS services ──────────────────────────────────────────────────────────
|
|
847
|
+
("software.amazon.awssdk", "s3", {"id": "aws-s3", "label": "AWS S3"}),
|
|
848
|
+
("software.amazon.awssdk", "dynamodb", {"id": "aws-dynamodb", "label": "AWS DynamoDB"}),
|
|
849
|
+
("software.amazon.awssdk", "rds", {"id": "aws-rds", "label": "AWS RDS"}),
|
|
850
|
+
("software.amazon.awssdk", "lambda", {"id": "aws-lambda", "label": "AWS Lambda"}),
|
|
851
|
+
("software.amazon.awssdk", "sns", {"id": "aws-sns", "label": "AWS SNS"}),
|
|
852
|
+
("software.amazon.awssdk", "sqs", {"id": "aws-sqs", "label": "AWS SQS"}),
|
|
853
|
+
("software.amazon.awssdk", "secretsmanager", {"id": "aws-secrets", "label": "AWS Secrets Manager"}),
|
|
854
|
+
("software.amazon.awssdk", "cloudwatch", {"id": "aws-cloudwatch", "label": "AWS CloudWatch"}),
|
|
855
|
+
("software.amazon.awssdk", "", {"id": "aws", "label": "AWS SDK"}),
|
|
856
|
+
("com.amazonaws", "", {"id": "aws", "label": "AWS SDK"}),
|
|
857
|
+
# ── Google Cloud ──────────────────────────────────────────────────────────
|
|
858
|
+
("com.google.cloud", "google-cloud-storage", {"id": "gcs", "label": "Google Cloud Storage"}),
|
|
859
|
+
("com.google.cloud", "google-cloud-pubsub", {"id": "google-pubsub", "label": "Google Pub/Sub"}),
|
|
860
|
+
("com.google.cloud", "google-cloud-firestore", {"id": "firestore", "label": "Google Firestore"}),
|
|
861
|
+
("com.google.cloud", "google-cloud-spanner", {"id": "cloud-spanner", "label": "Cloud Spanner"}),
|
|
862
|
+
("com.google.cloud", "google-cloud-secretmanager", {"id": "gcp-secrets", "label": "GCP Secret Manager"}),
|
|
863
|
+
("com.google.cloud", "google-cloud-bigquery", {"id": "bigquery", "label": "BigQuery"}),
|
|
864
|
+
# ── Messaging ─────────────────────────────────────────────────────────────
|
|
865
|
+
("org.apache.kafka", "", {"id": "kafka", "label": "Apache Kafka"}),
|
|
866
|
+
("org.apache.activemq", "", {"id": "activemq", "label": "Apache ActiveMQ"}),
|
|
867
|
+
("com.rabbitmq", "", {"id": "rabbitmq", "label": "RabbitMQ"}),
|
|
868
|
+
("com.ibm.mq", "", {"id": "ibm-mq", "label": "IBM MQ"}),
|
|
869
|
+
# ── Analytics ─────────────────────────────────────────────────────────────
|
|
870
|
+
("com.databricks", "", {"id": "databricks", "label": "Databricks"}),
|
|
871
|
+
("net.snowflake", "", {"id": "snowflake", "label": "Snowflake"}),
|
|
872
|
+
# ── Observability ─────────────────────────────────────────────────────────
|
|
873
|
+
("com.datadoghq", "", {"id": "datadog", "label": "Datadog"}),
|
|
874
|
+
("com.newrelic", "", {"id": "newrelic", "label": "New Relic"}),
|
|
875
|
+
("co.elastic", "", {"id": "elasticsearch", "label": "Elasticsearch"}),
|
|
876
|
+
("io.micrometer", "micrometer-registry-prometheus", {"id": "prometheus", "label": "Prometheus"}),
|
|
877
|
+
("com.splunk", "", {"id": "splunk", "label": "Splunk"}),
|
|
878
|
+
# ── Secrets / Config ──────────────────────────────────────────────────────
|
|
879
|
+
("com.bettercloud", "vault-java-driver", {"id": "hashicorp-vault", "label": "HashiCorp Vault"}),
|
|
880
|
+
# ── Auth ──────────────────────────────────────────────────────────────────
|
|
881
|
+
("com.okta", "", {"id": "okta", "label": "Okta"}),
|
|
882
|
+
("com.auth0", "", {"id": "auth0", "label": "Auth0"}),
|
|
883
|
+
# ── SaaS ──────────────────────────────────────────────────────────────────
|
|
884
|
+
("com.force.api", "", {"id": "salesforce", "label": "Salesforce"}),
|
|
885
|
+
("com.sforce", "", {"id": "salesforce", "label": "Salesforce"}),
|
|
886
|
+
# ── IBM ───────────────────────────────────────────────────────────────────
|
|
887
|
+
("com.ibm", "", {"id": "ibm", "label": "IBM"}),
|
|
888
|
+
]
|
|
889
|
+
|
|
890
|
+
_NPM_SERVICE_RULES: list[tuple[str, dict]] = [
|
|
891
|
+
# ── AI ────────────────────────────────────────────────────────────────────
|
|
892
|
+
("@anthropic-ai/sdk", {"id": "anthropic-claude", "label": "Anthropic Claude"}),
|
|
893
|
+
("anthropic", {"id": "anthropic-claude", "label": "Anthropic Claude"}),
|
|
894
|
+
("openai", {"id": "openai", "label": "OpenAI"}),
|
|
895
|
+
("@azure/openai", {"id": "azure-openai", "label": "Azure OpenAI"}),
|
|
896
|
+
("@google-cloud/aiplatform", {"id": "google-vertex-ai", "label": "Google Vertex AI"}),
|
|
897
|
+
("@google/generative-ai", {"id": "google-gemini", "label": "Google Gemini"}),
|
|
898
|
+
# ── Azure ─────────────────────────────────────────────────────────────────
|
|
899
|
+
("@azure/cosmos", {"id": "azure-cosmos-db", "label": "Azure Cosmos DB"}),
|
|
900
|
+
("@azure/storage-blob", {"id": "azure-blob", "label": "Azure Blob Storage"}),
|
|
901
|
+
("@azure/service-bus", {"id": "azure-service-bus","label": "Azure Service Bus"}),
|
|
902
|
+
("@azure/event-hubs", {"id": "azure-event-hubs", "label": "Azure Event Hubs"}),
|
|
903
|
+
("@azure/eventgrid", {"id": "azure-event-grid", "label": "Azure Event Grid"}),
|
|
904
|
+
("@azure/synapse-analytics", {"id": "azure-synapse", "label": "Azure Synapse"}),
|
|
905
|
+
("@azure/keyvault", {"id": "azure-keyvault", "label": "Azure Key Vault"}),
|
|
906
|
+
("@azure/app-configuration", {"id": "azure-appconfig", "label": "Azure App Config"}),
|
|
907
|
+
("@azure/msal", {"id": "azure-ad", "label": "Azure AD (Entra)"}),
|
|
908
|
+
("mssql", {"id": "azure-sql", "label": "Azure SQL / MSSQL"}),
|
|
909
|
+
# ── Databases — on-premise & cloud ───────────────────────────────────────
|
|
910
|
+
("pg", {"id": "postgresql", "label": "PostgreSQL"}),
|
|
911
|
+
("mysql2", {"id": "mysql", "label": "MySQL"}),
|
|
912
|
+
("mariadb", {"id": "mariadb", "label": "MariaDB"}),
|
|
913
|
+
("mongodb", {"id": "mongodb", "label": "MongoDB"}),
|
|
914
|
+
("mongoose", {"id": "mongodb", "label": "MongoDB"}),
|
|
915
|
+
("ioredis", {"id": "redis", "label": "Redis"}),
|
|
916
|
+
("redis", {"id": "redis", "label": "Redis"}),
|
|
917
|
+
("cassandra-driver", {"id": "cassandra", "label": "Cassandra"}),
|
|
918
|
+
("@datastax/astra-db-ts", {"id": "cassandra", "label": "Cassandra / Astra"}),
|
|
919
|
+
("@google-cloud/firestore", {"id": "firestore", "label": "Google Firestore"}),
|
|
920
|
+
# ── AWS services ──────────────────────────────────────────────────────────
|
|
921
|
+
("@aws-sdk/client-s3", {"id": "aws-s3", "label": "AWS S3"}),
|
|
922
|
+
("@aws-sdk/client-dynamodb", {"id": "aws-dynamodb", "label": "AWS DynamoDB"}),
|
|
923
|
+
("@aws-sdk/client-lambda", {"id": "aws-lambda", "label": "AWS Lambda"}),
|
|
924
|
+
("@aws-sdk/client-sns", {"id": "aws-sns", "label": "AWS SNS"}),
|
|
925
|
+
("@aws-sdk/client-sqs", {"id": "aws-sqs", "label": "AWS SQS"}),
|
|
926
|
+
("@aws-sdk/client-secrets-manager", {"id": "aws-secrets", "label": "AWS Secrets Manager"}),
|
|
927
|
+
("@aws-sdk/client-cloudwatch", {"id": "aws-cloudwatch", "label": "AWS CloudWatch"}),
|
|
928
|
+
("@aws-sdk/client-rds", {"id": "aws-rds", "label": "AWS RDS"}),
|
|
929
|
+
# ── Google Cloud ──────────────────────────────────────────────────────────
|
|
930
|
+
("@google-cloud/storage", {"id": "gcs", "label": "Google Cloud Storage"}),
|
|
931
|
+
("@google-cloud/pubsub", {"id": "google-pubsub", "label": "Google Pub/Sub"}),
|
|
932
|
+
("@google-cloud/spanner", {"id": "cloud-spanner", "label": "Cloud Spanner"}),
|
|
933
|
+
("@google-cloud/secret-manager",{"id": "gcp-secrets", "label": "GCP Secret Manager"}),
|
|
934
|
+
("@google-cloud/bigquery", {"id": "bigquery", "label": "BigQuery"}),
|
|
935
|
+
# ── Messaging ─────────────────────────────────────────────────────────────
|
|
936
|
+
("kafkajs", {"id": "kafka", "label": "Apache Kafka"}),
|
|
937
|
+
("kafka-node", {"id": "kafka", "label": "Apache Kafka"}),
|
|
938
|
+
("amqplib", {"id": "rabbitmq", "label": "RabbitMQ"}),
|
|
939
|
+
("activemq-stomp", {"id": "activemq", "label": "Apache ActiveMQ"}),
|
|
940
|
+
("ibmmq", {"id": "ibm-mq", "label": "IBM MQ"}),
|
|
941
|
+
# ── Analytics ─────────────────────────────────────────────────────────────
|
|
942
|
+
("@databricks/sql", {"id": "databricks", "label": "Databricks"}),
|
|
943
|
+
# ── Observability ─────────────────────────────────────────────────────────
|
|
944
|
+
("dd-trace", {"id": "datadog", "label": "Datadog"}),
|
|
945
|
+
("newrelic", {"id": "newrelic", "label": "New Relic"}),
|
|
946
|
+
("@elastic/elasticsearch", {"id": "elasticsearch", "label": "Elasticsearch"}),
|
|
947
|
+
("prom-client", {"id": "prometheus", "label": "Prometheus"}),
|
|
948
|
+
("splunk-logging", {"id": "splunk", "label": "Splunk"}),
|
|
949
|
+
("@grafana/data", {"id": "grafana", "label": "Grafana"}),
|
|
950
|
+
# ── Secrets ───────────────────────────────────────────────────────────────
|
|
951
|
+
("node-vault", {"id": "hashicorp-vault", "label": "HashiCorp Vault"}),
|
|
952
|
+
# ── Auth ──────────────────────────────────────────────────────────────────
|
|
953
|
+
("@auth0/auth0-react", {"id": "auth0", "label": "Auth0"}),
|
|
954
|
+
("@auth0/auth0-spa-js", {"id": "auth0", "label": "Auth0"}),
|
|
955
|
+
("@okta/okta-react", {"id": "okta", "label": "Okta"}),
|
|
956
|
+
("@okta/okta-auth-js", {"id": "okta", "label": "Okta"}),
|
|
957
|
+
# ── Payments & comms ──────────────────────────────────────────────────────
|
|
958
|
+
("stripe", {"id": "stripe", "label": "Stripe"}),
|
|
959
|
+
("twilio", {"id": "twilio", "label": "Twilio"}),
|
|
960
|
+
("@sendgrid/mail", {"id": "sendgrid", "label": "SendGrid"}),
|
|
961
|
+
# ── SaaS CRM ──────────────────────────────────────────────────────────────
|
|
962
|
+
("jsforce", {"id": "salesforce", "label": "Salesforce"}),
|
|
963
|
+
("@salesforce/core", {"id": "salesforce", "label": "Salesforce"}),
|
|
964
|
+
# ── AWS catch-all (must be LAST) ──────────────────────────────────────────
|
|
965
|
+
("@aws-sdk", {"id": "aws", "label": "AWS SDK"}),
|
|
966
|
+
("aws-sdk", {"id": "aws", "label": "AWS SDK"}),
|
|
967
|
+
]
|
|
968
|
+
|
|
969
|
+
# Hostname / URL fragment → service (ORDER MATTERS — more specific first)
|
|
970
|
+
_URL_SERVICE_RULES: list[tuple[str, dict]] = [
|
|
971
|
+
# ── AI ────────────────────────────────────────────────────────────────────
|
|
972
|
+
("api.anthropic.com", {"id": "anthropic-claude", "label": "Anthropic Claude"}),
|
|
973
|
+
("api.openai.com", {"id": "openai", "label": "OpenAI"}),
|
|
974
|
+
("openai.azure.com", {"id": "azure-openai", "label": "Azure OpenAI"}),
|
|
975
|
+
("cognitiveservices.azure.com", {"id": "azure-ai", "label": "Azure AI"}),
|
|
976
|
+
("generativelanguage.googleapis.com", {"id": "google-gemini","label": "Google Gemini"}),
|
|
977
|
+
("aiplatform.googleapis.com", {"id": "google-vertex-ai", "label": "Google Vertex AI"}),
|
|
978
|
+
# ── Azure (specific before generic) ───────────────────────────────────────
|
|
979
|
+
("cosmos.azure.com", {"id": "azure-cosmos-db", "label": "Azure Cosmos DB"}),
|
|
980
|
+
("blob.core.windows.net", {"id": "azure-blob", "label": "Azure Blob Storage"}),
|
|
981
|
+
("file.core.windows.net", {"id": "azure-blob", "label": "Azure File Storage"}),
|
|
982
|
+
("core.windows.net", {"id": "azure-blob", "label": "Azure Blob Storage"}),
|
|
983
|
+
("servicebus.windows.net", {"id": "azure-service-bus", "label": "Azure Service Bus"}),
|
|
984
|
+
("eventhub.windows.net", {"id": "azure-event-hubs", "label": "Azure Event Hubs"}),
|
|
985
|
+
("eventgrid.azure.net", {"id": "azure-event-grid", "label": "Azure Event Grid"}),
|
|
986
|
+
("database.windows.net", {"id": "azure-sql", "label": "Azure SQL"}),
|
|
987
|
+
("vault.azure.net", {"id": "azure-keyvault", "label": "Azure Key Vault"}),
|
|
988
|
+
("appconfig.azure.net", {"id": "azure-appconfig", "label": "Azure App Config"}),
|
|
989
|
+
("login.microsoftonline.com", {"id": "azure-ad", "label": "Azure AD (Entra)"}),
|
|
990
|
+
("sts.windows.net", {"id": "azure-ad", "label": "Azure AD (Entra)"}),
|
|
991
|
+
("graph.microsoft.com", {"id": "azure-ad", "label": "Azure AD (Entra)"}),
|
|
992
|
+
("azuresynapse.net", {"id": "azure-synapse", "label": "Azure Synapse"}),
|
|
993
|
+
# ── AWS (specific before generic) ─────────────────────────────────────────
|
|
994
|
+
("s3.amazonaws.com", {"id": "aws-s3", "label": "AWS S3"}),
|
|
995
|
+
("s3.", {"id": "aws-s3", "label": "AWS S3"}),
|
|
996
|
+
("rds.amazonaws.com", {"id": "aws-rds", "label": "AWS RDS"}),
|
|
997
|
+
("lambda.amazonaws.com", {"id": "aws-lambda", "label": "AWS Lambda"}),
|
|
998
|
+
("sns.amazonaws.com", {"id": "aws-sns", "label": "AWS SNS"}),
|
|
999
|
+
("amazonaws.com/sqs", {"id": "aws-sqs", "label": "AWS SQS"}),
|
|
1000
|
+
("secretsmanager.amazonaws.com",{"id": "aws-secrets", "label": "AWS Secrets Manager"}),
|
|
1001
|
+
("cloudwatch.amazonaws.com", {"id": "aws-cloudwatch", "label": "AWS CloudWatch"}),
|
|
1002
|
+
("es.amazonaws.com", {"id": "elasticsearch", "label": "Elasticsearch"}),
|
|
1003
|
+
("amazonaws.com", {"id": "aws", "label": "AWS"}),
|
|
1004
|
+
# ── Google Cloud ──────────────────────────────────────────────────────────
|
|
1005
|
+
("storage.googleapis.com", {"id": "gcs", "label": "Google Cloud Storage"}),
|
|
1006
|
+
("pubsub.googleapis.com", {"id": "google-pubsub", "label": "Google Pub/Sub"}),
|
|
1007
|
+
("firestore.googleapis.com", {"id": "firestore", "label": "Google Firestore"}),
|
|
1008
|
+
("spanner.googleapis.com", {"id": "cloud-spanner", "label": "Cloud Spanner"}),
|
|
1009
|
+
("secretmanager.googleapis.com",{"id": "gcp-secrets", "label": "GCP Secret Manager"}),
|
|
1010
|
+
("bigquery.googleapis.com", {"id": "bigquery", "label": "BigQuery"}),
|
|
1011
|
+
("run.app", {"id": "cloud-run", "label": "Google Cloud Run"}),
|
|
1012
|
+
# ── Analytics ─────────────────────────────────────────────────────────────
|
|
1013
|
+
("azdatabricks.net", {"id": "databricks", "label": "Databricks"}),
|
|
1014
|
+
("databricks.com", {"id": "databricks", "label": "Databricks"}),
|
|
1015
|
+
("snowflakecomputing.com", {"id": "snowflake", "label": "Snowflake"}),
|
|
1016
|
+
# ── Observability ─────────────────────────────────────────────────────────
|
|
1017
|
+
("datadoghq.com", {"id": "datadog", "label": "Datadog"}),
|
|
1018
|
+
("newrelic.com", {"id": "newrelic", "label": "New Relic"}),
|
|
1019
|
+
("elastic.co", {"id": "elasticsearch", "label": "Elasticsearch"}),
|
|
1020
|
+
("hec.splunk.com", {"id": "splunk", "label": "Splunk"}),
|
|
1021
|
+
("splunk.com", {"id": "splunk", "label": "Splunk"}),
|
|
1022
|
+
("grafana.com", {"id": "grafana", "label": "Grafana"}),
|
|
1023
|
+
# ── Secrets ───────────────────────────────────────────────────────────────
|
|
1024
|
+
("vault.hashicorp.com", {"id": "hashicorp-vault", "label": "HashiCorp Vault"}),
|
|
1025
|
+
# ── Messaging ─────────────────────────────────────────────────────────────
|
|
1026
|
+
("kafka", {"id": "kafka", "label": "Apache Kafka"}),
|
|
1027
|
+
# ── Auth ──────────────────────────────────────────────────────────────────
|
|
1028
|
+
("auth0.com", {"id": "auth0", "label": "Auth0"}),
|
|
1029
|
+
("okta.com", {"id": "okta", "label": "Okta"}),
|
|
1030
|
+
# ── SaaS ──────────────────────────────────────────────────────────────────
|
|
1031
|
+
("stripe.com", {"id": "stripe", "label": "Stripe"}),
|
|
1032
|
+
("twilio.com", {"id": "twilio", "label": "Twilio"}),
|
|
1033
|
+
("sendgrid.net", {"id": "sendgrid", "label": "SendGrid"}),
|
|
1034
|
+
("salesforce.com", {"id": "salesforce", "label": "Salesforce"}),
|
|
1035
|
+
("force.com", {"id": "salesforce", "label": "Salesforce"}),
|
|
1036
|
+
("service-now.com", {"id": "servicenow", "label": "ServiceNow"}),
|
|
1037
|
+
("servicenow.com", {"id": "servicenow", "label": "ServiceNow"}),
|
|
1038
|
+
]
|
|
1039
|
+
|
|
1040
|
+
# JDBC URL / host / db_type patterns → service
|
|
1041
|
+
_DB_SERVICE_RULES: list[tuple[str, dict]] = [
|
|
1042
|
+
# Azure SQL (most specific first)
|
|
1043
|
+
("database.windows.net", {"id": "azure-sql", "label": "Azure SQL / MSSQL"}),
|
|
1044
|
+
("sqlserver", {"id": "azure-sql", "label": "Azure SQL / MSSQL"}),
|
|
1045
|
+
# Other relational
|
|
1046
|
+
("postgresql", {"id": "postgresql", "label": "PostgreSQL"}),
|
|
1047
|
+
("postgres", {"id": "postgresql", "label": "PostgreSQL"}),
|
|
1048
|
+
("mysql", {"id": "mysql", "label": "MySQL"}),
|
|
1049
|
+
("mariadb", {"id": "mariadb", "label": "MariaDB"}),
|
|
1050
|
+
("oracle", {"id": "oracle-db", "label": "Oracle DB"}),
|
|
1051
|
+
("db2", {"id": "ibm-db2", "label": "IBM DB2"}),
|
|
1052
|
+
("h2", {"id": "h2", "label": "H2 Database"}),
|
|
1053
|
+
("hana", {"id": "sap-hana", "label": "SAP HANA"}),
|
|
1054
|
+
("sybase", {"id": "sybase", "label": "Sybase"}),
|
|
1055
|
+
# NoSQL
|
|
1056
|
+
("mongodb", {"id": "mongodb", "label": "MongoDB"}),
|
|
1057
|
+
("redis", {"id": "redis", "label": "Redis"}),
|
|
1058
|
+
("cassandra", {"id": "cassandra", "label": "Cassandra"}),
|
|
1059
|
+
("cosmos", {"id": "azure-cosmos-db","label": "Azure Cosmos DB"}),
|
|
1060
|
+
("couchbase", {"id": "couchbase", "label": "Couchbase"}),
|
|
1061
|
+
("neo4j", {"id": "neo4j", "label": "Neo4j"}),
|
|
1062
|
+
# AWS RDS (by hostname)
|
|
1063
|
+
("rds.amazonaws.com", {"id": "aws-rds", "label": "AWS RDS"}),
|
|
1064
|
+
# Analytics
|
|
1065
|
+
("databricks", {"id": "databricks", "label": "Databricks"}),
|
|
1066
|
+
("synapse", {"id": "azure-synapse", "label": "Azure Synapse"}),
|
|
1067
|
+
("snowflake", {"id": "snowflake", "label": "Snowflake"}),
|
|
1068
|
+
("bigquery", {"id": "bigquery", "label": "BigQuery"}),
|
|
1069
|
+
("redshift", {"id": "aws-redshift", "label": "AWS Redshift"}),
|
|
1070
|
+
]
|
|
1071
|
+
|
|
1072
|
+
# Category per service id (for colour grouping in the graph)
|
|
1073
|
+
_SERVICE_CATEGORIES: dict[str, str] = {
|
|
1074
|
+
# AI
|
|
1075
|
+
"anthropic-claude": "ai",
|
|
1076
|
+
"openai": "ai",
|
|
1077
|
+
"azure-openai": "ai",
|
|
1078
|
+
"azure-ai": "ai",
|
|
1079
|
+
"google-vertex-ai": "ai",
|
|
1080
|
+
"google-gemini": "ai",
|
|
1081
|
+
# Azure
|
|
1082
|
+
"azure": "azure",
|
|
1083
|
+
"azure-cosmos-db": "azure",
|
|
1084
|
+
"azure-blob": "azure",
|
|
1085
|
+
"azure-service-bus":"azure",
|
|
1086
|
+
"azure-event-hubs": "azure",
|
|
1087
|
+
"azure-event-grid": "azure",
|
|
1088
|
+
"azure-table": "azure",
|
|
1089
|
+
"azure-synapse": "analytics",
|
|
1090
|
+
"azure-keyvault": "azure",
|
|
1091
|
+
"azure-appconfig": "azure",
|
|
1092
|
+
"azure-ad": "auth",
|
|
1093
|
+
"azure-sql": "database",
|
|
1094
|
+
# AWS
|
|
1095
|
+
"aws": "cloud",
|
|
1096
|
+
"aws-s3": "cloud",
|
|
1097
|
+
"aws-lambda": "cloud",
|
|
1098
|
+
"aws-cloudwatch": "observability",
|
|
1099
|
+
"aws-sqs": "messaging",
|
|
1100
|
+
"aws-sns": "messaging",
|
|
1101
|
+
"aws-dynamodb": "database",
|
|
1102
|
+
"aws-rds": "database",
|
|
1103
|
+
"aws-redshift": "analytics",
|
|
1104
|
+
"aws-secrets": "azure", # grouped with secrets/config
|
|
1105
|
+
# Google Cloud
|
|
1106
|
+
"gcs": "cloud",
|
|
1107
|
+
"google-pubsub": "messaging",
|
|
1108
|
+
"firestore": "database",
|
|
1109
|
+
"cloud-spanner": "database",
|
|
1110
|
+
"gcp-secrets": "cloud",
|
|
1111
|
+
"cloud-run": "cloud",
|
|
1112
|
+
# Databases
|
|
1113
|
+
"postgresql": "database",
|
|
1114
|
+
"mysql": "database",
|
|
1115
|
+
"mariadb": "database",
|
|
1116
|
+
"oracle-db": "database",
|
|
1117
|
+
"ibm-db2": "database",
|
|
1118
|
+
"h2": "database",
|
|
1119
|
+
"sap-hana": "database",
|
|
1120
|
+
"sybase": "database",
|
|
1121
|
+
"mongodb": "database",
|
|
1122
|
+
"redis": "database",
|
|
1123
|
+
"cassandra": "database",
|
|
1124
|
+
"couchbase": "database",
|
|
1125
|
+
"neo4j": "database",
|
|
1126
|
+
# Messaging
|
|
1127
|
+
"kafka": "messaging",
|
|
1128
|
+
"rabbitmq": "messaging",
|
|
1129
|
+
"activemq": "messaging",
|
|
1130
|
+
"ibm-mq": "messaging",
|
|
1131
|
+
# Analytics
|
|
1132
|
+
"databricks": "analytics",
|
|
1133
|
+
"bigquery": "analytics",
|
|
1134
|
+
"snowflake": "analytics",
|
|
1135
|
+
# Observability
|
|
1136
|
+
"datadog": "observability",
|
|
1137
|
+
"newrelic": "observability",
|
|
1138
|
+
"elasticsearch": "observability",
|
|
1139
|
+
"prometheus": "observability",
|
|
1140
|
+
"grafana": "observability",
|
|
1141
|
+
"splunk": "observability",
|
|
1142
|
+
# Auth
|
|
1143
|
+
"auth0": "auth",
|
|
1144
|
+
"okta": "auth",
|
|
1145
|
+
"hashicorp-vault": "auth",
|
|
1146
|
+
# SaaS
|
|
1147
|
+
"stripe": "saas",
|
|
1148
|
+
"twilio": "saas",
|
|
1149
|
+
"sendgrid": "saas",
|
|
1150
|
+
"salesforce": "saas",
|
|
1151
|
+
"servicenow": "saas",
|
|
1152
|
+
# IBM
|
|
1153
|
+
"ibm": "cloud",
|
|
1154
|
+
"ibm-mq": "messaging",
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _classify_library(group_id: str, artifact_id: str) -> dict | None:
|
|
1159
|
+
for g, a, svc in _MAVEN_SERVICE_RULES:
|
|
1160
|
+
if g and not group_id.startswith(g):
|
|
1161
|
+
continue
|
|
1162
|
+
if a and a not in artifact_id:
|
|
1163
|
+
continue
|
|
1164
|
+
if g or a:
|
|
1165
|
+
return svc
|
|
1166
|
+
return None
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _classify_npm_package(pkg: str) -> dict | None:
|
|
1170
|
+
for pattern, svc in _NPM_SERVICE_RULES:
|
|
1171
|
+
if pkg == pattern or pkg.startswith(pattern):
|
|
1172
|
+
return svc
|
|
1173
|
+
return None
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _classify_db_url(url: str, host: str, db_type: str) -> dict | None:
|
|
1177
|
+
combined = f"{url} {host} {db_type}".lower()
|
|
1178
|
+
for pattern, svc in _DB_SERVICE_RULES:
|
|
1179
|
+
if pattern in combined:
|
|
1180
|
+
return svc
|
|
1181
|
+
return None
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _classify_api_url(url: str) -> dict | None:
|
|
1185
|
+
url_l = url.lower()
|
|
1186
|
+
for pattern, svc in _URL_SERVICE_RULES:
|
|
1187
|
+
if pattern in url_l:
|
|
1188
|
+
return svc
|
|
1189
|
+
return None
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _service_category(svc_id: str) -> str:
|
|
1193
|
+
return _SERVICE_CATEGORIES.get(svc_id, "external")
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# ---------------------------------------------------------------------------
|
|
1197
|
+
# Pure helper functions
|
|
1198
|
+
# ---------------------------------------------------------------------------
|
|
1199
|
+
|
|
1200
|
+
def _normalise_path(path: str) -> str:
|
|
1201
|
+
"""Lowercase, strip leading slash, collapse path params, strip query string."""
|
|
1202
|
+
p = (path or "").strip().lstrip("/").lower().split("?")[0]
|
|
1203
|
+
p = re.sub(r'(?:\{[^}]+\}|:[^/]+|\d+)', '{p}', p)
|
|
1204
|
+
return p.strip("/")
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _strip_suffix(name: str) -> str:
|
|
1208
|
+
"""Remove common class-name suffixes to get the domain concept."""
|
|
1209
|
+
for suffix in _SERVICE_SUFFIXES:
|
|
1210
|
+
if name.endswith(suffix) and len(name) > len(suffix):
|
|
1211
|
+
return name[: -len(suffix)]
|
|
1212
|
+
return name
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _normalise_name(name: str) -> str:
|
|
1216
|
+
"""Convert CamelCase/snake_case/kebab to lowercase words joined."""
|
|
1217
|
+
# Insert space before uppercase sequences
|
|
1218
|
+
s = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
|
|
1219
|
+
s = re.sub(r'([a-z\d])([A-Z])', r'\1 \2', s)
|
|
1220
|
+
s = s.replace("_", " ").replace("-", " ").lower().strip()
|
|
1221
|
+
return s
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _synonym_group(word: str) -> int | None:
|
|
1225
|
+
return _WORD_TO_GROUP.get(word.lower())
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _find_semantic_matches(names_a: set[str], names_b: set[str]) -> list[str]:
|
|
1229
|
+
"""Return list of concept strings that appear in both sets (with synonym expansion)."""
|
|
1230
|
+
shared = []
|
|
1231
|
+
for na in names_a:
|
|
1232
|
+
for nb in names_b:
|
|
1233
|
+
if na == nb:
|
|
1234
|
+
shared.append(na)
|
|
1235
|
+
break
|
|
1236
|
+
# Check synonym overlap: any word in na shares a group with any word in nb
|
|
1237
|
+
words_a = set(na.split())
|
|
1238
|
+
words_b = set(nb.split())
|
|
1239
|
+
for wa in words_a:
|
|
1240
|
+
ga = _synonym_group(wa)
|
|
1241
|
+
if ga is None:
|
|
1242
|
+
continue
|
|
1243
|
+
for wb in words_b:
|
|
1244
|
+
if _synonym_group(wb) == ga:
|
|
1245
|
+
shared.append(f"{na} ~ {nb}")
|
|
1246
|
+
break
|
|
1247
|
+
else:
|
|
1248
|
+
continue
|
|
1249
|
+
break
|
|
1250
|
+
return list(dict.fromkeys(shared)) # preserve order, deduplicate
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def _group_shared_artifact(
|
|
1254
|
+
rows: list[dict],
|
|
1255
|
+
key_field: str,
|
|
1256
|
+
relation_type: str,
|
|
1257
|
+
label: str,
|
|
1258
|
+
) -> list[RepositoryRelationship]:
|
|
1259
|
+
"""Group artifact rows by artifact name; emit relationship for each pair of repos sharing it."""
|
|
1260
|
+
artifact_to_repos: dict[str, set[str]] = {}
|
|
1261
|
+
for r in rows:
|
|
1262
|
+
artifact = (r.get(key_field) or "").strip()
|
|
1263
|
+
repo = r.get("repo", "")
|
|
1264
|
+
if artifact and repo:
|
|
1265
|
+
artifact_to_repos.setdefault(artifact, set()).add(repo)
|
|
1266
|
+
|
|
1267
|
+
out = []
|
|
1268
|
+
for artifact, repo_set in artifact_to_repos.items():
|
|
1269
|
+
repo_list = sorted(repo_set)
|
|
1270
|
+
for i, ra in enumerate(repo_list):
|
|
1271
|
+
for rb in repo_list[i + 1:]:
|
|
1272
|
+
out.append(RepositoryRelationship(
|
|
1273
|
+
from_repo=ra,
|
|
1274
|
+
to_repo=rb,
|
|
1275
|
+
relation_type=relation_type,
|
|
1276
|
+
confidence="MEDIUM",
|
|
1277
|
+
evidence=[f"{label}: {artifact}"],
|
|
1278
|
+
details=[{key_field: artifact}],
|
|
1279
|
+
))
|
|
1280
|
+
return out
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _dedup_relationships(
|
|
1284
|
+
rels: list[RepositoryRelationship],
|
|
1285
|
+
) -> list[RepositoryRelationship]:
|
|
1286
|
+
"""
|
|
1287
|
+
For the same (from, to, type) keep only the highest-confidence entry.
|
|
1288
|
+
Merge evidence lists.
|
|
1289
|
+
"""
|
|
1290
|
+
_order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
1291
|
+
best: dict[tuple, RepositoryRelationship] = {}
|
|
1292
|
+
for r in rels:
|
|
1293
|
+
key = (r.from_repo, r.to_repo, r.relation_type)
|
|
1294
|
+
existing = best.get(key)
|
|
1295
|
+
if existing is None or _order[r.confidence] < _order[existing.confidence]:
|
|
1296
|
+
best[key] = r
|
|
1297
|
+
else:
|
|
1298
|
+
# Merge evidence
|
|
1299
|
+
existing.evidence = list(dict.fromkeys(existing.evidence + r.evidence))[:10]
|
|
1300
|
+
existing.details = (existing.details + r.details)[:20]
|
|
1301
|
+
return list(best.values())
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _rel_to_dict(r: RepositoryRelationship) -> dict[str, Any]:
|
|
1305
|
+
return {
|
|
1306
|
+
"from": r.from_repo,
|
|
1307
|
+
"to": r.to_repo,
|
|
1308
|
+
"type": r.relation_type,
|
|
1309
|
+
"confidence": r.confidence,
|
|
1310
|
+
"evidence": r.evidence,
|
|
1311
|
+
"details": r.details,
|
|
1312
|
+
}
|