diff --git a/docs/dependency-aware-scope-propagation.md b/docs/dependency-aware-scope-propagation.md index 13f84ec..ffc37ca 100644 --- a/docs/dependency-aware-scope-propagation.md +++ b/docs/dependency-aware-scope-propagation.md @@ -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 diff --git a/docs/dependency-visualization-exploration.md b/docs/dependency-visualization-exploration.md new file mode 100644 index 0000000..5ddbac2 --- /dev/null +++ b/docs/dependency-visualization-exploration.md @@ -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. diff --git a/src/repo_registry/core/models.py b/src/repo_registry/core/models.py index 7fec1cd..b0cdf73 100644 --- a/src/repo_registry/core/models.py +++ b/src/repo_registry/core/models.py @@ -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 diff --git a/src/repo_registry/core/service.py b/src/repo_registry/core/service.py index 16ba7f0..cebd79b 100644 --- a/src/repo_registry/core/service.py +++ b/src/repo_registry/core/service.py @@ -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", diff --git a/src/repo_registry/storage/sqlite.py b/src/repo_registry/storage/sqlite.py index 99d8102..a68d566 100644 --- a/src/repo_registry/storage/sqlite.py +++ b/src/repo_registry/storage/sqlite.py @@ -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"], + ) diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 4cd6a96..34a158e 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -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"], diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index b4f83e0..f1b477c 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -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 = "" diff --git a/src/repo_registry/web_ui/views.py b/src/repo_registry/web_ui/views.py index ef0d22a..b72c1b7 100644 --- a/src/repo_registry/web_ui/views.py +++ b/src/repo_registry/web_ui/views.py @@ -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( Fit + + Profiles + + Profile Unsaved exploration + + Name + Mode fullimpactpath + + Description + + Save + Duplicate + Delete + + No profile selected. + + + + Filters + + + Layer + Any layer + factevidencefeature + capabilityabilityscope + + Action blurhideshow + + Text + + Apply Rule + Clear Rules + + + + + Manual + + Show + Blur + Hide + Reset + + + + Focus + + Depth + + Focus + Clear + + + Legend @@ -1758,13 +1825,28 @@ def dependency_graph_view(