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(display_name)}

+ Dependency Graph Export SCOPE Back @@ -1578,6 +1625,7 @@ def analysis_run_diff_detail( body = f"""

{escape(display_name)} · Change Review

+ Impact Graph Target Run Repository
@@ -1618,6 +1666,250 @@ def analysis_run_diff_detail( ) +@router.get("/ui/repos/{repository_id}/dependency-graph") +def dependency_graph_view( + 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), +) -> HTMLResponse: + try: + repository = service.get_repository(repository_id) + graph = 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 + display_name = repository_display_name(repository) + query = "" + if base_analysis_run_id is not None and target_analysis_run_id is not None: + query = ( + f"?base_analysis_run_id={base_analysis_run_id}" + f"&target_analysis_run_id={target_analysis_run_id}" + ) + endpoint = json.dumps(f"/repos/{repository_id}/dependency-graph{query}") + metrics = graph["metrics"] + changed_note = ( + f"Impact view for run #{base_analysis_run_id} to run #{target_analysis_run_id}." + if graph["mode"] == "impact" + else "Full approved dependency graph." + ) + body = f""" +
+

{escape(display_name)} · Dependency Graph

+ Repository +
+

{escape(changed_note)}

+
+
+ {metrics['node_count']} nodes + {metrics['edge_count']} edges + {metrics['propagation_breadth']} impacted + depth {metrics['max_depth']} + scope {'impacted' if metrics['scope_impacted'] else 'current'} +
+
+
+ + +
+ + + """ + return page( + f"{display_name} Dependency Graph", + body, + selected_repository=display_name, + selected_repository_id=repository.id, + ) + + @router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve") def approve_candidate_graph_from_form( repository_id: int, diff --git a/tests/test_web_api.py b/tests/test_web_api.py index a37ab67..e781c82 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -189,6 +189,9 @@ def test_openapi_contract_snapshot_for_stable_agent_paths(): "/repos/{repository_id}/ability-map": { "get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"} }, + "/repos/{repository_id}/dependency-graph": { + "get": {"tags": ["registry"], "success_schema": "object"} + }, "/repos/{repository_id}/analysis-runs": { "get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"}, "post": {"tags": ["analysis"], "success_schema": "ScanSummaryResponse"}, @@ -1365,6 +1368,10 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): f'SCOPE' in detail_response.text ) + assert ( + f'Dependency Graph' + in detail_response.text + ) repo_scope_response = client.get(f"/ui/repos/{repository_id}/scope") assert repo_scope_response.status_code == 200 @@ -1491,6 +1498,24 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): assert "Elements" in approved_detail.text assert "q=Report+Service+Status" in approved_detail.text + graph_response = client.get(f"/repos/{repository_id}/dependency-graph") + assert graph_response.status_code == 200 + graph_payload = graph_response.json() + assert graph_payload["mode"] == "full" + assert graph_payload["metrics"]["node_count"] >= 4 + assert graph_payload["metrics"]["edge_count"] >= 3 + assert any( + element["data"].get("kind") == "scope" + for element in graph_payload["elements"] + if "source" not in element["data"] + ) + + graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph") + assert graph_page.status_code == 200 + assert "Dependency Graph" in graph_page.text + assert "cytoscape.min.js" in graph_page.text + assert 'data-graph-mode="impact"' in graph_page.text + scope_listing = client.get( f"/ui/repos/{repository_id}/elements", params={"scope": "all", "type": "scopes", "entry_filter": "approved"}, diff --git a/workplans/RREG-WP-0008-dependency-aware-scope-propagation.md b/workplans/RREG-WP-0008-dependency-aware-scope-propagation.md index b4cfd20..6deb8c4 100644 --- a/workplans/RREG-WP-0008-dependency-aware-scope-propagation.md +++ b/workplans/RREG-WP-0008-dependency-aware-scope-propagation.md @@ -115,7 +115,7 @@ Acceptance criteria: ```task id: RREG-WP-0008-T05 -status: todo +status: in_progress priority: medium state_hub_task_id: "02147d64-d339-40e2-b88d-406567bfa366" ``` @@ -129,6 +129,30 @@ Acceptance criteria: abilities, and scope. - Evidence remains the bridge between observed facts and interpreted claims. +## Interactive Dependency Graph Visualization + +```task +id: RREG-WP-0008-T07 +status: done +priority: medium +state_hub_task_id: "a1f57c22-017b-43ef-99b4-99c10a6bccd5" +``` + +Provide an interactive visualization of the dependency graph so users can inspect +fact-to-scope propagation paths, same-layer normalization signals, stale nodes, +and review recommendations. + +Acceptance criteria: +- Users can pan, zoom, and select dependency graph nodes. +- Node styling distinguishes facts, evidence, features, capabilities, abilities, + and the root scope. +- Edge styling distinguishes dependency type, strength, and same-layer + normalization signals. +- Users can switch between full graph, impact-only, and selected-path views. +- The implementation framework choice is documented with tradeoffs before UI + implementation starts in + `docs/adr-dependency-graph-visualization-framework.md`. + ## Documentation And Terminology ```task