Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 52 additions & 4 deletions src/vouch/provenance/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import json
from enum import StrEnum

from ..models import PageStatus
Expand Down Expand Up @@ -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
Expand All @@ -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)


Expand Down Expand Up @@ -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 ------------------------------------------------------


Expand Down
35 changes: 35 additions & 0 deletions tests/test_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------------


Expand Down
Loading