From 787b5f72626f86f09f9b2c02d39e3b0d0bcb65b8 Mon Sep 17 00:00:00 2001 From: hidef Date: Tue, 25 Aug 2026 13:41:10 +0900 Subject: [PATCH] feat(serve): report build provenance in graph_stats graph_stats is the only place an MCP client can ask what it is querying, and it could not answer how old that graph is. Over MCP there is no fallback: the client cannot stat graph.json, so an agent had no way to tell a graph built minutes ago from one built last month, and answered questions about current code from either. The commit was already on disk and already consumed by the HTML report and the CLI, but json_graph.node_link_graph copies only data["graph"] onto G.graph and drops every other top-level key, so serve.py never saw it. _load_graph now lifts the provenance keys the same way the adjacent _logical_directed flag is stashed, under private names so a graph loaded on the read path cannot round-trip them into a nested data["graph"]. Every existing graph therefore gains the commit line with no rebuild. to_json also records a top-level built_at UTC stamp. It answers a different question than the commit -- which revision this describes, versus when this file was written -- and only the latter measures staleness: a graph can be a week old while sitting on a commit that is still HEAD. Consistent with that, the stamp is deliberately not preserved across a cluster-only rewrite the way #2534 preserves the commit, since cluster does not redo the extraction but does rewrite the file. It is always written rather than conditional, because a clock read cannot fail the way _git_head can outside a repo. Both graph comparators now pop built_at alongside built_at_commit. A field that changes on every write would otherwise make "did the graph change?" answer yes forever and rewrite graph.json and GRAPH_REPORT.md on every incremental run. For the same reason the stamp is injectable: the two round-trip tests assert byte-identity across two writes, which no wall-clock field can satisfy unless the caller can pin it, so they now pin the stamp exactly as they already pinned the commit. graph_stats output is asserted by full-string equality rather than substring, including the complete 40-char SHA -- a truncating regression would slip past an `in` check, which is how an earlier provenance change of mine shipped a wrong path shape behind a green endswith() assertion. --- CHANGELOG.md | 4 ++ graphify/export.py | 29 ++++++++++- graphify/serve.py | 38 ++++++++++++--- graphify/watch.py | 6 +++ tests/test_export.py | 62 +++++++++++++++++++++-- tests/test_serve_http.py | 103 +++++++++++++++++++++++++++++++++++++++ tests/test_watch.py | 52 ++++++++++++++++++++ 7 files changed, 280 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2509900116..18ebd2edc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feature: `graph_stats` now reports build provenance — `Built from commit:` and `Built at:` — so an agent querying over MCP can tell how stale the graph it is answering from actually is. `built_at_commit` was already written into `graph.json` and consumed by the HTML report and the CLI, but `node_link_graph` keeps only `data["graph"]` and dropped it on load, so the MCP surface never saw it; every existing graph gains the commit line without a rebuild. `graph.json` also records a new top-level `built_at` UTC stamp (`YYYY-MM-DDTHH:MM:SSZ`), which answers a different question than the commit — when the file was written, not which revision it describes — and is what a consumer that cannot stat the file needs in order to judge freshness. The stamp is injectable like the commit so byte-identity round-trips stay assertable, and it is excluded from the graph-comparison helpers, since a field that changes on every write would otherwise make every incremental rebuild look like a change and rewrite `graph.json` and `GRAPH_REPORT.md` forever. + ## 0.9.49 (2026-08-24) - Feature: `graphify merge-graphs` now links a type declaration that two repos share — same fully-qualified namespace and name, from different repos — with a `same_type_as` edge, so a shared contract type is navigable across the repo boundary; two unrelated types that merely share a short name are not linked (#3007, thanks @durmazoguzhan). diff --git a/graphify/export.py b/graphify/export.py index 69136befce..49865274f2 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -9,7 +9,7 @@ import shutil import sys from collections import Counter -from datetime import date +from datetime import date, datetime, timezone from pathlib import Path import networkx as nx from networkx.readwrite import json_graph @@ -214,6 +214,17 @@ def _git_head(cwd: "str | Path | None" = None) -> str | None: return None +def _utc_now_stamp() -> str: + """Current UTC time as ``YYYY-MM-DDTHH:MM:SSZ``. + + Second precision, fixed width: the stamp answers "how stale is this graph", + where sub-second resolution is noise and a ragged field makes graph.json + diffs harder to read. UTC because a graph is routinely built on one machine + and queried from another. + """ + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # Sentinel: an existing graph.json is present and non-empty but cannot be parsed # into a node count (corrupt, mid-write, or structurally wrong). The caller must # fail CLOSED on this — the same way to_json's #479 guard refuses to overwrite @@ -263,7 +274,7 @@ def existing_graph_node_count(path: "str | Path"): return len(nodes) if isinstance(nodes, list) else MALFORMED_GRAPH -def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool: +def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, built_at: str | None = None, community_labels: dict[int, str] | None = None) -> bool: # Safety check: refuse to silently shrink an existing graph (#479) existing_path = Path(output_path) if not force and existing_path.exists(): @@ -405,6 +416,20 @@ def _canonical(item: dict, lead: tuple[str, ...]) -> dict: commit = built_at_commit if built_at_commit is not None else _git_head(Path(output_path).resolve().parent) if commit: data["built_at_commit"] = commit + # When this graph.json was WRITTEN, as distinct from which revision it + # describes (built_at_commit). Consumers that can see the graph but not its + # file mtime — the MCP tools most of all — have no other way to judge how + # stale an answer is. Always present: unlike the commit, a clock reading + # never fails, so there is no "outside a repo" case to omit. + # + # Injectable for the same reason built_at_commit is: the round-trip tests + # assert graph.json is byte-identical across two writes, which no + # wall-clock field can satisfy unless the caller can pin it. + # + # Deliberately NOT preserved across a cluster-only rewrite the way #2534 + # preserves the commit. The commit describes the extraction, which cluster + # does not redo; the stamp describes the file, which cluster does rewrite. + data["built_at"] = built_at if built_at is not None else _utc_now_stamp() from graphify.paths import write_json_atomic # Atomic write: a crash/ENOSPC mid-write must not truncate a good graph.json. write_json_atomic(output_path, data, indent=2) diff --git a/graphify/serve.py b/graphify/serve.py index 58c0925b4e..7fece51f74 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -55,6 +55,15 @@ def _load_graph(graph_path: str) -> nx.Graph: except TypeError: G = json_graph.node_link_graph(data) G.graph["_logical_directed"] = _logical_directed + # node_link_graph copies only data["graph"] onto G.graph and drops every + # other top-level key, so build provenance — which export.to_json writes + # at the top level — does not survive the load. Stash it the same way the + # logical-direction flag above is stashed, under private names so a graph + # loaded here can never round-trip these into a nested data["graph"]. + for _prov in ("built_at", "built_at_commit"): + _val = data.get(_prov) + if isinstance(_val, str) and _val.strip(): + G.graph["_" + _prov] = _val.strip() # Attach the work-memory overlay (derived sidecar next to graph.json) so # the query/MCP read surface can annotate NODE lines display-only. Empty # when no sidecar exists, leaving un-annotated output byte-identical. @@ -1858,14 +1867,27 @@ def _tool_god_nodes(arguments: dict) -> str: def _tool_graph_stats(_: dict) -> str: confs = [d.get("confidence", "EXTRACTED") for _, _, d in G.edges(data=True)] total = len(confs) or 1 - return ( - f"Nodes: {G.number_of_nodes()}\n" - f"Edges: {G.number_of_edges()}\n" - f"Communities: {len(communities)}\n" - f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%\n" - f"INFERRED: {round(confs.count('INFERRED')/total*100)}%\n" - f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%\n" - ) + lines = [ + f"Nodes: {G.number_of_nodes()}", + f"Edges: {G.number_of_edges()}", + f"Communities: {len(communities)}", + f"EXTRACTED: {round(confs.count('EXTRACTED')/total*100)}%", + f"INFERRED: {round(confs.count('INFERRED')/total*100)}%", + f"AMBIGUOUS: {round(confs.count('AMBIGUOUS')/total*100)}%", + ] + # Provenance, when the graph carries it. An agent reading these stats + # over MCP cannot stat the file, so without these lines it has no way to + # tell a graph built minutes ago from one built last month — and it will + # answer questions about today's code from either. Appended rather than + # prepended, and omitted entirely when absent, so a pre-provenance graph + # renders exactly as it did before. + built_at = G.graph.get("_built_at") + if built_at: + lines.append(f"Built at: {built_at}") + commit = G.graph.get("_built_at_commit") + if commit: + lines.append(f"Built from commit: {commit}") + return "\n".join(lines) + "\n" def _tool_shortest_path(arguments: dict) -> str: return _shortest_path_text(G, arguments) diff --git a/graphify/watch.py b/graphify/watch.py index 8ad02c4df8..25db9073bb 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -806,6 +806,11 @@ def _node_community_map(graph_data: dict) -> dict[str, int]: def _canonical_graph_for_compare(graph_data: dict) -> dict: canonical = dict(graph_data) canonical.pop("built_at_commit", None) + # Provenance is not content. The write stamp changes on every single write + # by construction, so leaving it in would make "did the graph change?" + # answer yes forever — rewriting graph.json and GRAPH_REPORT.md on every + # incremental run and destroying the no-op skip this comparison exists for. + canonical.pop("built_at", None) # A missing "directed" key means the same thing as "directed": false # everywhere else in the codebase (#2342's --no-cluster path only started # writing the key once it began inheriting it from the existing graph). @@ -825,6 +830,7 @@ def _canonical_graph_for_compare(graph_data: dict) -> dict: def _canonical_topology_for_compare(graph_data: dict) -> dict: canonical = dict(graph_data) canonical.pop("built_at_commit", None) + canonical.pop("built_at", None) nodes = canonical.get("nodes") if isinstance(nodes, list): diff --git a/tests/test_export.py b/tests/test_export.py index d957b87957..b361c7d51b 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -9,6 +9,11 @@ FIXTURES = Path(__file__).parent / "fixtures" +# Byte-identity across two writes is only assertable when EVERY provenance field +# is pinned. built_at_commit was already pinned; the write stamp needs the same +# treatment or the two writes differ whenever they straddle a second boundary. +_PINNED_STAMP = "1970-01-01T00:00:00Z" + def make_graph(): return build_from_json(json.loads((FIXTURES / "extraction.json").read_text())) @@ -100,12 +105,12 @@ def test_to_json_field_order_stable_across_read_rebuild(tmp_path): first = tmp_path / "first.json" to_json(build_from_json(extraction), communities, str(first), - built_at_commit="fixed", force=True) + built_at_commit="fixed", built_at=_PINNED_STAMP, force=True) reread = json.loads(first.read_text()) second = tmp_path / "second.json" to_json(build_from_json(reread), communities, str(second), - built_at_commit="fixed", force=True) + built_at_commit="fixed", built_at=_PINNED_STAMP, force=True) # Byte-identity is the strongest statement of "no cosmetic churn". assert first.read_bytes() == second.read_bytes() @@ -140,11 +145,11 @@ def test_to_json_field_order_stable_with_non_ascii_labels(tmp_path): communities = {0: ["a_cafe", "b_ja"]} first = tmp_path / "first.json" to_json(build_from_json(extraction), communities, str(first), - built_at_commit="fixed", force=True) + built_at_commit="fixed", built_at=_PINNED_STAMP, force=True) reread = json.loads(first.read_text()) second = tmp_path / "second.json" to_json(build_from_json(reread), communities, str(second), - built_at_commit="fixed", force=True) + built_at_commit="fixed", built_at=_PINNED_STAMP, force=True) assert first.read_bytes() == second.read_bytes(), "non-ASCII round-trip churned field order" # the reorder preserves the non-ASCII value labels = {n["id"]: n.get("label") for n in json.loads(first.read_text())["nodes"]} @@ -1087,3 +1092,52 @@ def test_hyperedge_convex_hull_js_is_geometrically_sound(): proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60) assert proc.returncode == 0, proc.stderr assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}" + + +def test_to_json_stamps_built_at_in_exact_utc_format(tmp_path): + """An unpinned write stamps the wall clock as YYYY-MM-DDTHH:MM:SSZ. + + The format is asserted with a full-string regex and a strict strptime, not + with a loose `endswith("Z")` — a weak predicate here is what let a previous + provenance change ship the wrong string shape while its test stayed green. + """ + from datetime import datetime, timedelta, timezone + + out = tmp_path / "graph.json" + to_json(build_from_json({"nodes": [{"id": "a", "label": "A", "file_type": "code", + "source_file": "a.py"}], "edges": []}), + {0: ["a"]}, str(out), built_at_commit="fixed", force=True) + + stamp = json.loads(out.read_text())["built_at"] + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", stamp), stamp + parsed = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + # UTC, not local time: a stamp built from a naive local clock would drift by + # the machine's offset, which is invisible in CI running at UTC+0. + assert abs(datetime.now(timezone.utc) - parsed) < timedelta(minutes=5), stamp + + +def test_to_json_built_at_is_written_verbatim_when_pinned(tmp_path): + """A caller-supplied stamp lands byte-for-byte — no reformatting, no clock.""" + out = tmp_path / "graph.json" + to_json(build_from_json({"nodes": [{"id": "a", "label": "A", "file_type": "code", + "source_file": "a.py"}], "edges": []}), + {0: ["a"]}, str(out), built_at_commit="fixed", + built_at="2020-01-02T03:04:05Z", force=True) + assert json.loads(out.read_text())["built_at"] == "2020-01-02T03:04:05Z" + + +def test_to_json_built_at_is_present_even_outside_a_git_repo(tmp_path, monkeypatch): + """built_at_commit can legitimately be absent (no repo); the stamp cannot. + + Guards the asymmetry: the commit is written under `if commit:`, so copying + that shape for the stamp would silently drop it whenever a clock read + returned something falsy-looking. + """ + monkeypatch.setattr("graphify.export._git_head", lambda cwd=None: None) + out = tmp_path / "graph.json" + to_json(build_from_json({"nodes": [{"id": "a", "label": "A", "file_type": "code", + "source_file": "a.py"}], "edges": []}), + {0: ["a"]}, str(out), force=True) + data = json.loads(out.read_text()) + assert "built_at_commit" not in data + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", data["built_at"]) diff --git a/tests/test_serve_http.py b/tests/test_serve_http.py index 7893a0f4e3..a717386f65 100644 --- a/tests/test_serve_http.py +++ b/tests/test_serve_http.py @@ -361,3 +361,106 @@ def test_cli_api_key_from_env(monkeypatch): monkeypatch.setattr(serve_mod, "serve_http", lambda gp, **k: captured.update(**k)) serve_mod._main(["g.json", "--transport", "http"]) assert captured["api_key"] == "from-env" + + +# --- graph_stats build provenance (MCP consumers cannot stat the file) ------- + +_STATS_WITHOUT_PROVENANCE = ( + "Nodes: 2\n" + "Edges: 1\n" + "Communities: 1\n" + "EXTRACTED: 100%\n" + "INFERRED: 0%\n" + "AMBIGUOUS: 0%\n" +) + + +def _graph_file_with(tmp_path: Path, name: str, **extra) -> str: + p = tmp_path / name + p.write_text(json.dumps({**SAMPLE_GRAPH, **extra}), encoding="utf-8") + return str(p) + + +def test_graph_stats_output_is_unchanged_for_a_graph_without_provenance(tmp_path): + """Backward compatibility, pinned as a full-string equality rather than a + substring check: a graph built before these fields existed must render + exactly the six lines it always did, with no placeholder rows.""" + app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool(client, headers, "graph_stats", {}, rid=2) + assert out == _STATS_WITHOUT_PROVENANCE + + +def test_graph_stats_reports_build_stamp_and_commit(tmp_path): + """Both provenance fields reach the tool output verbatim and in full. + + The commit is asserted as the whole 40-char SHA, not a prefix: an agent that + wants to check out the exact revision the graph describes needs all of it, + and a truncating regression would pass any `in`-style assertion. + """ + sha = "d6ff04064219c45e6cb1aeda8e66c292b6650307" + graph = _graph_file_with( + tmp_path, "stamped.json", + built_at="2026-08-25T09:15:42Z", built_at_commit=sha, + ) + app = serve_mod._build_http_app(graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool(client, headers, "graph_stats", {}, rid=2) + assert out == ( + _STATS_WITHOUT_PROVENANCE + + "Built at: 2026-08-25T09:15:42Z\n" + + f"Built from commit: {sha}\n" + ) + + +def test_graph_stats_reports_commit_alone_when_there_is_no_stamp(tmp_path): + """Every graph written by a released version already carries the commit but + not the stamp, so the commit line must not be gated on the stamp.""" + sha = "0d8e8757fce5efc39ce2013d90de906765d48841" + graph = _graph_file_with(tmp_path, "commit-only.json", built_at_commit=sha) + app = serve_mod._build_http_app(graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool(client, headers, "graph_stats", {}, rid=2) + assert out == _STATS_WITHOUT_PROVENANCE + f"Built from commit: {sha}\n" + + +@pytest.mark.parametrize("junk", [None, 0, 12345, "", " ", [], {"a": 1}, True]) +def test_graph_stats_ignores_non_string_provenance(tmp_path, junk): + """A hand-edited or foreign-tooling graph must not turn a stats call into a + crash or a line reading "Built at: None".""" + graph = _graph_file_with(tmp_path, f"junk-{abs(hash(repr(junk)))}.json", + built_at=junk, built_at_commit=junk) + app = serve_mod._build_http_app(graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + out = _call_tool(client, headers, "graph_stats", {}, rid=2) + assert out == _STATS_WITHOUT_PROVENANCE + + +def test_graph_stats_provenance_follows_project_path(tmp_path): + """Provenance is per-graph, so it must be read from the graph the call + selected — not leaked from the server's default graph.""" + sha = "1111111111111111111111111111111111111111" + default_graph = _graph_file(tmp_path) # no provenance + proj = tmp_path / "proj" + (proj / "graphify-out").mkdir(parents=True) + (proj / "graphify-out" / "graph.json").write_text( + json.dumps({**SAMPLE_GRAPH, "built_at": "2026-01-01T00:00:00Z", + "built_at_commit": sha}), + encoding="utf-8", + ) + app = serve_mod._build_http_app(default_graph, json_response=True) + with _client(app) as client: + headers = _init_session(client) + scoped = _call_tool(client, headers, "graph_stats", + {"project_path": str(proj)}, rid=2) + default = _call_tool(client, headers, "graph_stats", {}, rid=3) + assert scoped == ( + _STATS_WITHOUT_PROVENANCE + + "Built at: 2026-01-01T00:00:00Z\n" + + f"Built from commit: {sha}\n" + ) + assert default == _STATS_WITHOUT_PROVENANCE diff --git a/tests/test_watch.py b/tests/test_watch.py index 25d0cd9673..0f0dc888dc 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -3769,3 +3769,55 @@ def test_subfolder_marker_incremental_matches_cold_build(tmp_path, monkeypatch): f"incremental vs cold id drift: only-incremental={sorted(incremental_ids - cold_ids)[:5]}, " f"only-cold={sorted(cold_ids - incremental_ids)[:5]}" ) + + +# --- build stamp is provenance, not content --------------------------------- + +def _stamped(stamp: str, *, nodes=None) -> dict: + return { + "directed": False, + "nodes": nodes if nodes is not None else [{"id": "a", "label": "A"}], + "links": [], + "built_at": stamp, + "built_at_commit": "deadbeef", + } + + +def test_graph_comparators_ignore_the_build_stamp(): + """Two graphs identical but for `built_at` must compare EQUAL. + + The stamp changes on every write by construction. If the comparators saw it, + "did the graph change?" would answer yes forever: every incremental run would + rewrite graph.json and GRAPH_REPORT.md, destroying the no-op skip that + _canonical_graph_for_compare exists to provide. + """ + from graphify.watch import ( + _canonical_graph_for_compare, + _canonical_topology_for_compare, + ) + + old = _stamped("2020-01-01T00:00:00Z") + new = _stamped("2026-08-25T09:15:42Z") + + for fn in (_canonical_graph_for_compare, _canonical_topology_for_compare): + assert fn(old) == fn(new), f"{fn.__name__} treated the write stamp as content" + # The stamp is removed, not merely normalised to a shared value. + assert "built_at" not in fn(new), fn.__name__ + + +def test_graph_comparators_still_detect_a_real_change_alongside_a_new_stamp(): + """The red half of the guard above: ignoring the stamp must not blind the + comparators to an actual topology change that arrives with it. A test that + can only ever pass is not a test.""" + from graphify.watch import ( + _canonical_graph_for_compare, + _canonical_topology_for_compare, + ) + + old = _stamped("2020-01-01T00:00:00Z") + changed = _stamped( + "2026-08-25T09:15:42Z", + nodes=[{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], + ) + for fn in (_canonical_graph_for_compare, _canonical_topology_for_compare): + assert fn(old) != fn(changed), f"{fn.__name__} missed a real node change"