"""#4854: a file node and a declaration inside it must never share an id. Every fixture states the labels and edges the extractor must produce. The tests assert behaviour (which nodes and edges exist, and what references point at), not the helper that assigns the id. """ from __future__ import annotations import hashlib import shutil from pathlib import Path import pytest from graphify.build import build from graphify.extract import extract def _corpus(tmp_path: Path, files: dict[str, str], name: str = "corpus") -> tuple[Path, Path]: root = tmp_path / name root.mkdir(parents=True, exist_ok=True) for rel, text in files.items(): path = root / rel path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return root, tmp_path / f"{name}-cache" def _run(tmp_path: Path, files: dict[str, str], name: str = "corpus") -> dict: root, cache = _corpus(tmp_path, files, name) return extract( sorted(p for p in root.rglob("*") if p.is_file()), cache_root=cache, root=root, parallel=False, ) def _labels(result: dict) -> set[tuple[str, str]]: return {(node["label"], node["source_file"]) for node in result["nodes"]} def _id_of(result: dict, label: str, source_file: str) -> str: found = [ node["id"] for node in result["nodes"] if node["label"] == label and node["source_file"] == source_file ] assert len(found) == 1, f"expected one {label!r} in {source_file}, got {found}" return found[0] def _edge_facts(result: dict) -> set[tuple[tuple[str, str], str, tuple[str, str]]]: by_id = { node["id"]: (node["label"], node["source_file"]) for node in result["nodes"] } facts = set() for edge in result["edges"]: facts.add((by_id[edge["source"]], edge["relation"], by_id[edge["target"]])) return facts def _assert_clean(result: dict, *, labels: list[tuple[str, str]]) -> None: got = _labels(result) for fact in labels: assert fact in got, f"missing node {fact}; got {sorted(got)}" ids = [node["id"] for node in result["nodes"]] assert len(ids) == len(set(ids)), f"duplicate ids: {sorted(i for i in ids if ids.count(i) > 1)}" self_edges = [ edge for edge in result["edges"] if edge["source"] == edge["target"] and edge["relation"] == "contains" ] assert not self_edges, f"self-contains edges: {self_edges}" FIXTURES = [ ("js_class", {"a.js": "class Js { run() { return 1; } }\n"}, [("a.js", "a.js"), ("Js", "a.js"), (".run()", "a.js")], [(("a.js", "a.js"), "contains", ("Js", "a.js")), (("Js", "a.js"), "method", (".run()", "a.js"))]), ("js_export_class", {"a.js": "export class Js { run() { return 1; } }\n"}, [("a.js", "a.js"), ("Js", "a.js"), (".run()", "a.js")], [(("a.js", "a.js"), "contains", ("Js", "a.js")), (("Js", "a.js"), "method", (".run()", "a.js"))]), ("js_func_decl", {"a.js": "function js() { return 1; }\n"}, [("a.js", "a.js"), ("js()", "a.js")], [(("a.js", "a.js"), "contains", ("js()", "a.js"))]), ("js_const", {"a.js": "export const Js = 1;\n"}, [("a.js", "a.js"), ("Js", "a.js")], [(("a.js", "a.js"), "contains", ("Js", "a.js"))]), ("js_arrow_const", {"a.js": "const js = () => 1;\n"}, [("a.js", "a.js"), ("js()", "a.js")], [(("a.js", "a.js"), "contains", ("js()", "a.js"))]), ("ts_class", {"a.ts": "class Ts { run() { return 1; } }\n"}, [("a.ts", "a.ts"), ("Ts", "a.ts"), (".run()", "a.ts")], [(("a.ts", "a.ts"), "contains", ("Ts", "a.ts")), (("Ts", "a.ts"), "method", (".run()", "a.ts"))]), ("py_class", {"m.py": "class Py:\n def run(self):\n return 1\n"}, [("m.py", "m.py"), ("Py", "m.py"), (".run()", "m.py")], [(("m.py", "m.py"), "contains", ("Py", "m.py")), (("Py", "m.py"), "method", (".run()", "m.py"))]), ("java_class", {"Foo.java": "class Java { void run() {} }\n"}, [("Foo.java", "Foo.java"), ("Java", "Foo.java"), (".run()", "Foo.java")], [(("Foo.java", "Foo.java"), "contains", ("Java", "Foo.java")), (("Java", "Foo.java"), "method", (".run()", "Foo.java"))]), ("php_class", {"x.php": " 1;\n"}, [("a.js", "a.js")], []), ] @pytest.mark.parametrize( "name,files,labels,edges", FIXTURES, ids=[fixture[0] for fixture in FIXTURES], ) def test_fixture_matrix(tmp_path, name, files, labels, edges): result = _run(tmp_path, files, name) _assert_clean(result, labels=labels) facts = _edge_facts(result) for edge in edges: assert edge in facts, f"missing edge {edge}; got {sorted(facts)}" if name.startswith("empty_name_"): assert _labels(result) == {("a.js", "a.js")} _assert_build_keeps(result, labels) def _assert_build_keeps(result: dict, labels: list[tuple[str, str]]) -> None: built = {data.get("label") for _, data in build([result]).nodes(data=True)} for label, _source in labels: assert label in built, f"build() dropped {label!r}; kept {sorted(built)}" def test_salt_cannot_be_occupied_by_a_real_symbol(tmp_path): """A legal identifier equal to the salt must not steal the class id.""" salt = hashlib.sha1(b"Js").hexdigest()[:12] result = _run( tmp_path, {"a.js": f"class Js {{}}\nexport const ${salt} = 1;\n"}, "salt-clash", ) _assert_clean(result, labels=[("a.js", "a.js"), ("Js", "a.js"), (f"${salt}", "a.js")]) ids = {_id_of(result, "Js", "a.js"), _id_of(result, f"${salt}", "a.js")} assert len(ids) == 2 _assert_build_keeps(result, [("a.js", "a.js"), ("Js", "a.js"), (f"${salt}", "a.js")]) def test_bundle_min_js_keeps_class_and_method(tmp_path): result = _run( tmp_path, {"bundle.min.js": "class Js { run() { return 1; } }\n"}, "bundle", ) _assert_clean(result, labels=[("bundle.min.js", "bundle.min.js"), ("Js", "bundle.min.js"), (".run()", "bundle.min.js")]) assert (("bundle.min.js", "bundle.min.js"), "contains", ("Js", "bundle.min.js")) in _edge_facts(result) assert (("Js", "bundle.min.js"), "method", (".run()", "bundle.min.js")) in _edge_facts(result) def test_rust_struct_keeps_its_node(tmp_path): result = _run( tmp_path, {"a.rs": "struct Rs { x: i32 }\nfn main() {}\n"}, "rust", ) _assert_clean(result, labels=[("a.rs", "a.rs"), ("Rs", "a.rs"), ("main()", "a.rs")]) assert _id_of(result, "Rs", "a.rs") != _id_of(result, "a.rs", "a.rs") def test_cross_file_decorator_targets_the_declared_class(tmp_path): result = _run( tmp_path, { "a.ts": "export class Ts {}\n", "b.ts": 'import { Ts } from "./a";\n@Ts class Other {}\n', }, "cross-decorator", ) class_id = _id_of(result, "Ts", "a.ts") other_id = _id_of(result, "Other", "b.ts") file_id = _id_of(result, "b.ts", "b.ts") targets = {edge["target"] for edge in result["edges"] if edge["source"] == other_id} assert class_id in targets, f"decorator reference missing; targets={targets}" assert file_id not in targets, "decorator reference points at the file node" def test_cross_file_decorator_prefers_the_imported_module(tmp_path): """Two modules declare Ts; the decorator must bind the imported one.""" result = _run( tmp_path, { "a1.ts": "export class Ts {}\n", "a2.ts": "export class Ts {}\n", "b.ts": 'import { Ts } from "./a1";\n@Ts class Other {}\n', }, "cross-ambiguous", ) first = _id_of(result, "Ts", "a1.ts") second = _id_of(result, "Ts", "a2.ts") other = _id_of(result, "Other", "b.ts") targets = {edge["target"] for edge in result["edges"] if edge["source"] == other} assert first in targets, f"decorator did not bind the imported Ts; targets={targets}" assert second not in targets, "decorator bound the wrong module's Ts" def test_cross_file_inheritance_targets_the_declared_class(tmp_path): result = _run( tmp_path, { "a.ts": "export class Ts {}\n", "b.ts": 'import { Ts } from "./a";\nexport class Other extends Ts {}\n', }, "cross-inherits", ) class_id = _id_of(result, "Ts", "a.ts") assert (("Other", "b.ts"), "inherits", ("Ts", "a.ts")) in _edge_facts(result) other_id = _id_of(result, "Other", "b.ts") targets = {edge["target"] for edge in result["edges"] if edge["source"] == other_id} assert class_id in targets def test_prototype_method_attaches_to_the_salted_class(tmp_path): result = _run( tmp_path, {"a.js": "class Js {}\nJs.prototype.run = function run() {}\n"}, "prototype", ) _assert_clean(result, labels=[("a.js", "a.js"), ("Js", "a.js"), (".run()", "a.js")]) assert (("Js", "a.js"), "method", (".run()", "a.js")) in _edge_facts(result) assert len([n for n in result["nodes"] if n["label"] == "Js"]) == 1 ZOO = [ ("a.js", "class Js {}", "class Zzz {}"), ("a.js", "function js() {}", "function zzz() {}"), ("a.js", "const js = () => 1;", "const zzz = () => 1;"), ("a.ts", "class Ts {}", "class Zzz {}"), ("a.ts", "interface Ts {}", "interface Zzz {}"), ("a.ts", "enum Ts { A }", "enum Zzz { A }"), ("a.ts", "type Ts = number;", "type Zzz = number;"), ("m.py", "class Py: pass", "class Zzz: pass"), ("m.py", "def py(): pass", "def zzz(): pass"), ("Foo.java", "class Java {}", "class Zzz {}"), ("Foo.java", "interface Java {}", "interface Zzz {}"), ("Foo.java", "enum Java { A }", "enum Zzz { A }"), ("Foo.java", "record Java(int x) {}", "record Zzz(int x) {}"), ("x.php", " str | None: import re match = re.search( r"(?:class|struct|interface|record|enum|trait|module|object|type|namespace|function|const|def|fn|fun|func|protocol)\s+([A-Za-z_]\w*)", body, ) return match.group(1) if match else None def _label_for(result: dict, filename: str, name: str) -> str | None: for label, source in _labels(result): if source == filename and label.rstrip("()") == name: return label return None @pytest.mark.parametrize("filename,body,control", ZOO, ids=[f"{i}-{z[0]}" for i, z in enumerate(ZOO)]) def test_zoo_declaration_kinds_keep_their_node(tmp_path, filename, body, control): declared = _declared_name(body) control_name = _declared_name(control) assert declared and control_name control_result = _run(tmp_path, {filename: control}, f"zoo-control-{declared}") control_label = _label_for(control_result, filename, control_name) if control_label is None: pytest.skip(f"extractor does not model this kind ({filename}: {control_name})") result = _run(tmp_path, {filename: body}, f"zoo-{declared}") declared_label = _label_for(result, filename, declared) assert declared_label is not None, f"{filename}: {declared} was dropped" _assert_clean(result, labels=[(filename, filename), (declared_label, filename)]) assert _id_of(result, declared_label, filename) != _id_of(result, filename, filename) _assert_build_keeps(result, [(filename, filename), (declared_label, filename)]) def test_fresh_and_cached_extraction_agree(tmp_path): files = { "a.ts": "export class Ts {}\n", "b.ts": 'import { Ts } from "./a";\n@Ts class Other {}\n', } root, cache = _corpus(tmp_path, files, "cache-parity") paths = sorted(p for p in root.rglob("*") if p.is_file()) first = extract(paths, cache_root=cache, root=root, parallel=False) second = extract(paths, cache_root=cache, root=root, parallel=False) assert first["nodes"] == second["nodes"] assert first["edges"] == second["edges"] def test_schema_bumped_for_the_id_change(): from graphify import cache assert cache._AST_CACHE_SCHEMA >= 34 def test_decl_ids_survive_the_ast_cache(tmp_path): root, cache = _corpus(tmp_path, {"a.ts": "export class Ts {}\n@Ts class Other {}\n"}, "decl-cache") paths = sorted(p for p in root.rglob("*") if p.is_file()) fresh = extract(paths, cache_root=cache, root=root, parallel=False) warm = extract(paths, cache_root=cache, root=root, parallel=False) assert _id_of(fresh, "Ts", "a.ts") == _id_of(warm, "Ts", "a.ts") assert (("Other", "a.ts"), "references", ("Ts", "a.ts")) in _edge_facts(warm)