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
16 changes: 16 additions & 0 deletions graphify/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}):
Expand Down
49 changes: 49 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading