Dependency graph vizualization

This commit is contained in:
2026-05-03 12:52:51 +02:00
parent 60ebef0281
commit 8a5e38ce68
7 changed files with 543 additions and 1 deletions

View File

@@ -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.

View File

@@ -47,6 +47,18 @@ The current implementation exposes this through `RegistryService`:
The impact result includes changed fact keys, impacted items, reason chains, The impact result includes changed fact keys, impacted items, reason chains,
maximum propagation depth, breadth, and whether the root scope was affected. 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 ## Metrics
Propagation depth says how far a source change bubbled up. Propagation breadth Propagation depth says how far a source change bubbled up. Propagation breadth

View File

@@ -1090,6 +1090,115 @@ class RegistryService:
graph=graph, 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( def approve_analysis_run_changes(
self, self,
repository_id: int, repository_id: int,
@@ -2300,6 +2409,24 @@ class RegistryService:
return evidence.reference return evidence.reference
return f"{kind}:{item_id}" 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( def _chunk_index(
self, self,
chunks: Sequence[ContentChunk], chunks: Sequence[ContentChunk],

View File

@@ -1139,6 +1139,28 @@ def get_ability_map(
raise HTTPException(status_code=404, detail=str(exc)) from exc 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( @app.get(
"/repos/{repository_id}/export", "/repos/{repository_id}/export",
tags=["discovery"], tags=["discovery"],

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import asdict from dataclasses import asdict
from html import escape from html import escape
from pathlib import Path from pathlib import Path
@@ -200,6 +201,49 @@ def page(
color: #1f2933; color: #1f2933;
font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; 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; }} .actions {{ display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }}
.header-brand {{ display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }} .header-brand {{ display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }}
.header-context {{ color: #b6c8d6; font-weight: 650; }} .header-context {{ color: #b6c8d6; font-weight: 650; }}
@@ -211,6 +255,8 @@ def page(
header {{ padding: 12px 16px; }} header {{ padding: 12px 16px; }}
main {{ padding: 16px; }} main {{ padding: 16px; }}
.grid {{ grid-template-columns: 1fr; }} .grid {{ grid-template-columns: 1fr; }}
.graph-shell {{ grid-template-columns: 1fr; }}
.graph-canvas {{ min-height: 560px; }}
table, tbody, tr, td {{ display: block; width: 100%; }} table, tbody, tr, td {{ display: block; width: 100%; }}
thead {{ display: none; }} thead {{ display: none; }}
td {{ border-bottom: 0; padding: 6px 0; }} td {{ border-bottom: 0; padding: 6px 0; }}
@@ -780,6 +826,7 @@ def repository_detail(
body = f""" body = f"""
<div class="actions"> <div class="actions">
<h1 style="margin-right:auto">{escape(display_name)}</h1> <h1 style="margin-right:auto">{escape(display_name)}</h1>
<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph">Dependency Graph</a>
<a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a> <a class="button secondary" href="/ui/repos/{repository_id}/export">Export</a>
<a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a> <a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a>
<a class="button secondary" href="/ui">Back</a> <a class="button secondary" href="/ui">Back</a>
@@ -1578,6 +1625,7 @@ def analysis_run_diff_detail(
body = f""" body = f"""
<div class="actions"> <div class="actions">
<h1 style="margin-right:auto">{escape(display_name)} · Change Review</h1> <h1 style="margin-right:auto">{escape(display_name)} · Change Review</h1>
<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph?base_analysis_run_id={base_analysis_run_id}&target_analysis_run_id={target_analysis_run_id}">Impact Graph</a>
<a class="button secondary" href="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}">Target Run</a> <a class="button secondary" href="/ui/repos/{repository_id}/analysis-runs/{target_analysis_run_id}">Target Run</a>
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a> <a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</div> </div>
@@ -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"""
<div class="actions">
<h1 style="margin-right:auto">{escape(display_name)} · Dependency Graph</h1>
<a class="button secondary" href="/ui/repos/{repository_id}">Repository</a>
</div>
<p class="muted">{escape(changed_note)}</p>
<section class="panel">
<div class="actions">
<span class="pill">{metrics['node_count']} nodes</span>
<span class="pill">{metrics['edge_count']} edges</span>
<span class="pill">{metrics['propagation_breadth']} impacted</span>
<span class="pill">depth {metrics['max_depth']}</span>
<span class="pill">scope {'impacted' if metrics['scope_impacted'] else 'current'}</span>
</div>
</section>
<section class="graph-shell" style="margin-top:18px">
<div id="dependency-graph" class="graph-canvas" role="img" aria-label="Interactive dependency graph"></div>
<aside class="graph-sidebar">
<section class="panel">
<h2>View</h2>
<div class="graph-controls" role="toolbar" aria-label="Graph view controls">
<button class="secondary" type="button" data-graph-mode="full" aria-pressed="true">Full</button>
<button class="secondary" type="button" data-graph-mode="impact" aria-pressed="false">Impact</button>
<button class="secondary" type="button" data-graph-mode="path" aria-pressed="false">Path</button>
<button class="secondary" type="button" data-graph-fit>Fit</button>
</div>
</section>
<section class="panel">
<h2>Legend</h2>
<div class="legend-grid">
<span class="legend-swatch" style="background:#64748b"></span><span>Fact</span>
<span class="legend-swatch" style="background:#0891b2"></span><span>Evidence</span>
<span class="legend-swatch" style="background:#7c3aed"></span><span>Feature</span>
<span class="legend-swatch" style="background:#0f766e"></span><span>Capability</span>
<span class="legend-swatch" style="background:#b45309"></span><span>Ability</span>
<span class="legend-swatch" style="background:#be123c"></span><span>Scope</span>
</div>
</section>
<section class="panel">
<h2>Selection</h2>
<div id="graph-detail" class="muted">Select a node or edge.</div>
</section>
</aside>
</section>
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<script>
(() => {{
const endpoint = {endpoint};
const container = document.getElementById("dependency-graph");
const detail = document.getElementById("graph-detail");
const modeButtons = Array.from(document.querySelectorAll("[data-graph-mode]"));
const fitButton = document.querySelector("[data-graph-fit]");
let cy = null;
let mode = "full";
let selected = null;
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
const renderDetailList = (items) => {{
if (!items || items.length === 0) return "";
return `<ul class="detail-list">${{items.map((item) => `<li>${{escapeHtml(item)}}</li>`).join("")}}</ul>`;
}};
const showDetails = (element) => {{
if (!element) {{
detail.className = "muted";
detail.textContent = "Select a node or edge.";
return;
}}
const data = element.data();
detail.className = "";
if (element.isNode()) {{
detail.innerHTML = `
<p><strong>${{escapeHtml(data.label)}}</strong></p>
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p>
<p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
${{data.recommendedAction ? `<p>Recommended action: <strong>${{escapeHtml(data.recommendedAction)}}</strong></p>` : ""}}
${{renderDetailList(data.reasons)}}
`;
}} else {{
detail.innerHTML = `
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
<p><span class="pill">${{escapeHtml(data.strength)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
<p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p>
<p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p>
`;
}}
}};
const visibleForMode = () => {{
if (!cy) return cy.collection();
if (mode === "full") return cy.elements();
if (mode === "impact") {{
const touched = cy.nodes(".stale, .changed");
return touched.union(touched.connectedEdges().filter((edge) => edge.connectedNodes(".stale, .changed").length > 0));
}}
if (mode === "path" && selected) {{
return selected.union(selected.predecessors()).union(selected.successors());
}}
return cy.elements();
}};
const applyMode = (nextMode) => {{
mode = nextMode;
modeButtons.forEach((button) => {{
button.setAttribute("aria-pressed", String(button.dataset.graphMode === mode));
}});
if (!cy) return;
cy.elements().addClass("hidden");
visibleForMode().removeClass("hidden");
cy.layout({{ name: "breadthfirst", directed: true, spacingFactor: 1.3, animate: true }}).run();
cy.fit(cy.elements(":visible"), 48);
}};
const style = [
{{
selector: "node",
style: {{
"background-color": "#64748b",
"border-color": "#334155",
"border-width": 1,
"color": "#1f2933",
"font-size": 11,
"height": 36,
"label": "data(label)",
"text-background-color": "#ffffff",
"text-background-opacity": .85,
"text-background-padding": 2,
"text-margin-y": -7,
"text-max-width": 130,
"text-valign": "top",
"text-wrap": "wrap",
"width": 36
}}
}},
{{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }},
{{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }},
{{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle", "width": 56 }} }},
{{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon", "height": 46, "width": 46 }} }},
{{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star", "height": 58, "width": 58 }} }},
{{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }},
{{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }},
{{
selector: "edge",
style: {{
"curve-style": "bezier",
"line-color": "#94a3b8",
"target-arrow-color": "#94a3b8",
"target-arrow-shape": "triangle",
"width": 2
}}
}},
{{ selector: "edge[strength = 'strong']", style: {{ "width": 4, "line-color": "#475569", "target-arrow-color": "#475569" }} }},
{{ selector: "edge[strength = 'weak']", style: {{ "width": 1, "line-style": "dotted" }} }},
{{ selector: "edge.same-layer", style: {{ "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }},
{{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }},
{{ selector: ".hidden", style: {{ "display": "none" }} }}
];
if (!window.cytoscape) {{
container.innerHTML = '<p class="notice warn">Cytoscape.js could not be loaded.</p>';
return;
}}
fetch(endpoint)
.then((response) => {{
if (!response.ok) throw new Error(`Graph request failed: ${{response.status}}`);
return response.json();
}})
.then((payload) => {{
cy = cytoscape({{
container,
elements: payload.elements,
style,
layout: {{ name: "breadthfirst", directed: true, spacingFactor: 1.25 }}
}});
cy.on("tap", "node, edge", (event) => {{
selected = event.target;
showDetails(selected);
if (mode === "path") applyMode("path");
}});
cy.on("tap", (event) => {{
if (event.target === cy) {{
selected = null;
showDetails(null);
if (mode === "path") applyMode("path");
}}
}});
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
modeButtons.forEach((button) => {{
button.addEventListener("click", () => applyMode(button.dataset.graphMode));
}});
applyMode(payload.mode === "impact" ? "impact" : "full");
}})
.catch((error) => {{
container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`;
}});
}})();
</script>
"""
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") @router.post("/ui/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve")
def approve_candidate_graph_from_form( def approve_candidate_graph_from_form(
repository_id: int, repository_id: int,

View File

@@ -189,6 +189,9 @@ def test_openapi_contract_snapshot_for_stable_agent_paths():
"/repos/{repository_id}/ability-map": { "/repos/{repository_id}/ability-map": {
"get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"} "get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"}
}, },
"/repos/{repository_id}/dependency-graph": {
"get": {"tags": ["registry"], "success_schema": "object"}
},
"/repos/{repository_id}/analysis-runs": { "/repos/{repository_id}/analysis-runs": {
"get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"}, "get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"},
"post": {"tags": ["analysis"], "success_schema": "ScanSummaryResponse"}, "post": {"tags": ["analysis"], "success_schema": "ScanSummaryResponse"},
@@ -1365,6 +1368,10 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
f'<a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a>' f'<a class="button secondary" href="/ui/repos/{repository_id}/scope">SCOPE</a>'
in detail_response.text in detail_response.text
) )
assert (
f'<a class="button secondary" href="/ui/repos/{repository_id}/dependency-graph">Dependency Graph</a>'
in detail_response.text
)
repo_scope_response = client.get(f"/ui/repos/{repository_id}/scope") repo_scope_response = client.get(f"/ui/repos/{repository_id}/scope")
assert repo_scope_response.status_code == 200 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 "Elements" in approved_detail.text
assert "q=Report+Service+Status" 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( scope_listing = client.get(
f"/ui/repos/{repository_id}/elements", f"/ui/repos/{repository_id}/elements",
params={"scope": "all", "type": "scopes", "entry_filter": "approved"}, params={"scope": "all", "type": "scopes", "entry_filter": "approved"},

View File

@@ -115,7 +115,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0008-T05 id: RREG-WP-0008-T05
status: todo status: in_progress
priority: medium priority: medium
state_hub_task_id: "02147d64-d339-40e2-b88d-406567bfa366" state_hub_task_id: "02147d64-d339-40e2-b88d-406567bfa366"
``` ```
@@ -129,6 +129,30 @@ Acceptance criteria:
abilities, and scope. abilities, and scope.
- Evidence remains the bridge between observed facts and interpreted claims. - 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 ## Documentation And Terminology
```task ```task