Complete graph explorer projection

This commit is contained in:
2026-05-18 17:04:17 +02:00
parent 91f329f878
commit d2056c9046
8 changed files with 77 additions and 11 deletions

View File

@@ -35,6 +35,7 @@ railiance-fabric unresolved
railiance-fabric blast-radius openbao-kv-v2-mount railiance-fabric blast-radius openbao-kv-v2-mount
railiance-fabric export --format json railiance-fabric export --format json
railiance-fabric export --format mermaid railiance-fabric export --format mermaid
railiance-fabric export --format graph-explorer
``` ```
See `docs/discovery-queries.md` for command details. See `docs/discovery-queries.md` for command details.

View File

@@ -78,8 +78,19 @@ Export the graph as Mermaid:
railiance-fabric export --format mermaid railiance-fabric export --format mermaid
``` ```
Export the graph as the manifest-compatible graph explorer payload:
```bash
railiance-fabric export --format graph-explorer
```
The JSON export has two top-level arrays: The JSON export has two top-level arrays:
- `nodes`: service, capability, interface, dependency, and binding nodes - `nodes`: service, capability, interface, dependency, and binding nodes
- `edges`: graph relationships such as `provides`, `exposes`, - `edges`: graph relationships such as `provides`, `exposes`,
`available_via`, `consumes`, `binds:<status>`, and `uses_interface` `available_via`, `consumes`, `binds:<status>`, and `uses_interface`
The graph explorer payload wraps those nodes and edges as Cytoscape-compatible
elements with stable keys, layers, display state, visual facets, source
references, and deep links. The registry service exposes the same projection at
`GET /exports/graph-explorer`.

View File

@@ -23,6 +23,12 @@ GET /exports/graph-explorer/manifest
GET /exports/graph-explorer GET /exports/graph-explorer
``` ```
The local CLI can emit the same payload for repo-local inspection:
```bash
railiance-fabric export --format graph-explorer
```
The manifest tells a graph shell where to load data, which fields are stable, 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 which layers exist, which filter fields are available, and which modes the host
supports. supports.

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from .loader import declaration_files, load_yaml from .loader import declaration_files, load_yaml
from .graph import FabricGraph, build_graph from .graph import FabricGraph, build_graph
from .graph_explorer import fabric_graph_explorer_payload
from .validation import validate_roots from .validation import validate_roots
@@ -57,9 +58,9 @@ def build_parser() -> argparse.ArgumentParser:
blast.add_argument("interface", help="Interface type or interface declaration id.") blast.add_argument("interface", help="Interface type or interface declaration id.")
blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) blast.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export = sub.add_parser("export", help="Export graph as JSON or Mermaid.") export = sub.add_parser("export", help="Export graph as JSON, Mermaid, or graph-explorer payload.")
export.add_argument("paths", nargs="*", type=Path, default=[Path(".")]) export.add_argument("paths", nargs="*", type=Path, default=[Path(".")])
export.add_argument("--format", choices=["json", "mermaid"], default="json") export.add_argument("--format", choices=["json", "mermaid", "graph-explorer"], default="json")
registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.") registry = sub.add_parser("registry", help="Feed a running Railiance Fabric registry service.")
registry_sub = registry.add_subparsers(dest="registry_command", required=True) registry_sub = registry.add_subparsers(dest="registry_command", required=True)
@@ -131,7 +132,12 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "export": if args.command == "export":
graph = _load_graph_or_exit(args.paths) graph = _load_graph_or_exit(args.paths)
print(graph.to_mermaid() if args.format == "mermaid" else graph.to_json()) if args.format == "mermaid":
print(graph.to_mermaid())
elif args.format == "graph-explorer":
print(json.dumps(fabric_graph_explorer_payload(graph.to_export()), indent=2, sort_keys=True))
else:
print(graph.to_json())
return 0 return 0
if args.command == "registry": if args.command == "registry":

View File

@@ -267,13 +267,16 @@ def _escape_mermaid(value: str) -> str:
def _export_attributes(declaration: Declaration) -> dict[str, Any]: def _export_attributes(declaration: Declaration) -> dict[str, Any]:
spec = declaration.spec spec = declaration.spec
base = _base_export_attributes(declaration)
if declaration.kind == "ServiceDeclaration": if declaration.kind == "ServiceDeclaration":
return { return {
**base,
"provides_capabilities": list(spec.get("provides_capabilities", [])), "provides_capabilities": list(spec.get("provides_capabilities", [])),
"exposes_interfaces": list(spec.get("exposes_interfaces", [])), "exposes_interfaces": list(spec.get("exposes_interfaces", [])),
} }
if declaration.kind == "CapabilityDeclaration": if declaration.kind == "CapabilityDeclaration":
return { return {
**base,
"capability_type": spec.get("capability_type", ""), "capability_type": spec.get("capability_type", ""),
"service_id": spec.get("service_id", ""), "service_id": spec.get("service_id", ""),
"interface_ids": list(spec.get("interface_ids", [])), "interface_ids": list(spec.get("interface_ids", [])),
@@ -281,6 +284,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
} }
if declaration.kind == "InterfaceDeclaration": if declaration.kind == "InterfaceDeclaration":
return { return {
**base,
"interface_type": spec.get("interface_type", ""), "interface_type": spec.get("interface_type", ""),
"service_id": spec.get("service_id", ""), "service_id": spec.get("service_id", ""),
"capability_ids": list(spec.get("capability_ids", [])), "capability_ids": list(spec.get("capability_ids", [])),
@@ -291,6 +295,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
requires = spec.get("requires", {}) requires = spec.get("requires", {})
interface = spec.get("interface", {}) interface = spec.get("interface", {})
return { return {
**base,
"consumer_service_id": spec.get("consumer_service_id", ""), "consumer_service_id": spec.get("consumer_service_id", ""),
"requires_capability_id": requires.get("capability_id", ""), "requires_capability_id": requires.get("capability_id", ""),
"requires_capability_type": requires.get("capability_type", ""), "requires_capability_type": requires.get("capability_type", ""),
@@ -300,9 +305,20 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
} }
if declaration.kind == "BindingAssertion": if declaration.kind == "BindingAssertion":
return { return {
**base,
"dependency_id": spec.get("dependency_id", ""), "dependency_id": spec.get("dependency_id", ""),
"provider_capability_id": spec.get("provider_capability_id", ""), "provider_capability_id": spec.get("provider_capability_id", ""),
"provider_interface_id": spec.get("provider_interface_id", ""), "provider_interface_id": spec.get("provider_interface_id", ""),
"status": spec.get("status", ""), "status": spec.get("status", ""),
} }
return {} return base
def _base_export_attributes(declaration: Declaration) -> dict[str, Any]:
source_links = declaration.metadata.get("source_links", [])
return {
"owner": declaration.metadata.get("owner", ""),
"description": declaration.spec.get("description", ""),
"source_path": str(declaration.path),
"source_links": source_links if isinstance(source_links, list) else [],
}

View File

@@ -282,13 +282,13 @@ def fabric_graph_explorer_payload(
"unresolved": is_unresolved, "unresolved": is_unresolved,
"confidence": 0.45 if is_unresolved else 1.0, "confidence": 0.45 if is_unresolved else 1.0,
"visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50, "visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50,
"ownership": "repo", "ownership": str(attributes.get("owner") or "repo"),
"attributes": attributes, "attributes": attributes,
"displayState": "show", "displayState": "show",
"visibilitySource": "default", "visibilitySource": "default",
"visibilityReason": "default", "visibilityReason": "default",
"sourceReferences": _source_references(node), "sourceReferences": _source_references(node),
"deepLinks": _node_links(node_id), "deepLinks": _node_links(node_id, attributes),
}, },
"classes": " ".join( "classes": " ".join(
part part
@@ -433,7 +433,7 @@ def _edge_strength(edge_type: str) -> str:
status = edge_type.split(":", 1)[1] status = edge_type.split(":", 1)[1]
if status in {"missing", "disputed"}: if status in {"missing", "disputed"}:
return "weak" return "weak"
if status == "accepted": if status in {"accepted", "exact"}:
return "strong" return "strong"
return "medium" return "medium"
return _EDGE_STRENGTH.get(edge_type, "medium") return _EDGE_STRENGTH.get(edge_type, "medium")
@@ -460,7 +460,7 @@ def _unresolved_dependency_ids(
if source not in dependency_ids or not edge_type.startswith("binds:"): if source not in dependency_ids or not edge_type.startswith("binds:"):
continue continue
status = edge_type.split(":", 1)[1] status = edge_type.split(":", 1)[1]
if status in {"accepted", "candidate"}: if status in {"accepted", "candidate", "exact", "compatible"}:
resolved.add(source) resolved.add(source)
if status in {"missing", "disputed"}: if status in {"missing", "disputed"}:
unresolved.add(source) unresolved.add(source)
@@ -470,6 +470,9 @@ def _unresolved_dependency_ids(
def _node_description(kind: str, attributes: object) -> str: def _node_description(kind: str, attributes: object) -> str:
if not isinstance(attributes, dict): if not isinstance(attributes, dict):
return "" return ""
description = str(attributes.get("description", ""))
if description:
return description
if kind == "CapabilityDeclaration": if kind == "CapabilityDeclaration":
return str(attributes.get("capability_type", "")) return str(attributes.get("capability_type", ""))
if kind == "InterfaceDeclaration": if kind == "InterfaceDeclaration":
@@ -492,14 +495,21 @@ def _source_references(node: dict[str, Any]) -> list[dict[str, str]]:
attributes = node.get("attributes") attributes = node.get("attributes")
references: list[dict[str, str]] = [] references: list[dict[str, str]] = []
if isinstance(attributes, dict): if isinstance(attributes, dict):
source_path = str(attributes.get("source_path") or "")
if source_path:
references.append({"label": "Declaration", "path": source_path})
for source in attributes.get("source_links", []): for source in attributes.get("source_links", []):
if isinstance(source, dict): if isinstance(source, dict):
references.append({key: str(value) for key, value in source.items()}) references.append({key: str(value) for key, value in source.items()})
return references return references
def _node_links(node_id: str) -> dict[str, str]: def _node_links(node_id: str, attributes: dict[str, Any] | None = None) -> dict[str, str]:
return {"registry": f"/graph/nodes/{node_id}"} links = {"registry": f"/graph/nodes/{node_id}"}
source_path = str((attributes or {}).get("source_path") or "")
if source_path:
links["sourceFile"] = source_path
return links
def _repository_links(repository: dict[str, Any]) -> dict[str, str]: def _repository_links(repository: dict[str, Any]) -> dict[str, str]:

View File

@@ -6,6 +6,7 @@ import urllib.request
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from railiance_fabric.cli import main as cli_main
from railiance_fabric.graph import build_graph from railiance_fabric.graph import build_graph
from railiance_fabric.graph_explorer import ( from railiance_fabric.graph_explorer import (
fabric_graph_explorer_manifest, fabric_graph_explorer_manifest,
@@ -47,7 +48,22 @@ def test_graph_explorer_manifest_and_payload_validate() -> None:
assert registered_only["data"]["reviewState"] == "candidate" assert registered_only["data"]["reviewState"] == "candidate"
assert registered_only["data"]["unresolved"] is True assert registered_only["data"]["unresolved"] is True
assert any(edge["data"]["edgeType"] == "declares" for edge in edges) assert any(edge["data"]["edgeType"] == "declares" for edge in edges)
assert any(node["data"]["sourceReferences"] for node in nodes if node["data"]["kind"] != "Repository")
assert payload["metrics"]["registered_repo_count"] == 2 assert payload["metrics"]["registered_repo_count"] == 2
assert payload["metrics"]["unresolved_count"] == 0
def test_cli_exports_graph_explorer_payload(capsys) -> None:
assert cli_main(["export", "--format", "graph-explorer"]) == 0
payload = json.loads(capsys.readouterr().out)
_validate_schema("graph-explorer-payload.schema.yaml", payload)
assert payload["kind"] == "GraphExplorerPayload"
assert any(
element["data"].get("sourceReferences")
for element in payload["elements"]
if "source" not in element["data"]
)
def test_graph_explorer_payload_accepts_repo_scoping_shape() -> None: def test_graph_explorer_payload_accepts_repo_scoping_shape() -> None:

View File

@@ -184,7 +184,7 @@ Acceptance notes:
```task ```task
id: RAIL-FAB-WP-0008-T03 id: RAIL-FAB-WP-0008-T03
status: in_progress status: done
priority: high priority: high
state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3" state_hub_task_id: "ecd967fc-05ed-4cda-bca2-cf74e26e60b3"
``` ```