diff --git a/README.md b/README.md index ea1e28d..9a8231a 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,14 @@ GET /repositories/{repo_slug}/inventory GET /repositories/{repo_slug}/snapshots GET /repositories/{repo_slug}/snapshots/diff GET /search?q=jsonschema +GET /exports/graph-explorer/manifest +GET /exports/graph-explorer ``` See `docs/registry-onboarding.md` for the multi-repo manifest and operating loop. + +The graph explorer export is the first executable slice of the interactive +Fabric map. See `docs/graph-explorer-transfer-review.md` for the repo-scoping +transfer review and `docs/graph-explorer-contract.md` for the shared manifest +and payload contract. diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md new file mode 100644 index 0000000..f5b6985 --- /dev/null +++ b/docs/graph-explorer-contract.md @@ -0,0 +1,145 @@ +# Graph Explorer Contract + +This note defines the first manifest and payload contract for the interactive +Fabric map and the possible reusable graph explorer engine. + +The contract is intentionally host-neutral. Fabric and repo-scoping should be +able to use the same interaction shell by exposing a manifest and a graph +payload with stable fields. + +## Files + +- `schemas/graph-explorer-manifest.schema.yaml` validates a host manifest. +- `schemas/graph-explorer-payload.schema.yaml` validates graph payloads. +- `railiance_fabric.graph_explorer` provides the first Fabric registry + manifest and payload projection. + +## Registry Endpoints + +The registry service exposes the first Fabric projection: + +```text +GET /exports/graph-explorer/manifest +GET /exports/graph-explorer +``` + +The manifest tells a graph shell where to load data, which fields are stable, +which layers exist, which filter fields are available, and which modes the host +supports. + +The payload is compatible with Cytoscape-style element arrays: + +```json +{ + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "GraphExplorerPayload", + "manifest_id": "railiance-fabric.registry-map", + "mode": "full", + "elements": [ + { + "data": { + "id": "repo:railiance-fabric", + "stableKey": "repo:railiance-fabric", + "kind": "Repository", + "layer": "repository", + "label": "Railiance Fabric", + "displayState": "show" + } + } + ], + "hidden_elements": [] +} +``` + +## Required Payload Semantics + +Every element must include: + +- `id`: the current element id used by the graph library. +- `stableKey`: the durable id used by profile rules, manual overrides, layout + state, and deep links. +- `kind`: host-specific entity kind. +- `layer`: host-declared layer used for layout, grouping, and color. +- `displayState`: one of `show`, `blur`, or `hide`. + +Edges are ordinary elements whose data also includes: + +- `source` +- `target` +- `edgeType` +- `strength` +- `sameLayer` + +Hosts should also include useful optional fields when available: `label`, +`name`, `description`, `repo`, `domain`, `lifecycle`, `reviewState`, +`freshnessState`, `confidence`, `visualSize`, `ownership`, `unresolved`, +`sourceReferences`, and `deepLinks`. + +## Display State Ownership + +The contract allows either the host service or the engine to evaluate display +state. + +The precedence rule is fixed: + +1. Default element state is `show`. +2. Rules are applied in list order; later matching rules override earlier + matching rules. +3. Manual overrides win last. +4. Edges connected to hidden nodes are hidden. +5. Edges connected to blurred nodes may carry a contextual muted class or data + hint. + +Repo-scoping currently evaluates this host-side. A future extracted engine can +evaluate it client-side for static exports, but host-provided `displayState` +must remain valid input. + +## Fabric Layers + +The first Fabric manifest declares: + +| Layer | Purpose | +|-------|---------| +| `repository` | Registered source repositories, including registered-only repos. | +| `service` | Service declarations. | +| `capability` | Capability declarations. | +| `interface` | Interface declarations. | +| `dependency` | Dependency declarations, including unresolved dependency nodes. | +| `binding` | Binding assertions between consumer dependencies and providers. | +| `library` | Future library/SBOM inventory nodes. | + +## Repo-Scoping Compatibility + +Repo-scoping can adapt without a rewrite because its current graph payload +already exposes most required fields: + +- `id`, `stableKey`, `kind`, `layer`, labels, and metadata-rich data objects. +- `displayState`, `visibilitySource`, and `visibilityReason`. +- edge `source`, `target`, `dependencyType`, `strength`, `sameLayer`, and + visual width. +- profile data, filter rules, manual overrides, hidden elements, and orphaned + overrides. + +The main adapter work is manifest generation and small field aliases: +`dependencyType` can map to `edgeType`, repo-scoping layers become manifest +layers, and existing profile endpoints can be listed under manifest +`endpoints.profiles`. + +## Extraction Boundary + +The extracted `graph-explorer-engine` should own: + +- graph rendering and layout controls +- filter and manual override UI +- hover popups and selection detail panels +- profile UI when the host declares profile endpoints +- URL state and copied state blobs +- schema definitions and compatibility tests + +Host repos should own: + +- graph projection and metadata enrichment +- profile persistence +- authentication and authorization +- domain-specific graph modes +- deep links back to source systems diff --git a/docs/graph-explorer-transfer-review.md b/docs/graph-explorer-transfer-review.md new file mode 100644 index 0000000..118fce2 --- /dev/null +++ b/docs/graph-explorer-transfer-review.md @@ -0,0 +1,84 @@ +# Graph Explorer Transfer Review + +This note closes the first implementation step for `RAIL-FAB-WP-0008-T01`. +It reviews the repo-scoping dependency graph work from `RREG-WP-0010` and +`RREG-WP-0011` and turns the transferable parts into Fabric requirements. + +## Source Implementation + +Repo-scoping already has a useful graph explorer shape: + +- `docs/adr-dependency-graph-visualization-framework.md` chooses Cytoscape.js + because the required interaction model is graph-native: pan, zoom, selection, + layouts, styling, filtering, and path exploration. +- `docs/dependency-visualization-exploration.md` defines layered graph data, + `show` / `blur` / `hide` display states, deterministic filters, manual + overrides, and saved view profiles. +- `src/repo_scoping/core/service.py` emits Cytoscape-compatible payloads with + stable keys, metadata-rich node and edge data, visibility state, hidden + elements, profile application, confidence sizing, and edge strength sizing. +- `src/repo_scoping/web_ui/views.py` provides the first UI shell: profile + controls, structured filters, manual overrides, focus depth, hover popups, + selection details, and a layered layout. +- `tests/test_web_api.py` verifies graph endpoints, ad hoc filters, profile + creation, duplicate profiles, latest-profile defaulting, and UI wiring. + +## Reusable Behaviors + +Fabric should carry these behaviors into the shared graph explorer contract: + +- Cytoscape-compatible element arrays with `data` and optional `classes`. +- Stable element keys that survive refreshes, so layout state, profile rules, + and deep links do not churn. +- Metadata-rich nodes and edges rather than UI-only labels. +- Explicit `layer`, `kind`, `reviewState`, `freshnessState`, and + `displayState` fields. +- Visibility actions of `show`, `blur`, and `hide`, with later rules overriding + earlier rules and manual overrides winning last. +- Hidden element reporting, so over-filtered views are recoverable. +- Edge treatment for context-muted nodes. +- View profiles that can persist filter rules and manual overrides when a host + provides profile endpoints. +- Hover popups for compact inspection and selection panels for full detail. +- Layout modes for full graph, impact or filtered graph, selected path, and + neighborhood focus. +- Visual hints for confidence, strength, same-layer edges, stale or unresolved + state, and review state. + +## Adapter-Owned Semantics + +These repo-scoping concepts should remain in the repo-scoping adapter and not +be hard-coded into the engine: + +- Layer names of `fact`, `evidence`, `feature`, `capability`, `ability`, and + `scope`. +- Candidate graph approval semantics. +- Dependency impact analysis over base and target analysis runs. +- Document fact normalization and README/SCOPE de-duplication. +- Scope curation recommendations. + +Fabric has its own adapter semantics: + +- Layer names of `repository`, `service`, `capability`, `interface`, + `dependency`, `binding`, and `library`. +- Registered-only repositories without accepted graph snapshots. +- State Hub repo ids, workplan links, and registry endpoints as deep links. +- Unresolved dependencies where provider bindings are missing or disputed. +- Blast-radius and provider-chain exploration across accepted Fabric + declarations. + +## Extraction Recommendation + +Start in `railiance-fabric` with a host-neutral manifest and payload contract, +plus a Fabric registry projection. The first UI shell can live locally while +the contract is still moving. + +Extract into a separate repository only after two host adapters are proven: + +1. Fabric registry map adapter. +2. Repo-scoping dependency graph adapter. + +The likely extracted repository is `graph-explorer-engine`. It should own the +static interaction shell, schema definitions, layout/filter/profile client +logic, and adapter manifest contract. Host repos should keep graph projection, +profile persistence, authentication, and domain-specific deep links. diff --git a/docs/registry-api.md b/docs/registry-api.md index a74e0d4..904ee54 100644 --- a/docs/registry-api.md +++ b/docs/registry-api.md @@ -64,4 +64,6 @@ GET /exports/state-hub GET /exports/backstage GET /exports/xregistry GET /exports/libraries/xregistry +GET /exports/graph-explorer/manifest +GET /exports/graph-explorer ``` diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py new file mode 100644 index 0000000..7020aa9 --- /dev/null +++ b/railiance_fabric/graph_explorer.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + + +DISPLAY_STATES = ("show", "blur", "hide") +LAYER_ORDER = ( + "repository", + "service", + "capability", + "interface", + "dependency", + "binding", + "library", +) + +_KIND_LAYER = { + "Repository": "repository", + "ServiceDeclaration": "service", + "CapabilityDeclaration": "capability", + "InterfaceDeclaration": "interface", + "DependencyDeclaration": "dependency", + "BindingAssertion": "binding", + "Library": "library", +} + +_LAYER_COLORS = { + "repository": "#475569", + "service": "#0f766e", + "capability": "#2563eb", + "interface": "#7c3aed", + "dependency": "#b45309", + "binding": "#be123c", + "library": "#0891b2", +} + +_EDGE_STRENGTH = { + "provides": "strong", + "exposes": "strong", + "available_via": "medium", + "consumes": "medium", + "uses_interface": "medium", + "declares": "weak", +} + + +def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: + """Return the host manifest for the reusable graph explorer shell.""" + + return { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "GraphExplorerManifest", + "id": "railiance-fabric.registry-map", + "title": "Railiance Fabric Registry Map", + "description": "Manifest for exploring registered Fabric ecosystem entities.", + "engine": { + "suggested_package": "graph-explorer-engine", + "preferred_library": "cytoscape", + "display_state_owner": "host-or-engine", + }, + "endpoints": { + "graph": { + "method": "GET", + "url": f"{base_url}/exports/graph-explorer", + }, + "manifest": { + "method": "GET", + "url": f"{base_url}/exports/graph-explorer/manifest", + }, + }, + "identity": { + "node_id_field": "id", + "stable_key_field": "stableKey", + "edge_id_field": "id", + "source_field": "source", + "target_field": "target", + }, + "layers": [ + { + "id": layer, + "label": layer.replace("_", " ").title(), + "order": index, + "color": _LAYER_COLORS[layer], + } + for index, layer in enumerate(LAYER_ORDER) + ], + "grouping_fields": ["domain", "repo", "layer", "kind", "lifecycle", "unresolved"], + "search_fields": [ + "id", + "stableKey", + "label", + "name", + "description", + "repo", + "domain", + "kind", + "layer", + "edgeType", + ], + "filter": { + "actions": list(DISPLAY_STATES), + "fields": [ + {"id": "kind", "label": "Kind", "type": "string"}, + {"id": "layer", "label": "Layer", "type": "string"}, + {"id": "repo", "label": "Repo", "type": "string"}, + {"id": "domain", "label": "Domain", "type": "string"}, + {"id": "lifecycle", "label": "Lifecycle", "type": "string"}, + {"id": "reviewState", "label": "Review State", "type": "string"}, + {"id": "unresolved", "label": "Unresolved", "type": "boolean"}, + {"id": "edgeType", "label": "Edge Type", "type": "string"}, + {"id": "strength", "label": "Strength", "type": "string"}, + {"id": "sameLayer", "label": "Same Layer", "type": "boolean"}, + {"id": "text", "label": "Text", "type": "string"}, + ], + }, + "visual_encodings": { + "node_color": "layer", + "node_shape": "kind", + "node_size": "confidence", + "edge_width": "strength", + "edge_style": "sameLayer", + "edge_opacity": "displayState", + }, + "detail": { + "node_fields": [ + "name", + "kind", + "layer", + "repo", + "domain", + "lifecycle", + "unresolved", + "description", + "sourceReferences", + "deepLinks", + ], + "edge_fields": [ + "edgeType", + "strength", + "source", + "target", + "sourceLayer", + "targetLayer", + "sameLayer", + "deepLinks", + ], + }, + "modes": [ + { + "id": "full", + "label": "Full", + "description": "Show the complete registered ecosystem graph.", + }, + { + "id": "selected-path", + "label": "Selected Path", + "description": "Show selected nodes with predecessor and successor context.", + "requires_selection": True, + }, + { + "id": "neighborhood", + "label": "Neighborhood", + "description": "Show the selected node neighborhood by configurable depth.", + "requires_selection": True, + }, + { + "id": "onboarding-gaps", + "label": "Onboarding Gaps", + "description": "Highlight registered repos without accepted Fabric graph snapshots.", + }, + { + "id": "unresolved", + "label": "Unresolved", + "description": "Highlight dependencies that have no accepted provider binding.", + }, + ], + "profile_persistence": "none", + "shareable_state": { + "url_parameters": True, + "profile_id": False, + "state_blob": True, + }, + } + + +def fabric_graph_explorer_payload( + graph: dict[str, Any], + repositories: list[dict[str, Any]] | None = None, + snapshot_repo_slugs: set[str] | None = None, +) -> dict[str, Any]: + """Project a Fabric graph export into the shared graph explorer payload.""" + + source_nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)] + source_edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)] + repositories = repositories or [] + snapshot_repo_slugs = snapshot_repo_slugs or set() + + layers_by_id = { + str(node.get("id", "")): _layer_for_kind(str(node.get("kind", ""))) + for node in source_nodes + } + source_repo_slugs = { + str(node.get("repo", "")).strip() + for node in source_nodes + if str(node.get("repo", "")).strip() + } + registered_repo_slugs = { + str(repo.get("slug", "")).strip() + for repo in repositories + if str(repo.get("slug", "")).strip() + } + repo_slugs = set(source_repo_slugs) + repo_slugs.update(registered_repo_slugs) + repo_slugs.update(snapshot_repo_slugs) + repo_slugs.discard("") + unresolved = _unresolved_dependency_ids(source_nodes, source_edges) + + elements: list[dict[str, Any]] = [] + repository_index = {str(repo.get("slug", "")): repo for repo in repositories} + for slug in sorted(repo_slugs): + repo = repository_index.get(slug, {}) + has_snapshot = slug in snapshot_repo_slugs or slug in source_repo_slugs + is_registered = slug in registered_repo_slugs + repo_id = f"repo:{slug}" + elements.append( + { + "data": { + "id": repo_id, + "stableKey": repo_id, + "kind": "Repository", + "layer": "repository", + "label": str(repo.get("name") or slug), + "name": str(repo.get("name") or slug), + "description": ( + "Registered repository with accepted Fabric graph snapshot." + if has_snapshot + else "Registered repository without an accepted Fabric graph snapshot." + ), + "repo": slug, + "domain": "railiance", + "lifecycle": "active" if has_snapshot else "registered-only", + "reviewState": "accepted" if has_snapshot else "candidate", + "freshnessState": "current" if has_snapshot else "missing", + "unresolved": is_registered and not has_snapshot, + "confidence": 1.0 if has_snapshot else 0.3, + "visualSize": 56 if has_snapshot else 42, + "ownership": "registry", + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "default", + "sourceReferences": [], + "deepLinks": _repository_links(repo), + }, + "classes": "repository accepted" if has_snapshot else "repository candidate unresolved", + } + ) + + for node in source_nodes: + node_id = str(node.get("id", "")) + if not node_id: + continue + kind = str(node.get("kind", "")) + layer = _layer_for_kind(kind) + is_unresolved = node_id in unresolved + attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + elements.append( + { + "data": { + "id": node_id, + "stableKey": node_id, + "kind": kind, + "layer": layer, + "label": str(node.get("name") or node_id), + "name": str(node.get("name") or node_id), + "description": _node_description(kind, attributes), + "repo": str(node.get("repo", "")), + "domain": str(node.get("domain", "")), + "lifecycle": str(node.get("lifecycle", "")), + "reviewState": "accepted", + "freshnessState": "current", + "unresolved": is_unresolved, + "confidence": 0.45 if is_unresolved else 1.0, + "visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50, + "ownership": "repo", + "attributes": attributes, + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "default", + "sourceReferences": _source_references(node), + "deepLinks": _node_links(node_id), + }, + "classes": " ".join( + part + for part in (layer, kind, "unresolved" if is_unresolved else "accepted") + if part + ), + } + ) + + edge_index = 0 + for edge in source_edges: + source = str(edge.get("from", "")) + target = str(edge.get("to", "")) + edge_type = str(edge.get("type", "")) + if not source or not target: + continue + source_layer = layers_by_id.get(source, "unknown") + target_layer = layers_by_id.get(target, "unknown") + strength = _edge_strength(edge_type) + edge_id = f"edge:{edge_index}:{source}:{edge_type}:{target}" + edge_index += 1 + elements.append( + { + "data": { + "id": edge_id, + "stableKey": edge_id, + "kind": "edge", + "layer": "relationship", + "label": edge_type, + "source": source, + "target": target, + "sourceLayer": source_layer, + "targetLayer": target_layer, + "edgeType": edge_type, + "dependencyType": edge_type, + "strength": strength, + "edgeWidth": _edge_width(strength), + "sameLayer": source_layer == target_layer, + "reviewState": "accepted", + "freshnessState": "current", + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "default", + "deepLinks": {}, + }, + "classes": " ".join( + part + for part in ( + edge_type.replace(":", "-"), + strength, + "same-layer" if source_layer == target_layer else "", + ) + if part + ), + } + ) + + for slug in sorted(repo_slugs): + repo_id = f"repo:{slug}" + for node in source_nodes: + node_id = str(node.get("id", "")) + if str(node.get("repo", "")) != slug or not node_id: + continue + edge_id = f"edge:{edge_index}:{repo_id}:declares:{node_id}" + edge_index += 1 + target_layer = layers_by_id.get(node_id, "unknown") + elements.append( + { + "data": { + "id": edge_id, + "stableKey": edge_id, + "kind": "edge", + "layer": "relationship", + "label": "declares", + "source": repo_id, + "target": node_id, + "sourceLayer": "repository", + "targetLayer": target_layer, + "edgeType": "declares", + "dependencyType": "declares", + "strength": "weak", + "edgeWidth": 1, + "sameLayer": False, + "reviewState": "accepted", + "freshnessState": "current", + "displayState": "show", + "visibilitySource": "default", + "visibilityReason": "default", + "deepLinks": {}, + }, + "classes": "declares weak", + } + ) + + visible_nodes = [element for element in elements if "source" not in element["data"]] + visible_edges = [element for element in elements if "source" in element["data"]] + return { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "GraphExplorerPayload", + "manifest_id": "railiance-fabric.registry-map", + "generated_at": _utc_now(), + "repository": {"slug": "registry", "name": "Railiance Fabric Registry"}, + "mode": "full", + "profile": None, + "metrics": { + "node_count": len(visible_nodes), + "edge_count": len(visible_edges), + "hidden_count": 0, + "blurred_count": 0, + "registered_repo_count": len(registered_repo_slugs), + "repo_node_count": len(repo_slugs), + "registered_only_repo_count": sum( + 1 + for element in visible_nodes + if element["data"].get("repo") in registered_repo_slugs + and element["data"].get("lifecycle") == "registered-only" + ), + "unresolved_count": len(unresolved), + }, + "filter": { + "rules": [], + "manual_overrides": {}, + "orphaned_overrides": [], + "precedence": "later rules override earlier rules; manual overrides win last", + "connected_edge_behavior": "edges connected to hidden nodes are hidden", + }, + "elements": elements, + "hidden_elements": [], + } + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _layer_for_kind(kind: str) -> str: + return _KIND_LAYER.get(kind, kind.lower() or "unknown") + + +def _edge_strength(edge_type: str) -> str: + if edge_type.startswith("binds:"): + status = edge_type.split(":", 1)[1] + if status in {"missing", "disputed"}: + return "weak" + if status == "accepted": + return "strong" + return "medium" + return _EDGE_STRENGTH.get(edge_type, "medium") + + +def _edge_width(strength: str) -> int: + return {"weak": 1, "medium": 3, "strong": 5}.get(strength, 2) + + +def _unresolved_dependency_ids( + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], +) -> set[str]: + dependency_ids = { + str(node.get("id", "")) + for node in nodes + if node.get("kind") == "DependencyDeclaration" and str(node.get("id", "")) + } + resolved: set[str] = set() + unresolved: set[str] = set() + for edge in edges: + edge_type = str(edge.get("type", "")) + source = str(edge.get("from", "")) + if source not in dependency_ids or not edge_type.startswith("binds:"): + continue + status = edge_type.split(":", 1)[1] + if status in {"accepted", "candidate"}: + resolved.add(source) + if status in {"missing", "disputed"}: + unresolved.add(source) + return (dependency_ids - resolved) | unresolved + + +def _node_description(kind: str, attributes: object) -> str: + if not isinstance(attributes, dict): + return "" + if kind == "CapabilityDeclaration": + return str(attributes.get("capability_type", "")) + if kind == "InterfaceDeclaration": + version = str(attributes.get("version", "")) + interface_type = str(attributes.get("interface_type", "")) + return " ".join(part for part in (interface_type, version) if part) + if kind == "DependencyDeclaration": + return str( + attributes.get("requires_capability_type") + or attributes.get("requires_capability_id") + or attributes.get("interface_type") + or "" + ) + if kind == "BindingAssertion": + return str(attributes.get("status", "")) + return "" + + +def _source_references(node: dict[str, Any]) -> list[dict[str, str]]: + attributes = node.get("attributes") + references: list[dict[str, str]] = [] + if isinstance(attributes, dict): + for source in attributes.get("source_links", []): + if isinstance(source, dict): + references.append({key: str(value) for key, value in source.items()}) + return references + + +def _node_links(node_id: str) -> dict[str, str]: + return {"registry": f"/graph/nodes/{node_id}"} + + +def _repository_links(repository: dict[str, Any]) -> dict[str, str]: + slug = str(repository.get("slug", "")) + links = {"registry": f"/repositories/{slug}"} if slug else {} + state_hub_repo_id = str(repository.get("state_hub_repo_id") or "") + if state_hub_repo_id: + links["stateHubRepo"] = f"/repos/by-id/{state_hub_repo_id}" + return links diff --git a/railiance_fabric/server.py b/railiance_fabric/server.py index a247e17..b200f0d 100644 --- a/railiance_fabric/server.py +++ b/railiance_fabric/server.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse +from .graph_explorer import fabric_graph_explorer_manifest, fabric_graph_explorer_payload from .registry import ( RegistryError, RegistryStore, @@ -79,6 +80,14 @@ class RegistryHandler(BaseHTTPRequestHandler): return HTTPStatus.OK, backstage_projection(self.store.combined_graph()) if parts == ["exports", "xregistry"]: return HTTPStatus.OK, xregistry_projection(self.store.combined_graph()) + if parts == ["exports", "graph-explorer"]: + return HTTPStatus.OK, fabric_graph_explorer_payload( + self.store.combined_graph(), + self.store.list_repositories(), + {str(snapshot["repo_slug"]) for snapshot in self.store.latest_snapshots()}, + ) + if parts == ["exports", "graph-explorer", "manifest"]: + return HTTPStatus.OK, fabric_graph_explorer_manifest() if parts == ["artifacts"]: return HTTPStatus.OK, self.store.list_artifacts( repo_slug=_query_optional(query, "repo_slug"), diff --git a/schemas/graph-explorer-manifest.schema.yaml b/schemas/graph-explorer-manifest.schema.yaml new file mode 100644 index 0000000..0fd51bb --- /dev/null +++ b/schemas/graph-explorer-manifest.schema.yaml @@ -0,0 +1,232 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://railiance.local/fabric/schemas/graph-explorer-manifest.schema.yaml" +title: "GraphExplorerManifest" +type: object +additionalProperties: false +required: + - apiVersion + - kind + - id + - title + - endpoints + - identity + - layers + - filter + - modes + - profile_persistence +properties: + apiVersion: + $ref: "./common.schema.yaml#/$defs/apiVersion" + kind: + type: string + const: GraphExplorerManifest + id: + type: string + minLength: 3 + title: + type: string + minLength: 1 + description: + type: string + engine: + type: object + additionalProperties: false + properties: + suggested_package: + type: string + preferred_library: + type: string + display_state_owner: + type: string + enum: + - host + - engine + - host-or-engine + endpoints: + type: object + additionalProperties: false + required: + - graph + properties: + graph: + $ref: "#/$defs/endpoint" + manifest: + $ref: "#/$defs/endpoint" + refilter: + $ref: "#/$defs/endpoint" + profiles: + type: object + additionalProperties: false + properties: + list: + $ref: "#/$defs/endpoint" + create: + $ref: "#/$defs/endpoint" + get: + $ref: "#/$defs/endpoint" + update: + $ref: "#/$defs/endpoint" + delete: + $ref: "#/$defs/endpoint" + duplicate: + $ref: "#/$defs/endpoint" + latest_default: + type: boolean + identity: + type: object + additionalProperties: false + required: + - node_id_field + - stable_key_field + - edge_id_field + - source_field + - target_field + properties: + node_id_field: + type: string + stable_key_field: + type: string + edge_id_field: + type: string + source_field: + type: string + target_field: + type: string + layers: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: + - id + - label + - order + properties: + id: + type: string + minLength: 1 + label: + type: string + minLength: 1 + order: + type: integer + minimum: 0 + color: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + grouping_fields: + $ref: "#/$defs/stringList" + search_fields: + $ref: "#/$defs/stringList" + filter: + type: object + additionalProperties: false + required: + - actions + - fields + properties: + actions: + type: array + minItems: 1 + uniqueItems: true + items: + type: string + enum: + - show + - blur + - hide + fields: + type: array + items: + type: object + additionalProperties: false + required: + - id + - label + - type + properties: + id: + type: string + minLength: 1 + label: + type: string + minLength: 1 + type: + type: string + enum: + - string + - number + - boolean + - array + visual_encodings: + type: object + additionalProperties: + type: string + detail: + type: object + additionalProperties: false + properties: + node_fields: + $ref: "#/$defs/stringList" + edge_fields: + $ref: "#/$defs/stringList" + modes: + type: array + minItems: 1 + items: + type: object + additionalProperties: false + required: + - id + - label + properties: + id: + type: string + minLength: 1 + label: + type: string + minLength: 1 + description: + type: string + requires_selection: + type: boolean + profile_persistence: + type: string + enum: + - none + - host + shareable_state: + type: object + additionalProperties: false + properties: + url_parameters: + type: boolean + profile_id: + type: boolean + state_blob: + type: boolean + +$defs: + endpoint: + type: object + additionalProperties: false + required: + - method + - url + properties: + method: + type: string + enum: + - GET + - POST + - PATCH + - DELETE + url: + type: string + minLength: 1 + stringList: + type: array + items: + type: string + minLength: 1 diff --git a/schemas/graph-explorer-payload.schema.yaml b/schemas/graph-explorer-payload.schema.yaml new file mode 100644 index 0000000..9f45a0d --- /dev/null +++ b/schemas/graph-explorer-payload.schema.yaml @@ -0,0 +1,258 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "https://railiance.local/fabric/schemas/graph-explorer-payload.schema.yaml" +title: "GraphExplorerPayload" +type: object +additionalProperties: false +required: + - apiVersion + - kind + - elements + - hidden_elements +properties: + apiVersion: + $ref: "./common.schema.yaml#/$defs/apiVersion" + kind: + type: string + const: GraphExplorerPayload + manifest_id: + type: string + generated_at: + type: string + format: date-time + repository: + type: object + additionalProperties: true + scope: + type: object + additionalProperties: true + mode: + type: string + profile: + anyOf: + - type: "null" + - $ref: "#/$defs/profile" + metrics: + type: object + additionalProperties: + anyOf: + - type: integer + - type: number + - type: boolean + - type: string + - type: "null" + filter: + type: object + additionalProperties: false + properties: + rules: + type: array + items: + $ref: "#/$defs/rule" + manual_overrides: + type: object + additionalProperties: + $ref: "#/$defs/displayState" + orphaned_overrides: + type: array + items: + type: string + precedence: + type: string + connected_edge_behavior: + type: string + elements: + type: array + items: + $ref: "#/$defs/element" + hidden_elements: + type: array + items: + $ref: "#/$defs/element" + impacts: + type: array + items: + type: object + additionalProperties: true + changed_fact_keys: + type: array + items: + type: string + +$defs: + element: + type: object + additionalProperties: false + required: + - data + properties: + data: + type: object + additionalProperties: true + required: + - id + - stableKey + - kind + - layer + - displayState + properties: + id: + $ref: "#/$defs/stableKey" + key: + $ref: "#/$defs/stableKey" + stableKey: + $ref: "#/$defs/stableKey" + kind: + type: string + minLength: 1 + layer: + type: string + minLength: 1 + label: + type: string + name: + type: string + description: + type: string + repo: + type: string + domain: + type: string + lifecycle: + type: string + reviewState: + type: string + freshnessState: + type: string + displayState: + $ref: "#/$defs/displayState" + visibilitySource: + type: string + visibilityReason: + type: string + confidence: + anyOf: + - type: number + minimum: 0 + maximum: 1 + - type: "null" + visualSize: + type: number + minimum: 1 + ownership: + type: string + unresolved: + type: boolean + source: + $ref: "#/$defs/stableKey" + target: + $ref: "#/$defs/stableKey" + sourceLayer: + type: string + targetLayer: + type: string + edgeType: + type: string + dependencyType: + type: string + strength: + type: string + enum: + - weak + - medium + - strong + edgeWidth: + type: number + minimum: 0 + sameLayer: + type: boolean + connectedToBlurred: + type: boolean + path: + type: string + value: + type: string + attributes: + anyOf: + - type: object + additionalProperties: true + - type: array + items: + type: string + metadata: + type: object + additionalProperties: true + sourceReferences: + type: array + items: + type: object + additionalProperties: + anyOf: + - type: string + - type: integer + - type: number + - type: "null" + deepLinks: + type: object + additionalProperties: + type: string + classes: + type: string + group: + type: string + enum: + - nodes + - edges + displayState: + type: string + enum: + - show + - blur + - hide + profile: + type: object + additionalProperties: true + required: + - name + properties: + id: + anyOf: + - type: string + - type: integer + repository_id: + anyOf: + - type: string + - type: integer + name: + type: string + description: + type: string + default_mode: + type: string + filter_rules: + type: array + items: + $ref: "#/$defs/rule" + manual_overrides: + type: object + additionalProperties: + $ref: "#/$defs/displayState" + rule: + type: object + additionalProperties: true + required: + - action + properties: + action: + $ref: "#/$defs/displayState" + name: + type: string + description: + type: string + match: + type: object + additionalProperties: true + stableKey: + type: string + minLength: 1 + maxLength: 240 + pattern: "^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$" diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py new file mode 100644 index 0000000..0a5469c --- /dev/null +++ b/tests/test_graph_explorer.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +import threading +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path + +from railiance_fabric.graph import build_graph +from railiance_fabric.graph_explorer import ( + fabric_graph_explorer_manifest, + fabric_graph_explorer_payload, +) +from railiance_fabric.registry import RegistryStore +from railiance_fabric.schema_validation import draft202012_validator +from railiance_fabric.server import RegistryHandler + + +def test_graph_explorer_manifest_and_payload_validate() -> None: + graph = build_graph([Path(".")]).to_export() + manifest = fabric_graph_explorer_manifest() + payload = fabric_graph_explorer_payload( + graph, + [ + { + "slug": "railiance-fabric", + "name": "Railiance Fabric", + "state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05", + }, + { + "slug": "registered-only", + "name": "Registered Only", + }, + ], + {"railiance-fabric"}, + ) + + _validate_schema("graph-explorer-manifest.schema.yaml", manifest) + _validate_schema("graph-explorer-payload.schema.yaml", payload) + + nodes = [element for element in payload["elements"] if "source" not in element["data"]] + edges = [element for element in payload["elements"] if "source" in element["data"]] + registered_only = next( + element for element in nodes if element["data"]["id"] == "repo:registered-only" + ) + + assert registered_only["data"]["reviewState"] == "candidate" + assert registered_only["data"]["unresolved"] is True + assert any(edge["data"]["edgeType"] == "declares" for edge in edges) + assert payload["metrics"]["registered_repo_count"] == 2 + + +def test_graph_explorer_payload_accepts_repo_scoping_shape() -> None: + payload = { + "apiVersion": "railiance.fabric/v1alpha1", + "kind": "GraphExplorerPayload", + "manifest_id": "repo-scoping.dependency-graph", + "mode": "full", + "profile": { + "id": 1, + "repository_id": 1, + "name": "Evidence Audit", + "description": "Show supporting evidence.", + "default_mode": "full", + "filter_rules": [ + {"name": "Blur facts", "action": "blur", "match": {"layer": "fact"}} + ], + "manual_overrides": {"feature:1": "show"}, + }, + "filter": { + "rules": [], + "manual_overrides": {}, + "orphaned_overrides": [], + }, + "elements": [ + { + "data": { + "id": "fact:document:README.md", + "stableKey": "fact:document:README.md", + "kind": "fact", + "layer": "fact", + "label": "README.md", + "displayState": "blur", + "reviewState": "accepted", + "freshnessState": "current", + "confidence": 0.8, + "visualSize": 50, + "sourceReferences": [{"path": "README.md", "kind": "document"}], + }, + "classes": "fact display-blur", + }, + { + "data": { + "id": "capability:1", + "stableKey": "capability:1", + "kind": "capability", + "layer": "capability", + "label": "Registry Capabilities", + "displayState": "show", + "reviewState": "accepted", + }, + }, + { + "data": { + "id": "edge:fact:capability", + "stableKey": "edge:fact:capability", + "kind": "edge", + "layer": "dependency", + "label": "supports", + "source": "fact:document:README.md", + "target": "capability:1", + "edgeType": "supports", + "dependencyType": "supports", + "strength": "strong", + "edgeWidth": 5, + "sameLayer": False, + "displayState": "show", + }, + "classes": "supports strong", + }, + ], + "hidden_elements": [], + } + + _validate_schema("graph-explorer-payload.schema.yaml", payload) + + +def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: + store = RegistryStore(tmp_path / "registry.sqlite3") + store.init_schema() + store.upsert_repository( + { + "slug": "railiance-fabric", + "name": "Railiance Fabric", + "state_hub_repo_id": "2c0de614-e468-4eb6-8157-470649ac8c05", + } + ) + store.upsert_repository({"slug": "registered-only", "name": "Registered Only"}) + store.add_snapshot( + "railiance-fabric", + { + "commit": "test-commit", + "generated_at": "2026-05-18T00:00:00Z", + "graph": build_graph([Path(".")]).to_export(), + }, + ) + + class Handler(RegistryHandler): + pass + + Handler.store = store + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + base_url = f"http://127.0.0.1:{server.server_port}" + with urllib.request.urlopen( + f"{base_url}/exports/graph-explorer/manifest", + timeout=5, + ) as response: + manifest = json.loads(response.read()) + with urllib.request.urlopen(f"{base_url}/exports/graph-explorer", timeout=5) as response: + payload = json.loads(response.read()) + + _validate_schema("graph-explorer-manifest.schema.yaml", manifest) + _validate_schema("graph-explorer-payload.schema.yaml", payload) + assert manifest["id"] == "railiance-fabric.registry-map" + assert payload["metrics"]["registered_only_repo_count"] == 1 + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _validate_schema(schema_name: str, payload: dict[str, object]) -> None: + validator = draft202012_validator(Path("schemas") / schema_name) + validator.validate(payload) diff --git a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md index cdb68f6..52ebd74 100644 --- a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md +++ b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md @@ -4,7 +4,7 @@ type: workplan title: "Interactive Fabric Map" domain: railiance repo: railiance-fabric -status: proposed +status: active owner: codex topic_slug: railiance planning_priority: high @@ -132,7 +132,7 @@ Candidate extraction shape: ```task id: RAIL-FAB-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "9844a9a7-f285-4523-a8d6-4ca62008ce08" ``` @@ -158,7 +158,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "cb0cc9f1-5225-47e5-8b47-a945c92e7168" ``` @@ -184,7 +184,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0008-T03 -status: todo +status: in_progress priority: high state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3" ```