graph-dependency-analyzer 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. graph_dependency_analyzer-0.2.0.dist-info/METADATA +158 -0
  2. graph_dependency_analyzer-0.2.0.dist-info/RECORD +86 -0
  3. graph_dependency_analyzer-0.2.0.dist-info/WHEEL +5 -0
  4. graph_dependency_analyzer-0.2.0.dist-info/entry_points.txt +2 -0
  5. graph_dependency_analyzer-0.2.0.dist-info/top_level.txt +1 -0
  6. src/__init__.py +0 -0
  7. src/application/__init__.py +0 -0
  8. src/application/dtos/__init__.py +0 -0
  9. src/application/dtos/affected_component.py +59 -0
  10. src/application/dtos/batch_processing_result.py +17 -0
  11. src/application/dtos/impact_analysis_response.py +56 -0
  12. src/application/dtos/impact_query_request.py +38 -0
  13. src/application/dtos/index_batch_request.py +53 -0
  14. src/application/dtos/index_batch_response.py +72 -0
  15. src/application/dtos/index_result.py +20 -0
  16. src/application/dtos/repository_index_result.py +79 -0
  17. src/application/exceptions.py +41 -0
  18. src/application/services/__init__.py +0 -0
  19. src/application/services/file_scanner_service.py +258 -0
  20. src/application/services/progress_tracker.py +132 -0
  21. src/application/use_cases/__init__.py +0 -0
  22. src/application/use_cases/clear_all_data_use_case.py +180 -0
  23. src/application/use_cases/index_batch_use_case.py +153 -0
  24. src/application/use_cases/index_repository_use_case.py +683 -0
  25. src/application/use_cases/indexing_orchestrator.py +181 -0
  26. src/application/use_cases/list_repositories_use_case.py +84 -0
  27. src/cli.py +512 -0
  28. src/config/__init__.py +5 -0
  29. src/config/container.py +211 -0
  30. src/config/logging_config.py +123 -0
  31. src/config/settings.py +165 -0
  32. src/domain/__init__.py +0 -0
  33. src/domain/entities/__init__.py +20 -0
  34. src/domain/entities/ast_node.py +32 -0
  35. src/domain/entities/code_chunk.py +45 -0
  36. src/domain/entities/code_file.py +44 -0
  37. src/domain/entities/dependency_graph.py +110 -0
  38. src/domain/entities/repository.py +46 -0
  39. src/domain/exceptions.py +36 -0
  40. src/domain/repositories/__init__.py +22 -0
  41. src/domain/repositories/i_code_parser.py +50 -0
  42. src/domain/repositories/i_dependency_extractor.py +35 -0
  43. src/domain/repositories/i_embedding_provider.py +31 -0
  44. src/domain/repositories/i_graph_repository.py +78 -0
  45. src/domain/repositories/i_vector_repository.py +55 -0
  46. src/domain/services/__init__.py +8 -0
  47. src/domain/services/dependency_extractor.py +962 -0
  48. src/domain/services/semantic_chunk_builder.py +719 -0
  49. src/domain/value_objects/__init__.py +12 -0
  50. src/domain/value_objects/chunk_type.py +24 -0
  51. src/domain/value_objects/dependency_type.py +22 -0
  52. src/domain/value_objects/node_type.py +22 -0
  53. src/domain/value_objects/qualified_name.py +78 -0
  54. src/infrastructure/__init__.py +0 -0
  55. src/infrastructure/exceptions.py +41 -0
  56. src/infrastructure/external_apis/__init__.py +0 -0
  57. src/infrastructure/external_apis/ollama_embedding_provider.py +134 -0
  58. src/infrastructure/external_apis/openai_embedding_provider.py +231 -0
  59. src/infrastructure/parsing/__init__.py +20 -0
  60. src/infrastructure/parsing/config_file_parser.py +347 -0
  61. src/infrastructure/parsing/gradle_dependency_parser.py +159 -0
  62. src/infrastructure/parsing/groovy_parser.py +198 -0
  63. src/infrastructure/parsing/maven_dependency_parser.py +147 -0
  64. src/infrastructure/parsing/microfrontend_config_parser.py +242 -0
  65. src/infrastructure/parsing/npm_package_dependency_parser.py +255 -0
  66. src/infrastructure/parsing/python_parser.py +211 -0
  67. src/infrastructure/parsing/sql_schema_parser.py +658 -0
  68. src/infrastructure/parsing/tree_sitter_java_parser.py +434 -0
  69. src/infrastructure/parsing/tree_sitter_typescript_parser.py +600 -0
  70. src/infrastructure/persistence/__init__.py +0 -0
  71. src/infrastructure/persistence/graph_export_repository.py +443 -0
  72. src/infrastructure/persistence/neo4j_dependency_repository.py +657 -0
  73. src/infrastructure/persistence/qdrant_vector_repository.py +357 -0
  74. src/infrastructure/persistence/repository_relationship_repository.py +1312 -0
  75. src/presentation/__init__.py +0 -0
  76. src/presentation/api/__init__.py +0 -0
  77. src/presentation/api/rest/__init__.py +0 -0
  78. src/presentation/api/rest/exception_handlers.py +174 -0
  79. src/presentation/api/rest/main.py +80 -0
  80. src/presentation/api/rest/routers/__init__.py +5 -0
  81. src/presentation/api/rest/routers/admin.py +103 -0
  82. src/presentation/api/rest/routers/analysis.py +980 -0
  83. src/presentation/api/rest/routers/export.py +442 -0
  84. src/presentation/api/rest/routers/health.py +117 -0
  85. src/presentation/api/rest/routers/indexing.py +148 -0
  86. src/presentation/api/rest/routers/relationships.py +1089 -0
@@ -0,0 +1,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
+ }