diff --git a/README.md b/README.md index 9a8231a..0ff817a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/discovery-queries.md b/docs/discovery-queries.md index 32cbc28..016e347 100644 --- a/docs/discovery-queries.md +++ b/docs/discovery-queries.md @@ -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:`, 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`. diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index f5b6985..8ba69ea 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -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. diff --git a/railiance_fabric/cli.py b/railiance_fabric/cli.py index b1c264a..5b62e96 100644 --- a/railiance_fabric/cli.py +++ b/railiance_fabric/cli.py @@ -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": diff --git a/railiance_fabric/graph.py b/railiance_fabric/graph.py index 77d12d4..38bf41f 100644 --- a/railiance_fabric/graph.py +++ b/railiance_fabric/graph.py @@ -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 [], + } diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index 7020aa9..be166ea 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -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]: diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 0a5469c..02da7e0 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -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: diff --git a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md index 52ebd74..6470126 100644 --- a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md +++ b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md @@ -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" ```