#!/usr/bin/env python3 """Config knowledge-graph edges (ATLAS-WP-0004-T03). Express the config-typed relationships declared on surface entries as a normalized, queryable edge list at `registry/indexes/graph.yaml`. config-atlas owns the *config semantics* of each edge (consumed_by, overrides, depends_on_secret, related_to); the State Hub owns topology/identity storage (ecosystem-boundaries §2.5, repo-boundary). This tool therefore *emits* a graph artifact the hub can ingest -- it does not build a separate graph store. Modes: python3 tools/config_graph.py # regenerate graph.yaml python3 tools/config_graph.py --check # fail if graph.yaml stale python3 tools/config_graph.py --surface # edges touching a surface id The emitted graph is queryable by surface id (both edge directions). """ from __future__ import annotations import sys try: import yaml except ImportError as exc: # pragma: no cover raise SystemExit(f"setup error: missing PyYAML ({exc}). pip install pyyaml") from effective_config import ROOT, SURFACES_DIR, load_entry GRAPH_PATH = ROOT / "registry" / "indexes" / "graph.yaml" EDGE_TYPES = ["consumed_by", "overrides", "depends_on_secret", "related_to"] def _secret_node(ref: str) -> str: return f"secret:{ref}" def build_graph() -> dict: entries = {p.stem: load_entry(p) for p in sorted(SURFACES_DIR.glob("*.md"))} nodes: dict[str, dict] = {} edges: list[dict] = [] def node(nid: str, ntype: str, **attrs): if nid not in nodes: nodes[nid] = {"id": nid, "type": ntype, **{k: v for k, v in attrs.items() if v}} for sid, e in entries.items(): node(sid, "surface", kind=e.get("kind"), owner=e.get("owner")) rel = e.get("relations", {}) or {} for svc in rel.get("consumed_by", []) or []: node(svc, "service") edges.append({"src": sid, "type": "consumed_by", "dst": svc}) for tgt in rel.get("overrides", []) or []: node(tgt, "surface") edges.append({"src": sid, "type": "overrides", "dst": tgt}) for sec in rel.get("depends_on_secret", []) or []: node(_secret_node(sec), "secret") edges.append({"src": sid, "type": "depends_on_secret", "dst": _secret_node(sec)}) for tgt in rel.get("related_to", []) or []: node(tgt, "surface") edges.append({"src": sid, "type": "related_to", "dst": tgt}) edges.sort(key=lambda x: (x["src"], x["type"], x["dst"])) return { "version": 1, "generated_by": "tools/config_graph.py", "note": "config-typed edges emitted from surface relations; ingest into the State Hub graph (it owns storage).", "summary": {"nodes": len(nodes), "edges": len(edges)}, "nodes": [nodes[k] for k in sorted(nodes)], "edges": edges, } def write_graph() -> dict: graph = build_graph() GRAPH_PATH.write_text(yaml.safe_dump(graph, sort_keys=False)) return graph def edges_for(surface_id: str, graph: dict) -> list[dict]: return [e for e in graph["edges"] if e["src"] == surface_id or e["dst"] == surface_id] def main(argv: list[str]) -> int: if argv and argv[0] == "--surface": if len(argv) != 2: print(__doc__) return 2 sid = argv[1] graph = yaml.safe_load(GRAPH_PATH.read_text()) if GRAPH_PATH.exists() else build_graph() hits = edges_for(sid, graph) if not hits: print(f"no edges for {sid}") return 0 print(f"edges touching {sid}:") for e in hits: arrow = "->" if e["src"] == sid else "<-" other = e["dst"] if e["src"] == sid else e["src"] print(f" {arrow} {e['type']:18} {other}") return 0 if argv and argv[0] == "--check": current = GRAPH_PATH.read_text() if GRAPH_PATH.exists() else "" expected = yaml.safe_dump(build_graph(), sort_keys=False) if current != expected: print("FAIL: registry/indexes/graph.yaml is stale — run `make graph`", file=sys.stderr) return 1 print("OK: graph.yaml up to date") return 0 g = write_graph() print(f"wrote {GRAPH_PATH.relative_to(ROOT)}: {g['summary']['nodes']} nodes, {g['summary']['edges']} edges") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))