diff --git a/docs/dependency-visualization-exploration.md b/docs/dependency-visualization-exploration.md index 5ddbac2..6d22a5b 100644 --- a/docs/dependency-visualization-exploration.md +++ b/docs/dependency-visualization-exploration.md @@ -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 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 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 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 when a profile references nodes or edges that no longer exist. ## Filter Rules Rules are JSON objects with an `action` of `show`, `blur`, or `hide`, plus a -`match` object. Common match fields include `kind`, `layer`, `primaryClass`, -`attributes`, `confidence`, `freshnessState`, `ownership`, `dependencyType`, -`strength`, `sameLayer`, `path`, and `text`. +`match` object. Common match fields include `kind`, `layer`, `reviewState`, +`primaryClass`, `attributes`, `confidence`, `freshnessState`, `ownership`, +`dependencyType`, `strength`, `sameLayer`, `path`, and `text`. 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 `/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: - name and optional description @@ -84,3 +94,7 @@ Example profiles: 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 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. diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index cebd79b..7096654 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -1101,6 +1101,7 @@ class RegistryService: profile_id: int | None = None, rules: list[dict[str, Any]] | None = None, manual_overrides: dict[str, str] | None = None, + use_latest_profile: bool = True, ) -> dict[str, object]: impact = 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() ability_map = self.store.get_ability_map(repository_id) - facts_by_id = { - fact.id: fact for fact in self.store.list_observed_facts(repository_id) - } + facts_by_id = {fact.id: fact for fact in self.store.list_observed_facts(repository_id)} characteristic_index = self._dependency_characteristic_index(ability_map) nodes: dict[str, dict[str, object]] = {} edge_sources: dict[str, DependencyEdge] = {} @@ -1132,6 +1131,8 @@ class RegistryService: profile = ( self.store.get_dependency_graph_profile(repository_id, profile_id) 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 ) 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 {}), **(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: if key in nodes: @@ -1150,6 +1157,11 @@ class RegistryService: if fact is not None: detail = { "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, "primaryClass": fact.metadata.get("source_role", fact.kind), "attributes": self._dependency_fact_attributes(fact), @@ -1174,13 +1186,16 @@ class RegistryService: "stableKey": key, "kind": 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") or self._dependency_node_label(repository_id, kind, key, item_id), "description": detail.get("description", ""), "primaryClass": detail.get("primaryClass", kind), "attributes": detail.get("attributes", []), "confidence": detail.get("confidence"), + "visualSize": self._dependency_node_size(detail.get("confidence")), "ownership": self._ownership_for_kind(kind), "freshnessState": ( 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.target_kind, edge.target_key, edge.target_id) 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}" source_data = nodes[edge.source_key]["data"] target_data = nodes[edge.target_key]["data"] @@ -1230,6 +1245,7 @@ class RegistryService: "stableKey": edge_id, "kind": "edge", "layer": "dependency", + "reviewState": "accepted", "source": edge.source_key, "target": edge.target_key, "sourceKind": edge.source_kind, @@ -1238,6 +1254,7 @@ class RegistryService: "targetLayer": self._dependency_layer(edge.target_kind), "dependencyType": edge.dependency_type, "strength": edge.strength, + "edgeWidth": self._dependency_edge_width(edge.strength), "edgeSource": edge.source, "sameLayer": edge.same_layer, "freshnessState": ( @@ -1284,6 +1301,11 @@ class RegistryService: for element in nodes.values() 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]] = [] hidden_elements: list[dict[str, object]] = [] orphaned_overrides = sorted( @@ -1302,12 +1324,21 @@ class RegistryService: "displayState": "hide", "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"]["connectedToBlurred"] = connected_to_blurred element["classes"] = " ".join( part for part in ( element.get("classes", ""), f"display-{state['displayState']}", + "connects-blurred" if connected_to_blurred else "", "manual-override" if state["visibilitySource"] == "manual" else "", "rule-derived" if state["visibilitySource"] == "rule" else "", ) @@ -2578,6 +2609,48 @@ class RegistryService: attributes.append(value) 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: if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}: return kind @@ -2642,6 +2715,8 @@ class RegistryService: actual = data.get(key) if key == "dependencyType": actual = data.get("dependencyType") + elif key == "reviewState": + actual = data.get("reviewState") elif key == "sameLayer": actual = bool(data.get("sameLayer")) elif key == "attributes": diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index a68d566..751ae92 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -2410,6 +2410,27 @@ class RegistryStore: raise NotFoundError(f"dependency graph profile {profile_id} was not found") 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( self, repository_id: int, diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 34a158e..76ed2c0 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -1154,6 +1154,7 @@ def get_dependency_graph( base_analysis_run_id: int | None = Query(default=None), target_analysis_run_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), ) -> dict[str, object]: try: @@ -1162,6 +1163,7 @@ def get_dependency_graph( base_analysis_run_id=base_analysis_run_id, target_analysis_run_id=target_analysis_run_id, profile_id=profile_id, + use_latest_profile=use_latest_profile, ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @@ -1189,6 +1191,7 @@ def filter_dependency_graph( profile_id=profile_id, rules=payload.rules, manual_overrides=payload.manual_overrides, + use_latest_profile=profile_id is not None, ) except NotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index b72c1b7..7eda0ef 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -208,11 +208,26 @@ def page( min-height: 680px; }} .graph-canvas {{ + position: relative; min-height: 680px; border: 1px solid var(--line); border-radius: 8px; 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 {{ display: grid; gap: 12px; @@ -1778,6 +1793,11 @@ def dependency_graph_view( +
${{escapeHtml(data.label)}}
-${{escapeHtml(data.kind)}} ${{escapeHtml(data.layer)}} ${{escapeHtml(data.displayState)}} ${{escapeHtml(data.freshnessState)}}
+${{escapeHtml(data.kind)}} ${{escapeHtml(data.layer)}} ${{escapeHtml(data.reviewState)}} ${{escapeHtml(data.displayState)}} ${{escapeHtml(data.freshnessState)}}
+ ${{data.confidence !== null && data.confidence !== undefined ? `Confidence: ${{escapeHtml(data.confidence)}}
` : ""}}Ownership: ${{escapeHtml(data.ownership || "unknown")}}
Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}
${{data.description ? `${{escapeHtml(data.description)}}
` : ""}} @@ -1931,7 +1958,7 @@ def dependency_graph_view( }} else {{ detail.innerHTML = `${{escapeHtml(data.dependencyType)}}
-${{escapeHtml(data.strength)}} ${{escapeHtml(data.displayState)}} ${{data.sameLayer ? 'same layer' : ""}}
+${{escapeHtml(data.strength)}} ${{escapeHtml(data.reviewState)}} ${{escapeHtml(data.displayState)}} ${{data.sameLayer ? 'same layer' : ""}}
${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}
Source: ${{escapeHtml(data.edgeSource)}}
Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}
@@ -1939,6 +1966,45 @@ def dependency_graph_view( }} }}; + const popupHtml = (element) => {{ + const data = element.data(); + if (element.isNode()) {{ + const path = data.path ? `${{escapeHtml(data.path)}}
` : ""; + const confidence = data.confidence !== null && data.confidence !== undefined + ? `confidence ${{escapeHtml(data.confidence)}}` + : ""; + return ` +${{escapeHtml(data.name || data.label)}}
+${{escapeHtml(data.kind)}} ${{escapeHtml(data.layer)}} ${{escapeHtml(data.reviewState)}}
+${{escapeHtml(data.displayState)}} ${{escapeHtml(data.freshnessState)}} ${{confidence}}
+${{escapeHtml(data.ownership || "unknown")}}
+ ${{path}} + `; + }} + return ` +${{escapeHtml(data.dependencyType)}}
+${{escapeHtml(data.strength)}} ${{data.sameLayer ? 'same layer' : ""}}
+${{escapeHtml(data.sourceMetadata?.name || data.source)}} -> ${{escapeHtml(data.targetMetadata?.name || data.target)}}
+${{escapeHtml(data.edgeSource)}}
+ `; + }}; + + 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 = () => {{ if (!cy) return cy.collection(); if (focusCollection) return focusCollection; @@ -1989,7 +2055,7 @@ def dependency_graph_view( "border-width": 1, "color": "#1f2933", "font-size": 11, - "height": 36, + "height": "data(visualSize)", "label": "data(label)", "text-background-color": "#ffffff", "text-background-opacity": .85, @@ -1998,14 +2064,14 @@ def dependency_graph_view( "text-max-width": 130, "text-valign": "top", "text-wrap": "wrap", - "width": 36 + "width": "data(visualSize)" }} }}, {{ 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[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle" }} }}, + {{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon" }} }}, + {{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star" }} }}, {{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }}, {{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }}, {{ @@ -2015,12 +2081,14 @@ def dependency_graph_view( "line-color": "#94a3b8", "target-arrow-color": "#94a3b8", "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 = 'weak']", style: {{ "width": 1, "line-style": "dotted" }} }}, + {{ selector: "edge[strength = 'strong']", style: {{ "line-color": "#475569", "target-arrow-color": "#475569" }} }}, + {{ 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.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.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" }} }}, @@ -2046,8 +2114,10 @@ def dependency_graph_view( }}); basePayload = payload; if (payload.profile) {{ - profileSelect.value = String(payload.profile.id); updateProfileForm(payload.profile); + loadProfiles(String(payload.profile.id)); + }} else {{ + loadProfiles(); }} cy.on("tap", "node, edge", (event) => {{ selected = event.target; @@ -2061,8 +2131,14 @@ def dependency_graph_view( if (mode === "path") applyMode("path"); }} }}); - cy.on("mouseover", ".display-blur", (event) => event.target.addClass("hover")); - cy.on("mouseout", ".display-blur", (event) => event.target.removeClass("hover")); + cy.on("mouseover", "node, edge", (event) => {{ + 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)); modeButtons.forEach((button) => {{ button.addEventListener("click", () => applyMode(button.dataset.graphMode)); @@ -2070,6 +2146,7 @@ def dependency_graph_view( document.querySelector("[data-filter-apply]").addEventListener("click", () => {{ const match = {{}}; if (filterLayer.value) match.layer = filterLayer.value; + if (filterReviewState.value) match.reviewState = filterReviewState.value; if (filterText.value.trim()) match.text = filterText.value.trim(); rules.push({{name: "UI filter", action: filterAction.value, match}}); refilter(); @@ -2162,8 +2239,7 @@ def dependency_graph_view( return loadProfiles(); }}); }}); - loadProfiles(); - applyMode(payload.mode === "impact" ? "impact" : "full"); + applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full"); }}) .catch((error) => {{ container.innerHTML = `${{escapeHtml(error.message)}}
`; diff --git a/tests/test_registry_service.py b/tests/test_registry_service.py index 87befbc..966cb51 100644 --- a/tests/test_registry_service.py +++ b/tests/test_registry_service.py @@ -333,16 +333,169 @@ def test_dependency_graph_enriches_layers_and_filters_with_profiles(tmp_path): assert fact_node["layer"] == "fact" assert fact_node["path"] == fact.path assert fact_node["displayState"] == "blur" + assert fact_node["reviewState"] == "accepted" + assert fact_node["visualSize"] == 36 assert feature_node["displayState"] == "show" assert feature_node["visibilitySource"] == "manual" + assert feature_node["visualSize"] == 50 assert evidence_node["layer"] == "evidence" + assert evidence_node["visualSize"] == 53 assert payload["filter"]["orphaned_overrides"] == ["missing:1"] assert payload["metrics"]["hidden_count"] == 0 - assert any( - element["data"].get("target") == f"feature:{feature_id}" - and element["data"].get("sourceKind") == "evidence" + evidence_edge = next( + element["data"] 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): diff --git a/tests/test_web_api.py b/tests/test_web_api.py index dc213ec..237cfd4 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -1542,8 +1542,27 @@ def test_ui_register_analyze_and_approve_loop(tmp_path): ) assert all( "layer" in element["data"] + and "reviewState" in element["data"] 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( 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.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") 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 'data-graph-mode="impact"' 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 "graph-popup" in graph_page.text scope_listing = client.get( f"/ui/repos/{repository_id}/elements", diff --git a/workplans/RREG-WP-0011-dependency-graph-exploration-polish.md b/workplans/RREG-WP-0011-dependency-graph-exploration-polish.md index 6e53213..c52b7be 100644 --- a/workplans/RREG-WP-0011-dependency-graph-exploration-polish.md +++ b/workplans/RREG-WP-0011-dependency-graph-exploration-polish.md @@ -4,7 +4,7 @@ type: workplan title: "Dependency Graph Exploration Polish" domain: capabilities repo: repo-scoping -status: active +status: done owner: codex topic_slug: foerster-capabilities created: "2026-05-04" @@ -24,7 +24,7 @@ inspection, and reducing redundant document-derived nodes. ```task id: RREG-WP-0011-T01 -status: todo +status: done priority: high state_hub_task_id: "8cca41a1-ddf2-4136-9707-70c7b8481a48" ``` @@ -44,7 +44,7 @@ Acceptance criteria: ```task id: RREG-WP-0011-T02 -status: todo +status: done priority: medium state_hub_task_id: "3759013b-8a78-45fb-afa5-5ff1f6644070" ``` @@ -63,7 +63,7 @@ Acceptance criteria: ```task id: RREG-WP-0011-T03 -status: todo +status: done priority: medium state_hub_task_id: "087a550f-a9f7-403c-9853-a04f5847211d" ``` @@ -81,7 +81,7 @@ Acceptance criteria: ```task id: RREG-WP-0011-T04 -status: todo +status: done priority: medium state_hub_task_id: "eac6ccd7-7c99-46f9-bf12-c3916b03f041" ``` @@ -102,7 +102,7 @@ Acceptance criteria: ```task id: RREG-WP-0011-T05 -status: todo +status: done priority: high state_hub_task_id: "d63489fc-5c08-486c-addc-53af84218028" ``` @@ -122,7 +122,7 @@ Acceptance criteria: ```task id: RREG-WP-0011-T06 -status: todo +status: done priority: high state_hub_task_id: "5477cfdd-7bd4-428f-a329-6255c4c58803" ```