generated from coulomb/repo-seed
Complete graph explorer projection
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 [],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user