mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 00:03:32 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
This commit is contained in:
1 parent
6478eb7123
commit
4293a638d4
1 file changed
+147
@@ -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]}"
|
||||
Reference in new issue
Block a user