diff --git a/docs/adr-dependency-graph-visualization-framework.md b/docs/adr-dependency-graph-visualization-framework.md new file mode 100644 index 0000000..3c69a08 --- /dev/null +++ b/docs/adr-dependency-graph-visualization-framework.md @@ -0,0 +1,40 @@ +# ADR: Dependency Graph Visualization Framework + +Status: accepted + +Context: dependency-aware scope propagation needs an interactive graph view where +curators can inspect fact-to-scope paths, stale nodes, evidence bridges, +same-layer normalization signals, and review recommendations. The visualization +must support practical graph exploration rather than only a static diagram. + +Decision: use Cytoscape.js for the interactive dependency graph visualization. + +Cytoscape.js is selected because it is graph-native: it supports pan, zoom, +selection, layouts, node and edge styling, filtering, and path highlighting in a +way that maps directly to the dependency model. It can be embedded inside the +existing FastAPI-served UI without requiring a full frontend rewrite. + +Variants considered: + +- React Flow: strong for polished node-card workflows and editable diagrams, but + it is more flow-builder-oriented than graph-analysis-oriented. Dense + dependency maps and impact-path exploration would require more custom layout + discipline. +- D3: maximum visual control and excellent for bespoke animated propagation, but + it carries the highest implementation and maintenance cost. It is too easy to + build a compelling custom view that becomes hard to evolve. +- Mermaid: lightweight and useful for static docs or fallback exports, but not + interactive enough for curator workflows that need selection, filtering, + pan/zoom, and path drilldown. + +Consequences: + +- The first visualization implementation should expose graph data as nodes and + edges that Cytoscape can consume directly. +- UI work can remain incremental: a single graph route and JSON endpoint can be + added before broader review-workflow screens. +- Cytoscape styling should encode repo-scoping semantics, not generic graph + decoration: node kind, edge strength, dependency type, staleness, and + same-layer flags should be visible. +- Mermaid may still be useful as an export or documentation format, but it is + not the primary interactive implementation. diff --git a/docs/dependency-aware-scope-propagation.md b/docs/dependency-aware-scope-propagation.md index 76c98b2..e17ee58 100644 --- a/docs/dependency-aware-scope-propagation.md +++ b/docs/dependency-aware-scope-propagation.md @@ -47,6 +47,18 @@ The current implementation exposes this through `RegistryService`: The impact result includes changed fact keys, impacted items, reason chains, maximum propagation depth, breadth, and whether the root scope was affected. +## Interactive Visualization + +The accepted implementation framework for the interactive graph view is +Cytoscape.js. See +`docs/adr-dependency-graph-visualization-framework.md` for the decision and the +tradeoffs against React Flow, D3, and Mermaid. + +The first UI implementation exposes `/repos/{repository_id}/dependency-graph` +as a Cytoscape-ready JSON payload and `/ui/repos/{repository_id}/dependency-graph` +as the graph page. The page supports full graph, impact-only, and selected-path +views with a detail panel for selected nodes and edges. + ## Metrics Propagation depth says how far a source change bubbled up. Propagation breadth diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index c8e2c66..16ba7f0 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -1090,6 +1090,115 @@ class RegistryService: graph=graph, ) + def dependency_graph_elements( + self, + repository_id: int, + *, + base_analysis_run_id: int | None = None, + target_analysis_run_id: int | None = None, + ) -> dict[str, object]: + impact = None + if base_analysis_run_id is not None or target_analysis_run_id is not None: + if base_analysis_run_id is None or target_analysis_run_id is None: + raise ValueError( + "base_analysis_run_id and target_analysis_run_id must be provided together" + ) + impact = self.analyze_dependency_impact( + repository_id, + base_analysis_run_id, + target_analysis_run_id, + ) + graph = impact.graph + else: + graph = self.build_dependency_graph(repository_id) + + impact_by_key = ( + {item.item_key: item for item in impact.impacts} if impact is not None else {} + ) + changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set() + nodes: dict[str, dict[str, object]] = {} + + def ensure_node(kind: str, key: str, item_id: int | None) -> None: + if key in nodes: + return + impact_item = impact_by_key.get(key) + is_changed_fact = key in changed_fact_keys + nodes[key] = { + "data": { + "id": key, + "kind": kind, + "label": self._dependency_node_label(repository_id, kind, key, item_id), + "ownership": self._ownership_for_kind(kind), + "freshnessState": ( + impact_item.freshness_state + if impact_item is not None + else "changed" + if is_changed_fact + else "current" + ), + "recommendedAction": ( + impact_item.recommended_action if impact_item is not None else "" + ), + "impactDepth": ( + impact_item.impact_depth if impact_item is not None else None + ), + "reasons": impact_item.reasons if impact_item is not None else [], + }, + "classes": " ".join( + class_name + for class_name in ( + kind, + "stale" if impact_item is not None else "current", + "changed" if is_changed_fact else "", + ) + if class_name + ), + } + + for edge in graph.edges: + ensure_node(edge.source_kind, edge.source_key, edge.source_id) + ensure_node(edge.target_kind, edge.target_key, edge.target_id) + + edges = [ + { + "data": { + "id": f"{edge.source_key}->{edge.target_key}:{index}", + "source": edge.source_key, + "target": edge.target_key, + "dependencyType": edge.dependency_type, + "strength": edge.strength, + "edgeSource": edge.source, + "sameLayer": edge.same_layer, + "label": edge.dependency_type, + }, + "classes": " ".join( + class_name + for class_name in ( + edge.dependency_type, + edge.strength, + "same-layer" if edge.same_layer else "", + ) + if class_name + ), + } + for index, edge in enumerate(graph.edges) + ] + return { + "repository": asdict(graph.repository), + "scope": asdict(graph.scope), + "mode": "impact" if impact is not None else "full", + "metrics": { + "node_count": len(nodes), + "edge_count": len(edges), + "propagation_breadth": impact.propagation_breadth if impact else 0, + "max_depth": impact.max_depth if impact else 0, + "scope_impacted": impact.scope_impacted if impact else False, + }, + "changed_fact_keys": impact.changed_fact_keys if impact else [], + "elements": [*nodes.values(), *edges], + "impacts": [asdict(item) for item in impact.impacts] if impact else [], + } + def approve_analysis_run_changes( self, repository_id: int, @@ -2300,6 +2409,24 @@ class RegistryService: return evidence.reference return f"{kind}:{item_id}" + def _dependency_node_label( + self, + repository_id: int, + kind: str, + key: str, + item_id: int | None, + ) -> str: + if item_id is not None and kind != "fact": + return self._dependency_display_name(repository_id, kind, item_id) + if kind == "fact": + parts = key.split(":", 3) + if len(parts) == 4: + _, fact_kind, path, name = parts + return f"{name} ({fact_kind}, {path})" + if item_id is not None: + return f"{kind}:{item_id}" + return key + def _chunk_index( self, chunks: Sequence[ContentChunk], diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 9a09274..4cd6a96 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -1139,6 +1139,28 @@ def get_ability_map( raise HTTPException(status_code=404, detail=str(exc)) from exc +@app.get( + "/repos/{repository_id}/dependency-graph", + tags=["registry"], +) +def get_dependency_graph( + repository_id: int, + base_analysis_run_id: int | None = Query(default=None), + target_analysis_run_id: int | None = Query(default=None), + service: RegistryService = Depends(get_service), +) -> dict[str, object]: + try: + return service.dependency_graph_elements( + repository_id, + base_analysis_run_id=base_analysis_run_id, + target_analysis_run_id=target_analysis_run_id, + ) + except NotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get( "/repos/{repository_id}/export", tags=["discovery"], diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index 49c8520..1d48dc1 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from dataclasses import asdict from html import escape from pathlib import Path @@ -200,6 +201,49 @@ def page( color: #1f2933; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }} + .graph-shell {{ + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 16px; + min-height: 680px; + }} + .graph-canvas {{ + min-height: 680px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f8fafc; + }} + .graph-sidebar {{ + display: grid; + gap: 12px; + align-content: start; + }} + .graph-controls {{ + display: flex; + gap: 8px; + flex-wrap: wrap; + }} + .graph-controls button[aria-pressed="true"] {{ + color: white; + background: var(--accent-dark); + border-color: var(--accent-dark); + }} + .legend-grid {{ + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 10px; + align-items: center; + }} + .legend-swatch {{ + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid var(--line); + }} + .detail-list {{ + margin: 8px 0 0; + padding-left: 18px; + }} .actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }} .header-brand {{ display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }} .header-context {{ color: #b6c8d6; font-weight: 650; }} @@ -211,6 +255,8 @@ def page( header {{ padding: 12px 16px; }} main {{ padding: 16px; }} .grid {{ grid-template-columns: 1fr; }} + .graph-shell {{ grid-template-columns: 1fr; }} + .graph-canvas {{ min-height: 560px; }} table, tbody, tr, td {{ display: block; width: 100%; }} thead {{ display: none; }} td {{ border-bottom: 0; padding: 6px 0; }} @@ -780,6 +826,7 @@ def repository_detail( body = f"""
{escape(changed_note)}
+