Skip to content
Open
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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
29 changes: 27 additions & 2 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionto_json()

fans out to 8 callees (efferent coupling); 53 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# Safety check: refuse to silently shrink an existing graph (#479)
existing_path = Path(output_path)
if not force and existing_path.exists():
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 30 additions & 8 deletions graphify/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions graphify/watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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):
Expand Down
62 changes: 58 additions & 4 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]}
Expand Down Expand Up @@ -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"])
103 changes: 103 additions & 0 deletions tests/test_serve_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading