import json import os import sys from collections import Counter from pathlib import Path import pytest from graphify.build import build_from_json from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, _DISPATCH FIXTURES = Path(__file__).parent / "fixtures" def test_make_id_strips_dots_and_underscores(): assert _make_id("_auth") == "auth" assert _make_id(".httpx._client") == "httpx_client" def test_make_id_consistent(): """Same input always produces same output.""" assert _make_id("foo", "Bar") == _make_id("foo", "Bar") def test_make_id_no_leading_trailing_underscores(): result = _make_id("__init__") assert not result.startswith("_") assert not result.endswith("_") def test_extract_python_finds_class(): result = extract_python(FIXTURES / "sample.py") labels = [n["label"] for n in result["nodes"]] assert "Transformer" in labels def test_extract_python_finds_methods(): result = extract_python(FIXTURES / "sample.py") labels = [n["label"] for n in result["nodes"]] assert any("__init__" in l or "forward" in l for l in labels) def test_extract_python_no_dangling_edges(): """All edge sources must reference a known node (targets may be external imports).""" result = extract_python(FIXTURES / "sample.py") node_ids = {n["id"] for n in result["nodes"]} for edge in result["edges"]: assert edge["source"] in node_ids, f"Dangling source: {edge['source']}" def test_structural_edges_are_extracted(): """contains / method / inherits / imports edges must always be EXTRACTED.""" result = extract_python(FIXTURES / "sample.py") structural = {"contains", "method", "inherits", "imports", "imports_from"} for edge in result["edges"]: if edge["relation"] in structural: assert edge["confidence"] == "EXTRACTED", f"Expected EXTRACTED: {edge}" def test_extract_merges_multiple_files(): files = list(FIXTURES.glob("*.py")) result = extract(files) assert len(result["nodes"]) > 0 assert result["input_tokens"] == 0 def test_extract_disambiguates_duplicate_symbol_ids_by_source_path(tmp_path): first = tmp_path / "apps/api/Program.cs" second = tmp_path / "tools/api/Program.cs" first.parent.mkdir(parents=True) second.parent.mkdir(parents=True) first.write_text("class Program { void Run() {} }\n", encoding="utf-8") second.write_text("class Program { void Run() {} }\n", encoding="utf-8") result = extract([first, second], cache_root=tmp_path) program_nodes = [ node for node in result["nodes"] if node["label"] == "Program" and node.get("source_file", "").endswith("Program.cs") ] assert len(program_nodes) == 2 assert len({node["id"] for node in program_nodes}) == 2 node_ids = {node["id"] for node in result["nodes"]} program_by_source = {node["source_file"]: node["id"] for node in program_nodes} file_nodes_by_source = { node["source_file"]: node["id"] for node in result["nodes"] if node["label"] == "Program.cs" } assert set(program_by_source) == set(file_nodes_by_source) contains_edges = [ edge for edge in result["edges"] if edge["relation"] == "contains" and edge["source_file"] in program_by_source ] assert len(contains_edges) == 2 for edge in contains_edges: assert edge["source"] == file_nodes_by_source[edge["source_file"]] assert edge["target"] == program_by_source[edge["source_file"]] for edge in result["edges"]: if edge["relation"] in {"contains", "method"}: assert edge["source"] in node_ids, f"Dangling structural source: {edge}" assert edge["target"] in node_ids, f"Dangling structural target: {edge}" def test_cpp_unresolved_base_class_stubs_stay_disambiguated_by_file(tmp_path): """Two different files' same-named, otherwise-undefined base class must not collapse onto one shared stub node. The C++ base_class_clause handler used to build its stub inline instead of calling ensure_named_node(), so it never tagged the stub with origin_file. Without that tag, _disambiguate_colliding_node_ids couldn't tell file A's reference to unresolved `Base` apart from file B's, and every file's unresolved base class merged onto one bare id -- which could then collide with an unrelated same-named real definition anywhere else in the corpus. """ first = tmp_path / "a" / "Foo.cpp" second = tmp_path / "b" / "Bar.cpp" first.parent.mkdir(parents=True) second.parent.mkdir(parents=True) first.write_text("class Foo : public Base {};\n", encoding="utf-8") second.write_text("class Bar : public Base {};\n", encoding="utf-8") result = extract([first, second], cache_root=tmp_path) base_stubs = [ node for node in result["nodes"] if node["label"] == "Base" and not node.get("source_file") ] assert len(base_stubs) == 2 assert len({node["id"] for node in base_stubs}) == 2 inherits_edges = [e for e in result["edges"] if e["relation"] == "inherits"] assert len(inherits_edges) == 2 assert len({e["target"] for e in inherits_edges}) == 2 def test_cross_file_type_annotation_refs_resolve_to_single_node(tmp_path): """#1402: a class defined once but referenced via type annotations in N other files must NOT create 1+N phantom duplicate nodes (with the referencing file's path — extension and all — baked into the id, e.g. ``pkg_a_py_thing``). The annotation references resolve to the single canonical definition. Contrast with test_extract_disambiguates_...: genuinely *defined* duplicates stay separate; only cross-file *references* collapse onto the real node.""" pkg = tmp_path / "pkg" pkg.mkdir() (pkg / "thing.py").write_text("class Thing:\n def run(self):\n return 1\n", encoding="utf-8") (pkg / "a.py").write_text("from pkg.thing import Thing\ndef use_a(obj: Thing) -> Thing:\n return obj\n", encoding="utf-8") (pkg / "b.py").write_text("from pkg.thing import Thing\ndef use_b(obj: Thing) -> Thing:\n return obj\n", encoding="utf-8") result = extract([pkg / "thing.py", pkg / "a.py", pkg / "b.py"], cache_root=tmp_path) thing_nodes = [n for n in result["nodes"] if n["label"] == "Thing"] assert len(thing_nodes) == 1, [n["id"] for n in thing_nodes] # The tell-tale phantom signature is the referencing file's path (with .py # extension) baked into the id — must not appear. assert "_py" not in thing_nodes[0]["id"], thing_nodes[0]["id"] def test_go_cross_file_type_refs_resolve_to_single_node(tmp_path): """#1402 (Go): the sourceless-stub fix landed in six extractors but the Go copy of ``ensure_named_node`` was missed, so a Go type defined once but referenced via parameter/return types in N sibling files produced 1+N phantom duplicate nodes with the referencing file's path (extension and all) baked into the id (e.g. ``pkg_a_go_thing``). Same-package references must resolve to the single canonical type node instead.""" pkg = tmp_path / "pkg" pkg.mkdir() (pkg / "thing.go").write_text( "package pkg\n\ntype Thing struct{}\n\nfunc (t Thing) Run() int { return 1 }\n", encoding="utf-8", ) (pkg / "a.go").write_text( "package pkg\n\nfunc UseA(obj Thing) Thing { return obj }\n", encoding="utf-8" ) (pkg / "b.go").write_text( "package pkg\n\nfunc UseB(obj Thing) Thing { return obj }\n", encoding="utf-8" ) result = extract([pkg / "thing.go", pkg / "a.go", pkg / "b.go"], cache_root=tmp_path) thing_nodes = [n for n in result["nodes"] if n["label"] == "Thing"] assert len(thing_nodes) == 1, [n["id"] for n in thing_nodes] # The phantom signature is the referencing file's path (with .go extension) # baked into the id — must not appear. assert "_go" not in thing_nodes[0]["id"], thing_nodes[0]["id"] def test_imported_type_stubs_do_not_collide_across_source_files(tmp_path): """#1462: imported stdlib/type stubs with the same label are distinct uses when there is no single project definition to rewire onto. They need the referencing file as a disambiguator while still keeping ``source_file`` empty so real project definitions can be rewired by #1402.""" first = tmp_path / "pkg/a.py" second = tmp_path / "pkg/b.py" first.parent.mkdir(parents=True) first.write_text("from pathlib import Path\ndef use_a(p: Path):\n return p\n", encoding="utf-8") second.write_text("from pathlib import Path\ndef use_b(p: Path):\n return p\n", encoding="utf-8") result = extract([first, second], cache_root=tmp_path) path_nodes = [node for node in result["nodes"] if node["label"] == "Path"] assert len(path_nodes) == 2 assert len({node["id"] for node in path_nodes}) == 2 assert all(not node.get("source_file") for node in path_nodes) def test_origin_file_is_not_serialized_into_extract_output(tmp_path): """origin_file is an internal disambiguation hint (#1462) consumed only by the colliding-id pass during extraction. It must not survive into the returned nodes (and thus graph.json), where it would ship as an absolute, machine-specific path — the "no absolute paths in output" contract (#555, #932). Disambiguation still keys on it first, so the two same-label cross-file stubs stay distinct.""" first = tmp_path / "pkg/a.py" second = tmp_path / "pkg/b.py" first.parent.mkdir(parents=True) first.write_text("from pathlib import Path\ndef use_a(p: Path):\n return p\n", encoding="utf-8") second.write_text("from pathlib import Path\ndef use_b(p: Path):\n return p\n", encoding="utf-8") result = extract([first, second], cache_root=tmp_path) # The internal field is gone from every node... assert all("origin_file" not in node for node in result["nodes"]) # ...so no node leaks the absolute sandbox path that origin_file used to carry. leaked = [ (node.get("id"), key, value) for node in result["nodes"] for key, value in node.items() if isinstance(value, str) and str(tmp_path) in value ] assert not leaked, f"absolute paths leaked into nodes: {leaked}" # ...yet the colliding-id pass still kept the two cross-file stubs distinct. path_nodes = [node for node in result["nodes"] if node["label"] == "Path"] assert len(path_nodes) == 2 assert len({node["id"] for node in path_nodes}) == 2 def test_go_imported_type_stubs_do_not_collide_across_source_files(tmp_path): """Go external types use their import path as canonical identity. #1462 kept unresolved bare stubs distinct per source file because Graphify could not tell whether they named the same external package. The Go import-aware resolver now has that evidence: two ``ext.Widget`` references intentionally share one sourceless node without colliding with a local ``Widget`` definition. """ first = tmp_path / "a/use_a.go" second = tmp_path / "b/use_b.go" first.parent.mkdir(parents=True) second.parent.mkdir(parents=True) first.write_text('package a\n\nimport "ext"\n\nfunc UseA(w ext.Widget) {}\n', encoding="utf-8") second.write_text('package b\n\nimport "ext"\n\nfunc UseB(w ext.Widget) {}\n', encoding="utf-8") result = extract([first, second], cache_root=tmp_path) widget_nodes = [node for node in result["nodes"] if node["label"] == "ext.Widget"] assert len(widget_nodes) == 1 assert all(not node.get("source_file") for node in widget_nodes) target = widget_nodes[0]["id"] refs = [edge for edge in result["edges"] if edge.get("relation") == "references"] assert len(refs) == 2 assert all(edge["target"] == target for edge in refs) def test_extract_updates_raw_call_callers_after_duplicate_id_disambiguation(tmp_path): first = tmp_path / "apps/api/Program.cs" second = tmp_path / "tools/api/Program.cs" target = tmp_path / "shared/Helper.cs" first.parent.mkdir(parents=True) second.parent.mkdir(parents=True) target.parent.mkdir(parents=True) first.write_text("class Program { void Run() { SharedHelper(); } }\n", encoding="utf-8") second.write_text("class Program { void Run() {} }\n", encoding="utf-8") target.write_text("class Helper { void SharedHelper() {} }\n", encoding="utf-8") result = extract([first, second, target], cache_root=tmp_path) node_ids = {node["id"] for node in result["nodes"]} for edge in result["edges"]: if edge["relation"] == "calls": assert edge["source"] in node_ids assert edge["target"] in node_ids def test_extract_rewires_unique_inheritance_stub_to_real_definition(tmp_path): definition = tmp_path / "interfaces.py" implementation = tmp_path / "services/BookStore.cs" definition.write_text("class BookStore:\n pass\n", encoding="utf-8") implementation.parent.mkdir(parents=True) implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8") result = extract([definition, implementation], cache_root=tmp_path) node_by_id = {node["id"]: node for node in result["nodes"]} inherits_edges = [edge for edge in result["edges"] if edge["relation"] == "inherits"] matching = [ edge for edge in inherits_edges if node_by_id[edge["source"]]["label"] == "SqliteBookStore" and node_by_id[edge["target"]]["label"] == "BookStore" ] assert matching assert matching[0]["target"] == next( node["id"] for node in result["nodes"] if node["label"] == "BookStore" and node.get("source_file") == "interfaces.py" ) assert all( not (node["label"] == "BookStore" and not node.get("source_file")) for node in result["nodes"] ) def test_extract_keeps_stub_when_multiple_real_definitions_match(tmp_path): first = tmp_path / "a/interfaces.py" second = tmp_path / "b/interfaces.py" implementation = tmp_path / "services/BookStore.cs" first.parent.mkdir(parents=True) second.parent.mkdir(parents=True) implementation.parent.mkdir(parents=True) first.write_text("class BookStore:\n pass\n", encoding="utf-8") second.write_text("class BookStore:\n pass\n", encoding="utf-8") implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8") result = extract([first, second, implementation], cache_root=tmp_path) stubs = [ node for node in result["nodes"] if node["label"] == "BookStore" and not node.get("source_file") ] assert stubs def test_extract_does_not_rewire_inheritance_stub_to_same_named_function(tmp_path): definition = tmp_path / "factory.py" implementation = tmp_path / "services/BookStore.cs" definition.write_text("def BookStore():\n return object()\n", encoding="utf-8") implementation.parent.mkdir(parents=True) implementation.write_text("class SqliteBookStore : BookStore { }\n", encoding="utf-8") result = extract([definition, implementation], cache_root=tmp_path) node_by_id = {node["id"]: node for node in result["nodes"]} inherits_edges = [edge for edge in result["edges"] if edge["relation"] == "inherits"] assert any( node["label"] == "BookStore" and not node.get("source_file") for node in result["nodes"] ) assert not any( node_by_id[edge["source"]]["label"] == "SqliteBookStore" and node_by_id[edge["target"]]["label"] == "BookStore()" for edge in inherits_edges ) def test_extract_does_not_rewire_constructor_method_to_same_named_class(tmp_path): source = tmp_path / "Sample.java" source.write_text( "class DataProcessor {\n" " public DataProcessor() {}\n" "}\n", encoding="utf-8", ) result = extract([source], cache_root=tmp_path) constructor_nodes = [ node for node in result["nodes"] if node["label"] == ".DataProcessor()" ] assert constructor_nodes assert not any( edge["source"] == edge["target"] for edge in result["edges"] ) def test_collect_files_from_dir(): from graphify.extract import _DISPATCH files = collect_files(FIXTURES) supported = set(_DISPATCH.keys()) assert all(f.suffix in supported for f in files) assert len(files) > 0 def test_collect_files_skips_hidden(): files = collect_files(FIXTURES) for f in files: assert not any(part.startswith(".") for part in f.parts) def test_collect_files_follows_symlinked_directory(requires_symlinks, tmp_path): real_dir = tmp_path / "real_src" real_dir.mkdir() (real_dir / "lib.py").write_text("x = 1") (tmp_path / "linked_src").symlink_to(real_dir) files_no = collect_files(tmp_path, follow_symlinks=False) files_yes = collect_files(tmp_path, follow_symlinks=True) assert [f.name for f in files_no].count("lib.py") == 1 assert [f.name for f in files_yes].count("lib.py") == 2 def test_collect_files_skips_out_of_root_symlinked_directory(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.py").write_text("token = 'outside'") (root / "linked_secret").symlink_to(outside) files = collect_files(root, follow_symlinks=True) assert not any("linked_secret" in str(f) for f in files) def test_collect_files_skips_out_of_root_symlinked_file_by_default(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.py").write_text("token = 'outside'") (root / "secret_link.py").symlink_to(outside / "secret.py") files = collect_files(root) assert not any(f.name == "secret_link.py" for f in files) def test_collect_files_handles_circular_symlinks(requires_symlinks, tmp_path): sub = tmp_path / "pkg" sub.mkdir() (sub / "mod.py").write_text("x = 1") (sub / "cycle").symlink_to(tmp_path) files = collect_files(tmp_path, follow_symlinks=True) assert any(f.name == "mod.py" for f in files) def _legacy_collect_files(target, *, root=None): """The pre-#1261 rglob-per-extension implementation, kept as a parity oracle.""" from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore extensions = set(_DISPATCH.keys()) ignore_root = root if root is not None else target patterns = _load_graphifyignore(ignore_root) results = [] for ext in sorted(extensions): results.extend( p for p in target.rglob(f"*{ext}") if p.suffix == ext and not any(_is_noise_dir(part) for part in p.parts) and not (patterns and _is_ignored(p, ignore_root, patterns)) ) return sorted(results) def test_collect_files_parity_with_legacy_on_fixtures(): assert collect_files(FIXTURES) == _legacy_collect_files(FIXTURES) def test_collect_files_parity_with_legacy_synthetic(tmp_path): (tmp_path / "src" / "deep").mkdir(parents=True) (tmp_path / "src" / "app.py").write_text("x = 1") (tmp_path / "src" / "deep" / "lib.ts").write_text("export const x = 1") (tmp_path / "src" / "deep" / "notes.txt").write_text("not code") # Fortran case distinction: .f and .F are distinct dispatch entries (tmp_path / "src" / "legacy.f").write_text(" END") (tmp_path / "src" / "modern.F").write_text(" END") # Hidden dirs are traversed (only noise dirs are skipped) (tmp_path / ".github").mkdir() (tmp_path / ".github" / "ci.sh").write_text("echo hi") # Noise dirs must be excluded entirely (tmp_path / "node_modules" / "pkg").mkdir(parents=True) (tmp_path / "node_modules" / "pkg" / "index.js").write_text("x") (tmp_path / "__pycache__").mkdir() (tmp_path / "__pycache__" / "app.py").write_text("x") # Ignore rules incl. a negation, so directory-level pruning must not # swallow re-included files (tmp_path / "gen").mkdir() (tmp_path / "gen" / "skip.py").write_text("x") (tmp_path / "vendored").mkdir() (tmp_path / "vendored" / "drop.py").write_text("x") (tmp_path / "vendored" / "keep.py").write_text("x") (tmp_path / ".gitignore").write_text("gen/\nvendored/*.py\n!vendored/keep.py\n") result = collect_files(tmp_path) assert result == _legacy_collect_files(tmp_path) names = {f.name for f in result} assert names == {"app.py", "lib.ts", "legacy.f", "modern.F", "ci.sh", "keep.py"} def test_collect_files_walks_each_directory_once(tmp_path, monkeypatch): """collect_files must scan every directory at most once and never descend into noise dirs (#1261). The old implementation ran one rglob pass per supported extension (~85 walks) and filtered node_modules/.git paths only after descending into them. """ (tmp_path / "src").mkdir() (tmp_path / "src" / "a.py").write_text("x = 1") (tmp_path / "node_modules" / "pkg").mkdir(parents=True) (tmp_path / "node_modules" / "pkg" / "index.js").write_text("x") scanned: list[str] = [] real_scandir = os.scandir def counting_scandir(path=".", *args, **kwargs): scanned.append(os.fspath(path)) return real_scandir(path, *args, **kwargs) monkeypatch.setattr(os, "scandir", counting_scandir) files = collect_files(tmp_path) monkeypatch.undo() assert files == [tmp_path / "src" / "a.py"] # The traversal must be visible as plain os.scandir calls (single os.walk) assert any(s.endswith("src") for s in scanned) # Noise dirs are pruned before descending, not filtered afterwards assert not any("node_modules" in s for s in scanned) # No directory is read more than once counts = Counter(scanned) assert max(counts.values()) == 1 def test_no_dangling_edges_on_extract(): """After merging multiple files, no internal edges should be dangling.""" files = list(FIXTURES.glob("*.py")) result = extract(files) node_ids = {n["id"] for n in result["nodes"]} internal_relations = {"contains", "method", "inherits", "calls"} for edge in result["edges"]: if edge["relation"] in internal_relations: assert edge["source"] in node_ids, f"Dangling source: {edge}" assert edge["target"] in node_ids, f"Dangling target: {edge}" def test_calls_edges_emitted(): """Call-graph pass must produce INFERRED calls edges.""" result = extract_python(FIXTURES / "sample_calls.py") calls = [e for e in result["edges"] if e["relation"] == "calls"] assert len(calls) > 0, "Expected at least one calls edge" def test_calls_edges_are_extracted(): """AST-resolved call edges are deterministic and should be EXTRACTED/1.0.""" result = extract_python(FIXTURES / "sample_calls.py") for edge in result["edges"]: if edge["relation"] == "calls": assert edge["confidence"] == "EXTRACTED" assert edge["weight"] == 1.0 def test_python_call_edges_have_call_context(): result = extract_python(FIXTURES / "sample_calls.py") call_edges = [e for e in result["edges"] if e["relation"] == "calls"] assert call_edges assert all(e.get("context") == "call" for e in call_edges) def test_calls_no_self_loops(): result = extract_python(FIXTURES / "sample_calls.py") for edge in result["edges"]: if edge["relation"] == "calls": assert edge["source"] != edge["target"], f"Self-loop: {edge}" def test_run_analysis_calls_compute_score(): """run_analysis() calls compute_score() - must appear as a calls edge.""" result = extract_python(FIXTURES / "sample_calls.py") calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"} node_by_label = {n["label"]: n["id"] for n in result["nodes"]} src = node_by_label.get("run_analysis()") tgt = node_by_label.get("compute_score()") assert src and tgt, "run_analysis or compute_score node not found" assert (src, tgt) in calls, f"run_analysis -> compute_score not found in {calls}" def test_run_analysis_calls_normalize(): result = extract_python(FIXTURES / "sample_calls.py") calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"} node_by_label = {n["label"]: n["id"] for n in result["nodes"]} src = node_by_label.get("run_analysis()") tgt = node_by_label.get("normalize()") assert src and tgt assert (src, tgt) in calls def test_method_calls_module_function(): """Analyzer.process() calls run_analysis() - cross class→function calls edge.""" result = extract_python(FIXTURES / "sample_calls.py") calls = {(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"} node_by_label = {n["label"]: n["id"] for n in result["nodes"]} src = node_by_label.get(".process()") tgt = node_by_label.get("run_analysis()") assert src and tgt assert (src, tgt) in calls def test_calls_deduplication(): """Same caller→callee pair must appear only once even if called multiple times.""" result = extract_python(FIXTURES / "sample_calls.py") call_pairs = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] assert len(call_pairs) == len(set(call_pairs)), "Duplicate calls edges found" def test_cross_file_calls_skip_ambiguous_duplicate_labels(tmp_path): """Unqualified cross-file calls must not guess between duplicate helper names.""" caller = tmp_path / "caller.py" helper_a = tmp_path / "a.py" helper_b = tmp_path / "b.py" caller.write_text("def run():\n log()\n") helper_a.write_text("def log():\n return 'a'\n") helper_b.write_text("def log():\n return 'b'\n") result = extract([caller, helper_a, helper_b], cache_root=tmp_path) nodes = {n["id"]: n for n in result["nodes"]} calls = [ e for e in result["edges"] if e["relation"] == "calls" and e["confidence"] == "INFERRED" ] assert not any( nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "log()" for e in calls ) def test_cross_file_call_survives_same_named_test_mock(tmp_path): """A real cross-file call must NOT be erased by a same-named test mock. src/caller.py calls save(); src/service.py defines the real save(); a test mock save() lives in tests/test_service.py. Before #1553 the ambiguous-name god-node guard dropped the edge entirely. Now the non-test tie-breaker keeps exactly one caller->save edge pointing at the SRC definition. """ src = tmp_path / "src" tests = tmp_path / "tests" src.mkdir() tests.mkdir() (src / "service.py").write_text("def save():\n return 'real'\n") (src / "caller.py").write_text("def run():\n save()\n") (tests / "test_service.py").write_text("def save():\n return 'mock'\n") result = extract( [src / "caller.py", src / "service.py", tests / "test_service.py"], cache_root=tmp_path, ) nodes = {n["id"]: n for n in result["nodes"]} save_calls = [ e for e in result["edges"] if e["relation"] == "calls" and nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "save()" ] assert len(save_calls) == 1, f"expected exactly one run->save edge, got {save_calls}" target_sf = (nodes[save_calls[0]["target"]].get("source_file") or "") assert "service.py" in target_sf and "test_service.py" not in target_sf, target_sf def test_cross_file_call_god_node_guard_two_real_defs(tmp_path): """Two genuine NON-test defs of the same name + one caller => ZERO edges. Proves #543/#1219 is not reopened by the #1553 tie-breakers: with no test candidate to drop and no proximity winner, the guard still bails. """ pkg_a = tmp_path / "a" pkg_b = tmp_path / "b" pkg_c = tmp_path / "c" for d in (pkg_a, pkg_b, pkg_c): d.mkdir() (pkg_a / "svc.py").write_text("def save():\n return 'a'\n") (pkg_b / "svc.py").write_text("def save():\n return 'b'\n") (pkg_c / "caller.py").write_text("def run():\n save()\n") result = extract( [pkg_c / "caller.py", pkg_a / "svc.py", pkg_b / "svc.py"], cache_root=tmp_path, ) nodes = {n["id"]: n for n in result["nodes"]} save_calls = [ e for e in result["edges"] if e["relation"] == "calls" and nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "save()" ] assert save_calls == [], f"god-node guard must bail, got {save_calls}" def test_cross_file_call_survives_many_test_mocks(tmp_path): """One src def + many same-named test stubs + caller => exactly one src edge.""" src = tmp_path / "src" tests = tmp_path / "tests" src.mkdir() tests.mkdir() (src / "service.py").write_text("def save():\n return 'real'\n") (src / "caller.py").write_text("def run():\n save()\n") for i in range(5): (tests / f"thing{i}_test.py").write_text("def save():\n return 'mock'\n") paths = [src / "caller.py", src / "service.py"] + sorted(tests.glob("*_test.py")) result = extract(paths, cache_root=tmp_path) nodes = {n["id"]: n for n in result["nodes"]} save_calls = [ e for e in result["edges"] if e["relation"] == "calls" and nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "save()" ] assert len(save_calls) == 1, f"expected one run->save edge, got {save_calls}" assert "service.py" in (nodes[save_calls[0]["target"]].get("source_file") or "") def test_extract_generic_surfaces_tree_sitter_version_mismatch_hint(monkeypatch): """When Language() raises TypeError (e.g. old tree-sitter binding meets a new tree-sitter API), the error message should point users at the upgrade path instead of leaving a bare 'missing 1 required positional argument'. """ import sys import types from graphify.extract import _extract_generic, LanguageConfig # Build a fake tree_sitter module whose Language() raises TypeError - # this is exactly what users see when an older tree-sitter is paired # with a newer language binding. fake_ts = types.ModuleType("tree_sitter") def _raise(*args, **kwargs): raise TypeError("missing 1 required positional argument: 'name'") fake_ts.Language = _raise fake_ts.Parser = None monkeypatch.setitem(sys.modules, "tree_sitter", fake_ts) # Stub the language module so import_module returns something with .language fake_lang_mod = types.ModuleType("fake_ts_lang") fake_lang_mod.language = lambda: object() monkeypatch.setitem(sys.modules, "fake_ts_lang", fake_lang_mod) config = LanguageConfig(ts_module="fake_ts_lang", ts_language_fn="language") result = _extract_generic(Path("dummy.txt"), config) assert "error" in result assert "tree-sitter version mismatch" in result["error"] assert "pip install --upgrade" in result["error"] def test_extract_js_destructured_require_imports_from(): """`const { foo } = require('./mod')` must emit imports_from to the resolved module path.""" from graphify.extract import extract_js result = extract_js(FIXTURES / "cjs_require.js") imports_from = [e for e in result["edges"] if e["relation"] == "imports_from"] targets = [e["target"] for e in imports_from] # Must resolve relative require() targets to file ids so they connect across the corpus assert any("foundation" in t for t in targets), f"No foundation import_from: {targets}" assert any("utils" in t for t in targets), f"No utils import_from: {targets}" assert any("helpers" in t for t in targets), f"No helpers import_from: {targets}" for e in imports_from: assert e["confidence"] == "EXTRACTED" def test_extract_js_destructured_require_named_symbols(): """Destructured CJS requires must emit symbol-level `imports` edges per binder.""" from graphify.extract import extract_js, _make_id, _file_stem result = extract_js(FIXTURES / "cjs_require.js") sym_targets = [e["target"] for e in result["edges"] if e["relation"] == "imports"] foundation_stem = _file_stem(FIXTURES / "foundation.js") assert _make_id(foundation_stem, "loadFoundation") in sym_targets assert _make_id(foundation_stem, "validateConfig") in sym_targets def test_extract_js_member_require_emits_property_symbol(): """`const x = require('./m').y` must emit symbol edge for `y`.""" from graphify.extract import extract_js, _make_id, _file_stem result = extract_js(FIXTURES / "cjs_require.js") sym_targets = [e["target"] for e in result["edges"] if e["relation"] == "imports"] helpers_stem = _file_stem(FIXTURES / "helpers.js") assert _make_id(helpers_stem, "helperFn") in sym_targets def test_extract_js_function_scoped_require_emits_import_edge(tmp_path): """Lazy CommonJS requires belong to their enclosing function, not nowhere.""" target = tmp_path / "target.js" target.write_text("exports.helper = () => 42;\n", encoding="utf-8") caller = tmp_path / "lazy.js" caller.write_text( "function useItLazily() {\n" " const { helper } = require('./target');\n" " return helper();\n" "}\n", encoding="utf-8", ) result = extract([caller, target], cache_root=tmp_path, root=tmp_path, parallel=False) labels = {node["id"]: node["label"] for node in result["nodes"]} lazy_edges = [ edge for edge in result["edges"] if edge["relation"] == "imports_from" and "target" in edge["target"] ] assert len(lazy_edges) == 1 assert labels[lazy_edges[0]["source"]] == "useItLazily()" assert lazy_edges[0]["confidence"] == "EXTRACTED" def test_extract_js_dynamic_require_variable_is_not_fabricated(tmp_path): """A lazy `require(someVar)` has no static string target, so the body pass must skip it rather than fabricate an edge to a guessed path (#2700).""" caller = tmp_path / "dyn.js" caller.write_text( "function load(name) {\n" " const mod = require(name);\n" " return mod;\n" "}\n", encoding="utf-8", ) result = extract([caller], cache_root=tmp_path, root=tmp_path, parallel=False) assert not [e for e in result["edges"] if e["relation"] in ("imports_from", "imports")] def test_extract_js_module_scope_require_still_single_edge(tmp_path): """No-double-count regression: the module-level and body require passes must never both emit for the same require — a top-level require stays exactly one imports_from edge (#2700).""" target = tmp_path / "target.js" target.write_text("exports.helper = () => 42;\n", encoding="utf-8") caller = tmp_path / "top.js" caller.write_text("const { helper } = require('./target');\n", encoding="utf-8") result = extract([caller, target], cache_root=tmp_path, root=tmp_path, parallel=False) lazy_edges = [ e for e in result["edges"] if e["relation"] == "imports_from" and "target" in e["target"] ] assert len(lazy_edges) == 1 def test_extract_js_arrow_function_still_extracted(): """Regression: arrow functions in lexical_declaration must still produce nodes.""" from graphify.extract import extract_js arrow_fixture = FIXTURES / "_arrow_only.js" arrow_fixture.write_text("const greet = () => console.log('hi');\n") try: result = extract_js(arrow_fixture) labels = [n["label"] for n in result["nodes"]] assert "greet()" in labels finally: arrow_fixture.unlink() def test_extract_js_this_assigned_methods(tmp_path): """`this.X = () => {}` / `this.X = function(){}` in a constructor-style function body must be captured as methods owned by that function. This is the dominant pattern in pre-class JS (DAOs, route handlers): the methods live in the function body, which is otherwise only walked for calls, so before this they were entirely invisible as symbols. """ from graphify.extract import extract_js f = tmp_path / "dao.js" f.write_text( "function UserDAO(db) {\n" " this.addUser = (name) => { return name; };\n" " this.getUser = function(id) { return id; };\n" "}\n" ) result = extract_js(f) by_label = {n["label"]: n for n in result["nodes"]} assert "UserDAO()" in by_label assert ".addUser()" in by_label assert ".getUser()" in by_label # The methods are owned by UserDAO via a `method` edge. owner = by_label["UserDAO()"]["id"] method_edges = { (e["source"], by_label_by_id(result, e["target"])) for e in result["edges"] if e["relation"] == "method" } assert (owner, ".addUser()") in method_edges assert (owner, ".getUser()") in method_edges def test_extract_js_commonjs_exports_assignment(tmp_path): """`exports.X = fn` and `module.exports.X = fn` must produce function nodes.""" from graphify.extract import extract_js f = tmp_path / "mod.js" f.write_text( "exports.alpha = (x) => x;\n" "module.exports.beta = function(y) { return y; };\n" ) labels = [n["label"] for n in extract_js(f)["nodes"]] assert "alpha()" in labels assert "beta()" in labels def test_extract_js_prototype_method_assignment(tmp_path): """`Foo.prototype.bar = fn` must be captured as a method owned by Foo.""" from graphify.extract import extract_js f = tmp_path / "proto.js" f.write_text( "function Foo() {}\n" "Foo.prototype.bar = function() { return 1; };\n" ) by_label = {n["label"]: n for n in extract_js(f)["nodes"]} assert "Foo()" in by_label assert ".bar()" in by_label def test_extract_js_const_function_expression(tmp_path): """`const f = function(){}` (function expression, not arrow) must be captured.""" from graphify.extract import extract_js f = tmp_path / "fnexpr.js" f.write_text("const handler = function(req, res) { return res; };\n") labels = [n["label"] for n in extract_js(f)["nodes"]] assert "handler()" in labels def test_extract_ts_class_arrow_field(tmp_path): """A class field initialised with an arrow function (`x = () => {}`) must be captured as a method of the class — common in React/TS component classes.""" from graphify.extract import extract_js f = tmp_path / "comp.ts" f.write_text( "class Widget {\n" " onClick = (e) => { return e; };\n" " render() { return null; }\n" "}\n" ) by_label = {n["label"]: n for n in extract_js(f)["nodes"]} assert "Widget" in by_label assert ".onClick()" in by_label # arrow field assert ".render()" in by_label # plain method (regression guard) def test_extract_js_arbitrary_member_assignment_not_captured(tmp_path): """Guard against the phantom-god-node class (#1077): an arbitrary `obj.x = fn` (obj is neither this/exports/module.exports/.prototype) must NOT produce a node.""" from graphify.extract import extract_js f = tmp_path / "noise.js" f.write_text( "const obj = {};\n" "obj.whatever = () => 1;\n" ) labels = [n["label"] for n in extract_js(f)["nodes"]] assert "whatever()" not in labels assert ".whatever()" not in labels def test_extract_js_nested_function_declarations(tmp_path): """#2653: function declarations nested inside another function emit nodes, source contains edges from the enclosing function, and attribute call edges correctly.""" from graphify.extract import extract f = tmp_path / "Panel.tsx" f.write_text( "function doThing() {}\n" "export function Panel() {\n" " function handleClick() {\n" " doThing()\n" " }\n" " return