From 4293a638d4c711710436b8a8930368a63bcad858 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Thu, 8 Oct 2026 12:03:39 +0530 Subject: [PATCH] test: pin graph.json invariance across checkout folder, rebuild and line endings Seven bugs of one shape (#4152, #4154, #4156, #4164, #4180, #4182, #4188) were each found by diffing two builds of the same tree by hand: a checkout path leaking into an id, a rebuild writing a different graph than the first build, CRLF ids differing from LF, an edge to a node that is never declared. Each fix got its own narrow test, so the next extractor or write-path change can bring the same class back unseen. tests/test_graph_invariance.py builds the whole tests/fixtures corpus (every extractor) through the real CLI, --no-cluster and clustered, and checks: two folders give the same graph, the checkout path appears nowhere in graph.json, a rebuild of the unchanged tree changes nothing, LF and CRLF give the same graph, and every raw edge endpoint is declared. On 0.9.79 the three checks those fixes cover fail; on 0.9.80 all 12 pass in about 20 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf --- tests/test_graph_invariance.py | 147 +++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/test_graph_invariance.py diff --git a/tests/test_graph_invariance.py b/tests/test_graph_invariance.py new file mode 100644 index 000000000..25b0cf113 --- /dev/null +++ b/tests/test_graph_invariance.py @@ -0,0 +1,147 @@ +"""The same source tree must give the same graph.json, however it is built. + +Each test builds the whole ``tests/fixtures`` corpus (every language graphify +ships an extractor for) two ways that must not matter, through the real CLI, +and compares every node and edge with all attributes: + +- two checkouts in different folders (no checkout path in any id or edge), +- a cold build and a rebuild of the unchanged tree, +- an LF checkout and a CRLF checkout of the same files, +- and, for the raw ``--no-cluster`` output, every edge endpoint is a declared node. + +Bugs of this shape were found one at a time by diffing builds by hand (#4152, +#4154, #4156, #4164, #4180, #4182, #4188); this pins the invariants for every +extractor at once, so a new extractor or a change to the write paths cannot +silently break them. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from collections import Counter +from pathlib import Path + +import pytest + +FIXTURES = Path(__file__).parent / "fixtures" +# Attributes that are not a function of the source tree: community ids are +# assigned by clustering and are compared elsewhere (#3761). +_UNSTABLE_KEYS = frozenset({"community", "community_name"}) + + +def _copy(src: Path, dst: Path, *, crlf: bool = False) -> Path: + shutil.copytree(src, dst, ignore=shutil.ignore_patterns("graphify-out")) + if crlf: + for path in dst.rglob("*"): + if not path.is_file(): + continue + data = path.read_bytes() + if b"\0" in data: + continue + try: + data.decode("utf-8") + except UnicodeDecodeError: + continue + path.write_bytes(data.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")) + return dst + + +def _build(root: Path, *, cluster: bool) -> dict: + """Build ``root`` with the CLI and return graph.json, parsed, plus its raw text under ``"__text__"``.""" + args = [sys.executable, "-m", "graphify", "update", str(root), "--force"] + if not cluster: + args.append("--no-cluster") + env = dict(os.environ, PYTHONHASHSEED="0", PYTHONIOENCODING="utf-8") + result = subprocess.run(args, cwd=root, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr[-2000:] + text = (root / "graphify-out" / "graph.json").read_text(encoding="utf-8") + graph = json.loads(text) + graph["__text__"] = text + return graph + + +def _links(graph: dict) -> list[dict]: + return graph.get("links", graph.get("edges", [])) + + +def _canonical(graph: dict) -> tuple[Counter, Counter]: + def key(item: dict) -> str: + return json.dumps({k: v for k, v in item.items() if k not in _UNSTABLE_KEYS}, sort_keys=True) + return Counter(key(n) for n in graph["nodes"]), Counter(key(e) for e in _links(graph)) + + +def _assert_same(a: dict, b: dict, what: str) -> None: + keys_a = set(a) - {"__text__", "built_at_commit"} + keys_b = set(b) - {"__text__", "built_at_commit"} + assert keys_a == keys_b, f"{what} changed graph.json's top-level keys: {sorted(keys_a ^ keys_b)}" + nodes_a, edges_a = _canonical(a) + nodes_b, edges_b = _canonical(b) + only = { + "nodes only in the first": list((nodes_a - nodes_b).elements())[:5], + "nodes only in the second": list((nodes_b - nodes_a).elements())[:5], + "edges only in the first": list((edges_a - edges_b).elements())[:5], + "edges only in the second": list((edges_b - edges_a).elements())[:5], + } + assert not any(only.values()), f"{what} changed graph.json: " + json.dumps(only, indent=1)[:4000] + + +@pytest.fixture(scope="module", params=[False, True], ids=["no_cluster", "clustered"]) +def builds(request, tmp_path_factory) -> dict: + cluster = request.param + base = tmp_path_factory.mktemp("invariance_clustered" if cluster else "invariance_raw") + first = _copy(FIXTURES, base / "alice_home" / "proj") + second = _copy(FIXTURES, base / "bob_elsewhere" / "checkout" / "proj") + crlf = _copy(FIXTURES, base / "crlf_checkout" / "proj", crlf=True) + cold = _build(first, cluster=cluster) + return { + "cluster": cluster, + "cold": cold, + "other_folder": _build(second, cluster=cluster), + "rebuilt": _build(first, cluster=cluster), + "crlf": _build(crlf, cluster=cluster), + } + + +def test_fixture_corpus_is_not_trivial(builds): + # Guard against a silently empty build making every comparison pass. + assert len(builds["cold"]["nodes"]) > 500 + assert len(_links(builds["cold"])) > 500 + + +def test_checkout_folder_does_not_change_the_graph(builds): + _assert_same(builds["cold"], builds["other_folder"], "moving the checkout to another folder") + + +def test_graph_never_contains_the_checkout_path(builds): + for graph in (builds["cold"], builds["rebuilt"], builds["other_folder"]): + lowered = graph["__text__"].lower() + assert "alice_home" not in lowered and "bob_elsewhere" not in lowered, ( + "graph.json embeds the checkout path: " + + str([ + item.get("id") or (item.get("source"), item.get("target")) + for item in graph["nodes"] + _links(graph) + if "alice_home" in json.dumps(item).lower() or "bob_elsewhere" in json.dumps(item).lower() + ][:10]) + ) + + +def test_rebuilding_an_unchanged_tree_changes_nothing(builds): + _assert_same(builds["cold"], builds["rebuilt"], "rebuilding the unchanged tree") + + +def test_line_endings_do_not_change_the_graph(builds): + _assert_same(builds["cold"], builds["crlf"], "converting every text file to CRLF") + + +def test_every_edge_endpoint_is_a_declared_node(builds): + for graph in (builds["cold"], builds["rebuilt"]): + declared = {n["id"] for n in graph["nodes"]} + undeclared = [ + (e.get("relation"), e.get("source"), e.get("target")) + for e in _links(graph) + if e.get("source") not in declared or e.get("target") not in declared + ] + assert undeclared == [], f"edges with an undeclared endpoint: {undeclared[:10]}"