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 export --format json
railiance-fabric export --format mermaid
railiance-fabric export --format graph-explorer
```
See `docs/discovery-queries.md` for command details.

View File

@@ -78,8 +78,19 @@ Export the graph as 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:
- `nodes`: service, capability, interface, dependency, and binding nodes
- `edges`: graph relationships such as `provides`, `exposes`,
`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
```
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,
which layers exist, which filter fields are available, and which modes the host
supports.

View File

@@ -12,6 +12,7 @@ from pathlib import Path
from .loader import declaration_files, load_yaml
from .graph import FabricGraph, build_graph
from .graph_explorer import fabric_graph_explorer_payload
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("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("--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 = registry.add_subparsers(dest="registry_command", required=True)
@@ -131,7 +132,12 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "export":
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
if args.command == "registry":

View File

@@ -267,13 +267,16 @@ def _escape_mermaid(value: str) -> str:
def _export_attributes(declaration: Declaration) -> dict[str, Any]:
spec = declaration.spec
base = _base_export_attributes(declaration)
if declaration.kind == "ServiceDeclaration":
return {
**base,
"provides_capabilities": list(spec.get("provides_capabilities", [])),
"exposes_interfaces": list(spec.get("exposes_interfaces", [])),
}
if declaration.kind == "CapabilityDeclaration":
return {
**base,
"capability_type": spec.get("capability_type", ""),
"service_id": spec.get("service_id", ""),
"interface_ids": list(spec.get("interface_ids", [])),
@@ -281,6 +284,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
}
if declaration.kind == "InterfaceDeclaration":
return {
**base,
"interface_type": spec.get("interface_type", ""),
"service_id": spec.get("service_id", ""),
"capability_ids": list(spec.get("capability_ids", [])),
@@ -291,6 +295,7 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
requires = spec.get("requires", {})
interface = spec.get("interface", {})
return {
**base,
"consumer_service_id": spec.get("consumer_service_id", ""),
"requires_capability_id": requires.get("capability_id", ""),
"requires_capability_type": requires.get("capability_type", ""),
@@ -300,9 +305,20 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]:
}
if declaration.kind == "BindingAssertion":
return {
**base,
"dependency_id": spec.get("dependency_id", ""),
"provider_capability_id": spec.get("provider_capability_id", ""),
"provider_interface_id": spec.get("provider_interface_id", ""),
"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,
"confidence": 0.45 if is_unresolved else 1.0,
"visualSize": 34 if layer == "binding" else 46 if is_unresolved else 50,
"ownership": "repo",
"ownership": str(attributes.get("owner") or "repo"),
"attributes": attributes,
"displayState": "show",
"visibilitySource": "default",
"visibilityReason": "default",
"sourceReferences": _source_references(node),
"deepLinks": _node_links(node_id),
"deepLinks": _node_links(node_id, attributes),
},
"classes": " ".join(
part
@@ -433,7 +433,7 @@ def _edge_strength(edge_type: str) -> str:
status = edge_type.split(":", 1)[1]
if status in {"missing", "disputed"}:
return "weak"
if status == "accepted":
if status in {"accepted", "exact"}:
return "strong"
return "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:"):
continue
status = edge_type.split(":", 1)[1]
if status in {"accepted", "candidate"}:
if status in {"accepted", "candidate", "exact", "compatible"}:
resolved.add(source)
if status in {"missing", "disputed"}:
unresolved.add(source)
@@ -470,6 +470,9 @@ def _unresolved_dependency_ids(
def _node_description(kind: str, attributes: object) -> str:
if not isinstance(attributes, dict):
return ""
description = str(attributes.get("description", ""))
if description:
return description
if kind == "CapabilityDeclaration":
return str(attributes.get("capability_type", ""))
if kind == "InterfaceDeclaration":
@@ -492,14 +495,21 @@ def _source_references(node: dict[str, Any]) -> list[dict[str, str]]:
attributes = node.get("attributes")
references: list[dict[str, str]] = []
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", []):
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 _node_links(node_id: str, attributes: dict[str, Any] | None = None) -> dict[str, str]:
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]:

View File

@@ -6,6 +6,7 @@ import urllib.request
from http.server import ThreadingHTTPServer
from pathlib import Path
from railiance_fabric.cli import main as cli_main
from railiance_fabric.graph import build_graph
from railiance_fabric.graph_explorer import (
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"]["unresolved"] is True
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"]["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:

View File

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