Refinements to the dependency exploration ui

This commit is contained in:
2026-05-04 12:17:48 +02:00
parent 07c9a95efc
commit 356c0026ef
8 changed files with 408 additions and 36 deletions

View File

@@ -26,6 +26,10 @@ CLI command. Evidence should point directly to a capability when it supports the
capability as a whole and no narrower feature target is known. Facts can observe capability as a whole and no narrower feature target is known. Facts can observe
evidence and features; evidence can support features or capabilities. evidence and features; evidence can support features or capabilities.
Document-level facts are normalized for graph readability. Derived `SCOPE.md`
facts are suppressed when they only restate generated scope, and README/SCOPE
document facts that describe the same file-level support are represented once.
## Display States ## Display States
Each active graph element receives a `displayState`: Each active graph element receives a `displayState`:
@@ -38,15 +42,17 @@ Each active graph element receives a `displayState`:
Rule precedence is deterministic: later rules override earlier rules, then Rule precedence is deterministic: later rules override earlier rules, then
manual overrides win last. If a node is hidden, connected edges are hidden too. manual overrides win last. If a node is hidden, connected edges are hidden too.
If a node is blurred, connected edges receive a muted context hint so the
surrounding dependency path remains visible without competing for attention.
Manual overrides are stored by stable graph key and orphaned keys are surfaced Manual overrides are stored by stable graph key and orphaned keys are surfaced
when a profile references nodes or edges that no longer exist. when a profile references nodes or edges that no longer exist.
## Filter Rules ## Filter Rules
Rules are JSON objects with an `action` of `show`, `blur`, or `hide`, plus a Rules are JSON objects with an `action` of `show`, `blur`, or `hide`, plus a
`match` object. Common match fields include `kind`, `layer`, `primaryClass`, `match` object. Common match fields include `kind`, `layer`, `reviewState`,
`attributes`, `confidence`, `freshnessState`, `ownership`, `dependencyType`, `primaryClass`, `attributes`, `confidence`, `freshnessState`, `ownership`,
`strength`, `sameLayer`, `path`, and `text`. `dependencyType`, `strength`, `sameLayer`, `path`, and `text`.
Example: Example:
@@ -64,6 +70,10 @@ View profiles are repository-scoped saved graph perspectives. The profile API
supports create, list, load, update, duplicate, and delete operations under supports create, list, load, update, duplicate, and delete operations under
`/repos/{repository_id}/dependency-graph/profiles`. `/repos/{repository_id}/dependency-graph/profiles`.
When a graph is opened without an explicit `profile_id`, the most recently saved
repository profile is applied by default. API clients can pass
`use_latest_profile=false` to request an unsaved full graph view.
Profiles store: Profiles store:
- name and optional description - name and optional description
@@ -84,3 +94,7 @@ Example profiles:
Hiding evidence can make a graph appear cleaner while also removing the reason a Hiding evidence can make a graph appear cleaner while also removing the reason a
capability is trusted. Prefer blurring evidence when reviewing scope impact so capability is trusted. Prefer blurring evidence when reviewing scope impact so
the support chain remains visible as context. the support chain remains visible as context.
Nodes scale within a bounded size range when confidence is available. Edge width
is derived from dependency strength. Hovering a graph element shows a compact
popup; selecting it still opens the full side-panel detail.

View File

@@ -1101,6 +1101,7 @@ class RegistryService:
profile_id: int | None = None, profile_id: int | None = None,
rules: list[dict[str, Any]] | None = None, rules: list[dict[str, Any]] | None = None,
manual_overrides: dict[str, str] | None = None, manual_overrides: dict[str, str] | None = None,
use_latest_profile: bool = True,
) -> dict[str, object]: ) -> dict[str, object]:
impact = None impact = None
if base_analysis_run_id is not None or target_analysis_run_id is not None: if base_analysis_run_id is not None or target_analysis_run_id is not None:
@@ -1122,9 +1123,7 @@ class RegistryService:
) )
changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set() changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set()
ability_map = self.store.get_ability_map(repository_id) ability_map = self.store.get_ability_map(repository_id)
facts_by_id = { facts_by_id = {fact.id: fact for fact in self.store.list_observed_facts(repository_id)}
fact.id: fact for fact in self.store.list_observed_facts(repository_id)
}
characteristic_index = self._dependency_characteristic_index(ability_map) characteristic_index = self._dependency_characteristic_index(ability_map)
nodes: dict[str, dict[str, object]] = {} nodes: dict[str, dict[str, object]] = {}
edge_sources: dict[str, DependencyEdge] = {} edge_sources: dict[str, DependencyEdge] = {}
@@ -1132,6 +1131,8 @@ class RegistryService:
profile = ( profile = (
self.store.get_dependency_graph_profile(repository_id, profile_id) self.store.get_dependency_graph_profile(repository_id, profile_id)
if profile_id is not None if profile_id is not None
else self.store.latest_dependency_graph_profile(repository_id)
if use_latest_profile and not rules and not manual_overrides
else None else None
) )
merged_rules = [*(profile.filter_rules if profile is not None else []), *(rules or [])] merged_rules = [*(profile.filter_rules if profile is not None else []), *(rules or [])]
@@ -1139,6 +1140,12 @@ class RegistryService:
**(profile.manual_overrides if profile is not None else {}), **(profile.manual_overrides if profile is not None else {}),
**(manual_overrides or {}), **(manual_overrides or {}),
} }
graph_edges = [
display_edge
for edge in graph.edges
if (display_edge := self._dependency_display_edge(edge, facts_by_id))
is not None
]
def ensure_node(kind: str, key: str, item_id: int | None) -> None: def ensure_node(kind: str, key: str, item_id: int | None) -> None:
if key in nodes: if key in nodes:
@@ -1150,6 +1157,11 @@ class RegistryService:
if fact is not None: if fact is not None:
detail = { detail = {
"name": fact.name, "name": fact.name,
"label": (
f"{fact.path} ({fact.kind})"
if key.startswith("fact:document:")
else f"{fact.name} ({fact.kind}, {fact.path})"
),
"description": fact.value, "description": fact.value,
"primaryClass": fact.metadata.get("source_role", fact.kind), "primaryClass": fact.metadata.get("source_role", fact.kind),
"attributes": self._dependency_fact_attributes(fact), "attributes": self._dependency_fact_attributes(fact),
@@ -1174,13 +1186,16 @@ class RegistryService:
"stableKey": key, "stableKey": key,
"kind": kind, "kind": kind,
"layer": self._dependency_layer(kind), "layer": self._dependency_layer(kind),
"label": self._dependency_node_label(repository_id, kind, key, item_id), "label": detail.get("label")
or self._dependency_node_label(repository_id, kind, key, item_id),
"reviewState": "accepted",
"name": detail.get("name") "name": detail.get("name")
or self._dependency_node_label(repository_id, kind, key, item_id), or self._dependency_node_label(repository_id, kind, key, item_id),
"description": detail.get("description", ""), "description": detail.get("description", ""),
"primaryClass": detail.get("primaryClass", kind), "primaryClass": detail.get("primaryClass", kind),
"attributes": detail.get("attributes", []), "attributes": detail.get("attributes", []),
"confidence": detail.get("confidence"), "confidence": detail.get("confidence"),
"visualSize": self._dependency_node_size(detail.get("confidence")),
"ownership": self._ownership_for_kind(kind), "ownership": self._ownership_for_kind(kind),
"freshnessState": ( "freshnessState": (
impact_item.freshness_state impact_item.freshness_state
@@ -1212,12 +1227,12 @@ class RegistryService:
), ),
} }
for edge in graph.edges: for edge in graph_edges:
ensure_node(edge.source_kind, edge.source_key, edge.source_id) ensure_node(edge.source_kind, edge.source_key, edge.source_id)
ensure_node(edge.target_kind, edge.target_key, edge.target_id) ensure_node(edge.target_kind, edge.target_key, edge.target_id)
edges = [] edges = []
for index, edge in enumerate(graph.edges): for index, edge in enumerate(graph_edges):
edge_id = f"{edge.source_key}->{edge.target_key}:{index}" edge_id = f"{edge.source_key}->{edge.target_key}:{index}"
source_data = nodes[edge.source_key]["data"] source_data = nodes[edge.source_key]["data"]
target_data = nodes[edge.target_key]["data"] target_data = nodes[edge.target_key]["data"]
@@ -1230,6 +1245,7 @@ class RegistryService:
"stableKey": edge_id, "stableKey": edge_id,
"kind": "edge", "kind": "edge",
"layer": "dependency", "layer": "dependency",
"reviewState": "accepted",
"source": edge.source_key, "source": edge.source_key,
"target": edge.target_key, "target": edge.target_key,
"sourceKind": edge.source_kind, "sourceKind": edge.source_kind,
@@ -1238,6 +1254,7 @@ class RegistryService:
"targetLayer": self._dependency_layer(edge.target_kind), "targetLayer": self._dependency_layer(edge.target_kind),
"dependencyType": edge.dependency_type, "dependencyType": edge.dependency_type,
"strength": edge.strength, "strength": edge.strength,
"edgeWidth": self._dependency_edge_width(edge.strength),
"edgeSource": edge.source, "edgeSource": edge.source,
"sameLayer": edge.same_layer, "sameLayer": edge.same_layer,
"freshnessState": ( "freshnessState": (
@@ -1284,6 +1301,11 @@ class RegistryService:
for element in nodes.values() for element in nodes.values()
if visibility[element["data"]["id"]]["displayState"] == "hide" if visibility[element["data"]["id"]]["displayState"] == "hide"
} }
blurred_node_ids = {
element["data"]["id"]
for element in nodes.values()
if visibility[element["data"]["id"]]["displayState"] == "blur"
}
visible_elements: list[dict[str, object]] = [] visible_elements: list[dict[str, object]] = []
hidden_elements: list[dict[str, object]] = [] hidden_elements: list[dict[str, object]] = []
orphaned_overrides = sorted( orphaned_overrides = sorted(
@@ -1302,12 +1324,21 @@ class RegistryService:
"displayState": "hide", "displayState": "hide",
"visibilityReason": "connected-node-hidden", "visibilityReason": "connected-node-hidden",
} }
connected_to_blurred = (
"source" in element["data"]
and (
element["data"]["source"] in blurred_node_ids
or element["data"]["target"] in blurred_node_ids
)
)
element["data"].update(state) element["data"].update(state)
element["data"]["connectedToBlurred"] = connected_to_blurred
element["classes"] = " ".join( element["classes"] = " ".join(
part part
for part in ( for part in (
element.get("classes", ""), element.get("classes", ""),
f"display-{state['displayState']}", f"display-{state['displayState']}",
"connects-blurred" if connected_to_blurred else "",
"manual-override" if state["visibilitySource"] == "manual" else "", "manual-override" if state["visibilitySource"] == "manual" else "",
"rule-derived" if state["visibilitySource"] == "rule" else "", "rule-derived" if state["visibilitySource"] == "rule" else "",
) )
@@ -2578,6 +2609,48 @@ class RegistryService:
attributes.append(value) attributes.append(value)
return sorted(set(attributes)) return sorted(set(attributes))
def _dependency_display_edge(
self,
edge: DependencyEdge,
facts_by_id: dict[int, ObservedFact],
) -> DependencyEdge | None:
if edge.source_kind != "fact" or edge.source_id is None:
return edge
fact = facts_by_id.get(edge.source_id)
if fact is None:
return edge
if self._suppress_dependency_fact(fact):
return None
display_key = self._dependency_fact_display_key(fact)
if display_key == edge.source_key:
return edge
return replace(edge, source_key=display_key)
def _suppress_dependency_fact(self, fact: ObservedFact) -> bool:
return (
fact.path.lower().endswith("scope.md")
and fact.metadata.get("source_role") == "derived_scope"
)
def _dependency_fact_display_key(self, fact: ObservedFact) -> str:
document_paths = {"readme.md", "scope.md"}
if fact.path.lower() in document_paths and fact.kind in {
"documentation",
"intent",
"scope",
}:
return f"fact:document:{fact.path}"
return f"fact:{fact.kind}:{fact.path}:{fact.name}"
def _dependency_node_size(self, confidence: object) -> int:
if not isinstance(confidence, int | float):
return 36
bounded = max(0.0, min(float(confidence), 1.0))
return int(28 + (bounded * 28))
def _dependency_edge_width(self, strength: str) -> int:
return {"weak": 1, "medium": 3, "strong": 5}.get(strength, 2)
def _dependency_layer(self, kind: str) -> str: def _dependency_layer(self, kind: str) -> str:
if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}: if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}:
return kind return kind
@@ -2642,6 +2715,8 @@ class RegistryService:
actual = data.get(key) actual = data.get(key)
if key == "dependencyType": if key == "dependencyType":
actual = data.get("dependencyType") actual = data.get("dependencyType")
elif key == "reviewState":
actual = data.get("reviewState")
elif key == "sameLayer": elif key == "sameLayer":
actual = bool(data.get("sameLayer")) actual = bool(data.get("sameLayer"))
elif key == "attributes": elif key == "attributes":

View File

@@ -2410,6 +2410,27 @@ class RegistryStore:
raise NotFoundError(f"dependency graph profile {profile_id} was not found") raise NotFoundError(f"dependency graph profile {profile_id} was not found")
return self._dependency_graph_profile_from_row(row) return self._dependency_graph_profile_from_row(row)
def latest_dependency_graph_profile(
self,
repository_id: int,
) -> DependencyGraphViewProfile | None:
self.get_repository(repository_id)
with self.connect() as connection:
row = connection.execute(
"""
SELECT id, repository_id, name, description, default_mode,
filter_rules, manual_overrides, created_at, updated_at
FROM dependency_graph_view_profiles
WHERE repository_id = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(repository_id,),
).fetchone()
if row is None:
return None
return self._dependency_graph_profile_from_row(row)
def create_dependency_graph_profile( def create_dependency_graph_profile(
self, self,
repository_id: int, repository_id: int,

View File

@@ -1154,6 +1154,7 @@ def get_dependency_graph(
base_analysis_run_id: int | None = Query(default=None), base_analysis_run_id: int | None = Query(default=None),
target_analysis_run_id: int | None = Query(default=None), target_analysis_run_id: int | None = Query(default=None),
profile_id: int | None = Query(default=None), profile_id: int | None = Query(default=None),
use_latest_profile: bool = Query(default=True),
service: RegistryService = Depends(get_service), service: RegistryService = Depends(get_service),
) -> dict[str, object]: ) -> dict[str, object]:
try: try:
@@ -1162,6 +1163,7 @@ def get_dependency_graph(
base_analysis_run_id=base_analysis_run_id, base_analysis_run_id=base_analysis_run_id,
target_analysis_run_id=target_analysis_run_id, target_analysis_run_id=target_analysis_run_id,
profile_id=profile_id, profile_id=profile_id,
use_latest_profile=use_latest_profile,
) )
except NotFoundError as exc: except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -1189,6 +1191,7 @@ def filter_dependency_graph(
profile_id=profile_id, profile_id=profile_id,
rules=payload.rules, rules=payload.rules,
manual_overrides=payload.manual_overrides, manual_overrides=payload.manual_overrides,
use_latest_profile=profile_id is not None,
) )
except NotFoundError as exc: except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc

View File

@@ -208,11 +208,26 @@ def page(
min-height: 680px; min-height: 680px;
}} }}
.graph-canvas {{ .graph-canvas {{
position: relative;
min-height: 680px; min-height: 680px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
background: #f8fafc; background: #f8fafc;
}} }}
.graph-popup {{
position: absolute;
z-index: 20;
display: none;
width: 240px;
max-width: calc(100% - 24px);
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, .96);
box-shadow: 0 10px 24px rgba(15, 23, 42, .16);
pointer-events: none;
}}
.graph-popup p {{ margin-bottom: 6px; }}
.graph-sidebar {{ .graph-sidebar {{
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1778,6 +1793,11 @@ def dependency_graph_view(
</select></label> </select></label>
<label>Action <select id="filter-action"><option>blur</option><option>hide</option><option>show</option></select></label> <label>Action <select id="filter-action"><option>blur</option><option>hide</option><option>show</option></select></label>
</div> </div>
<label>Review state <select id="filter-review-state">
<option value="">Any state</option>
<option value="accepted">Accepted</option>
<option value="candidate">Candidate</option>
</select></label>
<label>Text <input id="filter-text" placeholder="Search labels, paths, descriptions"></label> <label>Text <input id="filter-text" placeholder="Search labels, paths, descriptions"></label>
<div class="graph-controls"> <div class="graph-controls">
<button class="secondary" type="button" data-filter-apply>Apply Rule</button> <button class="secondary" type="button" data-filter-apply>Apply Rule</button>
@@ -1836,9 +1856,11 @@ def dependency_graph_view(
const profileMode = document.getElementById("profile-mode"); const profileMode = document.getElementById("profile-mode");
const profileSummary = document.getElementById("profile-summary"); const profileSummary = document.getElementById("profile-summary");
const filterLayer = document.getElementById("filter-layer"); const filterLayer = document.getElementById("filter-layer");
const filterReviewState = document.getElementById("filter-review-state");
const filterText = document.getElementById("filter-text"); const filterText = document.getElementById("filter-text");
const filterAction = document.getElementById("filter-action"); const filterAction = document.getElementById("filter-action");
const focusDepth = document.getElementById("focus-depth"); const focusDepth = document.getElementById("focus-depth");
let hoverPopup = null;
let cy = null; let cy = null;
let mode = "full"; let mode = "full";
let selected = null; let selected = null;
@@ -1877,10 +1899,10 @@ def dependency_graph_view(
profileSummary.textContent = profile ? profileSummaryText(basePayload) : "No profile selected."; profileSummary.textContent = profile ? profileSummaryText(basePayload) : "No profile selected.";
}}; }};
const loadProfiles = () => fetch(profileEndpoint) const loadProfiles = (selectedProfileId = "") => fetch(profileEndpoint)
.then((response) => response.json()) .then((response) => response.json())
.then((profiles) => {{ .then((profiles) => {{
const current = profileSelect.value; const current = selectedProfileId || profileSelect.value;
profileSelect.innerHTML = '<option value="">Unsaved exploration</option>' + profiles profileSelect.innerHTML = '<option value="">Unsaved exploration</option>' + profiles
.map((profile) => `<option value="${{profile.id}}">${{escapeHtml(profile.name)}}</option>`) .map((profile) => `<option value="${{profile.id}}">${{escapeHtml(profile.name)}}</option>`)
.join(""); .join("");
@@ -1892,6 +1914,10 @@ def dependency_graph_view(
cy.elements().remove(); cy.elements().remove();
cy.add(payload.elements); cy.add(payload.elements);
profileSummary.textContent = payload.profile ? profileSummaryText(payload) : profileSummaryText(payload); profileSummary.textContent = payload.profile ? profileSummaryText(payload) : profileSummaryText(payload);
if (payload.profile) {{
updateProfileForm(payload.profile);
loadProfiles(String(payload.profile.id));
}}
applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full"); applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
}}; }};
@@ -1921,7 +1947,8 @@ def dependency_graph_view(
if (element.isNode()) {{ if (element.isNode()) {{
detail.innerHTML = ` detail.innerHTML = `
<p><strong>${{escapeHtml(data.label)}}</strong></p> <p><strong>${{escapeHtml(data.label)}}</strong></p>
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p> <p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p>
${{data.confidence !== null && data.confidence !== undefined ? `<p class="muted">Confidence: ${{escapeHtml(data.confidence)}}</p>` : ""}}
<p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p> <p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p> <p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
${{data.description ? `<p>${{escapeHtml(data.description)}}</p>` : ""}} ${{data.description ? `<p>${{escapeHtml(data.description)}}</p>` : ""}}
@@ -1931,7 +1958,7 @@ def dependency_graph_view(
}} else {{ }} else {{
detail.innerHTML = ` detail.innerHTML = `
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p> <p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
<p><span class="pill">${{escapeHtml(data.strength)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p> <p><span class="pill">${{escapeHtml(data.strength)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
<p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p> <p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p>
<p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p> <p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p>
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p> <p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
@@ -1939,6 +1966,45 @@ def dependency_graph_view(
}} }}
}}; }};
const popupHtml = (element) => {{
const data = element.data();
if (element.isNode()) {{
const path = data.path ? `<p class="muted">${{escapeHtml(data.path)}}</p>` : "";
const confidence = data.confidence !== null && data.confidence !== undefined
? `<span class="pill">confidence ${{escapeHtml(data.confidence)}}</span>`
: "";
return `
<p><strong>${{escapeHtml(data.name || data.label)}}</strong></p>
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span></p>
<p><span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span> ${{confidence}}</p>
<p class="muted">${{escapeHtml(data.ownership || "unknown")}}</p>
${{path}}
`;
}}
return `
<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.sourceMetadata?.name || data.source)}} -> ${{escapeHtml(data.targetMetadata?.name || data.target)}}</p>
<p class="muted">${{escapeHtml(data.edgeSource)}}</p>
`;
}};
const showPopup = (element, renderedPosition) => {{
if (!hoverPopup) {{
hoverPopup = document.createElement("div");
hoverPopup.className = "graph-popup";
container.appendChild(hoverPopup);
}}
hoverPopup.innerHTML = popupHtml(element);
hoverPopup.style.left = `${{Math.min(renderedPosition.x + 14, container.clientWidth - 250)}}px`;
hoverPopup.style.top = `${{Math.max(10, renderedPosition.y + 14)}}px`;
hoverPopup.style.display = "block";
}};
const hidePopup = () => {{
if (hoverPopup) hoverPopup.style.display = "none";
}};
const visibleForMode = () => {{ const visibleForMode = () => {{
if (!cy) return cy.collection(); if (!cy) return cy.collection();
if (focusCollection) return focusCollection; if (focusCollection) return focusCollection;
@@ -1989,7 +2055,7 @@ def dependency_graph_view(
"border-width": 1, "border-width": 1,
"color": "#1f2933", "color": "#1f2933",
"font-size": 11, "font-size": 11,
"height": 36, "height": "data(visualSize)",
"label": "data(label)", "label": "data(label)",
"text-background-color": "#ffffff", "text-background-color": "#ffffff",
"text-background-opacity": .85, "text-background-opacity": .85,
@@ -1998,14 +2064,14 @@ def dependency_graph_view(
"text-max-width": 130, "text-max-width": 130,
"text-valign": "top", "text-valign": "top",
"text-wrap": "wrap", "text-wrap": "wrap",
"width": 36 "width": "data(visualSize)"
}} }}
}}, }},
{{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }}, {{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }},
{{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }}, {{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }},
{{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle", "width": 56 }} }}, {{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle" }} }},
{{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon", "height": 46, "width": 46 }} }}, {{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon" }} }},
{{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star", "height": 58, "width": 58 }} }}, {{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star" }} }},
{{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }}, {{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }},
{{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }}, {{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }},
{{ {{
@@ -2015,12 +2081,14 @@ def dependency_graph_view(
"line-color": "#94a3b8", "line-color": "#94a3b8",
"target-arrow-color": "#94a3b8", "target-arrow-color": "#94a3b8",
"target-arrow-shape": "triangle", "target-arrow-shape": "triangle",
"width": 2 "width": "data(edgeWidth)"
}} }}
}}, }},
{{ selector: "edge[strength = 'strong']", style: {{ "width": 4, "line-color": "#475569", "target-arrow-color": "#475569" }} }}, {{ selector: "edge[strength = 'strong']", style: {{ "line-color": "#475569", "target-arrow-color": "#475569" }} }},
{{ selector: "edge[strength = 'weak']", style: {{ "width": 1, "line-style": "dotted" }} }}, {{ selector: "edge[strength = 'weak']", style: {{ "line-style": "dotted" }} }},
{{ selector: "edge.same-layer", style: {{ "curve-style": "unbundled-bezier", "control-point-distances": 45, "control-point-weights": .5, "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }}, {{ selector: "edge.same-layer", style: {{ "curve-style": "unbundled-bezier", "control-point-distances": 45, "control-point-weights": .5, "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }},
{{ selector: "edge.connects-blurred", style: {{ "line-color": "#d8dee8", "target-arrow-color": "#d8dee8", "opacity": .28 }} }},
{{ selector: "edge.connects-blurred.hover, edge.connects-blurred:selected", style: {{ "opacity": .75, "line-color": "#94a3b8", "target-arrow-color": "#94a3b8" }} }},
{{ selector: ".display-blur", style: {{ "opacity": .25, "label": "" }} }}, {{ selector: ".display-blur", style: {{ "opacity": .25, "label": "" }} }},
{{ selector: ".display-blur.hover, .display-blur:selected", style: {{ "opacity": .75, "label": "data(label)" }} }}, {{ selector: ".display-blur.hover, .display-blur:selected", style: {{ "opacity": .75, "label": "data(label)" }} }},
{{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }}, {{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }},
@@ -2046,8 +2114,10 @@ def dependency_graph_view(
}}); }});
basePayload = payload; basePayload = payload;
if (payload.profile) {{ if (payload.profile) {{
profileSelect.value = String(payload.profile.id);
updateProfileForm(payload.profile); updateProfileForm(payload.profile);
loadProfiles(String(payload.profile.id));
}} else {{
loadProfiles();
}} }}
cy.on("tap", "node, edge", (event) => {{ cy.on("tap", "node, edge", (event) => {{
selected = event.target; selected = event.target;
@@ -2061,8 +2131,14 @@ def dependency_graph_view(
if (mode === "path") applyMode("path"); if (mode === "path") applyMode("path");
}} }}
}}); }});
cy.on("mouseover", ".display-blur", (event) => event.target.addClass("hover")); cy.on("mouseover", "node, edge", (event) => {{
cy.on("mouseout", ".display-blur", (event) => event.target.removeClass("hover")); event.target.addClass("hover");
showPopup(event.target, event.renderedPosition);
}});
cy.on("mouseout", "node, edge", (event) => {{
event.target.removeClass("hover");
hidePopup();
}});
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48)); fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
modeButtons.forEach((button) => {{ modeButtons.forEach((button) => {{
button.addEventListener("click", () => applyMode(button.dataset.graphMode)); button.addEventListener("click", () => applyMode(button.dataset.graphMode));
@@ -2070,6 +2146,7 @@ def dependency_graph_view(
document.querySelector("[data-filter-apply]").addEventListener("click", () => {{ document.querySelector("[data-filter-apply]").addEventListener("click", () => {{
const match = {{}}; const match = {{}};
if (filterLayer.value) match.layer = filterLayer.value; if (filterLayer.value) match.layer = filterLayer.value;
if (filterReviewState.value) match.reviewState = filterReviewState.value;
if (filterText.value.trim()) match.text = filterText.value.trim(); if (filterText.value.trim()) match.text = filterText.value.trim();
rules.push({{name: "UI filter", action: filterAction.value, match}}); rules.push({{name: "UI filter", action: filterAction.value, match}});
refilter(); refilter();
@@ -2162,8 +2239,7 @@ def dependency_graph_view(
return loadProfiles(); return loadProfiles();
}}); }});
}}); }});
loadProfiles(); applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
applyMode(payload.mode === "impact" ? "impact" : "full");
}}) }})
.catch((error) => {{ .catch((error) => {{
container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`; container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`;

View File

@@ -333,16 +333,169 @@ def test_dependency_graph_enriches_layers_and_filters_with_profiles(tmp_path):
assert fact_node["layer"] == "fact" assert fact_node["layer"] == "fact"
assert fact_node["path"] == fact.path assert fact_node["path"] == fact.path
assert fact_node["displayState"] == "blur" assert fact_node["displayState"] == "blur"
assert fact_node["reviewState"] == "accepted"
assert fact_node["visualSize"] == 36
assert feature_node["displayState"] == "show" assert feature_node["displayState"] == "show"
assert feature_node["visibilitySource"] == "manual" assert feature_node["visibilitySource"] == "manual"
assert feature_node["visualSize"] == 50
assert evidence_node["layer"] == "evidence" assert evidence_node["layer"] == "evidence"
assert evidence_node["visualSize"] == 53
assert payload["filter"]["orphaned_overrides"] == ["missing:1"] assert payload["filter"]["orphaned_overrides"] == ["missing:1"]
assert payload["metrics"]["hidden_count"] == 0 assert payload["metrics"]["hidden_count"] == 0
assert any( evidence_edge = next(
element["data"].get("target") == f"feature:{feature_id}" element["data"]
and element["data"].get("sourceKind") == "evidence"
for element in payload["elements"] for element in payload["elements"]
if element["data"].get("target") == f"feature:{feature_id}"
and element["data"].get("sourceKind") == "evidence"
) )
assert evidence_edge["edgeWidth"] == 5
assert evidence_edge["reviewState"] == "accepted"
def test_dependency_graph_filters_review_state_and_marks_blurred_edges(tmp_path):
service = make_service(tmp_path)
repository = service.register_repository(
name="Review State",
url="https://example.com/review-state.git",
description="Review state fixture.",
)
ability_id = service.add_ability(repository.id, name="Graph Review")
capability_id = service.add_capability(repository.id, ability_id, name="Inspect")
feature_id = service.add_feature(
repository.id,
capability_id,
name="Inspector",
type="UI",
confidence=0.5,
)
payload = service.dependency_graph_elements(
repository.id,
rules=[
{
"name": "blur accepted",
"action": "blur",
"match": {"reviewState": "accepted"},
}
],
use_latest_profile=False,
)
feature = next(
element["data"]
for element in payload["elements"]
if element["data"].get("id") == f"feature:{feature_id}"
)
edge = next(
element["data"]
for element in payload["elements"]
if element["data"].get("source") == f"feature:{feature_id}"
)
assert feature["displayState"] == "blur"
assert edge["connectedToBlurred"] is True
def test_dependency_graph_uses_latest_profile_by_default(tmp_path):
service = make_service(tmp_path)
repository = service.register_repository(
name="Latest Profile",
url="https://example.com/latest-profile.git",
description="Latest profile fixture.",
)
ability_id = service.add_ability(repository.id, name="Profile Defaults")
service.add_capability(repository.id, ability_id, name="Load Profile")
first = service.create_dependency_graph_profile(
repository.id,
name="First",
filter_rules=[
{"name": "blur abilities", "action": "blur", "match": {"layer": "ability"}}
],
)
second = service.create_dependency_graph_profile(
repository.id,
name="Second",
filter_rules=[
{"name": "hide abilities", "action": "hide", "match": {"layer": "ability"}}
],
)
default_payload = service.dependency_graph_elements(repository.id)
explicit_payload = service.dependency_graph_elements(
repository.id,
profile_id=first.id,
)
unsaved_payload = service.dependency_graph_elements(
repository.id,
use_latest_profile=False,
)
assert default_payload["profile"]["id"] == second.id
assert default_payload["metrics"]["hidden_count"] >= 1
assert explicit_payload["profile"]["id"] == first.id
assert unsaved_payload["profile"] is None
def test_dependency_graph_deduplicates_document_fact_nodes(tmp_path):
service = make_service(tmp_path)
repository = service.register_repository(
name="Docs",
url="https://example.com/docs.git",
description="Document graph fixture.",
)
ability_id = service.add_ability(repository.id, name="Documented Operation")
capability_id = service.add_capability(repository.id, ability_id, name="Read Docs")
run = service.store.create_analysis_run(repository.id)
with service.store.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO observed_facts
(repository_id, analysis_run_id, snapshot_id, kind, path, name, value, metadata)
VALUES (?, ?, NULL, 'documentation', 'README.md', 'README', '', '{}')
""",
(repository.id, run.id),
)
readme_fact_id = int(cursor.lastrowid)
cursor = connection.execute(
"""
INSERT INTO observed_facts
(repository_id, analysis_run_id, snapshot_id, kind, path, name, value, metadata)
VALUES (?, ?, NULL, 'scope', 'SCOPE.md', 'SCOPE', '', ?)
""",
(repository.id, run.id, '{"source_role": "derived_scope"}'),
)
scope_fact_id = int(cursor.lastrowid)
service.store.create_feature(
repository.id,
capability_id,
name="README backed feature",
type="docs",
location="README.md",
confidence=0.7,
source_refs=[
SourceReference(
fact_id=readme_fact_id,
path="README.md",
kind="documentation",
name="README",
),
SourceReference(
fact_id=scope_fact_id,
path="SCOPE.md",
kind="scope",
name="SCOPE",
),
],
)
payload = service.dependency_graph_elements(repository.id, use_latest_profile=False)
fact_nodes = [
element["data"]
for element in payload["elements"]
if element["data"].get("kind") == "fact"
]
assert [node["id"] for node in fact_nodes] == ["fact:document:README.md"]
assert fact_nodes[0]["label"] == "README.md (documentation)"
def test_manual_registry_updates_and_deletes_approved_entries(tmp_path): def test_manual_registry_updates_and_deletes_approved_entries(tmp_path):

View File

@@ -1542,8 +1542,27 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
) )
assert all( assert all(
"layer" in element["data"] "layer" in element["data"]
and "reviewState" in element["data"]
for element in graph_payload["elements"] for element in graph_payload["elements"]
) )
review_filter_response = client.post(
f"/repos/{repository_id}/dependency-graph/filter",
json={
"rules": [
{
"name": "blur accepted",
"action": "blur",
"match": {"reviewState": "accepted"},
}
],
"manual_overrides": {},
},
)
assert review_filter_response.status_code == 200
assert all(
element["data"]["displayState"] == "blur"
for element in review_filter_response.json()["elements"]
)
profile_response = client.post( profile_response = client.post(
f"/repos/{repository_id}/dependency-graph/profiles", f"/repos/{repository_id}/dependency-graph/profiles",
@@ -1578,6 +1597,15 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
) )
assert duplicate_response.status_code == 201 assert duplicate_response.status_code == 201
assert duplicate_response.json()["name"] == "Hide Facts Copy" assert duplicate_response.json()["name"] == "Hide Facts Copy"
latest_response = client.get(f"/repos/{repository_id}/dependency-graph")
assert latest_response.status_code == 200
assert latest_response.json()["profile"]["name"] == "Hide Facts Copy"
unsaved_response = client.get(
f"/repos/{repository_id}/dependency-graph",
params={"use_latest_profile": False},
)
assert unsaved_response.status_code == 200
assert unsaved_response.json()["profile"] is None
graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph") graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph")
assert graph_page.status_code == 200 assert graph_page.status_code == 200
@@ -1585,7 +1613,9 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert "cytoscape.min.js" in graph_page.text assert "cytoscape.min.js" in graph_page.text
assert 'data-graph-mode="impact"' in graph_page.text assert 'data-graph-mode="impact"' in graph_page.text
assert 'id="profile-select"' in graph_page.text assert 'id="profile-select"' in graph_page.text
assert 'id="filter-review-state"' in graph_page.text
assert 'data-override="blur"' in graph_page.text assert 'data-override="blur"' in graph_page.text
assert "graph-popup" in graph_page.text
scope_listing = client.get( scope_listing = client.get(
f"/ui/repos/{repository_id}/elements", f"/ui/repos/{repository_id}/elements",

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Dependency Graph Exploration Polish" title: "Dependency Graph Exploration Polish"
domain: capabilities domain: capabilities
repo: repo-scoping repo: repo-scoping
status: active status: done
owner: codex owner: codex
topic_slug: foerster-capabilities topic_slug: foerster-capabilities
created: "2026-05-04" created: "2026-05-04"
@@ -24,7 +24,7 @@ inspection, and reducing redundant document-derived nodes.
```task ```task
id: RREG-WP-0011-T01 id: RREG-WP-0011-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "8cca41a1-ddf2-4136-9707-70c7b8481a48" state_hub_task_id: "8cca41a1-ddf2-4136-9707-70c7b8481a48"
``` ```
@@ -44,7 +44,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0011-T02 id: RREG-WP-0011-T02
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "3759013b-8a78-45fb-afa5-5ff1f6644070" state_hub_task_id: "3759013b-8a78-45fb-afa5-5ff1f6644070"
``` ```
@@ -63,7 +63,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0011-T03 id: RREG-WP-0011-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "087a550f-a9f7-403c-9853-a04f5847211d" state_hub_task_id: "087a550f-a9f7-403c-9853-a04f5847211d"
``` ```
@@ -81,7 +81,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0011-T04 id: RREG-WP-0011-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "eac6ccd7-7c99-46f9-bf12-c3916b03f041" state_hub_task_id: "eac6ccd7-7c99-46f9-bf12-c3916b03f041"
``` ```
@@ -102,7 +102,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0011-T05 id: RREG-WP-0011-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "d63489fc-5c08-486c-addc-53af84218028" state_hub_task_id: "d63489fc-5c08-486c-addc-53af84218028"
``` ```
@@ -122,7 +122,7 @@ Acceptance criteria:
```task ```task
id: RREG-WP-0011-T06 id: RREG-WP-0011-T06
status: todo status: done
priority: high priority: high
state_hub_task_id: "5477cfdd-7bd4-428f-a329-6255c4c58803" state_hub_task_id: "5477cfdd-7bd4-428f-a329-6255c4c58803"
``` ```