diff --git a/CHANGELOG.md b/CHANGELOG.md index 250990011..18ebd2edc 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 69136befc..49865274f 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 58c0925b4..7fece51f7 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 8ad02c4df..25db9073b 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 d957b8795..b361c7d51 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 7893a0f4e..a717386f6 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 25d0cd967..0f0dc888d 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"