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,1089 @@
1
+ """
2
+ Repository Relationships Router
3
+
4
+ GET /api/analysis/repository-relationships
5
+ Discover and return all relationships between indexed repositories.
6
+ Uses 4-pass strategy: explicit → path-matching → semantic names → shared artifacts.
7
+
8
+ GET /api/analysis/repository-relationships/graph.html
9
+ Two-level interactive graph:
10
+ Level 1 (overview) — one node per repository, edges show cross-repo relationships
11
+ Level 2 (drill-down) — double-click a repo node to expand its internal graph
12
+ (classes, components, endpoints, APIs) with cross-repo edges highlighted
13
+ """
14
+ import json
15
+ from typing import Any
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
18
+ from fastapi.responses import HTMLResponse, JSONResponse
19
+ import structlog
20
+
21
+ from dependency_injector.wiring import Provide, inject
22
+
23
+ from src.config.container import Container
24
+ from src.infrastructure.persistence.repository_relationship_repository import (
25
+ RepositoryRelationshipRepository,
26
+ )
27
+
28
+ logger = structlog.get_logger(__name__)
29
+
30
+ router = APIRouter(prefix="/api/analysis", tags=["relationships"])
31
+
32
+ _CONF_COLOURS = {
33
+ "HIGH": "#27AE60",
34
+ "MEDIUM": "#F39C12",
35
+ "LOW": "#E74C3C",
36
+ }
37
+
38
+ _RELATION_LABELS = {
39
+ "CALLS_BACKEND": "calls HTTP",
40
+ "CALLS_API": "calls API",
41
+ "USES_LIBRARY": "shared lib",
42
+ "DEPENDS_ON": "depends on",
43
+ "POSSIBLE_MATCH": "possible link",
44
+ "SHARED_DB": "shared DB",
45
+ "SHARED_EVENT": "shared event",
46
+ "USES_SERVICE": "uses service",
47
+ "USES_DB_SCHEMA": "uses DB schema",
48
+ }
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # JSON endpoint
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @router.get(
56
+ "/repository-relationships",
57
+ status_code=status.HTTP_200_OK,
58
+ summary="Discover relationships between all indexed repositories",
59
+ description=(
60
+ "4-pass discovery:\n"
61
+ "1. Explicit (HIGH) — CALLS_BACKEND, USES_LIBRARY, DEPENDS_ON\n"
62
+ "2. Path matching (MEDIUM) — unmatched API calls fuzzy-matched to endpoints\n"
63
+ "3. Semantic names (LOW/MEDIUM) — shared domain concepts across class names\n"
64
+ "4. Shared artifacts (MEDIUM) — same DB table or event name\n\n"
65
+ "Every relationship includes `confidence`, `evidence`, and `details`."
66
+ ),
67
+ )
68
+ @inject
69
+ async def get_repository_relationships(
70
+ confidence: str | None = Query(
71
+ default=None,
72
+ description="Filter by minimum confidence: HIGH | MEDIUM | LOW",
73
+ pattern="^(HIGH|MEDIUM|LOW)$",
74
+ ),
75
+ relation_type: str | None = Query(
76
+ default=None,
77
+ description="Filter by relation type, e.g. CALLS_BACKEND",
78
+ ),
79
+ repo: str | None = Query(
80
+ default=None,
81
+ description="Only show relationships involving this repository",
82
+ ),
83
+ relationship_repo: RepositoryRelationshipRepository = Depends(
84
+ Provide[Container.relationship_repository]
85
+ ),
86
+ ) -> JSONResponse:
87
+ logger.info("repository_relationships_requested", confidence=confidence, relation_type=relation_type, repo=repo)
88
+ try:
89
+ graph = await relationship_repo.discover_all()
90
+ rels = graph.relationships
91
+
92
+ if confidence:
93
+ order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
94
+ max_order = order[confidence]
95
+ rels = [r for r in rels if order.get(r["confidence"], 99) <= max_order]
96
+ if relation_type:
97
+ rels = [r for r in rels if r["type"] == relation_type]
98
+ if repo:
99
+ rels = [r for r in rels if r["from"] == repo or r["to"] == repo]
100
+
101
+ return JSONResponse(content={
102
+ "repositories": graph.repositories,
103
+ "relationships": rels,
104
+ "meta": {
105
+ **graph.meta,
106
+ "filtered_count": len(rels),
107
+ "filters_applied": {"confidence": confidence, "relation_type": relation_type, "repo": repo},
108
+ },
109
+ })
110
+ except Exception as e:
111
+ logger.error("repository_relationships_failed", error=str(e))
112
+ raise HTTPException(status_code=500, detail=f"Repository relationship discovery failed: {e}")
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Two-level interactive HTML graph
117
+ # ---------------------------------------------------------------------------
118
+
119
+ @router.get(
120
+ "/repository-relationships/graph.html",
121
+ status_code=status.HTTP_200_OK,
122
+ response_class=HTMLResponse,
123
+ summary="Two-level interactive repository relationship graph",
124
+ description=(
125
+ "Level 1 — overview: one node per repository, edges = cross-repo relationships. "
126
+ "Level 2 — drill-down: double-click a repository node to expand its internal "
127
+ "dependency graph (classes, components, endpoints, APIs). "
128
+ "Cross-repo edges are highlighted in the drill-down view. "
129
+ "Press Esc or the Back button to return to Level 1."
130
+ ),
131
+ )
132
+ @inject
133
+ async def get_repository_relationships_html(
134
+ confidence: str | None = Query(
135
+ default=None,
136
+ description="Filter by minimum confidence: HIGH | MEDIUM | LOW",
137
+ pattern="^(HIGH|MEDIUM|LOW)$",
138
+ ),
139
+ relationship_repo: RepositoryRelationshipRepository = Depends(
140
+ Provide[Container.relationship_repository]
141
+ ),
142
+ ) -> HTMLResponse:
143
+ try:
144
+ graph = await relationship_repo.discover_all()
145
+ rels = graph.relationships
146
+ if confidence:
147
+ order = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
148
+ max_order = order[confidence]
149
+ rels = [r for r in rels if order.get(r["confidence"], 99) <= max_order]
150
+
151
+ html = _build_two_level_html(graph.repositories, rels, graph.meta, graph.external_services)
152
+ return HTMLResponse(content=html)
153
+ except Exception as e:
154
+ logger.error("repository_relationships_html_failed", error=str(e))
155
+ raise HTTPException(status_code=500, detail=f"Repository graph HTML failed: {e}")
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # HTML builder — two-level graph
160
+ # ---------------------------------------------------------------------------
161
+
162
+ def _build_two_level_html(
163
+ repos: list[str],
164
+ relationships: list[dict[str, Any]],
165
+ meta: dict[str, Any],
166
+ external_services: list[dict[str, Any]] | None = None,
167
+ ) -> str:
168
+ # Colours per external service category
169
+ _EXT_CATEGORY_COLOURS = {
170
+ "ai": {"bg": "#7B2FBE", "border": "#9B59B6"}, # purple
171
+ "azure": {"bg": "#0078D4", "border": "#106EBE"}, # azure blue
172
+ "database": {"bg": "#C0392B", "border": "#E74C3C"}, # red
173
+ "messaging": {"bg": "#16A085", "border": "#1ABC9C"}, # teal
174
+ "analytics": {"bg": "#D35400", "border": "#E67E22"}, # orange
175
+ "observability":{"bg": "#2C3E50","border": "#34495E"}, # dark
176
+ "auth": {"bg": "#27AE60", "border": "#2ECC71"}, # green
177
+ "cloud": {"bg": "#2980B9", "border": "#3498DB"}, # light blue
178
+ "saas": {"bg": "#8E44AD", "border": "#9B59B6"}, # violet
179
+ "external": {"bg": "#7F8C8D", "border": "#95A5A6"}, # grey
180
+ }
181
+
182
+ out_degree: dict[str, int] = {}
183
+ in_degree: dict[str, int] = {}
184
+ for rel in relationships:
185
+ out_degree[rel["from"]] = out_degree.get(rel["from"], 0) + 1
186
+ in_degree[rel["to"]] = in_degree.get(rel["to"], 0) + 1
187
+
188
+ # Level-1 nodes — repositories
189
+ l1_nodes = []
190
+ for repo in repos:
191
+ out_d = out_degree.get(repo, 0)
192
+ in_d = in_degree.get(repo, 0)
193
+ size = max(18, min(55, 18 + (out_d + in_d) * 4))
194
+ l1_nodes.append({
195
+ "id": repo,
196
+ "label": repo,
197
+ "title": (
198
+ f"<b>{repo}</b><br/>"
199
+ f"outgoing: {out_d} &nbsp;·&nbsp; incoming: {in_d}<br/>"
200
+ f"<i>Double-click to drill down</i>"
201
+ ),
202
+ "size": size,
203
+ "color": {
204
+ "background": "#4A90D9",
205
+ "border": "#2C3E50",
206
+ "highlight": {"background": "#e94560", "border": "#fff"},
207
+ "hover": {"background": "#74B3E8", "border": "#fff"},
208
+ },
209
+ "font": {"color": "#eee", "size": 14, "bold": True},
210
+ "shape": "dot",
211
+ "_node_type": "REPOSITORY",
212
+ })
213
+
214
+ # Level-1 external service nodes
215
+ ext_svcs = external_services or []
216
+ seen_ext_ids: set[str] = set()
217
+ for svc in ext_svcs:
218
+ svc_id = svc["id"]
219
+ if svc_id in seen_ext_ids:
220
+ continue
221
+ seen_ext_ids.add(svc_id)
222
+ cat = svc.get("category", "external")
223
+ colours = _EXT_CATEGORY_COLOURS.get(cat, _EXT_CATEGORY_COLOURS["external"])
224
+ # Count how many repos use this service
225
+ usage_count = sum(
226
+ 1 for r in relationships
227
+ if r.get("to") == svc_id and r.get("type") == "USES_SERVICE"
228
+ )
229
+ l1_nodes.append({
230
+ "id": svc_id,
231
+ "label": svc["label"],
232
+ "title": (
233
+ f"<b>{svc['label']}</b><br/>"
234
+ f"category: {cat}<br/>"
235
+ f"used by {usage_count} repo(s)"
236
+ ),
237
+ "size": max(14, min(40, 14 + usage_count * 5)),
238
+ "color": {
239
+ "background": colours["bg"],
240
+ "border": colours["border"],
241
+ "highlight": {"background": "#e94560", "border": "#fff"},
242
+ "hover": {"background": "#fff", "border": colours["border"]},
243
+ },
244
+ "font": {"color": "#eee", "size": 11},
245
+ "shape": "diamond",
246
+ "_node_type": "EXTERNAL_SERVICE",
247
+ "_category": cat,
248
+ })
249
+
250
+ # Level-1 edges — cross-repo + repo→service relationships
251
+ l1_edges = []
252
+ for i, rel in enumerate(relationships):
253
+ is_service = rel.get("type") == "USES_SERVICE"
254
+ colour = _CONF_COLOURS.get(rel["confidence"], "#888")
255
+ if is_service:
256
+ # Use category colour of the service node
257
+ svc_id = rel.get("to", "")
258
+ svc_obj = next((s for s in ext_svcs if s["id"] == svc_id), None)
259
+ cat = svc_obj.get("category", "external") if svc_obj else "external"
260
+ colour = _EXT_CATEGORY_COLOURS.get(cat, _EXT_CATEGORY_COLOURS["external"])["bg"]
261
+ label = _RELATION_LABELS.get(rel["type"], rel["type"])
262
+ evidence_html = "<br/>".join(rel.get("evidence", [])[:4])
263
+ l1_edges.append({
264
+ "id": i,
265
+ "from": rel["from"],
266
+ "to": rel["to"],
267
+ "label": label,
268
+ "title": (
269
+ f"<b>{label}</b> &nbsp;<span style='color:{colour}'>[{rel['confidence']}]</span>"
270
+ f"<br/>{evidence_html}"
271
+ ),
272
+ "color": {"color": colour, "highlight": "#e94560", "hover": "#fff"},
273
+ "font": {"color": colour, "size": 10, "align": "middle"},
274
+ "arrows": "to",
275
+ "width": 2 if rel["confidence"] == "HIGH" else 1,
276
+ "dashes": rel["confidence"] == "LOW",
277
+ "_confidence": rel["confidence"],
278
+ "_type": rel["type"],
279
+ "_evidence": rel.get("evidence", []),
280
+ "_details": rel.get("details", [])[:5],
281
+ })
282
+
283
+ total_repos = meta.get("total_repos", len(repos))
284
+ explicit = meta.get("explicit", 0)
285
+ inferred = meta.get("path_inferred", 0) + meta.get("semantic_inferred", 0)
286
+ shared = meta.get("shared_artifact", 0)
287
+ ext_count = len(seen_ext_ids)
288
+
289
+ l1_nodes_json = json.dumps(l1_nodes)
290
+ l1_edges_json = json.dumps(l1_edges)
291
+ conf_colours_json = json.dumps(_CONF_COLOURS)
292
+
293
+ return f"""<!DOCTYPE html>
294
+ <html lang="en">
295
+ <head>
296
+ <meta charset="UTF-8"/>
297
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
298
+ <title>Repository Dependency Explorer</title>
299
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
300
+ <style>
301
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
302
+ body {{
303
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
304
+ background: #0d1117; color: #c9d1d9;
305
+ height: 100vh; display: flex; flex-direction: column; overflow: hidden;
306
+ }}
307
+
308
+ /* ── header ── */
309
+ #header {{
310
+ background: #161b22; padding: 10px 20px;
311
+ display: flex; align-items: center; gap: 12px;
312
+ border-bottom: 1px solid #30363d; flex-shrink: 0; min-height: 48px;
313
+ }}
314
+ #header h1 {{ font-size: 15px; font-weight: 700; color: #58a6ff; white-space: nowrap; }}
315
+ #breadcrumb {{
316
+ display: flex; align-items: center; gap: 6px;
317
+ font-size: 13px; color: #8b949e; flex: 1;
318
+ }}
319
+ #breadcrumb .crumb {{ color: #58a6ff; cursor: pointer; }}
320
+ #breadcrumb .crumb:hover {{ text-decoration: underline; }}
321
+ #breadcrumb .sep {{ color: #30363d; }}
322
+ #back-btn {{
323
+ display: none; background: #21262d; color: #c9d1d9;
324
+ border: 1px solid #30363d; padding: 5px 14px; border-radius: 6px;
325
+ cursor: pointer; font-size: 13px; transition: background .15s;
326
+ }}
327
+ #back-btn:hover {{ background: #30363d; }}
328
+ #header .meta {{ font-size: 11px; color: #8b949e; margin-left: auto; white-space: nowrap; }}
329
+
330
+ /* ── toolbar ── */
331
+ #toolbar {{
332
+ background: #161b22; padding: 7px 20px;
333
+ display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
334
+ border-bottom: 1px solid #30363d; flex-shrink: 0;
335
+ }}
336
+ #toolbar input {{
337
+ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
338
+ padding: 5px 10px; border-radius: 6px; font-size: 13px; width: 190px;
339
+ }}
340
+ #toolbar input:focus {{ outline: none; border-color: #58a6ff; }}
341
+ #toolbar button {{
342
+ background: #21262d; color: #c9d1d9; border: 1px solid #30363d;
343
+ padding: 5px 13px; border-radius: 6px; cursor: pointer; font-size: 13px;
344
+ }}
345
+ #toolbar button:hover {{ background: #30363d; }}
346
+ #toolbar button.primary {{
347
+ background: #1f6feb; border-color: #1f6feb; color: #fff;
348
+ }}
349
+ #toolbar button.primary:hover {{ background: #388bfd; }}
350
+ #toolbar select {{
351
+ background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
352
+ padding: 5px 8px; border-radius: 6px; font-size: 13px;
353
+ }}
354
+ #level-badge {{
355
+ font-size: 11px; padding: 2px 8px; border-radius: 10px;
356
+ font-weight: 600; letter-spacing: .5px;
357
+ }}
358
+ .badge-l1 {{ background: #1f6feb; color: #fff; }}
359
+ .badge-l2 {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
360
+ #legend {{
361
+ display: flex; gap: 12px; align-items: center; margin-left: auto;
362
+ }}
363
+ .leg {{ display: flex; align-items: center; gap: 5px; font-size: 11px; color: #8b949e; }}
364
+ .leg-dot {{ width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }}
365
+
366
+ /* ── main ── */
367
+ #main {{ display: flex; flex: 1; overflow: hidden; }}
368
+ #canvas {{ flex: 1; position: relative; }}
369
+ #network {{ width: 100%; height: 100%; }}
370
+
371
+ /* drill-down hint overlay */
372
+ #hint {{
373
+ position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
374
+ background: rgba(22,27,34,.85); border: 1px solid #30363d;
375
+ padding: 6px 14px; border-radius: 20px; font-size: 12px; color: #8b949e;
376
+ pointer-events: none; transition: opacity .3s;
377
+ }}
378
+
379
+ /* ── sidebar ── */
380
+ #sidebar {{
381
+ width: 310px; background: #161b22; border-left: 1px solid #30363d;
382
+ display: flex; flex-direction: column; flex-shrink: 0;
383
+ }}
384
+ #sidebar-header {{
385
+ padding: 12px 16px 8px; border-bottom: 1px solid #30363d; flex-shrink: 0;
386
+ }}
387
+ #sidebar-header h2 {{ font-size: 13px; font-weight: 600; color: #58a6ff; }}
388
+ #sidebar-body {{ flex: 1; overflow-y: auto; padding: 12px; }}
389
+ .card {{
390
+ background: #0d1117; border: 1px solid #30363d; border-radius: 8px;
391
+ padding: 10px 12px; margin-bottom: 10px; font-size: 12px;
392
+ }}
393
+ .card-title {{ font-weight: 600; color: #c9d1d9; margin-bottom: 6px; font-size: 13px; }}
394
+ .card-row {{ display: flex; justify-content: space-between; margin-bottom: 3px; }}
395
+ .card-label {{ color: #8b949e; }}
396
+ .card-val {{ color: #c9d1d9; text-align: right; max-width: 170px; word-break: break-all; }}
397
+ .badge {{
398
+ display: inline-block; font-size: 10px; font-weight: 700;
399
+ padding: 1px 6px; border-radius: 4px; letter-spacing: .4px;
400
+ }}
401
+ .badge-HIGH {{ background: #1a4731; color: #3fb950; border: 1px solid #238636; }}
402
+ .badge-MEDIUM {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
403
+ .badge-LOW {{ background: #3d0f0f; color: #f85149; border: 1px solid #da3633; }}
404
+ .badge-code {{ background: #1f3d6e; color: #58a6ff; border: 1px solid #1f6feb; }}
405
+ .badge-mod {{ background: #1a4731; color: #3fb950; border: 1px solid #238636; }}
406
+ .badge-api {{ background: #3d2b00; color: #e3b341; border: 1px solid #9e6a03; }}
407
+ .ev-list {{ margin-top: 6px; }}
408
+ .ev-item {{ color: #8b949e; padding: 2px 0; border-top: 1px solid #21262d; font-size: 11px; }}
409
+ .empty {{ color: #484f58; font-style: italic; font-size: 12px; }}
410
+ .drill-hint {{
411
+ background: #1f2937; border: 1px solid #1f6feb;
412
+ border-radius: 6px; padding: 8px 10px; margin-bottom: 10px;
413
+ font-size: 11px; color: #58a6ff;
414
+ }}
415
+
416
+ /* ── status bar ── */
417
+ #statusbar {{
418
+ padding: 4px 20px; background: #161b22; border-top: 1px solid #30363d;
419
+ font-size: 11px; color: #484f58; flex-shrink: 0;
420
+ }}
421
+ </style>
422
+ </head>
423
+ <body>
424
+
425
+ <div id="header">
426
+ <h1>&#9741; Dependency Explorer</h1>
427
+ <div id="breadcrumb">
428
+ <span class="crumb" onclick="goLevel1()">All Repositories</span>
429
+ <span class="sep" id="bc-sep" style="display:none">›</span>
430
+ <span id="bc-repo" style="display:none; color:#e3b341"></span>
431
+ </div>
432
+ <button id="back-btn" onclick="goLevel1()">&#8592; Back to overview</button>
433
+ <span class="meta" id="header-meta">
434
+ {total_repos} repos &nbsp;·&nbsp;
435
+ {ext_count} external services &nbsp;·&nbsp;
436
+ {len(relationships)} relationships &nbsp;·&nbsp;
437
+ {explicit} explicit &nbsp;·&nbsp;
438
+ {inferred} inferred
439
+ </span>
440
+ </div>
441
+
442
+ <div id="toolbar">
443
+ <span id="level-badge" class="level-badge badge-l1">LEVEL 1 — Overview</span>
444
+ <input id="search" type="text" placeholder="Search…"/>
445
+ <button class="primary" onclick="doSearch()">Find</button>
446
+ <button onclick="resetView()">Reset view</button>
447
+ <select id="confFilter" onchange="applyFilter()">
448
+ <option value="">All confidence</option>
449
+ <option value="HIGH">HIGH only</option>
450
+ <option value="MEDIUM">HIGH + MEDIUM</option>
451
+ </select>
452
+ <select id="typeFilter" onchange="applyFilter()">
453
+ <option value="">All types</option>
454
+ <option value="calls HTTP">calls HTTP</option>
455
+ <option value="calls API">calls API</option>
456
+ <option value="shared lib">shared lib</option>
457
+ <option value="depends on">depends on</option>
458
+ <option value="possible link">possible link</option>
459
+ <option value="shared DB">shared DB</option>
460
+ <option value="shared event">shared event</option>
461
+ <option value="uses service">uses service</option>
462
+ <option value="uses DB schema">uses DB schema</option>
463
+ </select>
464
+ <div id="legend">
465
+ <div class="leg"><div class="leg-dot" style="background:#27AE60"></div>HIGH</div>
466
+ <div class="leg"><div class="leg-dot" style="background:#F39C12"></div>MEDIUM</div>
467
+ <div class="leg"><div class="leg-dot" style="background:#E74C3C"></div>LOW</div>
468
+ <div class="leg"><div class="leg-dot" style="background:#4A90D9"></div>Repository</div>
469
+ <div class="leg"><div class="leg-dot" style="background:#74B3E8"></div>Class/Component</div>
470
+ <div class="leg"><div class="leg-dot" style="background:#E8A838"></div>API/Endpoint</div>
471
+ </div>
472
+ </div>
473
+
474
+ <div id="main">
475
+ <div id="canvas">
476
+ <div id="network"></div>
477
+ <div id="hint">Double-click a repository to drill down ↓</div>
478
+ </div>
479
+ <div id="sidebar">
480
+ <div id="sidebar-header"><h2 id="sidebar-title">Details</h2></div>
481
+ <div id="sidebar-body">
482
+ <p class="empty">Click a node or edge to inspect it.</p>
483
+ </div>
484
+ </div>
485
+ </div>
486
+
487
+ <div id="statusbar" id="statusBar">Loading…</div>
488
+
489
+ <script>
490
+ // ── Data from server ──────────────────────────────────────────────────────
491
+ const L1_NODES = {l1_nodes_json};
492
+ const L1_EDGES = {l1_edges_json};
493
+ const CONF_COLOURS = {conf_colours_json};
494
+
495
+ const CONF_ORDER = {{HIGH:0, MEDIUM:1, LOW:2}};
496
+
497
+ // ── State ─────────────────────────────────────────────────────────────────
498
+ let network = null;
499
+ let visNodes = null;
500
+ let visEdges = null;
501
+ let currentLevel = 1;
502
+ let currentRepo = null;
503
+
504
+ // ── Level-1 helpers ───────────────────────────────────────────────────────
505
+ function l1FilteredData() {{
506
+ const confF = document.getElementById('confFilter').value;
507
+ const typeF = document.getElementById('typeFilter').value;
508
+ const maxOrder = confF === 'HIGH' ? 0 : confF === 'MEDIUM' ? 1 : 99;
509
+
510
+ const edges = L1_EDGES.filter(e => {{
511
+ if ((CONF_ORDER[e._confidence] ?? 2) > maxOrder) return false;
512
+ if (typeF && e.label !== typeF) return false;
513
+ return true;
514
+ }});
515
+
516
+ const nodeIds = new Set();
517
+ edges.forEach(e => {{ nodeIds.add(e.from); nodeIds.add(e.to); }});
518
+ // always show isolated repos too
519
+ L1_NODES.forEach(n => nodeIds.add(n.id));
520
+ const nodes = L1_NODES.filter(n => nodeIds.has(n.id));
521
+ return {{nodes, edges}};
522
+ }}
523
+
524
+ // ── Init Level-1 ─────────────────────────────────────────────────────────
525
+ function initLevel1() {{
526
+ currentLevel = 1;
527
+ currentRepo = null;
528
+
529
+ document.getElementById('level-badge').textContent = 'LEVEL 1 — Overview';
530
+ document.getElementById('level-badge').className = 'level-badge badge-l1';
531
+ document.getElementById('back-btn').style.display = 'none';
532
+ document.getElementById('bc-sep').style.display = 'none';
533
+ document.getElementById('bc-repo').style.display = 'none';
534
+ document.getElementById('hint').style.opacity = '1';
535
+ document.getElementById('confFilter').style.display = '';
536
+ document.getElementById('typeFilter').style.display = '';
537
+
538
+ const {{nodes, edges}} = l1FilteredData();
539
+ visNodes = new vis.DataSet(nodes);
540
+ visEdges = new vis.DataSet(edges);
541
+
542
+ if (network) network.destroy();
543
+ network = new vis.Network(
544
+ document.getElementById('network'),
545
+ {{nodes: visNodes, edges: visEdges}},
546
+ {{
547
+ physics: {{
548
+ enabled: true,
549
+ solver: 'forceAtlas2Based',
550
+ forceAtlas2Based: {{
551
+ gravitationalConstant: -120,
552
+ centralGravity: 0.005,
553
+ springLength: 200,
554
+ springConstant: 0.04,
555
+ damping: 0.9,
556
+ }},
557
+ stabilization: {{iterations: 400, updateInterval: 25}},
558
+ }},
559
+ interaction: {{
560
+ hover: true, tooltipDelay: 80,
561
+ navigationButtons: true, keyboard: true,
562
+ multiselect: false,
563
+ }},
564
+ layout: {{improvedLayout: true}},
565
+ }}
566
+ );
567
+
568
+ network.on('click', params => onL1Click(params));
569
+ network.on('doubleClick', params => onL1DoubleClick(params));
570
+ network.on('stabilizationIterationsDone', () => {{
571
+ network.setOptions({{physics: false}});
572
+ setStatus(nodes.length + ' repositories · ' + edges.length + ' relationships · stabilized');
573
+ }});
574
+ setStatus(nodes.length + ' repositories · ' + edges.length + ' relationships · stabilizing…');
575
+ setSidebar('Details', '<p class="empty">Click a node or edge to inspect it.</p>');
576
+ }}
577
+
578
+ function onL1Click(params) {{
579
+ if (params.nodes.length > 0) {{
580
+ const node = visNodes.get(params.nodes[0]);
581
+ if (node && node._node_type === 'EXTERNAL_SERVICE') showExtServiceCard(node);
582
+ else showRepoCard(params.nodes[0]);
583
+ }} else if (params.edges.length > 0) showEdgeCard(params.edges[0]);
584
+ else setSidebar('Details', '<p class="empty">Click a node or edge.</p>');
585
+ }}
586
+
587
+ function onL1DoubleClick(params) {{
588
+ if (params.nodes.length === 0) return;
589
+ const node = visNodes.get(params.nodes[0]);
590
+ // External service nodes don't have an internal graph — skip drill-down
591
+ if (node && node._node_type === 'EXTERNAL_SERVICE') return;
592
+ drillDown(params.nodes[0]);
593
+ }}
594
+
595
+ function showRepoCard(repoId) {{
596
+ const edges = L1_EDGES.filter(e => e.from === repoId || e.to === repoId);
597
+ const outEdges = edges.filter(e => e.from === repoId);
598
+ const inEdges = edges.filter(e => e.to === repoId);
599
+
600
+ let html = `
601
+ <div class="card">
602
+ <div class="card-title">&#128230; ${{repoId}}</div>
603
+ <div class="card-row"><span class="card-label">Outgoing calls</span><span class="card-val">${{outEdges.length}}</span></div>
604
+ <div class="card-row"><span class="card-label">Incoming calls</span><span class="card-val">${{inEdges.length}}</span></div>
605
+ </div>
606
+ <div class="drill-hint">&#8592; Double-click this node to explore its internal dependency graph</div>
607
+ `;
608
+
609
+ if (outEdges.length) {{
610
+ html += '<div class="card"><div class="card-title">Calls →</div>';
611
+ outEdges.slice(0,10).forEach(e => {{
612
+ html += `<div class="card-row">
613
+ <span class="card-label">${{e.to}}</span>
614
+ <span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
615
+ </div>
616
+ <div style="font-size:11px;color:#8b949e;margin-bottom:5px">${{e._evidence.slice(0,2).join('<br/>')}}</div>`;
617
+ }});
618
+ html += '</div>';
619
+ }}
620
+ if (inEdges.length) {{
621
+ html += '<div class="card"><div class="card-title">← Called by</div>';
622
+ inEdges.slice(0,10).forEach(e => {{
623
+ html += `<div class="card-row">
624
+ <span class="card-label">${{e.from}}</span>
625
+ <span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
626
+ </div>`;
627
+ }});
628
+ html += '</div>';
629
+ }}
630
+ setSidebar(repoId, html);
631
+ }}
632
+
633
+ function showEdgeCard(edgeId) {{
634
+ const edge = visEdges.get(edgeId);
635
+ if (!edge) return;
636
+ let html = `
637
+ <div class="card">
638
+ <div class="card-title">${{edge.from}} → ${{edge.to}}</div>
639
+ <div class="card-row"><span class="card-label">Type</span><span class="card-val">${{edge.label}}</span></div>
640
+ <div class="card-row"><span class="card-label">Confidence</span>
641
+ <span class="card-val"><span class="badge badge-${{edge._confidence}}">${{edge._confidence}}</span></span>
642
+ </div>
643
+ </div>`;
644
+ if (edge._evidence && edge._evidence.length) {{
645
+ html += '<div class="card"><div class="card-title">Evidence</div><div class="ev-list">';
646
+ edge._evidence.forEach(ev => {{ html += `<div class="ev-item">${{ev}}</div>`; }});
647
+ html += '</div></div>';
648
+ }}
649
+ if (edge._details && edge._details.length) {{
650
+ html += '<div class="card"><div class="card-title">Details</div>';
651
+ edge._details.slice(0,5).forEach(d => {{
652
+ const kv = Object.entries(d).slice(0,4);
653
+ kv.forEach(([k,v]) => {{
654
+ html += `<div class="card-row"><span class="card-label">${{k}}</span><span class="card-val">${{v}}</span></div>`;
655
+ }});
656
+ }});
657
+ html += '</div>';
658
+ }}
659
+ setSidebar(`${{edge.from}} → ${{edge.to}}`, html);
660
+ }}
661
+
662
+ function showExtServiceCard(node) {{
663
+ const svcId = node.id;
664
+ const cat = node._category || 'external';
665
+ // Find all repos that use this service
666
+ const callers = L1_EDGES.filter(e => e.to === svcId && e._type === 'USES_SERVICE');
667
+ const catIcons = {{
668
+ ai: '🤖', azure: '☁️', database: '🗄️', messaging: '📨',
669
+ analytics: '📊', observability: '📡', auth: '🔐', cloud: '☁️', saas: '🔌',
670
+ }};
671
+ const icon = catIcons[cat] || '🔌';
672
+ let html = `<div class="card">
673
+ <div class="card-title">${{icon}} ${{node.label}}</div>
674
+ <div class="card-row"><span class="card-label">Category</span>
675
+ <span class="card-val"><span class="badge badge-code">${{cat}}</span></span></div>
676
+ <div class="card-row"><span class="card-label">Used by</span>
677
+ <span class="card-val">${{callers.length}} repo(s)</span></div>
678
+ </div>`;
679
+ if (callers.length) {{
680
+ html += '<div class="card"><div class="card-title">Consumers</div>';
681
+ callers.forEach(e => {{
682
+ html += `<div class="card-row">
683
+ <span class="card-label">${{e.from}}</span>
684
+ <span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span>
685
+ </div>
686
+ <div style="font-size:11px;color:#8b949e;margin-bottom:5px">
687
+ ${{(e._evidence||[]).slice(0,2).join('<br/>')}}
688
+ </div>`;
689
+ }});
690
+ html += '</div>';
691
+ }}
692
+ setSidebar(node.label, html);
693
+ }}
694
+
695
+ // ── Level-2 drill-down ────────────────────────────────────────────────────
696
+ async function drillDown(repoId) {{
697
+ currentLevel = 2;
698
+ currentRepo = repoId;
699
+
700
+ document.getElementById('level-badge').textContent = 'LEVEL 2 — ' + repoId;
701
+ document.getElementById('level-badge').className = 'level-badge badge-l2';
702
+ document.getElementById('back-btn').style.display = '';
703
+ document.getElementById('bc-sep').style.display = '';
704
+ document.getElementById('bc-repo').style.display = '';
705
+ document.getElementById('bc-repo').textContent = repoId;
706
+ document.getElementById('hint').style.opacity = '0';
707
+ document.getElementById('confFilter').style.display = 'none';
708
+ document.getElementById('typeFilter').style.display = 'none';
709
+
710
+ setStatus('Loading internal graph for ' + repoId + '…');
711
+ setSidebar(repoId, '<p class="empty">Loading…</p>');
712
+
713
+ let graphData = null;
714
+ try {{
715
+ const resp = await fetch('/api/export/graph.json?repository=' + encodeURIComponent(repoId) + '&group=true&max_nodes=200');
716
+ if (resp.ok) graphData = await resp.json();
717
+ }} catch(err) {{
718
+ console.warn('graph.json fetch failed', err);
719
+ }}
720
+
721
+ if (!graphData || !graphData.nodes || graphData.nodes.length === 0) {{
722
+ setSidebar(repoId, `<p class="empty">No internal graph data found for "${{repoId}}".<br/>Index this repository first.</p>`);
723
+ setStatus('No data for ' + repoId);
724
+ initLevel2Empty(repoId);
725
+ return;
726
+ }}
727
+
728
+ // Sanitise node IDs — vis.js chokes on '.', '../', './' and other
729
+ // relative path fragments used as TypeScript import paths.
730
+ // We replace them with safe equivalents and patch edges to match.
731
+ const idMap = {{}}; // original → safe
732
+ graphData.nodes.forEach(n => {{
733
+ const orig = n.id;
734
+ // Replace ./ ../ and leading dots; collapse spaces
735
+ const safe = String(orig)
736
+ .replace(/^\.\.\//g, '__parent__/')
737
+ .replace(/^\.\//g, '__rel__/')
738
+ .replace(/^\.$/, '__dot__')
739
+ .replace(/^\.\.$/, '__dotdot__')
740
+ .replace(/[^a-zA-Z0-9_\-\/.:@]/g, '_');
741
+ idMap[orig] = safe;
742
+ n.id = safe;
743
+ }});
744
+ graphData.edges.forEach(e => {{
745
+ e.source = idMap[e.source] || e.source;
746
+ e.target = idMap[e.target] || e.target;
747
+ }});
748
+
749
+ initLevel2(repoId, graphData);
750
+ }}
751
+
752
+ // Colour by neo4j label (more specific than generic type)
753
+ const LABEL_COLOURS = {{
754
+ CLASS: '#4A90D9',
755
+ METHOD: '#74B3E8',
756
+ REPOSITORY: '#2C6FA8',
757
+ LIBRARY: '#52BE80',
758
+ NPM_PACKAGE: '#27AE60',
759
+ API: '#E8A838',
760
+ ENDPOINT: '#F39C12',
761
+ DATABASE: '#E67E22',
762
+ Component: '#5DADE2',
763
+ Microfrontend: '#A569BD',
764
+ WORKSPACE: '#BDC3C7',
765
+ PROJECT: '#85929E',
766
+ // DB schema objects
767
+ DB_TABLE: '#C0392B',
768
+ DB_VIEW: '#E74C3C',
769
+ DB_PROCEDURE: '#D35400',
770
+ DB_PACKAGE: '#E67E22',
771
+ DB_TRIGGER: '#A93226',
772
+ DB_SEQUENCE: '#CB4335',
773
+ DB_FUNCTION: '#BA4A00',
774
+ DB_COLLECTION: '#8E44AD',
775
+ }};
776
+ const TYPE_COLOURS = {{
777
+ code: '#4A90D9',
778
+ module: '#52BE80',
779
+ concept: '#E8A838',
780
+ document: '#9B59B6',
781
+ }};
782
+ const NODE_SHAPES = {{
783
+ CLASS: 'dot', METHOD: 'dot', Component: 'dot',
784
+ LIBRARY: 'square', NPM_PACKAGE: 'square',
785
+ API: 'diamond', ENDPOINT: 'diamond', DATABASE: 'diamond',
786
+ REPOSITORY: 'star', Microfrontend: 'hexagon',
787
+ DB_TABLE: 'database', DB_VIEW: 'diamond', DB_PROCEDURE: 'triangle',
788
+ DB_PACKAGE: 'triangle', DB_TRIGGER: 'triangleDown',
789
+ DB_COLLECTION: 'database',
790
+ }};
791
+
792
+ function nodeColour(n) {{
793
+ const lbl = (n.metadata || {{}}).neo4j_label || '';
794
+ return LABEL_COLOURS[lbl] || TYPE_COLOURS[n.type] || '#4A90D9';
795
+ }}
796
+ function nodeShape(n) {{
797
+ const lbl = (n.metadata || {{}}).neo4j_label || '';
798
+ return NODE_SHAPES[lbl] || (n.type === 'module' ? 'square' : n.type === 'concept' ? 'diamond' : 'dot');
799
+ }}
800
+ function nodeSize(n) {{
801
+ const lbl = (n.metadata || {{}}).neo4j_label || '';
802
+ if (lbl === 'REPOSITORY') return 20;
803
+ if (lbl === 'CLASS' || lbl === 'Component') return 12;
804
+ if (lbl === 'METHOD') return 8;
805
+ if (lbl === 'LIBRARY' || lbl === 'NPM_PACKAGE') return 11;
806
+ if (lbl === 'API' || lbl === 'ENDPOINT') return 10;
807
+ return 10;
808
+ }}
809
+
810
+ function initLevel2(repoId, graphData) {{
811
+ const visN = graphData.nodes.map(n => {{
812
+ const col = nodeColour(n);
813
+ return {{
814
+ id: n.id,
815
+ label: n.label,
816
+ title: buildNodeTooltip(n),
817
+ color: {{
818
+ background: col,
819
+ border: '#21262d',
820
+ highlight: {{background: '#e94560', border: '#fff'}},
821
+ hover: {{background: '#fff', border: col}},
822
+ }},
823
+ size: nodeSize(n),
824
+ shape: nodeShape(n),
825
+ font: {{color: '#c9d1d9', size: 10}},
826
+ _raw: n,
827
+ }};
828
+ }});
829
+
830
+ // Add "external repo" proxy nodes for cross-repo edges
831
+ const externalRepos = new Set();
832
+ L1_EDGES.forEach(e => {{
833
+ if (e.from === repoId && e.to !== repoId) externalRepos.add(e.to);
834
+ if (e.to === repoId && e.from !== repoId) externalRepos.add(e.from);
835
+ }});
836
+
837
+ externalRepos.forEach(extRepo => {{
838
+ visN.push({{
839
+ id: '__ext__' + extRepo,
840
+ label: extRepo,
841
+ title: `<b>External repo: ${{extRepo}}</b><br/>Connected via cross-repo relationship`,
842
+ color: {{
843
+ background: '#3d2b00',
844
+ border: '#9e6a03',
845
+ highlight: {{background: '#e3b341', border: '#fff'}},
846
+ }},
847
+ size: 18,
848
+ shape: 'box',
849
+ font: {{color: '#e3b341', size: 12, bold: true}},
850
+ }});
851
+ }});
852
+
853
+ // Internal edges
854
+ const visE = graphData.edges.map((e, i) => ({{
855
+ id: 'int_' + i,
856
+ from: e.source,
857
+ to: e.target,
858
+ label: e.relation,
859
+ color: {{color: '#30363d', highlight: '#58a6ff'}},
860
+ font: {{color: '#484f58', size: 9}},
861
+ arrows: 'to',
862
+ width: 1,
863
+ }}));
864
+
865
+ // Cross-repo edges — connect from the REPOSITORY node (or first node) to the external proxy
866
+ const repoRootNode =
867
+ graphData.nodes.find(n => n.id === repoId) ||
868
+ graphData.nodes.find(n => (n.metadata || {{}}).neo4j_label === 'REPOSITORY') ||
869
+ (graphData.nodes.length > 0 ? graphData.nodes[0] : null);
870
+ const rootId = repoRootNode ? repoRootNode.id : null;
871
+
872
+ L1_EDGES.forEach((e, i) => {{
873
+ const extRepo = e.from === repoId ? e.to : (e.to === repoId ? e.from : null);
874
+ if (!extRepo || !rootId) return;
875
+ const colour = CONF_COLOURS[e._confidence] || '#888';
876
+ visE.push({{
877
+ id: 'cross_' + i,
878
+ from: rootId,
879
+ to: '__ext__' + extRepo,
880
+ label: e.label,
881
+ title: `<b>Cross-repo: ${{e.label}}</b> [${{e._confidence}}]<br/>${{(e._evidence||[]).slice(0,3).join('<br/>')}}`,
882
+ color: {{color: colour, highlight: '#fff'}},
883
+ font: {{color: colour, size: 10, bold: true}},
884
+ arrows: e.from === repoId ? 'to' : 'from',
885
+ width: 3,
886
+ dashes: e._confidence === 'LOW',
887
+ }});
888
+ }});
889
+
890
+ visNodes = new vis.DataSet(visN);
891
+ visEdges = new vis.DataSet(visE);
892
+
893
+ if (network) network.destroy();
894
+ network = new vis.Network(
895
+ document.getElementById('network'),
896
+ {{nodes: visNodes, edges: visEdges}},
897
+ {{
898
+ physics: {{
899
+ enabled: true,
900
+ solver: 'barnesHut',
901
+ barnesHut: {{
902
+ gravitationalConstant: -3000,
903
+ centralGravity: 0.3,
904
+ springLength: 80,
905
+ springConstant: 0.04,
906
+ damping: 0.09,
907
+ }},
908
+ stabilization: {{iterations: 300}},
909
+ }},
910
+ interaction: {{
911
+ hover: true, tooltipDelay: 80,
912
+ navigationButtons: true, keyboard: true,
913
+ }},
914
+ }}
915
+ );
916
+
917
+ network.on('click', params => onL2Click(params));
918
+ network.on('doubleClick', params => {{
919
+ // double-click on external proxy node → navigate to that repo
920
+ if (params.nodes.length > 0) {{
921
+ const id = params.nodes[0];
922
+ if (id.startsWith('__ext__')) drillDown(id.replace('__ext__', ''));
923
+ }}
924
+ }});
925
+ network.on('stabilizationIterationsDone', () => {{
926
+ network.setOptions({{physics: false}});
927
+ setStatus(visN.length + ' nodes · ' + visE.length + ' edges · stabilized');
928
+ }});
929
+
930
+ setStatus(visN.length + ' nodes · ' + visE.length + ' edges · stabilizing…');
931
+ showL2Summary(repoId, graphData, externalRepos);
932
+ }}
933
+
934
+ function initLevel2Empty(repoId) {{
935
+ if (network) network.destroy();
936
+ visNodes = new vis.DataSet([{{id: repoId, label: repoId, color: {{background:'#4A90D9'}}, size: 25, shape: 'dot'}}]);
937
+ visEdges = new vis.DataSet([]);
938
+ network = new vis.Network(document.getElementById('network'), {{nodes: visNodes, edges: visEdges}}, {{}});
939
+ }}
940
+
941
+ function onL2Click(params) {{
942
+ if (params.nodes.length > 0) {{
943
+ const id = params.nodes[0];
944
+ if (id.startsWith('__ext__')) {{
945
+ const extRepo = id.replace('__ext__','');
946
+ showExternalRepoCard(extRepo);
947
+ }} else {{
948
+ const node = visNodes.get(id);
949
+ showInternalNodeCard(node);
950
+ }}
951
+ }} else if (params.edges.length > 0) {{
952
+ const edge = visEdges.get(params.edges[0]);
953
+ showInternalEdgeCard(edge);
954
+ }} else {{
955
+ showL2Summary(currentRepo, null, null);
956
+ }}
957
+ }}
958
+
959
+ function buildNodeTooltip(n) {{
960
+ const m = n.metadata || {{}};
961
+ const lines = [`<b>${{n.label}}</b>`, `type: ${{n.type}}`];
962
+ if (m.neo4j_label) lines.push(`neo4j: ${{m.neo4j_label}}`);
963
+ if (m.repository) lines.push(`repo: ${{m.repository}}`);
964
+ if (m.qualified_name) lines.push(`qname: ${{m.qualified_name}}`);
965
+ if (m.framework) lines.push(`fw: ${{m.framework}}`);
966
+ if (n.file) lines.push(`file: ${{n.file}}`);
967
+ return lines.join('<br/>');
968
+ }}
969
+
970
+ function showInternalNodeCard(node) {{
971
+ if (!node) return;
972
+ const r = node._raw || {{}};
973
+ const m = r.metadata || {{}};
974
+ let html = `<div class="card">
975
+ <div class="card-title">${{r.label || node.label}}</div>`;
976
+ const typeLabel = r.type || 'unknown';
977
+ const badgeClass = typeLabel === 'code' ? 'badge-code' : typeLabel === 'module' ? 'badge-mod' : 'badge-api';
978
+ html += `<div class="card-row"><span class="card-label">Type</span>
979
+ <span class="card-val"><span class="badge ${{badgeClass}}">${{typeLabel}}</span></span></div>`;
980
+ for (const [k,v] of Object.entries(m)) {{
981
+ if (v) html += `<div class="card-row"><span class="card-label">${{k}}</span><span class="card-val">${{v}}</span></div>`;
982
+ }}
983
+ if (r.file) html += `<div class="card-row"><span class="card-label">file</span><span class="card-val">${{r.file}}</span></div>`;
984
+ html += '</div>';
985
+ setSidebar(r.label || node.label, html);
986
+ }}
987
+
988
+ function showInternalEdgeCard(edge) {{
989
+ if (!edge) return;
990
+ setSidebar('Edge', `<div class="card">
991
+ <div class="card-title">${{edge.from || ''}} → ${{edge.to || ''}}</div>
992
+ <div class="card-row"><span class="card-label">Relation</span><span class="card-val">${{edge.label || ''}}</span></div>
993
+ </div>`);
994
+ }}
995
+
996
+ function showExternalRepoCard(extRepo) {{
997
+ const rels = L1_EDGES.filter(e => (e.from === currentRepo && e.to === extRepo) || (e.from === extRepo && e.to === currentRepo));
998
+ let html = `<div class="card">
999
+ <div class="card-title">&#128230; ${{extRepo}}</div>
1000
+ <p style="font-size:11px;color:#8b949e;margin-top:4px">External repository connected to <b>${{currentRepo}}</b></p>
1001
+ </div>
1002
+ <div class="drill-hint">Double-click this node to explore ${{extRepo}}</div>`;
1003
+ if (rels.length) {{
1004
+ html += '<div class="card"><div class="card-title">Relationships</div>';
1005
+ rels.forEach(e => {{
1006
+ const dir = e.from === currentRepo ? `${{currentRepo}} → ${{extRepo}}` : `${{extRepo}} → ${{currentRepo}}`;
1007
+ html += `<div class="card-row"><span class="card-label">${{dir}}</span>
1008
+ <span class="card-val"><span class="badge badge-${{e._confidence}}">${{e._confidence}}</span></span></div>
1009
+ <div style="font-size:11px;color:#8b949e;margin-bottom:5px">${{(e._evidence||[]).slice(0,2).join('<br/>')}}</div>`;
1010
+ }});
1011
+ html += '</div>';
1012
+ }}
1013
+ setSidebar(extRepo, html);
1014
+ }}
1015
+
1016
+ function showL2Summary(repoId, graphData, externalRepos) {{
1017
+ const nodeCount = graphData ? graphData.nodes.length : 0;
1018
+ const edgeCount = graphData ? graphData.edges.length : 0;
1019
+ const extList = externalRepos ? [...externalRepos] : [];
1020
+ const rels = L1_EDGES.filter(e => e.from === repoId || e.to === repoId);
1021
+
1022
+ let html = `<div class="card">
1023
+ <div class="card-title">&#128230; ${{repoId}}</div>
1024
+ <div class="card-row"><span class="card-label">Internal nodes</span><span class="card-val">${{nodeCount}}</span></div>
1025
+ <div class="card-row"><span class="card-label">Internal edges</span><span class="card-val">${{edgeCount}}</span></div>
1026
+ <div class="card-row"><span class="card-label">Cross-repo links</span><span class="card-val">${{rels.length}}</span></div>
1027
+ </div>`;
1028
+
1029
+ if (extList.length) {{
1030
+ html += '<div class="card"><div class="card-title">Connected repositories</div>';
1031
+ extList.forEach(ext => {{
1032
+ const r = rels.find(e => e.from === ext || e.to === ext);
1033
+ html += `<div class="card-row">
1034
+ <span class="card-label" style="cursor:pointer;color:#58a6ff" onclick="drillDown('${{ext}}')">${{ext}}</span>
1035
+ ${{r ? `<span class="card-val"><span class="badge badge-${{r._confidence}}">${{r._confidence}}</span></span>` : ''}}
1036
+ </div>`;
1037
+ }});
1038
+ html += '</div>';
1039
+ }}
1040
+ setSidebar(repoId, html);
1041
+ }}
1042
+
1043
+ // ── Navigation ────────────────────────────────────────────────────────────
1044
+ function goLevel1() {{
1045
+ initLevel1();
1046
+ }}
1047
+
1048
+ // ── Shared utils ──────────────────────────────────────────────────────────
1049
+ function setSidebar(title, bodyHtml) {{
1050
+ document.getElementById('sidebar-title').textContent = title;
1051
+ document.getElementById('sidebar-body').innerHTML = bodyHtml;
1052
+ }}
1053
+
1054
+ function setStatus(msg) {{
1055
+ document.getElementById('statusbar').textContent = msg;
1056
+ }}
1057
+
1058
+ function resetView() {{
1059
+ if (network) network.fit({{animation: true}});
1060
+ }}
1061
+
1062
+ function doSearch() {{
1063
+ const term = document.getElementById('search').value.toLowerCase().trim();
1064
+ if (!term || !visNodes) return;
1065
+ const match = visNodes.get().find(n => (n.label||'').toLowerCase().includes(term));
1066
+ if (match) {{
1067
+ network.selectNodes([match.id]);
1068
+ network.focus(match.id, {{scale: 1.8, animation: true}});
1069
+ if (currentLevel === 1) showRepoCard(match.id);
1070
+ else showInternalNodeCard(match);
1071
+ }}
1072
+ }}
1073
+
1074
+ function applyFilter() {{
1075
+ if (currentLevel === 1) initLevel1();
1076
+ }}
1077
+
1078
+ document.getElementById('search').addEventListener('keydown', e => {{
1079
+ if (e.key === 'Enter') doSearch();
1080
+ }});
1081
+ document.addEventListener('keydown', e => {{
1082
+ if (e.key === 'Escape' && currentLevel === 2) goLevel1();
1083
+ }});
1084
+
1085
+ // ── Boot ──────────────────────────────────────────────────────────────────
1086
+ initLevel1();
1087
+ </script>
1088
+ </body>
1089
+ </html>"""