generated from coulomb/repo-seed
repository-scoped dependency graph view profile persistence and interactive exploration features
This commit is contained in:
@@ -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
|
||||
|
||||
86
docs/dependency-visualization-exploration.md
Normal file
86
docs/dependency-visualization-exploration.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
@@ -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) => {{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user