Skip to content
Closed
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
147 changes: 147 additions & 0 deletions tests/test_graph_invariance.py
Original file line number Diff line number Diff line change
@@ -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]}"
Loading