diff --git a/graphify/export.py b/graphify/export.py index 03159ada8..f11fa52e8 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -325,6 +325,22 @@ def _json_sort_key(item: dict) -> str: if true_src is not None and true_tgt is not None: link["source"] = true_src link["target"] = true_tgt + # Canonicalize the key order WITHIN each node/link dict. node_link_data always + # appends the node key (`id`) at the end, so a node whose `id` was an inline + # attribute on a cold build (position varies) lands last after a read-rebuild + # (build_from_json consumes `id` as the pure node key). The values are + # identical either way, but the field order churns, so a byte-diff of two + # equivalent graph.json files is noisy and any position-sensitive consumer + # sees a spurious change on every round-trip. Emit a stable order — the + # identity keys first, then the remaining keys sorted — so the serialized + # form is invariant regardless of how the attribute was stored in memory. + def _canonical(item: dict, lead: tuple[str, ...]) -> dict: + leading = [k for k in lead if k in item] + rest = sorted(k for k in item if k not in leading) + return {k: item[k] for k in (*leading, *rest)} + + data["nodes"] = [_canonical(n, ("id", "label")) for n in data["nodes"]] + data["links"] = [_canonical(link, ("source", "target", "relation")) for link in data["links"]] data["nodes"].sort(key=_json_sort_key) data["links"].sort(key=_json_sort_key) if "hyperedges" not in getattr(G, "graph", {}): diff --git a/tests/test_export.py b/tests/test_export.py index d86e413ec..8f6e89d65 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -73,6 +73,55 @@ def make_graph(reverse=False): assert outputs[0].read_bytes() == outputs[1].read_bytes() +def test_to_json_field_order_stable_across_read_rebuild(tmp_path): + """graph.json survives a build -> write -> read-back -> write round-trip + byte-for-byte. node_link_data always appends the node key (`id`) last, so a + node whose `id` was an inline attribute on a cold build lands mid-dict, while + the same node after build_from_json consumes `id` as the pure node key lands + last — identical values, churned field order. Emitting a canonical key order + keeps the two serializations identical. Regression guard: the earlier + determinism test only varied insertion order within one build and missed + this.""" + extraction = { + "nodes": [ + {"id": "a_foo", "label": "foo", "file_type": "code", "source_file": "a.py"}, + {"id": "b_bar", "label": "bar", "file_type": "code", "source_file": "b.py"}, + {"id": "c_baz", "label": "baz", "file_type": "code", "source_file": "c.py"}, + ], + "edges": [ + {"source": "a_foo", "target": "b_bar", "relation": "calls", + "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "a.py"}, + {"source": "b_bar", "target": "c_baz", "relation": "references", + "confidence": "EXTRACTED", "confidence_score": 1.0, "source_file": "b.py"}, + ], + "hyperedges": [], + } + communities = {0: ["a_foo", "b_bar", "c_baz"]} + + first = tmp_path / "first.json" + to_json(build_from_json(extraction), communities, str(first), + built_at_commit="fixed", 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) + + # Byte-identity is the strongest statement of "no cosmetic churn". + assert first.read_bytes() == second.read_bytes() + + data = json.loads(first.read_text()) + # `id` leads every node; source/target lead every link — the identity-first + # order node_link_data does not guarantee on its own. + for node in data["nodes"]: + assert list(node.keys())[0] == "id" + for link in data["links"]: + assert list(link.keys())[:2] == ["source", "target"] + # Endpoints are never swapped by the reordering. + endpoints = sorted((e["source"], e["target"]) for e in data["links"]) + assert endpoints == [("a_foo", "b_bar"), ("b_bar", "c_baz")] + + def test_to_json_commit_fallback_uses_output_repo_not_cwd(tmp_path, monkeypatch): # Without an explicit built_at_commit, provenance must come from the repo # the graph is written into, not from whatever repo the shell happens to