repository-scoped dependency graph view profile persistence and interactive exploration features

This commit is contained in:
2026-05-04 11:26:25 +02:00
parent 98a65581ac
commit 4f04491734
11 changed files with 1365 additions and 32 deletions

View File

@@ -67,6 +67,10 @@ as a Cytoscape-ready JSON payload and `/ui/repos/{repository_id}/dependency-grap
as the graph page. The page supports full graph, impact-only, and selected-path
views with a detail panel for selected nodes and edges.
The expanded exploration model is documented in
`docs/dependency-visualization-exploration.md`, including layers, display
states, filter rules, manual overrides, and repository-scoped view profiles.
## Metrics
Propagation depth says how far a source change bubbled up. Propagation breadth

View File

@@ -0,0 +1,86 @@
# Dependency Visualization Exploration
Repository Scoping exposes approved repository characteristics as a layered
dependency graph for curator review. The graph is Cytoscape-compatible JSON from
`/repos/{repository_id}/dependency-graph`; the interactive view lives at
`/ui/repos/{repository_id}/dependency-graph`.
The visualization framework decision is recorded in
`docs/adr-dependency-graph-visualization-framework.md`.
## Layers
Graph nodes carry stable `layer` metadata:
- `fact`: deterministic scanner output, keyed by fact kind, path, and name.
- `evidence`: approved evidence records that interpret facts or source
references.
- `feature`: concrete implemented behavior or surface area.
- `capability`: curator-approved functional claim.
- `ability`: higher-level ability grouping.
- `scope`: repository-level scope summary.
Evidence sits between facts and interpreted claims. Evidence should point to a
feature when it supports a specific implementation surface, such as a test for a
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.
## Display States
Each active graph element receives a `displayState`:
- `show`: visible with normal labels and styling.
- `blur`: visible in a muted style; labels and descriptions are suppressed
until hover or selection.
- `hide`: removed from the active graph payload and listed in
`hidden_elements`.
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 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`.
Example:
```json
{
"name": "Blur deterministic facts",
"action": "blur",
"match": {"layer": "fact"}
}
```
## View Profiles
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`.
Profiles store:
- name and optional description
- default graph mode
- filter rules
- manual visibility overrides
- timestamps
Example profiles:
- Scope Impact: default impact mode; show stale or changed nodes and blur
current context.
- Hide Tooling Noise: hide facts with dependency or tooling attributes.
- Evidence Audit: show evidence and facts; blur abilities and scope.
- Same-Layer Normalization Review: show same-layer edges and blur ordinary
upward dependency arrows.
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.

View File

@@ -165,6 +165,19 @@ class DependencyEdge:
same_layer: bool = False
@dataclass(frozen=True)
class DependencyGraphViewProfile:
id: int
repository_id: int
name: str
description: str
default_mode: str
filter_rules: list[dict[str, Any]]
manual_overrides: dict[str, str]
created_at: str
updated_at: str
@dataclass(frozen=True)
class DependencyGraph:
repository: Repository

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import asdict, replace
from typing import Any
from repo_registry.core.models import (
AbilitySummary,
@@ -19,6 +20,7 @@ from repo_registry.core.models import (
ContentChunk,
DependencyEdge,
DependencyGraph,
DependencyGraphViewProfile,
DependencyImpactAnalysis,
DependencyImpactItem,
ExpectationGap,
@@ -1096,6 +1098,9 @@ class RegistryService:
*,
base_analysis_run_id: int | None = None,
target_analysis_run_id: int | None = None,
profile_id: int | None = None,
rules: list[dict[str, Any]] | None = None,
manual_overrides: dict[str, str] | None = None,
) -> dict[str, object]:
impact = None
if base_analysis_run_id is not None or target_analysis_run_id is not None:
@@ -1116,18 +1121,66 @@ class RegistryService:
{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()
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)
}
characteristic_index = self._dependency_characteristic_index(ability_map)
nodes: dict[str, dict[str, object]] = {}
edge_sources: dict[str, DependencyEdge] = {}
profile = (
self.store.get_dependency_graph_profile(repository_id, profile_id)
if profile_id is not None
else None
)
merged_rules = [*(profile.filter_rules if profile is not None else []), *(rules or [])]
merged_overrides = {
**(profile.manual_overrides if profile is not None else {}),
**(manual_overrides or {}),
}
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
detail = characteristic_index.get(key, {})
fact = facts_by_id.get(item_id) if kind == "fact" and item_id else None
if fact is not None:
detail = {
"name": fact.name,
"description": fact.value,
"primaryClass": fact.metadata.get("source_role", fact.kind),
"attributes": self._dependency_fact_attributes(fact),
"confidence": fact.metadata.get("confidence"),
"path": fact.path,
"value": fact.value,
"metadata": fact.metadata,
"sourceReferences": [
{
"fact_id": fact.id,
"path": fact.path,
"kind": fact.kind,
"name": fact.name,
"line": fact.metadata.get("line"),
}
],
}
nodes[key] = {
"data": {
"id": key,
"key": key,
"stableKey": key,
"kind": kind,
"layer": self._dependency_layer(kind),
"label": self._dependency_node_label(repository_id, kind, key, item_id),
"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"),
"ownership": self._ownership_for_kind(kind),
"freshnessState": (
impact_item.freshness_state
@@ -1143,6 +1196,10 @@ class RegistryService:
impact_item.impact_depth if impact_item is not None else None
),
"reasons": impact_item.reasons if impact_item is not None else [],
"path": detail.get("path", ""),
"value": detail.get("value", ""),
"metadata": detail.get("metadata", {}),
"sourceReferences": detail.get("sourceReferences", []),
},
"classes": " ".join(
class_name
@@ -1159,16 +1216,49 @@ class RegistryService:
ensure_node(edge.source_kind, edge.source_key, edge.source_id)
ensure_node(edge.target_kind, edge.target_key, edge.target_id)
edges = [
{
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"]
edge_sources[edge_id] = edge
edges.append(
{
"data": {
"id": f"{edge.source_key}->{edge.target_key}:{index}",
"id": edge_id,
"key": edge_id,
"stableKey": edge_id,
"kind": "edge",
"layer": "dependency",
"source": edge.source_key,
"target": edge.target_key,
"sourceKind": edge.source_kind,
"targetKind": edge.target_kind,
"sourceLayer": self._dependency_layer(edge.source_kind),
"targetLayer": self._dependency_layer(edge.target_kind),
"dependencyType": edge.dependency_type,
"strength": edge.strength,
"edgeSource": edge.source,
"sameLayer": edge.same_layer,
"freshnessState": (
"stale"
if edge.target_key in impact_by_key
else "changed"
if edge.source_key in changed_fact_keys
else "current"
),
"sourceMetadata": {
"key": edge.source_key,
"kind": edge.source_kind,
"layer": self._dependency_layer(edge.source_kind),
"name": source_data.get("name", edge.source_key),
},
"targetMetadata": {
"key": edge.target_key,
"kind": edge.target_kind,
"layer": self._dependency_layer(edge.target_kind),
"name": target_data.get("name", edge.target_key),
},
"label": edge.dependency_type,
},
"classes": " ".join(
@@ -1181,24 +1271,195 @@ class RegistryService:
if class_name
),
}
for index, edge in enumerate(graph.edges)
]
)
elements = [*nodes.values(), *edges]
visibility = self._evaluate_dependency_visibility(
elements,
merged_rules,
merged_overrides,
)
hidden_node_ids = {
element["data"]["id"]
for element in nodes.values()
if visibility[element["data"]["id"]]["displayState"] == "hide"
}
visible_elements: list[dict[str, object]] = []
hidden_elements: list[dict[str, object]] = []
orphaned_overrides = sorted(
key for key in merged_overrides if key not in visibility
)
for element in elements:
element_id = element["data"]["id"]
state = visibility[element_id]
if "source" in element["data"] and (
element["data"]["source"] in hidden_node_ids
or element["data"]["target"] in hidden_node_ids
):
state = {
**state,
"displayState": "hide",
"visibilityReason": "connected-node-hidden",
}
element["data"].update(state)
element["classes"] = " ".join(
part
for part in (
element.get("classes", ""),
f"display-{state['displayState']}",
"manual-override" if state["visibilitySource"] == "manual" else "",
"rule-derived" if state["visibilitySource"] == "rule" else "",
)
if part
)
if state["displayState"] == "hide":
hidden_elements.append(element)
else:
visible_elements.append(element)
return {
"repository": asdict(graph.repository),
"scope": asdict(graph.scope),
"mode": "impact" if impact is not None else "full",
"mode": (
profile.default_mode
if profile is not None and impact is None
else "impact"
if impact is not None
else "full"
),
"profile": asdict(profile) if profile is not None else None,
"metrics": {
"node_count": len(nodes),
"edge_count": len(edges),
"node_count": len(
[
element
for element in visible_elements
if "source" not in element["data"]
]
),
"edge_count": len(
[
element
for element in visible_elements
if "source" in element["data"]
]
),
"hidden_count": len(hidden_elements),
"blurred_count": len(
[
element
for element in visible_elements
if element["data"]["displayState"] == "blur"
]
),
"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,
},
"filter": {
"rules": merged_rules,
"manual_overrides": merged_overrides,
"orphaned_overrides": orphaned_overrides,
"precedence": "later rules override earlier rules; manual overrides win last",
"connected_edge_behavior": "edges connected to hidden nodes are hidden",
},
"changed_fact_keys": impact.changed_fact_keys if impact else [],
"elements": [*nodes.values(), *edges],
"elements": visible_elements,
"hidden_elements": hidden_elements,
"impacts": [asdict(item) for item in impact.impacts] if impact else [],
}
def list_dependency_graph_profiles(
self,
repository_id: int,
) -> list[DependencyGraphViewProfile]:
return self.store.list_dependency_graph_profiles(repository_id)
def get_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
) -> DependencyGraphViewProfile:
return self.store.get_dependency_graph_profile(repository_id, profile_id)
def create_dependency_graph_profile(
self,
repository_id: int,
*,
name: str,
description: str = "",
default_mode: str = "full",
filter_rules: list[dict[str, Any]] | None = None,
manual_overrides: dict[str, str] | None = None,
) -> DependencyGraphViewProfile:
self._validate_dependency_graph_profile_payload(
default_mode,
filter_rules or [],
manual_overrides or {},
)
return self.store.create_dependency_graph_profile(
repository_id,
name=name,
description=description,
default_mode=default_mode,
filter_rules=filter_rules or [],
manual_overrides=manual_overrides or {},
)
def update_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
*,
name: str | None = None,
description: str | None = None,
default_mode: str | None = None,
filter_rules: list[dict[str, Any]] | None = None,
manual_overrides: dict[str, str] | None = None,
) -> DependencyGraphViewProfile:
if default_mode is not None or filter_rules is not None or manual_overrides is not None:
current = self.store.get_dependency_graph_profile(repository_id, profile_id)
self._validate_dependency_graph_profile_payload(
default_mode or current.default_mode,
filter_rules if filter_rules is not None else current.filter_rules,
manual_overrides
if manual_overrides is not None
else current.manual_overrides,
)
return self.store.update_dependency_graph_profile(
repository_id,
profile_id,
name=name,
description=description,
default_mode=default_mode,
filter_rules=filter_rules,
manual_overrides=manual_overrides,
)
def duplicate_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
*,
name: str | None = None,
) -> DependencyGraphViewProfile:
profile = self.store.get_dependency_graph_profile(repository_id, profile_id)
return self.store.create_dependency_graph_profile(
repository_id,
name=name or f"{profile.name} Copy",
description=profile.description,
default_mode=profile.default_mode,
filter_rules=profile.filter_rules,
manual_overrides=profile.manual_overrides,
)
def delete_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
) -> None:
self.store.delete_dependency_graph_profile(repository_id, profile_id)
def approve_analysis_run_changes(
self,
repository_id: int,
@@ -2252,6 +2513,180 @@ class RegistryService:
for fact in facts
}
def _dependency_characteristic_index(
self,
ability_map: RepositoryAbilityMap,
) -> dict[str, dict[str, object]]:
index: dict[str, dict[str, object]] = {
self._dependency_key("scope", ability_map.scope.id): {
"name": ability_map.scope.name,
"description": ability_map.scope.description,
"primaryClass": "scope",
"attributes": ["scope"],
"confidence": ability_map.scope.confidence,
"sourceReferences": [],
}
}
for ability in ability_map.abilities:
index[self._dependency_key("ability", ability.id)] = {
"name": ability.name,
"description": ability.description,
"primaryClass": ability.primary_class,
"attributes": ability.attributes,
"confidence": ability.confidence,
"sourceReferences": [],
}
for capability in ability.capabilities:
index[self._dependency_key("capability", capability.id)] = {
"name": capability.name,
"description": capability.description,
"primaryClass": capability.primary_class,
"attributes": capability.attributes,
"confidence": capability.confidence,
"sourceReferences": [],
}
for feature in capability.features:
index[self._dependency_key("feature", feature.id)] = {
"name": feature.name,
"description": feature.location,
"primaryClass": feature.primary_class or feature.type,
"attributes": feature.attributes,
"confidence": feature.confidence,
"path": feature.location,
"sourceReferences": [
asdict(source_ref) for source_ref in feature.source_refs
],
}
for evidence in capability.evidence:
index[self._dependency_key("evidence", evidence.id)] = {
"name": evidence.reference,
"description": evidence.type,
"primaryClass": evidence.type,
"attributes": [evidence.type, evidence.strength],
"confidence": self._evidence_confidence(evidence.strength),
"sourceReferences": [
asdict(source_ref) for source_ref in evidence.source_refs
],
}
return index
def _dependency_fact_attributes(self, fact: ObservedFact) -> list[str]:
attributes = [fact.kind]
for key in ("source_role", "classification", "language", "framework"):
value = fact.metadata.get(key)
if isinstance(value, str) and value:
attributes.append(value)
return sorted(set(attributes))
def _dependency_layer(self, kind: str) -> str:
if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}:
return kind
return "dependency"
def _evaluate_dependency_visibility(
self,
elements: list[dict[str, object]],
rules: list[dict[str, Any]],
manual_overrides: dict[str, str],
) -> dict[str, dict[str, str]]:
visibility: dict[str, dict[str, str]] = {}
for element in elements:
data = element["data"]
element_id = str(data["id"])
state = "show"
source = "default"
reason = "default"
for index, rule in enumerate(rules):
action = str(rule.get("action", "show"))
if action not in {"show", "blur", "hide"}:
continue
if self._dependency_rule_matches(data, rule):
state = action
source = "rule"
reason = str(rule.get("name") or f"rule:{index}")
override = manual_overrides.get(element_id)
if override in {"show", "blur", "hide"}:
state = override
source = "manual"
reason = "manual-override"
visibility[element_id] = {
"displayState": state,
"visibilitySource": source,
"visibilityReason": reason,
}
return visibility
def _dependency_rule_matches(
self,
data: dict[str, Any],
rule: dict[str, Any],
) -> bool:
match = rule.get("match", rule)
if not isinstance(match, dict):
return False
for key, expected in match.items():
if key in {"action", "name", "description"}:
continue
if key == "text":
text = " ".join(
str(data.get(part, ""))
for part in ("label", "name", "description", "path", "value")
).lower()
if str(expected).lower() not in text:
return False
continue
if key == "path":
if str(expected).lower() not in str(data.get("path", "")).lower():
return False
continue
actual = data.get(key)
if key == "dependencyType":
actual = data.get("dependencyType")
elif key == "sameLayer":
actual = bool(data.get("sameLayer"))
elif key == "attributes":
actual_values = set(data.get("attributes") or [])
expected_values = expected if isinstance(expected, list) else [expected]
if not set(expected_values).intersection(actual_values):
return False
continue
elif key == "confidence":
if actual is None:
return False
threshold = float(expected)
if float(actual) < threshold:
return False
continue
if isinstance(expected, list):
if actual not in expected:
return False
elif actual != expected:
return False
return True
def _validate_dependency_graph_profile_payload(
self,
default_mode: str,
filter_rules: list[dict[str, Any]],
manual_overrides: dict[str, str],
) -> None:
if default_mode not in {"full", "impact", "path"}:
raise ValueError("default_mode must be one of full, impact, or path")
for rule in filter_rules:
action = rule.get("action")
if action not in {"show", "blur", "hide"}:
raise ValueError("filter rule action must be show, blur, or hide")
invalid = {
key: value
for key, value in manual_overrides.items()
if value not in {"show", "blur", "hide"}
}
if invalid:
raise ValueError("manual override values must be show, blur, or hide")
def _evidence_confidence(self, strength: str) -> float:
return {"strong": 0.9, "medium": 0.6, "weak": 0.3}.get(strength, 0.5)
def _capability_dependency_edges(
self,
capability,
@@ -2290,14 +2725,19 @@ class RegistryService:
)
for evidence in capability.evidence:
evidence_key = self._dependency_key("evidence", evidence.id)
evidence_target_kind = evidence.target_kind or "capability"
evidence_target_id = evidence.target_id or capability.id
edges.append(
self._dependency_edge(
source_kind="evidence",
source_id=evidence.id,
source_key=evidence_key,
target_kind="capability",
target_id=capability.id,
target_key=capability_key,
target_kind=evidence_target_kind,
target_id=evidence_target_id,
target_key=self._dependency_key(
evidence_target_kind,
evidence_target_id,
),
dependency_type="supports",
strength=evidence.strength or "medium",
source="approved_characteristic",

View File

@@ -16,6 +16,7 @@ from repo_registry.core.models import (
Capability,
CapabilitySummary,
ContentChunk,
DependencyGraphViewProfile,
Evidence,
ExpectationGap,
Feature,
@@ -53,6 +54,7 @@ class RegistryStore:
self._ensure_evidence_relationship_columns(connection)
self._ensure_characteristic_classification_columns(connection)
self._ensure_expectation_gaps_table(connection)
self._ensure_dependency_graph_profiles_table(connection)
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path)
@@ -235,6 +237,33 @@ class RegistryStore:
"CREATE INDEX IF NOT EXISTS idx_expectation_gaps_run ON expectation_gaps(analysis_run_id)"
)
def _ensure_dependency_graph_profiles_table(
self,
connection: sqlite3.Connection,
) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS dependency_graph_view_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repository_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
default_mode TEXT NOT NULL DEFAULT 'full',
filter_rules TEXT NOT NULL DEFAULT '[]',
manual_overrides TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(repository_id, name)
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_dependency_graph_profiles_repository
ON dependency_graph_view_profiles(repository_id)
"""
)
def create_repository(
self,
*,
@@ -2343,6 +2372,138 @@ class RegistryStore:
]
return RepositoryAbilityMap(repository=repository, scope=scope, abilities=abilities)
def list_dependency_graph_profiles(
self,
repository_id: int,
) -> list[DependencyGraphViewProfile]:
self.get_repository(repository_id)
with self.connect() as connection:
rows = 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 name COLLATE NOCASE, id
""",
(repository_id,),
).fetchall()
return [self._dependency_graph_profile_from_row(row) for row in rows]
def get_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
) -> DependencyGraphViewProfile:
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 = ? AND id = ?
""",
(repository_id, profile_id),
).fetchone()
if row is None:
raise NotFoundError(f"dependency graph profile {profile_id} was not found")
return self._dependency_graph_profile_from_row(row)
def create_dependency_graph_profile(
self,
repository_id: int,
*,
name: str,
description: str = "",
default_mode: str = "full",
filter_rules: list[dict[str, object]] | None = None,
manual_overrides: dict[str, str] | None = None,
) -> DependencyGraphViewProfile:
self.get_repository(repository_id)
try:
with self.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO dependency_graph_view_profiles
(repository_id, name, description, default_mode,
filter_rules, manual_overrides)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
repository_id,
name,
description,
default_mode,
json.dumps(filter_rules or []),
json.dumps(manual_overrides or {}),
),
)
profile_id = int(cursor.lastrowid)
except sqlite3.IntegrityError as exc:
raise ValueError(f"profile named {name!r} already exists") from exc
return self.get_dependency_graph_profile(repository_id, profile_id)
def update_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
*,
name: str | None = None,
description: str | None = None,
default_mode: str | None = None,
filter_rules: list[dict[str, object]] | None = None,
manual_overrides: dict[str, str] | None = None,
) -> DependencyGraphViewProfile:
self.get_dependency_graph_profile(repository_id, profile_id)
assignments: list[str] = []
values: list[object] = []
for column, value in (
("name", name),
("description", description),
("default_mode", default_mode),
):
if value is not None:
assignments.append(f"{column} = ?")
values.append(value)
if filter_rules is not None:
assignments.append("filter_rules = ?")
values.append(json.dumps(filter_rules))
if manual_overrides is not None:
assignments.append("manual_overrides = ?")
values.append(json.dumps(manual_overrides))
if not assignments:
return self.get_dependency_graph_profile(repository_id, profile_id)
values.extend([repository_id, profile_id])
try:
with self.connect() as connection:
connection.execute(
f"""
UPDATE dependency_graph_view_profiles
SET {", ".join(assignments)}, updated_at = CURRENT_TIMESTAMP
WHERE repository_id = ? AND id = ?
""",
values,
)
except sqlite3.IntegrityError as exc:
raise ValueError("profile name must be unique within a repository") from exc
return self.get_dependency_graph_profile(repository_id, profile_id)
def delete_dependency_graph_profile(
self,
repository_id: int,
profile_id: int,
) -> None:
self.get_dependency_graph_profile(repository_id, profile_id)
with self.connect() as connection:
connection.execute(
"""
DELETE FROM dependency_graph_view_profiles
WHERE repository_id = ? AND id = ?
""",
(repository_id, profile_id),
)
def search(
self,
query: str,
@@ -2875,3 +3036,19 @@ class RegistryStore:
status=row["status"],
created_at=row["created_at"],
)
@staticmethod
def _dependency_graph_profile_from_row(
row: sqlite3.Row,
) -> DependencyGraphViewProfile:
return DependencyGraphViewProfile(
id=row["id"],
repository_id=row["repository_id"],
name=row["name"],
description=row["description"],
default_mode=row["default_mode"],
filter_rules=json.loads(row["filter_rules"]),
manual_overrides=json.loads(row["manual_overrides"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
)

View File

@@ -44,6 +44,11 @@ from repo_registry.web_api.schemas import (
CapabilitySummaryResponse,
CapabilityUpdate,
ContentChunkResponse,
DependencyGraphAdHocFilters,
DependencyGraphProfileCreate,
DependencyGraphProfileDuplicate,
DependencyGraphProfileResponse,
DependencyGraphProfileUpdate,
EvidenceCreate,
EvidenceUpdate,
ErrorResponse,
@@ -125,6 +130,7 @@ OPENAPI_TAGS = [
{"name": "review", "description": "Candidate graph approval and correction workflow."},
{"name": "registry", "description": "Approved ability maps and manual registry CRUD."},
{"name": "scope", "description": "SCOPE.md generation, diffing, and writing."},
{"name": "visualization", "description": "Dependency graph exploration and view profiles."},
{"name": "search", "description": "Agent-facing discovery endpoints."},
{"name": "discovery", "description": "Comparison, gap analysis, and export helpers."},
]
@@ -1141,12 +1147,13 @@ def get_ability_map(
@app.get(
"/repos/{repository_id}/dependency-graph",
tags=["registry"],
tags=["visualization"],
)
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),
profile_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
@@ -1154,6 +1161,7 @@ def get_dependency_graph(
repository_id,
base_analysis_run_id=base_analysis_run_id,
target_analysis_run_id=target_analysis_run_id,
profile_id=profile_id,
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -1161,6 +1169,158 @@ def get_dependency_graph(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post(
"/repos/{repository_id}/dependency-graph/filter",
tags=["visualization"],
)
def filter_dependency_graph(
repository_id: int,
payload: DependencyGraphAdHocFilters,
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),
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,
profile_id=profile_id,
rules=payload.rules,
manual_overrides=payload.manual_overrides,
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get(
"/repos/{repository_id}/dependency-graph/profiles",
tags=["visualization"],
response_model=list[DependencyGraphProfileResponse],
)
def list_dependency_graph_profiles(
repository_id: int,
service: RegistryService = Depends(get_service),
) -> list[dict[str, object]]:
try:
return [
asdict(profile)
for profile in service.list_dependency_graph_profiles(repository_id)
]
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post(
"/repos/{repository_id}/dependency-graph/profiles",
tags=["visualization"],
status_code=201,
response_model=DependencyGraphProfileResponse,
)
def create_dependency_graph_profile(
repository_id: int,
payload: DependencyGraphProfileCreate,
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
return asdict(
service.create_dependency_graph_profile(
repository_id,
**payload.model_dump(),
)
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get(
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}",
tags=["visualization"],
response_model=DependencyGraphProfileResponse,
)
def get_dependency_graph_profile(
repository_id: int,
profile_id: int,
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
return asdict(service.get_dependency_graph_profile(repository_id, profile_id))
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.patch(
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}",
tags=["visualization"],
response_model=DependencyGraphProfileResponse,
)
def update_dependency_graph_profile(
repository_id: int,
profile_id: int,
payload: DependencyGraphProfileUpdate,
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
return asdict(
service.update_dependency_graph_profile(
repository_id,
profile_id,
**payload.model_dump(exclude_unset=True),
)
)
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.post(
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}/duplicate",
tags=["visualization"],
status_code=201,
response_model=DependencyGraphProfileResponse,
)
def duplicate_dependency_graph_profile(
repository_id: int,
profile_id: int,
payload: DependencyGraphProfileDuplicate,
service: RegistryService = Depends(get_service),
) -> dict[str, object]:
try:
return asdict(
service.duplicate_dependency_graph_profile(
repository_id,
profile_id,
name=payload.name,
)
)
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.delete(
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}",
tags=["visualization"],
status_code=204,
)
def delete_dependency_graph_profile(
repository_id: int,
profile_id: int,
service: RegistryService = Depends(get_service),
) -> None:
try:
service.delete_dependency_graph_profile(repository_id, profile_id)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get(
"/repos/{repository_id}/export",
tags=["discovery"],

View File

@@ -308,6 +308,49 @@ class CharacteristicRebuildResponse(BaseModel):
candidate_counts: dict[str, int]
class DependencyGraphRule(BaseModel):
action: str
name: str | None = None
match: dict[str, Any] = Field(default_factory=dict)
class DependencyGraphAdHocFilters(BaseModel):
rules: list[dict[str, Any]] = Field(default_factory=list)
manual_overrides: dict[str, str] = Field(default_factory=dict)
class DependencyGraphProfileCreate(BaseModel):
name: str
description: str = ""
default_mode: str = "full"
filter_rules: list[dict[str, Any]] = Field(default_factory=list)
manual_overrides: dict[str, str] = Field(default_factory=dict)
class DependencyGraphProfileUpdate(BaseModel):
name: str | None = None
description: str | None = None
default_mode: str | None = None
filter_rules: list[dict[str, Any]] | None = None
manual_overrides: dict[str, str] | None = None
class DependencyGraphProfileDuplicate(BaseModel):
name: str | None = None
class DependencyGraphProfileResponse(BaseModel):
id: int
repository_id: int
name: str
description: str
default_mode: str
filter_rules: list[dict[str, Any]]
manual_overrides: dict[str, str]
created_at: str
updated_at: str
class CandidateRejection(BaseModel):
notes: str = ""

View File

@@ -228,6 +228,15 @@ def page(
background: var(--accent-dark);
border-color: var(--accent-dark);
}}
.compact-controls {{
display: grid;
gap: 8px;
}}
.inline-fields {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}}
.legend-grid {{
display: grid;
grid-template-columns: auto 1fr;
@@ -1683,6 +1692,7 @@ 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),
profile_id: int | None = Query(default=None),
service: RegistryService = Depends(get_service),
) -> HTMLResponse:
try:
@@ -1691,6 +1701,7 @@ def dependency_graph_view(
repository_id,
base_analysis_run_id=base_analysis_run_id,
target_analysis_run_id=target_analysis_run_id,
profile_id=profile_id,
)
except NotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -1703,6 +1714,8 @@ def dependency_graph_view(
f"?base_analysis_run_id={base_analysis_run_id}"
f"&target_analysis_run_id={target_analysis_run_id}"
)
if profile_id is not None:
query = f"{query}{'&' if query else '?'}profile_id={profile_id}"
endpoint = json.dumps(f"/repos/{repository_id}/dependency-graph{query}")
metrics = graph["metrics"]
changed_note = (
@@ -1737,6 +1750,60 @@ def dependency_graph_view(
<button class="secondary" type="button" data-graph-fit>Fit</button>
</div>
</section>
<section class="panel">
<h2>Profiles</h2>
<div class="compact-controls">
<label>Profile <select id="profile-select"><option value="">Unsaved exploration</option></select></label>
<div class="inline-fields">
<label>Name <input id="profile-name" placeholder="Evidence Audit"></label>
<label>Mode <select id="profile-mode"><option>full</option><option>impact</option><option>path</option></select></label>
</div>
<label>Description <textarea id="profile-description" rows="2"></textarea></label>
<div class="graph-controls">
<button class="secondary" type="button" data-profile-save>Save</button>
<button class="secondary" type="button" data-profile-duplicate>Duplicate</button>
<button class="secondary" type="button" data-profile-delete>Delete</button>
</div>
<div id="profile-summary" class="muted">No profile selected.</div>
</div>
</section>
<section class="panel">
<h2>Filters</h2>
<div class="compact-controls">
<div class="inline-fields">
<label>Layer <select id="filter-layer">
<option value="">Any layer</option>
<option>fact</option><option>evidence</option><option>feature</option>
<option>capability</option><option>ability</option><option>scope</option>
</select></label>
<label>Action <select id="filter-action"><option>blur</option><option>hide</option><option>show</option></select></label>
</div>
<label>Text <input id="filter-text" placeholder="Search labels, paths, descriptions"></label>
<div class="graph-controls">
<button class="secondary" type="button" data-filter-apply>Apply Rule</button>
<button class="secondary" type="button" data-filter-clear>Clear Rules</button>
</div>
</div>
</section>
<section class="panel">
<h2>Manual</h2>
<div class="graph-controls">
<button class="secondary" type="button" data-override="show">Show</button>
<button class="secondary" type="button" data-override="blur">Blur</button>
<button class="secondary" type="button" data-override="hide">Hide</button>
<button class="secondary" type="button" data-overrides-reset>Reset</button>
</div>
</section>
<section class="panel">
<h2>Focus</h2>
<div class="compact-controls">
<label>Depth <input id="focus-depth" type="number" min="1" max="5" value="1"></label>
<div class="graph-controls">
<button class="secondary" type="button" data-focus-selected>Focus</button>
<button class="secondary" type="button" data-focus-clear>Clear</button>
</div>
</div>
</section>
<section class="panel">
<h2>Legend</h2>
<div class="legend-grid">
@@ -1758,13 +1825,28 @@ def dependency_graph_view(
<script>
(() => {{
const endpoint = {endpoint};
const profileEndpoint = "/repos/{repository_id}/dependency-graph/profiles";
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]");
const profileSelect = document.getElementById("profile-select");
const profileName = document.getElementById("profile-name");
const profileDescription = document.getElementById("profile-description");
const profileMode = document.getElementById("profile-mode");
const profileSummary = document.getElementById("profile-summary");
const filterLayer = document.getElementById("filter-layer");
const filterText = document.getElementById("filter-text");
const filterAction = document.getElementById("filter-action");
const focusDepth = document.getElementById("focus-depth");
let cy = null;
let mode = "full";
let selected = null;
let basePayload = null;
let activeProfile = null;
let rules = [];
let manualOverrides = {{}};
let focusCollection = null;
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
@@ -1777,6 +1859,57 @@ def dependency_graph_view(
return `<ul class="detail-list">${{items.map((item) => `<li>${{escapeHtml(item)}}</li>`).join("")}}</ul>`;
}};
const profileSummaryText = (payload) => {{
const filter = payload?.filter || {{}};
const ruleCount = (filter.rules || []).length;
const overrideCount = Object.keys(filter.manual_overrides || {{}}).length;
const orphanCount = (filter.orphaned_overrides || []).length;
return `${{ruleCount}} rules · ${{overrideCount}} overrides · ${{orphanCount}} orphaned`;
}};
const updateProfileForm = (profile) => {{
activeProfile = profile || null;
profileName.value = profile?.name || "";
profileDescription.value = profile?.description || "";
profileMode.value = profile?.default_mode || mode;
rules = profile ? [...profile.filter_rules] : [];
manualOverrides = profile ? {{...profile.manual_overrides}} : {{}};
profileSummary.textContent = profile ? profileSummaryText(basePayload) : "No profile selected.";
}};
const loadProfiles = () => fetch(profileEndpoint)
.then((response) => response.json())
.then((profiles) => {{
const current = profileSelect.value;
profileSelect.innerHTML = '<option value="">Unsaved exploration</option>' + profiles
.map((profile) => `<option value="${{profile.id}}">${{escapeHtml(profile.name)}}</option>`)
.join("");
profileSelect.value = current;
}});
const applyPayload = (payload) => {{
basePayload = payload;
cy.elements().remove();
cy.add(payload.elements);
profileSummary.textContent = payload.profile ? profileSummaryText(payload) : profileSummaryText(payload);
applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
}};
const refilter = () => fetch(`/repos/{repository_id}/dependency-graph/filter`, {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{rules, manual_overrides: manualOverrides}})
}})
.then((response) => {{
if (!response.ok) throw new Error(`Filter request failed: ${{response.status}}`);
return response.json();
}})
.then(applyPayload)
.catch((error) => {{
detail.className = "notice error";
detail.textContent = error.message;
}});
const showDetails = (element) => {{
if (!element) {{
detail.className = "muted";
@@ -1788,23 +1921,27 @@ def dependency_graph_view(
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><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 class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
${{data.description ? `<p>${{escapeHtml(data.description)}}</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><span class="pill">${{escapeHtml(data.strength)}}</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">Source: ${{escapeHtml(data.edgeSource)}}</p>
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
`;
}}
}};
const visibleForMode = () => {{
if (!cy) return cy.collection();
if (focusCollection) return focusCollection;
if (mode === "full") return cy.elements();
if (mode === "impact") {{
const touched = cy.nodes(".stale, .changed");
@@ -1816,6 +1953,22 @@ def dependency_graph_view(
return cy.elements();
}};
const runLayeredLayout = () => {{
const layerOrder = ["fact", "evidence", "feature", "capability", "ability", "scope"];
const visibleNodes = cy.nodes(":visible");
const width = Math.max(container.clientWidth, 720);
const height = Math.max(container.clientHeight, 560);
layerOrder.forEach((layer, layerIndex) => {{
const nodes = visibleNodes.filter((node) => node.data("layer") === layer);
const x = 70 + (width - 140) * (layerIndex / Math.max(layerOrder.length - 1, 1));
nodes.forEach((node, index) => {{
const y = 70 + (height - 140) * ((index + 1) / (nodes.length + 1));
node.position({{x, y}});
}});
}});
cy.fit(cy.elements(":visible"), 48);
}};
const applyMode = (nextMode) => {{
mode = nextMode;
modeButtons.forEach((button) => {{
@@ -1824,8 +1977,7 @@ def dependency_graph_view(
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);
runLayeredLayout();
}};
const style = [
@@ -1868,7 +2020,9 @@ def dependency_graph_view(
}},
{{ 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: "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: ".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" }} }},
{{ selector: ".hidden", style: {{ "display": "none" }} }}
];
@@ -1888,8 +2042,13 @@ def dependency_graph_view(
container,
elements: payload.elements,
style,
layout: {{ name: "breadthfirst", directed: true, spacingFactor: 1.25 }}
layout: {{ name: "preset" }}
}});
basePayload = payload;
if (payload.profile) {{
profileSelect.value = String(payload.profile.id);
updateProfileForm(payload.profile);
}}
cy.on("tap", "node, edge", (event) => {{
selected = event.target;
showDetails(selected);
@@ -1902,10 +2061,108 @@ 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"));
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
modeButtons.forEach((button) => {{
button.addEventListener("click", () => applyMode(button.dataset.graphMode));
}});
document.querySelector("[data-filter-apply]").addEventListener("click", () => {{
const match = {{}};
if (filterLayer.value) match.layer = filterLayer.value;
if (filterText.value.trim()) match.text = filterText.value.trim();
rules.push({{name: "UI filter", action: filterAction.value, match}});
refilter();
}});
document.querySelector("[data-filter-clear]").addEventListener("click", () => {{
rules = [];
refilter();
}});
document.querySelectorAll("[data-override]").forEach((button) => {{
button.addEventListener("click", () => {{
if (!selected) return;
manualOverrides[selected.id()] = button.dataset.override;
refilter();
}});
}});
document.querySelector("[data-overrides-reset]").addEventListener("click", () => {{
manualOverrides = {{}};
refilter();
}});
document.querySelector("[data-focus-selected]").addEventListener("click", () => {{
if (!selected) return;
let collection = selected;
const depth = Number(focusDepth.value || 1);
let frontier = selected;
for (let index = 0; index < depth; index += 1) {{
const next = frontier.neighborhood();
collection = collection.union(next);
frontier = next.nodes();
}}
focusCollection = collection;
applyMode(mode);
}});
document.querySelector("[data-focus-clear]").addEventListener("click", () => {{
focusCollection = null;
applyMode(mode);
}});
profileSelect.addEventListener("change", () => {{
const id = profileSelect.value;
if (!id) {{
updateProfileForm(null);
refilter();
return;
}}
window.location.href = `/ui/repos/{repository_id}/dependency-graph?profile_id=${{encodeURIComponent(id)}}`;
}});
document.querySelector("[data-profile-save]").addEventListener("click", () => {{
const payload = {{
name: profileName.value || "Untitled Graph View",
description: profileDescription.value,
default_mode: profileMode.value || mode,
filter_rules: rules,
manual_overrides: manualOverrides
}};
const url = activeProfile ? `${{profileEndpoint}}/${{activeProfile.id}}` : profileEndpoint;
fetch(url, {{
method: activeProfile ? "PATCH" : "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify(payload)
}})
.then((response) => {{
if (!response.ok) throw new Error(`Profile save failed: ${{response.status}}`);
return response.json();
}})
.then((profile) => {{
updateProfileForm(profile);
profileSelect.value = String(profile.id);
return loadProfiles();
}});
}});
document.querySelector("[data-profile-duplicate]").addEventListener("click", () => {{
if (!activeProfile) return;
fetch(`${{profileEndpoint}}/${{activeProfile.id}}/duplicate`, {{
method: "POST",
headers: {{"Content-Type": "application/json"}},
body: JSON.stringify({{name: `${{activeProfile.name}} Copy`}})
}})
.then((response) => response.json())
.then((profile) => {{
updateProfileForm(profile);
profileSelect.value = String(profile.id);
return loadProfiles();
}});
}});
document.querySelector("[data-profile-delete]").addEventListener("click", () => {{
if (!activeProfile) return;
fetch(`${{profileEndpoint}}/${{activeProfile.id}}`, {{method: "DELETE"}})
.then(() => {{
updateProfileForm(null);
profileSelect.value = "";
return loadProfiles();
}});
}});
loadProfiles();
applyMode(payload.mode === "impact" ? "impact" : "full");
}})
.catch((error) => {{

View File

@@ -263,6 +263,88 @@ def test_dependency_graph_flags_same_layer_edges(tmp_path):
assert same_layer_edges[0].target_key == f"capability:{second_capability_id}"
def test_dependency_graph_enriches_layers_and_filters_with_profiles(tmp_path):
service = make_service(tmp_path)
source = write_python_cli_repo(tmp_path)
repository = service.register_repository(
name="Graph Profile",
url=str(source),
description="Graph profile fixture.",
)
summary = service.analyze_repository(
repository.id,
source_path=str(source),
use_llm_assistance=False,
)
fact = next(item for item in summary.facts if item.kind == "framework")
source_ref = SourceReference(
fact_id=fact.id,
path=fact.path,
kind=fact.kind,
name=fact.name,
)
ability_id = service.add_ability(repository.id, name="Explore Graphs")
capability_id = service.add_capability(
repository.id,
ability_id,
name="Filter Dependency Graph",
)
feature_id = service.store.create_feature(
repository.id,
capability_id,
name="Graph filter control",
type="UI",
location="src/ui.py",
confidence=0.8,
source_refs=[source_ref],
)
service.store.create_evidence(
repository.id,
capability_id,
type="test",
reference="tests/test_ui.py",
strength="strong",
target_kind="feature",
target_id=feature_id,
source_refs=[source_ref],
)
profile = service.create_dependency_graph_profile(
repository.id,
name="Evidence Audit",
description="Blur non-evidence layers.",
default_mode="full",
filter_rules=[
{"name": "blur facts", "action": "blur", "match": {"layer": "fact"}},
{"name": "hide features", "action": "hide", "match": {"layer": "feature"}},
],
manual_overrides={f"feature:{feature_id}": "show", "missing:1": "hide"},
)
payload = service.dependency_graph_elements(repository.id, profile_id=profile.id)
nodes = [
element["data"]
for element in payload["elements"]
if "source" not in element["data"]
]
fact_node = next(node for node in nodes if node["kind"] == "fact")
feature_node = next(node for node in nodes if node["id"] == f"feature:{feature_id}")
evidence_node = next(node for node in nodes if node["kind"] == "evidence")
assert fact_node["layer"] == "fact"
assert fact_node["path"] == fact.path
assert fact_node["displayState"] == "blur"
assert feature_node["displayState"] == "show"
assert feature_node["visibilitySource"] == "manual"
assert evidence_node["layer"] == "evidence"
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"
for element in payload["elements"]
)
def test_manual_registry_updates_and_deletes_approved_entries(tmp_path):
service = make_service(tmp_path)
repository = service.register_repository(

View File

@@ -190,7 +190,37 @@ def test_openapi_contract_snapshot_for_stable_agent_paths():
"get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"}
},
"/repos/{repository_id}/dependency-graph": {
"get": {"tags": ["registry"], "success_schema": "object"}
"get": {"tags": ["visualization"], "success_schema": "object"}
},
"/repos/{repository_id}/dependency-graph/filter": {
"post": {"tags": ["visualization"], "success_schema": "object"}
},
"/repos/{repository_id}/dependency-graph/profiles": {
"get": {
"tags": ["visualization"],
"success_schema": "list[DependencyGraphProfileResponse]",
},
"post": {
"tags": ["visualization"],
"success_schema": "DependencyGraphProfileResponse",
},
},
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}": {
"delete": {"tags": ["visualization"], "success_schema": None},
"get": {
"tags": ["visualization"],
"success_schema": "DependencyGraphProfileResponse",
},
"patch": {
"tags": ["visualization"],
"success_schema": "DependencyGraphProfileResponse",
},
},
"/repos/{repository_id}/dependency-graph/profiles/{profile_id}/duplicate": {
"post": {
"tags": ["visualization"],
"success_schema": "DependencyGraphProfileResponse",
}
},
"/repos/{repository_id}/analysis-runs": {
"get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"},
@@ -1504,17 +1534,58 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
assert graph_payload["mode"] == "full"
assert graph_payload["metrics"]["node_count"] >= 4
assert graph_payload["metrics"]["edge_count"] >= 3
assert graph_payload["filter"]["precedence"].startswith("later rules")
assert any(
element["data"].get("kind") == "scope"
for element in graph_payload["elements"]
if "source" not in element["data"]
)
assert all(
"layer" in element["data"]
for element in graph_payload["elements"]
)
profile_response = client.post(
f"/repos/{repository_id}/dependency-graph/profiles",
json={
"name": "Hide Facts",
"description": "Reduce deterministic fact noise.",
"default_mode": "full",
"filter_rules": [
{"name": "facts", "action": "hide", "match": {"layer": "fact"}}
],
"manual_overrides": {},
},
)
assert profile_response.status_code == 201
profile = profile_response.json()
filtered_response = client.get(
f"/repos/{repository_id}/dependency-graph",
params={"profile_id": profile["id"]},
)
assert filtered_response.status_code == 200
filtered_payload = filtered_response.json()
assert filtered_payload["profile"]["name"] == "Hide Facts"
assert filtered_payload["metrics"]["hidden_count"] >= 1
assert all(
element["data"].get("layer") != "fact"
for element in filtered_payload["elements"]
if "source" not in element["data"]
)
duplicate_response = client.post(
f"/repos/{repository_id}/dependency-graph/profiles/{profile['id']}/duplicate",
json={"name": "Hide Facts Copy"},
)
assert duplicate_response.status_code == 201
assert duplicate_response.json()["name"] == "Hide Facts Copy"
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
assert 'id="profile-select"' in graph_page.text
assert 'data-override="blur"' in graph_page.text
scope_listing = client.get(
f"/ui/repos/{repository_id}/elements",

View File

@@ -4,11 +4,11 @@ type: workplan
title: "Dependency Visualization And Exploration"
domain: capabilities
repo: repo-scoping
status: active
status: done
owner: codex
topic_slug: foerster-capabilities
created: "2026-05-03"
updated: "2026-05-03"
updated: "2026-05-04"
state_hub_workstream_id: "da974de8-8ef2-4df5-a357-c323f525bb2e"
---
@@ -48,7 +48,7 @@ decide whether global/shareable profiles are worth adding.
```task
id: RREG-WP-0010-T01
status: todo
status: done
priority: high
state_hub_task_id: "65ac4acd-e61c-4211-94b8-142fc93209bc"
```
@@ -71,7 +71,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T02
status: todo
status: done
priority: high
state_hub_task_id: "e952bf83-efc3-4cb7-8abc-ac7ae00ba8e8"
```
@@ -90,7 +90,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T03
status: todo
status: done
priority: high
state_hub_task_id: "a3929bce-a2e3-4e2d-9c99-e4cc0ad2d325"
```
@@ -112,7 +112,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T04
status: todo
status: done
priority: high
state_hub_task_id: "2d51f5f5-a1e5-48ec-b297-a7376c97445c"
```
@@ -134,7 +134,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T05
status: todo
status: done
priority: medium
state_hub_task_id: "320065c7-934d-4350-9a29-3f9cac35a6db"
```
@@ -152,7 +152,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T06
status: todo
status: done
priority: high
state_hub_task_id: "95f2d2e5-b6b1-4ae3-b286-eab228372f2b"
```
@@ -172,7 +172,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T07
status: todo
status: done
priority: high
state_hub_task_id: "5f0f680f-1401-4c55-be27-cf7216efee0b"
```
@@ -192,7 +192,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T08
status: todo
status: done
priority: medium
state_hub_task_id: "9da7e01a-d192-403a-b217-4dbed6d87892"
```
@@ -211,7 +211,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T09
status: todo
status: done
priority: medium
state_hub_task_id: "4c5f69e7-878e-40a5-8997-277e232a761b"
```
@@ -232,7 +232,7 @@ Acceptance criteria:
```task
id: RREG-WP-0010-T10
status: todo
status: done
priority: medium
state_hub_task_id: "6524fced-02bf-47df-a808-505fe702d7aa"
```