mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
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
(cherry picked from commit 4293a638d4)
148 lines
5.9 KiB
Python
148 lines
5.9 KiB
Python
"""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]}"
|