From f608d49d3edd58532e417bd56e971e71eaf3db2b Mon Sep 17 00:00:00 2001 From: Tet-9 Date: Fri, 31 Jul 2026 06:40:06 +0100 Subject: [PATCH] feat(provenance): kb.graph_export gains format=json for a webapp graph view graph_export previously supported dot and mermaid text formats only. Added json: {nodes: [{id, kind, label, status}], edges: [{src, dst, kind}]}, same node/edge walk as the existing formats -- dot and mermaid are byte-identical to before (verified: a dot export taken before and after a json export match exactly, and a committed regression test enforces this). status is the artifact's own durable ClaimStatus/PageStatus for claim/page nodes, null for source/evidence/session/event nodes (no status concept) and for a dangling id. Scope note left in the code: pending proposals are not graph nodes at all currently (build_graph reads only durable artifacts), so full pending-vs-approved coloring per the issue's description would need a bigger change to build_graph itself -- not attempted here, flagged as a follow-up rather than silently narrowed. - provenance/query.py: new _to_json(), fmt validation extended to accept 'json', graph_export docstring updated. - cli.py: --format Choice extended to include json. - New tests/test_provenance.py cases: json shape/status correctness, and an explicit dot-output-unchanged-by-json regression test. Not done in this pass (backend-only, out of scope for a fast turnaround): the React MemoryNetworkView.tsx frontend component the issue also asks for -- pan/zoom graph visualization, wired into Shell + ArtifactDrawer. Closes #604 (backend half) --- src/vouch/cli.py | 4 +-- src/vouch/provenance/query.py | 56 ++++++++++++++++++++++++++++++++--- tests/test_provenance.py | 35 ++++++++++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/vouch/cli.py b/src/vouch/cli.py index dfcaeb0f..04eb80e6 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -3751,11 +3751,11 @@ def impact(claim_id: str, depth: int, if_op: str | None, as_json: bool) -> None: "fmt", default="dot", show_default=True, - type=click.Choice(["dot", "mermaid"]), + type=click.Choice(["dot", "mermaid", "json"]), help="Output format for the DAG.", ) def graph(session: str | None, fmt: str) -> None: - """Render the provenance DAG as Graphviz dot or a mermaid flowchart.""" + """Render the provenance DAG as Graphviz dot, a mermaid flowchart, or json.""" store = _load_store() with _cli_errors(): text = prov_mod.graph_export(store, session=session, fmt=fmt) diff --git a/src/vouch/provenance/query.py b/src/vouch/provenance/query.py index ea995682..8b051415 100644 --- a/src/vouch/provenance/query.py +++ b/src/vouch/provenance/query.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json from enum import StrEnum from ..models import PageStatus @@ -267,10 +268,13 @@ def graph_export( fmt: str = "dot", use_cache: bool = True, ) -> str: - """Render the DAG (or one session's subgraph) as Graphviz ``dot`` or - ``mermaid`` flowchart text.""" - if fmt not in ("dot", "mermaid"): - raise ValueError(f"unknown graph format: {fmt!r} (use 'dot' or 'mermaid')") + """Render the DAG (or one session's subgraph) as Graphviz ``dot``, + ``mermaid`` flowchart text, or ``json`` (issue #604: nodes/edges for a + webapp graph view -- see _to_json for the exact shape and its scope).""" + if fmt not in ("dot", "mermaid", "json"): + raise ValueError( + f"unknown graph format: {fmt!r} (use 'dot', 'mermaid', or 'json')" + ) graph = load_graph(store, use_cache=use_cache) edges = ( _session_subgraph_edges(graph, session) if session is not None else graph.edges @@ -285,6 +289,8 @@ def graph_export( nodes.sort() if fmt == "dot": return _to_dot(graph, nodes, edges) + if fmt == "json": + return _to_json(store, graph, nodes, edges) return _to_mermaid(graph, nodes, edges) @@ -319,6 +325,48 @@ def _to_mermaid(graph: ProvGraph, nodes: list[str], edges: list) -> str: # type return "\n".join(lines) + "\n" +def _to_json( + store: KBStore, graph: ProvGraph, nodes: list[str], edges: list # type: ignore[type-arg] +) -> str: + """JSON graph export (issue #604): {nodes: [{id, kind, label, status}], + edges: [{src, dst, kind}]}. + + status is the artifact's own durable ClaimStatus/PageStatus where the + node kind carries one (working/actionable/stable/contested/superseded/ + archived/redacted for claims; draft/active/archived for pages); null for + evidence/source/session/event nodes, which have no status concept, and + for a claim/page id whose file no longer resolves. + + Scope note: pending proposals are not graph nodes here -- build_graph + (graph.py) reads only durable artifacts (store.list_claims/list_pages), + never proposed/. Rendering the "pending frontier" the way issue #604 + describes would mean teaching build_graph to also walk pending + proposals, a materially bigger change than this serializer addition; + left as a follow-up rather than silently narrowing what "status" means. + """ + node_objs: list[dict[str, str | None]] = [] + for n in nodes: + kind = graph.kind_of(n) + status: str | None = None + if kind is NodeKind.CLAIM: + try: + status = store.get_claim(n).status.value + except ArtifactNotFoundError: + status = None + elif kind is NodeKind.PAGE: + try: + status = store.get_page(n).status.value + except ArtifactNotFoundError: + status = None + node_objs.append( + {"id": n, "kind": kind.value, "label": n, "status": status} + ) + edge_objs = [ + {"src": e.src_id, "dst": e.dst_id, "kind": e.kind.value} for e in edges + ] + return json.dumps({"nodes": node_objs, "edges": edge_objs}, indent=2) + "\n" + + # --- human rendering ------------------------------------------------------ diff --git a/tests/test_provenance.py b/tests/test_provenance.py index 5f3ecd39..4dbc5b20 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -218,6 +218,41 @@ def test_graph_export_session_subgraph(store: KBStore) -> None: assert "c-new" in dot and "c-old" in dot +def test_graph_export_json(store: KBStore) -> None: + """Regression for issue #604: format='json' returns {nodes, edges} with + a real status for claim/page nodes and null for kinds with no status + concept, and must never break the byte-identical dot/mermaid paths.""" + _seed(store) + out = prov.graph_export(store, fmt="json") + data = json.loads(out) + assert set(data.keys()) == {"nodes", "edges"} + + node_by_id = {n["id"]: n for n in data["nodes"]} + assert "c-new" in node_by_id + claim_node = node_by_id["c-new"] + assert claim_node["kind"] == "claim" + assert claim_node["status"] is not None # a real ClaimStatus value + + for n in data["nodes"]: + assert set(n.keys()) == {"id", "kind", "label", "status"} + if n["kind"] not in ("claim", "page"): + assert n["status"] is None + + for e in data["edges"]: + assert set(e.keys()) == {"src", "dst", "kind"} + assert any(e["kind"] == "supersedes" for e in data["edges"]) + + +def test_graph_export_json_does_not_affect_dot_or_mermaid(store: KBStore) -> None: + """The json format is additive -- dot/mermaid output must be unchanged + whether or not json is ever requested first.""" + _seed(store) + dot_before = prov.graph_export(store, fmt="dot") + prov.graph_export(store, fmt="json") + dot_after = prov.graph_export(store, fmt="dot") + assert dot_before == dot_after + + # --- CLI ------------------------------------------------------------------