generated from coulomb/repo-seed
Refinements to the dependency exploration ui
This commit is contained in:
@@ -26,6 +26,10 @@ CLI command. Evidence should point directly to a capability when it supports the
|
|||||||
capability as a whole and no narrower feature target is known. Facts can observe
|
capability as a whole and no narrower feature target is known. Facts can observe
|
||||||
evidence and features; evidence can support features or capabilities.
|
evidence and features; evidence can support features or capabilities.
|
||||||
|
|
||||||
|
Document-level facts are normalized for graph readability. Derived `SCOPE.md`
|
||||||
|
facts are suppressed when they only restate generated scope, and README/SCOPE
|
||||||
|
document facts that describe the same file-level support are represented once.
|
||||||
|
|
||||||
## Display States
|
## Display States
|
||||||
|
|
||||||
Each active graph element receives a `displayState`:
|
Each active graph element receives a `displayState`:
|
||||||
@@ -38,15 +42,17 @@ Each active graph element receives a `displayState`:
|
|||||||
|
|
||||||
Rule precedence is deterministic: later rules override earlier rules, then
|
Rule precedence is deterministic: later rules override earlier rules, then
|
||||||
manual overrides win last. If a node is hidden, connected edges are hidden too.
|
manual overrides win last. If a node is hidden, connected edges are hidden too.
|
||||||
|
If a node is blurred, connected edges receive a muted context hint so the
|
||||||
|
surrounding dependency path remains visible without competing for attention.
|
||||||
Manual overrides are stored by stable graph key and orphaned keys are surfaced
|
Manual overrides are stored by stable graph key and orphaned keys are surfaced
|
||||||
when a profile references nodes or edges that no longer exist.
|
when a profile references nodes or edges that no longer exist.
|
||||||
|
|
||||||
## Filter Rules
|
## Filter Rules
|
||||||
|
|
||||||
Rules are JSON objects with an `action` of `show`, `blur`, or `hide`, plus a
|
Rules are JSON objects with an `action` of `show`, `blur`, or `hide`, plus a
|
||||||
`match` object. Common match fields include `kind`, `layer`, `primaryClass`,
|
`match` object. Common match fields include `kind`, `layer`, `reviewState`,
|
||||||
`attributes`, `confidence`, `freshnessState`, `ownership`, `dependencyType`,
|
`primaryClass`, `attributes`, `confidence`, `freshnessState`, `ownership`,
|
||||||
`strength`, `sameLayer`, `path`, and `text`.
|
`dependencyType`, `strength`, `sameLayer`, `path`, and `text`.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -64,6 +70,10 @@ View profiles are repository-scoped saved graph perspectives. The profile API
|
|||||||
supports create, list, load, update, duplicate, and delete operations under
|
supports create, list, load, update, duplicate, and delete operations under
|
||||||
`/repos/{repository_id}/dependency-graph/profiles`.
|
`/repos/{repository_id}/dependency-graph/profiles`.
|
||||||
|
|
||||||
|
When a graph is opened without an explicit `profile_id`, the most recently saved
|
||||||
|
repository profile is applied by default. API clients can pass
|
||||||
|
`use_latest_profile=false` to request an unsaved full graph view.
|
||||||
|
|
||||||
Profiles store:
|
Profiles store:
|
||||||
|
|
||||||
- name and optional description
|
- name and optional description
|
||||||
@@ -84,3 +94,7 @@ Example profiles:
|
|||||||
Hiding evidence can make a graph appear cleaner while also removing the reason a
|
Hiding evidence can make a graph appear cleaner while also removing the reason a
|
||||||
capability is trusted. Prefer blurring evidence when reviewing scope impact so
|
capability is trusted. Prefer blurring evidence when reviewing scope impact so
|
||||||
the support chain remains visible as context.
|
the support chain remains visible as context.
|
||||||
|
|
||||||
|
Nodes scale within a bounded size range when confidence is available. Edge width
|
||||||
|
is derived from dependency strength. Hovering a graph element shows a compact
|
||||||
|
popup; selecting it still opens the full side-panel detail.
|
||||||
|
|||||||
@@ -1101,6 +1101,7 @@ class RegistryService:
|
|||||||
profile_id: int | None = None,
|
profile_id: int | None = None,
|
||||||
rules: list[dict[str, Any]] | None = None,
|
rules: list[dict[str, Any]] | None = None,
|
||||||
manual_overrides: dict[str, str] | None = None,
|
manual_overrides: dict[str, str] | None = None,
|
||||||
|
use_latest_profile: bool = True,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
impact = None
|
impact = None
|
||||||
if base_analysis_run_id is not None or target_analysis_run_id is not None:
|
if base_analysis_run_id is not None or target_analysis_run_id is not None:
|
||||||
@@ -1122,9 +1123,7 @@ class RegistryService:
|
|||||||
)
|
)
|
||||||
changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set()
|
changed_fact_keys = set(impact.changed_fact_keys) if impact is not None else set()
|
||||||
ability_map = self.store.get_ability_map(repository_id)
|
ability_map = self.store.get_ability_map(repository_id)
|
||||||
facts_by_id = {
|
facts_by_id = {fact.id: fact for fact in self.store.list_observed_facts(repository_id)}
|
||||||
fact.id: fact for fact in self.store.list_observed_facts(repository_id)
|
|
||||||
}
|
|
||||||
characteristic_index = self._dependency_characteristic_index(ability_map)
|
characteristic_index = self._dependency_characteristic_index(ability_map)
|
||||||
nodes: dict[str, dict[str, object]] = {}
|
nodes: dict[str, dict[str, object]] = {}
|
||||||
edge_sources: dict[str, DependencyEdge] = {}
|
edge_sources: dict[str, DependencyEdge] = {}
|
||||||
@@ -1132,6 +1131,8 @@ class RegistryService:
|
|||||||
profile = (
|
profile = (
|
||||||
self.store.get_dependency_graph_profile(repository_id, profile_id)
|
self.store.get_dependency_graph_profile(repository_id, profile_id)
|
||||||
if profile_id is not None
|
if profile_id is not None
|
||||||
|
else self.store.latest_dependency_graph_profile(repository_id)
|
||||||
|
if use_latest_profile and not rules and not manual_overrides
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
merged_rules = [*(profile.filter_rules if profile is not None else []), *(rules or [])]
|
merged_rules = [*(profile.filter_rules if profile is not None else []), *(rules or [])]
|
||||||
@@ -1139,6 +1140,12 @@ class RegistryService:
|
|||||||
**(profile.manual_overrides if profile is not None else {}),
|
**(profile.manual_overrides if profile is not None else {}),
|
||||||
**(manual_overrides or {}),
|
**(manual_overrides or {}),
|
||||||
}
|
}
|
||||||
|
graph_edges = [
|
||||||
|
display_edge
|
||||||
|
for edge in graph.edges
|
||||||
|
if (display_edge := self._dependency_display_edge(edge, facts_by_id))
|
||||||
|
is not None
|
||||||
|
]
|
||||||
|
|
||||||
def ensure_node(kind: str, key: str, item_id: int | None) -> None:
|
def ensure_node(kind: str, key: str, item_id: int | None) -> None:
|
||||||
if key in nodes:
|
if key in nodes:
|
||||||
@@ -1150,6 +1157,11 @@ class RegistryService:
|
|||||||
if fact is not None:
|
if fact is not None:
|
||||||
detail = {
|
detail = {
|
||||||
"name": fact.name,
|
"name": fact.name,
|
||||||
|
"label": (
|
||||||
|
f"{fact.path} ({fact.kind})"
|
||||||
|
if key.startswith("fact:document:")
|
||||||
|
else f"{fact.name} ({fact.kind}, {fact.path})"
|
||||||
|
),
|
||||||
"description": fact.value,
|
"description": fact.value,
|
||||||
"primaryClass": fact.metadata.get("source_role", fact.kind),
|
"primaryClass": fact.metadata.get("source_role", fact.kind),
|
||||||
"attributes": self._dependency_fact_attributes(fact),
|
"attributes": self._dependency_fact_attributes(fact),
|
||||||
@@ -1174,13 +1186,16 @@ class RegistryService:
|
|||||||
"stableKey": key,
|
"stableKey": key,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"layer": self._dependency_layer(kind),
|
"layer": self._dependency_layer(kind),
|
||||||
"label": self._dependency_node_label(repository_id, kind, key, item_id),
|
"label": detail.get("label")
|
||||||
|
or self._dependency_node_label(repository_id, kind, key, item_id),
|
||||||
|
"reviewState": "accepted",
|
||||||
"name": detail.get("name")
|
"name": detail.get("name")
|
||||||
or self._dependency_node_label(repository_id, kind, key, item_id),
|
or self._dependency_node_label(repository_id, kind, key, item_id),
|
||||||
"description": detail.get("description", ""),
|
"description": detail.get("description", ""),
|
||||||
"primaryClass": detail.get("primaryClass", kind),
|
"primaryClass": detail.get("primaryClass", kind),
|
||||||
"attributes": detail.get("attributes", []),
|
"attributes": detail.get("attributes", []),
|
||||||
"confidence": detail.get("confidence"),
|
"confidence": detail.get("confidence"),
|
||||||
|
"visualSize": self._dependency_node_size(detail.get("confidence")),
|
||||||
"ownership": self._ownership_for_kind(kind),
|
"ownership": self._ownership_for_kind(kind),
|
||||||
"freshnessState": (
|
"freshnessState": (
|
||||||
impact_item.freshness_state
|
impact_item.freshness_state
|
||||||
@@ -1212,12 +1227,12 @@ class RegistryService:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
for edge in graph.edges:
|
for edge in graph_edges:
|
||||||
ensure_node(edge.source_kind, edge.source_key, edge.source_id)
|
ensure_node(edge.source_kind, edge.source_key, edge.source_id)
|
||||||
ensure_node(edge.target_kind, edge.target_key, edge.target_id)
|
ensure_node(edge.target_kind, edge.target_key, edge.target_id)
|
||||||
|
|
||||||
edges = []
|
edges = []
|
||||||
for index, edge in enumerate(graph.edges):
|
for index, edge in enumerate(graph_edges):
|
||||||
edge_id = f"{edge.source_key}->{edge.target_key}:{index}"
|
edge_id = f"{edge.source_key}->{edge.target_key}:{index}"
|
||||||
source_data = nodes[edge.source_key]["data"]
|
source_data = nodes[edge.source_key]["data"]
|
||||||
target_data = nodes[edge.target_key]["data"]
|
target_data = nodes[edge.target_key]["data"]
|
||||||
@@ -1230,6 +1245,7 @@ class RegistryService:
|
|||||||
"stableKey": edge_id,
|
"stableKey": edge_id,
|
||||||
"kind": "edge",
|
"kind": "edge",
|
||||||
"layer": "dependency",
|
"layer": "dependency",
|
||||||
|
"reviewState": "accepted",
|
||||||
"source": edge.source_key,
|
"source": edge.source_key,
|
||||||
"target": edge.target_key,
|
"target": edge.target_key,
|
||||||
"sourceKind": edge.source_kind,
|
"sourceKind": edge.source_kind,
|
||||||
@@ -1238,6 +1254,7 @@ class RegistryService:
|
|||||||
"targetLayer": self._dependency_layer(edge.target_kind),
|
"targetLayer": self._dependency_layer(edge.target_kind),
|
||||||
"dependencyType": edge.dependency_type,
|
"dependencyType": edge.dependency_type,
|
||||||
"strength": edge.strength,
|
"strength": edge.strength,
|
||||||
|
"edgeWidth": self._dependency_edge_width(edge.strength),
|
||||||
"edgeSource": edge.source,
|
"edgeSource": edge.source,
|
||||||
"sameLayer": edge.same_layer,
|
"sameLayer": edge.same_layer,
|
||||||
"freshnessState": (
|
"freshnessState": (
|
||||||
@@ -1284,6 +1301,11 @@ class RegistryService:
|
|||||||
for element in nodes.values()
|
for element in nodes.values()
|
||||||
if visibility[element["data"]["id"]]["displayState"] == "hide"
|
if visibility[element["data"]["id"]]["displayState"] == "hide"
|
||||||
}
|
}
|
||||||
|
blurred_node_ids = {
|
||||||
|
element["data"]["id"]
|
||||||
|
for element in nodes.values()
|
||||||
|
if visibility[element["data"]["id"]]["displayState"] == "blur"
|
||||||
|
}
|
||||||
visible_elements: list[dict[str, object]] = []
|
visible_elements: list[dict[str, object]] = []
|
||||||
hidden_elements: list[dict[str, object]] = []
|
hidden_elements: list[dict[str, object]] = []
|
||||||
orphaned_overrides = sorted(
|
orphaned_overrides = sorted(
|
||||||
@@ -1302,12 +1324,21 @@ class RegistryService:
|
|||||||
"displayState": "hide",
|
"displayState": "hide",
|
||||||
"visibilityReason": "connected-node-hidden",
|
"visibilityReason": "connected-node-hidden",
|
||||||
}
|
}
|
||||||
|
connected_to_blurred = (
|
||||||
|
"source" in element["data"]
|
||||||
|
and (
|
||||||
|
element["data"]["source"] in blurred_node_ids
|
||||||
|
or element["data"]["target"] in blurred_node_ids
|
||||||
|
)
|
||||||
|
)
|
||||||
element["data"].update(state)
|
element["data"].update(state)
|
||||||
|
element["data"]["connectedToBlurred"] = connected_to_blurred
|
||||||
element["classes"] = " ".join(
|
element["classes"] = " ".join(
|
||||||
part
|
part
|
||||||
for part in (
|
for part in (
|
||||||
element.get("classes", ""),
|
element.get("classes", ""),
|
||||||
f"display-{state['displayState']}",
|
f"display-{state['displayState']}",
|
||||||
|
"connects-blurred" if connected_to_blurred else "",
|
||||||
"manual-override" if state["visibilitySource"] == "manual" else "",
|
"manual-override" if state["visibilitySource"] == "manual" else "",
|
||||||
"rule-derived" if state["visibilitySource"] == "rule" else "",
|
"rule-derived" if state["visibilitySource"] == "rule" else "",
|
||||||
)
|
)
|
||||||
@@ -2578,6 +2609,48 @@ class RegistryService:
|
|||||||
attributes.append(value)
|
attributes.append(value)
|
||||||
return sorted(set(attributes))
|
return sorted(set(attributes))
|
||||||
|
|
||||||
|
def _dependency_display_edge(
|
||||||
|
self,
|
||||||
|
edge: DependencyEdge,
|
||||||
|
facts_by_id: dict[int, ObservedFact],
|
||||||
|
) -> DependencyEdge | None:
|
||||||
|
if edge.source_kind != "fact" or edge.source_id is None:
|
||||||
|
return edge
|
||||||
|
fact = facts_by_id.get(edge.source_id)
|
||||||
|
if fact is None:
|
||||||
|
return edge
|
||||||
|
if self._suppress_dependency_fact(fact):
|
||||||
|
return None
|
||||||
|
display_key = self._dependency_fact_display_key(fact)
|
||||||
|
if display_key == edge.source_key:
|
||||||
|
return edge
|
||||||
|
return replace(edge, source_key=display_key)
|
||||||
|
|
||||||
|
def _suppress_dependency_fact(self, fact: ObservedFact) -> bool:
|
||||||
|
return (
|
||||||
|
fact.path.lower().endswith("scope.md")
|
||||||
|
and fact.metadata.get("source_role") == "derived_scope"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dependency_fact_display_key(self, fact: ObservedFact) -> str:
|
||||||
|
document_paths = {"readme.md", "scope.md"}
|
||||||
|
if fact.path.lower() in document_paths and fact.kind in {
|
||||||
|
"documentation",
|
||||||
|
"intent",
|
||||||
|
"scope",
|
||||||
|
}:
|
||||||
|
return f"fact:document:{fact.path}"
|
||||||
|
return f"fact:{fact.kind}:{fact.path}:{fact.name}"
|
||||||
|
|
||||||
|
def _dependency_node_size(self, confidence: object) -> int:
|
||||||
|
if not isinstance(confidence, int | float):
|
||||||
|
return 36
|
||||||
|
bounded = max(0.0, min(float(confidence), 1.0))
|
||||||
|
return int(28 + (bounded * 28))
|
||||||
|
|
||||||
|
def _dependency_edge_width(self, strength: str) -> int:
|
||||||
|
return {"weak": 1, "medium": 3, "strong": 5}.get(strength, 2)
|
||||||
|
|
||||||
def _dependency_layer(self, kind: str) -> str:
|
def _dependency_layer(self, kind: str) -> str:
|
||||||
if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}:
|
if kind in {"fact", "evidence", "feature", "capability", "ability", "scope"}:
|
||||||
return kind
|
return kind
|
||||||
@@ -2642,6 +2715,8 @@ class RegistryService:
|
|||||||
actual = data.get(key)
|
actual = data.get(key)
|
||||||
if key == "dependencyType":
|
if key == "dependencyType":
|
||||||
actual = data.get("dependencyType")
|
actual = data.get("dependencyType")
|
||||||
|
elif key == "reviewState":
|
||||||
|
actual = data.get("reviewState")
|
||||||
elif key == "sameLayer":
|
elif key == "sameLayer":
|
||||||
actual = bool(data.get("sameLayer"))
|
actual = bool(data.get("sameLayer"))
|
||||||
elif key == "attributes":
|
elif key == "attributes":
|
||||||
|
|||||||
@@ -2410,6 +2410,27 @@ class RegistryStore:
|
|||||||
raise NotFoundError(f"dependency graph profile {profile_id} was not found")
|
raise NotFoundError(f"dependency graph profile {profile_id} was not found")
|
||||||
return self._dependency_graph_profile_from_row(row)
|
return self._dependency_graph_profile_from_row(row)
|
||||||
|
|
||||||
|
def latest_dependency_graph_profile(
|
||||||
|
self,
|
||||||
|
repository_id: int,
|
||||||
|
) -> DependencyGraphViewProfile | None:
|
||||||
|
self.get_repository(repository_id)
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, repository_id, name, description, default_mode,
|
||||||
|
filter_rules, manual_overrides, created_at, updated_at
|
||||||
|
FROM dependency_graph_view_profiles
|
||||||
|
WHERE repository_id = ?
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(repository_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._dependency_graph_profile_from_row(row)
|
||||||
|
|
||||||
def create_dependency_graph_profile(
|
def create_dependency_graph_profile(
|
||||||
self,
|
self,
|
||||||
repository_id: int,
|
repository_id: int,
|
||||||
|
|||||||
@@ -1154,6 +1154,7 @@ def get_dependency_graph(
|
|||||||
base_analysis_run_id: int | None = Query(default=None),
|
base_analysis_run_id: int | None = Query(default=None),
|
||||||
target_analysis_run_id: int | None = Query(default=None),
|
target_analysis_run_id: int | None = Query(default=None),
|
||||||
profile_id: int | None = Query(default=None),
|
profile_id: int | None = Query(default=None),
|
||||||
|
use_latest_profile: bool = Query(default=True),
|
||||||
service: RegistryService = Depends(get_service),
|
service: RegistryService = Depends(get_service),
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
try:
|
try:
|
||||||
@@ -1162,6 +1163,7 @@ def get_dependency_graph(
|
|||||||
base_analysis_run_id=base_analysis_run_id,
|
base_analysis_run_id=base_analysis_run_id,
|
||||||
target_analysis_run_id=target_analysis_run_id,
|
target_analysis_run_id=target_analysis_run_id,
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
|
use_latest_profile=use_latest_profile,
|
||||||
)
|
)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
@@ -1189,6 +1191,7 @@ def filter_dependency_graph(
|
|||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
rules=payload.rules,
|
rules=payload.rules,
|
||||||
manual_overrides=payload.manual_overrides,
|
manual_overrides=payload.manual_overrides,
|
||||||
|
use_latest_profile=profile_id is not None,
|
||||||
)
|
)
|
||||||
except NotFoundError as exc:
|
except NotFoundError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -208,11 +208,26 @@ def page(
|
|||||||
min-height: 680px;
|
min-height: 680px;
|
||||||
}}
|
}}
|
||||||
.graph-canvas {{
|
.graph-canvas {{
|
||||||
|
position: relative;
|
||||||
min-height: 680px;
|
min-height: 680px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
}}
|
}}
|
||||||
|
.graph-popup {{
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
width: 240px;
|
||||||
|
max-width: calc(100% - 24px);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, .96);
|
||||||
|
box-shadow: 0 10px 24px rgba(15, 23, 42, .16);
|
||||||
|
pointer-events: none;
|
||||||
|
}}
|
||||||
|
.graph-popup p {{ margin-bottom: 6px; }}
|
||||||
.graph-sidebar {{
|
.graph-sidebar {{
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1778,6 +1793,11 @@ def dependency_graph_view(
|
|||||||
</select></label>
|
</select></label>
|
||||||
<label>Action <select id="filter-action"><option>blur</option><option>hide</option><option>show</option></select></label>
|
<label>Action <select id="filter-action"><option>blur</option><option>hide</option><option>show</option></select></label>
|
||||||
</div>
|
</div>
|
||||||
|
<label>Review state <select id="filter-review-state">
|
||||||
|
<option value="">Any state</option>
|
||||||
|
<option value="accepted">Accepted</option>
|
||||||
|
<option value="candidate">Candidate</option>
|
||||||
|
</select></label>
|
||||||
<label>Text <input id="filter-text" placeholder="Search labels, paths, descriptions"></label>
|
<label>Text <input id="filter-text" placeholder="Search labels, paths, descriptions"></label>
|
||||||
<div class="graph-controls">
|
<div class="graph-controls">
|
||||||
<button class="secondary" type="button" data-filter-apply>Apply Rule</button>
|
<button class="secondary" type="button" data-filter-apply>Apply Rule</button>
|
||||||
@@ -1836,9 +1856,11 @@ def dependency_graph_view(
|
|||||||
const profileMode = document.getElementById("profile-mode");
|
const profileMode = document.getElementById("profile-mode");
|
||||||
const profileSummary = document.getElementById("profile-summary");
|
const profileSummary = document.getElementById("profile-summary");
|
||||||
const filterLayer = document.getElementById("filter-layer");
|
const filterLayer = document.getElementById("filter-layer");
|
||||||
|
const filterReviewState = document.getElementById("filter-review-state");
|
||||||
const filterText = document.getElementById("filter-text");
|
const filterText = document.getElementById("filter-text");
|
||||||
const filterAction = document.getElementById("filter-action");
|
const filterAction = document.getElementById("filter-action");
|
||||||
const focusDepth = document.getElementById("focus-depth");
|
const focusDepth = document.getElementById("focus-depth");
|
||||||
|
let hoverPopup = null;
|
||||||
let cy = null;
|
let cy = null;
|
||||||
let mode = "full";
|
let mode = "full";
|
||||||
let selected = null;
|
let selected = null;
|
||||||
@@ -1877,10 +1899,10 @@ def dependency_graph_view(
|
|||||||
profileSummary.textContent = profile ? profileSummaryText(basePayload) : "No profile selected.";
|
profileSummary.textContent = profile ? profileSummaryText(basePayload) : "No profile selected.";
|
||||||
}};
|
}};
|
||||||
|
|
||||||
const loadProfiles = () => fetch(profileEndpoint)
|
const loadProfiles = (selectedProfileId = "") => fetch(profileEndpoint)
|
||||||
.then((response) => response.json())
|
.then((response) => response.json())
|
||||||
.then((profiles) => {{
|
.then((profiles) => {{
|
||||||
const current = profileSelect.value;
|
const current = selectedProfileId || profileSelect.value;
|
||||||
profileSelect.innerHTML = '<option value="">Unsaved exploration</option>' + profiles
|
profileSelect.innerHTML = '<option value="">Unsaved exploration</option>' + profiles
|
||||||
.map((profile) => `<option value="${{profile.id}}">${{escapeHtml(profile.name)}}</option>`)
|
.map((profile) => `<option value="${{profile.id}}">${{escapeHtml(profile.name)}}</option>`)
|
||||||
.join("");
|
.join("");
|
||||||
@@ -1892,6 +1914,10 @@ def dependency_graph_view(
|
|||||||
cy.elements().remove();
|
cy.elements().remove();
|
||||||
cy.add(payload.elements);
|
cy.add(payload.elements);
|
||||||
profileSummary.textContent = payload.profile ? profileSummaryText(payload) : profileSummaryText(payload);
|
profileSummary.textContent = payload.profile ? profileSummaryText(payload) : profileSummaryText(payload);
|
||||||
|
if (payload.profile) {{
|
||||||
|
updateProfileForm(payload.profile);
|
||||||
|
loadProfiles(String(payload.profile.id));
|
||||||
|
}}
|
||||||
applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
|
applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
|
||||||
}};
|
}};
|
||||||
|
|
||||||
@@ -1921,7 +1947,8 @@ def dependency_graph_view(
|
|||||||
if (element.isNode()) {{
|
if (element.isNode()) {{
|
||||||
detail.innerHTML = `
|
detail.innerHTML = `
|
||||||
<p><strong>${{escapeHtml(data.label)}}</strong></p>
|
<p><strong>${{escapeHtml(data.label)}}</strong></p>
|
||||||
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p>
|
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span></p>
|
||||||
|
${{data.confidence !== null && data.confidence !== undefined ? `<p class="muted">Confidence: ${{escapeHtml(data.confidence)}}</p>` : ""}}
|
||||||
<p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
|
<p class="muted">Ownership: ${{escapeHtml(data.ownership || "unknown")}}</p>
|
||||||
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
|
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
|
||||||
${{data.description ? `<p>${{escapeHtml(data.description)}}</p>` : ""}}
|
${{data.description ? `<p>${{escapeHtml(data.description)}}</p>` : ""}}
|
||||||
@@ -1931,7 +1958,7 @@ def dependency_graph_view(
|
|||||||
}} else {{
|
}} else {{
|
||||||
detail.innerHTML = `
|
detail.innerHTML = `
|
||||||
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
|
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
|
||||||
<p><span class="pill">${{escapeHtml(data.strength)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
|
<p><span class="pill">${{escapeHtml(data.strength)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span> <span class="pill">${{escapeHtml(data.displayState)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
|
||||||
<p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p>
|
<p class="muted">${{escapeHtml(data.source)}} -> ${{escapeHtml(data.target)}}</p>
|
||||||
<p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p>
|
<p class="muted">Source: ${{escapeHtml(data.edgeSource)}}</p>
|
||||||
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
|
<p class="muted">Visibility: ${{escapeHtml(data.visibilitySource)}} · ${{escapeHtml(data.visibilityReason)}}</p>
|
||||||
@@ -1939,6 +1966,45 @@ def dependency_graph_view(
|
|||||||
}}
|
}}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
const popupHtml = (element) => {{
|
||||||
|
const data = element.data();
|
||||||
|
if (element.isNode()) {{
|
||||||
|
const path = data.path ? `<p class="muted">${{escapeHtml(data.path)}}</p>` : "";
|
||||||
|
const confidence = data.confidence !== null && data.confidence !== undefined
|
||||||
|
? `<span class="pill">confidence ${{escapeHtml(data.confidence)}}</span>`
|
||||||
|
: "";
|
||||||
|
return `
|
||||||
|
<p><strong>${{escapeHtml(data.name || data.label)}}</strong></p>
|
||||||
|
<p><span class="pill">${{escapeHtml(data.kind)}}</span> <span class="pill">${{escapeHtml(data.layer)}}</span> <span class="pill">${{escapeHtml(data.reviewState)}}</span></p>
|
||||||
|
<p><span class="pill">${{escapeHtml(data.displayState)}}</span> <span class="pill">${{escapeHtml(data.freshnessState)}}</span> ${{confidence}}</p>
|
||||||
|
<p class="muted">${{escapeHtml(data.ownership || "unknown")}}</p>
|
||||||
|
${{path}}
|
||||||
|
`;
|
||||||
|
}}
|
||||||
|
return `
|
||||||
|
<p><strong>${{escapeHtml(data.dependencyType)}}</strong></p>
|
||||||
|
<p><span class="pill">${{escapeHtml(data.strength)}}</span> ${{data.sameLayer ? '<span class="pill">same layer</span>' : ""}}</p>
|
||||||
|
<p class="muted">${{escapeHtml(data.sourceMetadata?.name || data.source)}} -> ${{escapeHtml(data.targetMetadata?.name || data.target)}}</p>
|
||||||
|
<p class="muted">${{escapeHtml(data.edgeSource)}}</p>
|
||||||
|
`;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const showPopup = (element, renderedPosition) => {{
|
||||||
|
if (!hoverPopup) {{
|
||||||
|
hoverPopup = document.createElement("div");
|
||||||
|
hoverPopup.className = "graph-popup";
|
||||||
|
container.appendChild(hoverPopup);
|
||||||
|
}}
|
||||||
|
hoverPopup.innerHTML = popupHtml(element);
|
||||||
|
hoverPopup.style.left = `${{Math.min(renderedPosition.x + 14, container.clientWidth - 250)}}px`;
|
||||||
|
hoverPopup.style.top = `${{Math.max(10, renderedPosition.y + 14)}}px`;
|
||||||
|
hoverPopup.style.display = "block";
|
||||||
|
}};
|
||||||
|
|
||||||
|
const hidePopup = () => {{
|
||||||
|
if (hoverPopup) hoverPopup.style.display = "none";
|
||||||
|
}};
|
||||||
|
|
||||||
const visibleForMode = () => {{
|
const visibleForMode = () => {{
|
||||||
if (!cy) return cy.collection();
|
if (!cy) return cy.collection();
|
||||||
if (focusCollection) return focusCollection;
|
if (focusCollection) return focusCollection;
|
||||||
@@ -1989,7 +2055,7 @@ def dependency_graph_view(
|
|||||||
"border-width": 1,
|
"border-width": 1,
|
||||||
"color": "#1f2933",
|
"color": "#1f2933",
|
||||||
"font-size": 11,
|
"font-size": 11,
|
||||||
"height": 36,
|
"height": "data(visualSize)",
|
||||||
"label": "data(label)",
|
"label": "data(label)",
|
||||||
"text-background-color": "#ffffff",
|
"text-background-color": "#ffffff",
|
||||||
"text-background-opacity": .85,
|
"text-background-opacity": .85,
|
||||||
@@ -1998,14 +2064,14 @@ def dependency_graph_view(
|
|||||||
"text-max-width": 130,
|
"text-max-width": 130,
|
||||||
"text-valign": "top",
|
"text-valign": "top",
|
||||||
"text-wrap": "wrap",
|
"text-wrap": "wrap",
|
||||||
"width": 36
|
"width": "data(visualSize)"
|
||||||
}}
|
}}
|
||||||
}},
|
}},
|
||||||
{{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }},
|
{{ selector: "node[kind = 'evidence']", style: {{ "background-color": "#0891b2" }} }},
|
||||||
{{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }},
|
{{ selector: "node[kind = 'feature']", style: {{ "background-color": "#7c3aed" }} }},
|
||||||
{{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle", "width": 56 }} }},
|
{{ selector: "node[kind = 'capability']", style: {{ "background-color": "#0f766e", "shape": "round-rectangle" }} }},
|
||||||
{{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon", "height": 46, "width": 46 }} }},
|
{{ selector: "node[kind = 'ability']", style: {{ "background-color": "#b45309", "shape": "hexagon" }} }},
|
||||||
{{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star", "height": 58, "width": 58 }} }},
|
{{ selector: "node[kind = 'scope']", style: {{ "background-color": "#be123c", "shape": "star" }} }},
|
||||||
{{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }},
|
{{ selector: "node.stale", style: {{ "border-color": "#dc2626", "border-width": 4 }} }},
|
||||||
{{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }},
|
{{ selector: "node.changed", style: {{ "border-color": "#2563eb", "border-width": 4 }} }},
|
||||||
{{
|
{{
|
||||||
@@ -2015,12 +2081,14 @@ def dependency_graph_view(
|
|||||||
"line-color": "#94a3b8",
|
"line-color": "#94a3b8",
|
||||||
"target-arrow-color": "#94a3b8",
|
"target-arrow-color": "#94a3b8",
|
||||||
"target-arrow-shape": "triangle",
|
"target-arrow-shape": "triangle",
|
||||||
"width": 2
|
"width": "data(edgeWidth)"
|
||||||
}}
|
}}
|
||||||
}},
|
}},
|
||||||
{{ selector: "edge[strength = 'strong']", style: {{ "width": 4, "line-color": "#475569", "target-arrow-color": "#475569" }} }},
|
{{ selector: "edge[strength = 'strong']", style: {{ "line-color": "#475569", "target-arrow-color": "#475569" }} }},
|
||||||
{{ selector: "edge[strength = 'weak']", style: {{ "width": 1, "line-style": "dotted" }} }},
|
{{ selector: "edge[strength = 'weak']", style: {{ "line-style": "dotted" }} }},
|
||||||
{{ selector: "edge.same-layer", style: {{ "curve-style": "unbundled-bezier", "control-point-distances": 45, "control-point-weights": .5, "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }},
|
{{ selector: "edge.same-layer", style: {{ "curve-style": "unbundled-bezier", "control-point-distances": 45, "control-point-weights": .5, "line-color": "#f97316", "line-style": "dashed", "target-arrow-color": "#f97316" }} }},
|
||||||
|
{{ selector: "edge.connects-blurred", style: {{ "line-color": "#d8dee8", "target-arrow-color": "#d8dee8", "opacity": .28 }} }},
|
||||||
|
{{ selector: "edge.connects-blurred.hover, edge.connects-blurred:selected", style: {{ "opacity": .75, "line-color": "#94a3b8", "target-arrow-color": "#94a3b8" }} }},
|
||||||
{{ selector: ".display-blur", style: {{ "opacity": .25, "label": "" }} }},
|
{{ selector: ".display-blur", style: {{ "opacity": .25, "label": "" }} }},
|
||||||
{{ selector: ".display-blur.hover, .display-blur:selected", style: {{ "opacity": .75, "label": "data(label)" }} }},
|
{{ selector: ".display-blur.hover, .display-blur:selected", style: {{ "opacity": .75, "label": "data(label)" }} }},
|
||||||
{{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }},
|
{{ selector: ":selected", style: {{ "border-color": "#111827", "border-width": 5, "line-color": "#111827", "target-arrow-color": "#111827" }} }},
|
||||||
@@ -2046,8 +2114,10 @@ def dependency_graph_view(
|
|||||||
}});
|
}});
|
||||||
basePayload = payload;
|
basePayload = payload;
|
||||||
if (payload.profile) {{
|
if (payload.profile) {{
|
||||||
profileSelect.value = String(payload.profile.id);
|
|
||||||
updateProfileForm(payload.profile);
|
updateProfileForm(payload.profile);
|
||||||
|
loadProfiles(String(payload.profile.id));
|
||||||
|
}} else {{
|
||||||
|
loadProfiles();
|
||||||
}}
|
}}
|
||||||
cy.on("tap", "node, edge", (event) => {{
|
cy.on("tap", "node, edge", (event) => {{
|
||||||
selected = event.target;
|
selected = event.target;
|
||||||
@@ -2061,8 +2131,14 @@ def dependency_graph_view(
|
|||||||
if (mode === "path") applyMode("path");
|
if (mode === "path") applyMode("path");
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
cy.on("mouseover", ".display-blur", (event) => event.target.addClass("hover"));
|
cy.on("mouseover", "node, edge", (event) => {{
|
||||||
cy.on("mouseout", ".display-blur", (event) => event.target.removeClass("hover"));
|
event.target.addClass("hover");
|
||||||
|
showPopup(event.target, event.renderedPosition);
|
||||||
|
}});
|
||||||
|
cy.on("mouseout", "node, edge", (event) => {{
|
||||||
|
event.target.removeClass("hover");
|
||||||
|
hidePopup();
|
||||||
|
}});
|
||||||
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
|
fitButton.addEventListener("click", () => cy.fit(cy.elements(":visible"), 48));
|
||||||
modeButtons.forEach((button) => {{
|
modeButtons.forEach((button) => {{
|
||||||
button.addEventListener("click", () => applyMode(button.dataset.graphMode));
|
button.addEventListener("click", () => applyMode(button.dataset.graphMode));
|
||||||
@@ -2070,6 +2146,7 @@ def dependency_graph_view(
|
|||||||
document.querySelector("[data-filter-apply]").addEventListener("click", () => {{
|
document.querySelector("[data-filter-apply]").addEventListener("click", () => {{
|
||||||
const match = {{}};
|
const match = {{}};
|
||||||
if (filterLayer.value) match.layer = filterLayer.value;
|
if (filterLayer.value) match.layer = filterLayer.value;
|
||||||
|
if (filterReviewState.value) match.reviewState = filterReviewState.value;
|
||||||
if (filterText.value.trim()) match.text = filterText.value.trim();
|
if (filterText.value.trim()) match.text = filterText.value.trim();
|
||||||
rules.push({{name: "UI filter", action: filterAction.value, match}});
|
rules.push({{name: "UI filter", action: filterAction.value, match}});
|
||||||
refilter();
|
refilter();
|
||||||
@@ -2162,8 +2239,7 @@ def dependency_graph_view(
|
|||||||
return loadProfiles();
|
return loadProfiles();
|
||||||
}});
|
}});
|
||||||
}});
|
}});
|
||||||
loadProfiles();
|
applyMode(payload.mode === "impact" ? "impact" : payload.mode || "full");
|
||||||
applyMode(payload.mode === "impact" ? "impact" : "full");
|
|
||||||
}})
|
}})
|
||||||
.catch((error) => {{
|
.catch((error) => {{
|
||||||
container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`;
|
container.innerHTML = `<p class="notice error">${{escapeHtml(error.message)}}</p>`;
|
||||||
|
|||||||
@@ -333,16 +333,169 @@ def test_dependency_graph_enriches_layers_and_filters_with_profiles(tmp_path):
|
|||||||
assert fact_node["layer"] == "fact"
|
assert fact_node["layer"] == "fact"
|
||||||
assert fact_node["path"] == fact.path
|
assert fact_node["path"] == fact.path
|
||||||
assert fact_node["displayState"] == "blur"
|
assert fact_node["displayState"] == "blur"
|
||||||
|
assert fact_node["reviewState"] == "accepted"
|
||||||
|
assert fact_node["visualSize"] == 36
|
||||||
assert feature_node["displayState"] == "show"
|
assert feature_node["displayState"] == "show"
|
||||||
assert feature_node["visibilitySource"] == "manual"
|
assert feature_node["visibilitySource"] == "manual"
|
||||||
|
assert feature_node["visualSize"] == 50
|
||||||
assert evidence_node["layer"] == "evidence"
|
assert evidence_node["layer"] == "evidence"
|
||||||
|
assert evidence_node["visualSize"] == 53
|
||||||
assert payload["filter"]["orphaned_overrides"] == ["missing:1"]
|
assert payload["filter"]["orphaned_overrides"] == ["missing:1"]
|
||||||
assert payload["metrics"]["hidden_count"] == 0
|
assert payload["metrics"]["hidden_count"] == 0
|
||||||
assert any(
|
evidence_edge = next(
|
||||||
element["data"].get("target") == f"feature:{feature_id}"
|
element["data"]
|
||||||
and element["data"].get("sourceKind") == "evidence"
|
|
||||||
for element in payload["elements"]
|
for element in payload["elements"]
|
||||||
|
if element["data"].get("target") == f"feature:{feature_id}"
|
||||||
|
and element["data"].get("sourceKind") == "evidence"
|
||||||
)
|
)
|
||||||
|
assert evidence_edge["edgeWidth"] == 5
|
||||||
|
assert evidence_edge["reviewState"] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_graph_filters_review_state_and_marks_blurred_edges(tmp_path):
|
||||||
|
service = make_service(tmp_path)
|
||||||
|
repository = service.register_repository(
|
||||||
|
name="Review State",
|
||||||
|
url="https://example.com/review-state.git",
|
||||||
|
description="Review state fixture.",
|
||||||
|
)
|
||||||
|
ability_id = service.add_ability(repository.id, name="Graph Review")
|
||||||
|
capability_id = service.add_capability(repository.id, ability_id, name="Inspect")
|
||||||
|
feature_id = service.add_feature(
|
||||||
|
repository.id,
|
||||||
|
capability_id,
|
||||||
|
name="Inspector",
|
||||||
|
type="UI",
|
||||||
|
confidence=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = service.dependency_graph_elements(
|
||||||
|
repository.id,
|
||||||
|
rules=[
|
||||||
|
{
|
||||||
|
"name": "blur accepted",
|
||||||
|
"action": "blur",
|
||||||
|
"match": {"reviewState": "accepted"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
use_latest_profile=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
feature = next(
|
||||||
|
element["data"]
|
||||||
|
for element in payload["elements"]
|
||||||
|
if element["data"].get("id") == f"feature:{feature_id}"
|
||||||
|
)
|
||||||
|
edge = next(
|
||||||
|
element["data"]
|
||||||
|
for element in payload["elements"]
|
||||||
|
if element["data"].get("source") == f"feature:{feature_id}"
|
||||||
|
)
|
||||||
|
assert feature["displayState"] == "blur"
|
||||||
|
assert edge["connectedToBlurred"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_graph_uses_latest_profile_by_default(tmp_path):
|
||||||
|
service = make_service(tmp_path)
|
||||||
|
repository = service.register_repository(
|
||||||
|
name="Latest Profile",
|
||||||
|
url="https://example.com/latest-profile.git",
|
||||||
|
description="Latest profile fixture.",
|
||||||
|
)
|
||||||
|
ability_id = service.add_ability(repository.id, name="Profile Defaults")
|
||||||
|
service.add_capability(repository.id, ability_id, name="Load Profile")
|
||||||
|
first = service.create_dependency_graph_profile(
|
||||||
|
repository.id,
|
||||||
|
name="First",
|
||||||
|
filter_rules=[
|
||||||
|
{"name": "blur abilities", "action": "blur", "match": {"layer": "ability"}}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
second = service.create_dependency_graph_profile(
|
||||||
|
repository.id,
|
||||||
|
name="Second",
|
||||||
|
filter_rules=[
|
||||||
|
{"name": "hide abilities", "action": "hide", "match": {"layer": "ability"}}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
default_payload = service.dependency_graph_elements(repository.id)
|
||||||
|
explicit_payload = service.dependency_graph_elements(
|
||||||
|
repository.id,
|
||||||
|
profile_id=first.id,
|
||||||
|
)
|
||||||
|
unsaved_payload = service.dependency_graph_elements(
|
||||||
|
repository.id,
|
||||||
|
use_latest_profile=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert default_payload["profile"]["id"] == second.id
|
||||||
|
assert default_payload["metrics"]["hidden_count"] >= 1
|
||||||
|
assert explicit_payload["profile"]["id"] == first.id
|
||||||
|
assert unsaved_payload["profile"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_dependency_graph_deduplicates_document_fact_nodes(tmp_path):
|
||||||
|
service = make_service(tmp_path)
|
||||||
|
repository = service.register_repository(
|
||||||
|
name="Docs",
|
||||||
|
url="https://example.com/docs.git",
|
||||||
|
description="Document graph fixture.",
|
||||||
|
)
|
||||||
|
ability_id = service.add_ability(repository.id, name="Documented Operation")
|
||||||
|
capability_id = service.add_capability(repository.id, ability_id, name="Read Docs")
|
||||||
|
run = service.store.create_analysis_run(repository.id)
|
||||||
|
with service.store.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO observed_facts
|
||||||
|
(repository_id, analysis_run_id, snapshot_id, kind, path, name, value, metadata)
|
||||||
|
VALUES (?, ?, NULL, 'documentation', 'README.md', 'README', '', '{}')
|
||||||
|
""",
|
||||||
|
(repository.id, run.id),
|
||||||
|
)
|
||||||
|
readme_fact_id = int(cursor.lastrowid)
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO observed_facts
|
||||||
|
(repository_id, analysis_run_id, snapshot_id, kind, path, name, value, metadata)
|
||||||
|
VALUES (?, ?, NULL, 'scope', 'SCOPE.md', 'SCOPE', '', ?)
|
||||||
|
""",
|
||||||
|
(repository.id, run.id, '{"source_role": "derived_scope"}'),
|
||||||
|
)
|
||||||
|
scope_fact_id = int(cursor.lastrowid)
|
||||||
|
service.store.create_feature(
|
||||||
|
repository.id,
|
||||||
|
capability_id,
|
||||||
|
name="README backed feature",
|
||||||
|
type="docs",
|
||||||
|
location="README.md",
|
||||||
|
confidence=0.7,
|
||||||
|
source_refs=[
|
||||||
|
SourceReference(
|
||||||
|
fact_id=readme_fact_id,
|
||||||
|
path="README.md",
|
||||||
|
kind="documentation",
|
||||||
|
name="README",
|
||||||
|
),
|
||||||
|
SourceReference(
|
||||||
|
fact_id=scope_fact_id,
|
||||||
|
path="SCOPE.md",
|
||||||
|
kind="scope",
|
||||||
|
name="SCOPE",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = service.dependency_graph_elements(repository.id, use_latest_profile=False)
|
||||||
|
|
||||||
|
fact_nodes = [
|
||||||
|
element["data"]
|
||||||
|
for element in payload["elements"]
|
||||||
|
if element["data"].get("kind") == "fact"
|
||||||
|
]
|
||||||
|
assert [node["id"] for node in fact_nodes] == ["fact:document:README.md"]
|
||||||
|
assert fact_nodes[0]["label"] == "README.md (documentation)"
|
||||||
|
|
||||||
|
|
||||||
def test_manual_registry_updates_and_deletes_approved_entries(tmp_path):
|
def test_manual_registry_updates_and_deletes_approved_entries(tmp_path):
|
||||||
|
|||||||
@@ -1542,8 +1542,27 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
|||||||
)
|
)
|
||||||
assert all(
|
assert all(
|
||||||
"layer" in element["data"]
|
"layer" in element["data"]
|
||||||
|
and "reviewState" in element["data"]
|
||||||
for element in graph_payload["elements"]
|
for element in graph_payload["elements"]
|
||||||
)
|
)
|
||||||
|
review_filter_response = client.post(
|
||||||
|
f"/repos/{repository_id}/dependency-graph/filter",
|
||||||
|
json={
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"name": "blur accepted",
|
||||||
|
"action": "blur",
|
||||||
|
"match": {"reviewState": "accepted"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"manual_overrides": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert review_filter_response.status_code == 200
|
||||||
|
assert all(
|
||||||
|
element["data"]["displayState"] == "blur"
|
||||||
|
for element in review_filter_response.json()["elements"]
|
||||||
|
)
|
||||||
|
|
||||||
profile_response = client.post(
|
profile_response = client.post(
|
||||||
f"/repos/{repository_id}/dependency-graph/profiles",
|
f"/repos/{repository_id}/dependency-graph/profiles",
|
||||||
@@ -1578,6 +1597,15 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
|||||||
)
|
)
|
||||||
assert duplicate_response.status_code == 201
|
assert duplicate_response.status_code == 201
|
||||||
assert duplicate_response.json()["name"] == "Hide Facts Copy"
|
assert duplicate_response.json()["name"] == "Hide Facts Copy"
|
||||||
|
latest_response = client.get(f"/repos/{repository_id}/dependency-graph")
|
||||||
|
assert latest_response.status_code == 200
|
||||||
|
assert latest_response.json()["profile"]["name"] == "Hide Facts Copy"
|
||||||
|
unsaved_response = client.get(
|
||||||
|
f"/repos/{repository_id}/dependency-graph",
|
||||||
|
params={"use_latest_profile": False},
|
||||||
|
)
|
||||||
|
assert unsaved_response.status_code == 200
|
||||||
|
assert unsaved_response.json()["profile"] is None
|
||||||
|
|
||||||
graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph")
|
graph_page = client.get(f"/ui/repos/{repository_id}/dependency-graph")
|
||||||
assert graph_page.status_code == 200
|
assert graph_page.status_code == 200
|
||||||
@@ -1585,7 +1613,9 @@ def test_ui_register_analyze_and_approve_loop(tmp_path):
|
|||||||
assert "cytoscape.min.js" in graph_page.text
|
assert "cytoscape.min.js" in graph_page.text
|
||||||
assert 'data-graph-mode="impact"' in graph_page.text
|
assert 'data-graph-mode="impact"' in graph_page.text
|
||||||
assert 'id="profile-select"' in graph_page.text
|
assert 'id="profile-select"' in graph_page.text
|
||||||
|
assert 'id="filter-review-state"' in graph_page.text
|
||||||
assert 'data-override="blur"' in graph_page.text
|
assert 'data-override="blur"' in graph_page.text
|
||||||
|
assert "graph-popup" in graph_page.text
|
||||||
|
|
||||||
scope_listing = client.get(
|
scope_listing = client.get(
|
||||||
f"/ui/repos/{repository_id}/elements",
|
f"/ui/repos/{repository_id}/elements",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Dependency Graph Exploration Polish"
|
title: "Dependency Graph Exploration Polish"
|
||||||
domain: capabilities
|
domain: capabilities
|
||||||
repo: repo-scoping
|
repo: repo-scoping
|
||||||
status: active
|
status: done
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: foerster-capabilities
|
topic_slug: foerster-capabilities
|
||||||
created: "2026-05-04"
|
created: "2026-05-04"
|
||||||
@@ -24,7 +24,7 @@ inspection, and reducing redundant document-derived nodes.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T01
|
id: RREG-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8cca41a1-ddf2-4136-9707-70c7b8481a48"
|
state_hub_task_id: "8cca41a1-ddf2-4136-9707-70c7b8481a48"
|
||||||
```
|
```
|
||||||
@@ -44,7 +44,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T02
|
id: RREG-WP-0011-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "3759013b-8a78-45fb-afa5-5ff1f6644070"
|
state_hub_task_id: "3759013b-8a78-45fb-afa5-5ff1f6644070"
|
||||||
```
|
```
|
||||||
@@ -63,7 +63,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T03
|
id: RREG-WP-0011-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "087a550f-a9f7-403c-9853-a04f5847211d"
|
state_hub_task_id: "087a550f-a9f7-403c-9853-a04f5847211d"
|
||||||
```
|
```
|
||||||
@@ -81,7 +81,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T04
|
id: RREG-WP-0011-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "eac6ccd7-7c99-46f9-bf12-c3916b03f041"
|
state_hub_task_id: "eac6ccd7-7c99-46f9-bf12-c3916b03f041"
|
||||||
```
|
```
|
||||||
@@ -102,7 +102,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T05
|
id: RREG-WP-0011-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d63489fc-5c08-486c-addc-53af84218028"
|
state_hub_task_id: "d63489fc-5c08-486c-addc-53af84218028"
|
||||||
```
|
```
|
||||||
@@ -122,7 +122,7 @@ Acceptance criteria:
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RREG-WP-0011-T06
|
id: RREG-WP-0011-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "5477cfdd-7bd4-428f-a329-6255c4c58803"
|
state_hub_task_id: "5477cfdd-7bd4-428f-a329-6255c4c58803"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user