from pathlib import Path from graphify.extract import extract_python, extract, collect_files, _make_id 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_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(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_handles_circular_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 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_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_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_cross_file_call_promoted_to_extracted_with_import_evidence(tmp_path): """A cross-file `calls` edge must be EXTRACTED when the caller's file has an `imports` or `imports_from` edge linking it to the callee.""" caller = tmp_path / "caller.js" callee = tmp_path / "lib.js" caller.write_text( "const { doWork } = require('./lib');\n" "function run() { doWork(); }\n" ) callee.write_text( "function doWork() { return 1; }\n" "module.exports = { doWork };\n" ) result = extract([caller, callee], cache_root=tmp_path) nodes = {n["id"]: n for n in result["nodes"]} call_edges = [ e for e in result["edges"] if e["relation"] == "calls" and nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "doWork()" ] assert len(call_edges) == 1 assert call_edges[0]["confidence"] == "EXTRACTED" assert call_edges[0]["confidence_score"] == 1.0 def test_cross_file_call_remains_inferred_without_import_evidence(tmp_path): """A cross-file `calls` edge must stay INFERRED when there is no import edge — name collision alone is insufficient evidence.""" caller = tmp_path / "caller.js" callee = tmp_path / "lib.js" # Caller does NOT require lib — same-name function happens to exist elsewhere caller.write_text("function run() { doUnique(); }\n") callee.write_text( "function doUnique() { return 1; }\n" "module.exports = { doUnique };\n" ) result = extract([caller, callee], cache_root=tmp_path) nodes = {n["id"]: n for n in result["nodes"]} call_edges = [ e for e in result["edges"] if e["relation"] == "calls" and nodes[e["source"]]["label"] == "run()" and nodes[e["target"]]["label"] == "doUnique()" ] assert len(call_edges) == 1 assert call_edges[0]["confidence"] == "INFERRED" # ── TSX (JSX-aware) parsing ────────────────────────────────────────────────── # .tsx files require tree-sitter-typescript's `language_tsx`, not the plain # `language_typescript` grammar. Parsing JSX with the wrong grammar produces # silent ERROR nodes and drops every function/call inside JSX trees. def test_extract_tsx_finds_helpers_and_component(): """Functions defined alongside a JSX-returning component must be captured.""" from graphify.extract import extract_js result = extract_js(FIXTURES / "sample.tsx") labels = [n["label"] for n in result["nodes"]] assert any("fmtDate" in l for l in labels), f"fmtDate missing from {labels}" assert any("fmtCount" in l for l in labels), f"fmtCount missing from {labels}" assert any("App" in l for l in labels), f"App missing from {labels}" def test_extract_tsx_jsx_expression_calls_resolve(): """Calls inside JSX expressions like `{fmtDate(now)}` must yield call edges. Regression guard for the TSX language fix: with `language_typescript`, JSX is parsed as ERROR nodes and these call_expressions disappear. """ from graphify.extract import extract_js result = extract_js(FIXTURES / "sample.tsx") nodes_by_id = {n["id"]: n for n in result["nodes"]} call_targets = { nodes_by_id[e["target"]]["label"] for e in result["edges"] if e["relation"] == "calls" and e["target"] in nodes_by_id } assert "fmtDate()" in call_targets, ( f"JSX expression call to fmtDate() not captured. Targets: {call_targets}" ) assert "fmtCount()" in call_targets, ( f"JSX expression call to fmtCount() not captured. Targets: {call_targets}" ) def test_extract_tsx_uses_tsx_grammar(): """Wiring check: the .tsx config must use tree-sitter's `language_tsx`.""" from graphify.extract import _TSX_CONFIG, _TS_CONFIG assert _TSX_CONFIG.ts_language_fn == "language_tsx" assert _TS_CONFIG.ts_language_fn == "language_typescript" # --- Windows-spawn ProcessPool fallback (regression for #?) --- # When the caller has no `if __name__ == "__main__":` guard, ProcessPoolExecutor # on Windows raises BrokenProcessPool before any work completes. extract() must # detect this, warn, and fall back to sequential extraction rather than # propagating a 290-line traceback. def test_extract_falls_back_to_sequential_when_parallel_returns_false(tmp_path, monkeypatch): """extract() must run sequential when _extract_parallel signals failure (returns False).""" from graphify import extract as extract_mod files = [FIXTURES / "sample.py"] * 25 # >= _PARALLEL_THRESHOLD triggers parallel branch cache_root = tmp_path / "cache" cache_root.mkdir() calls = {"parallel": 0, "sequential": 0} real_sequential = extract_mod._extract_sequential def fake_parallel(uncached_work, per_file, effective_root, max_workers, total_files): calls["parallel"] += 1 return False # simulate the post-fix BrokenProcessPool branch def wrapped_sequential(*args, **kwargs): calls["sequential"] += 1 return real_sequential(*args, **kwargs) monkeypatch.setattr(extract_mod, "_extract_parallel", fake_parallel) monkeypatch.setattr(extract_mod, "_extract_sequential", wrapped_sequential) result = extract_mod.extract(files, cache_root=cache_root) assert calls["parallel"] == 1, "parallel path should have been attempted once" assert calls["sequential"] == 1, "sequential fallback should have run exactly once" assert result["nodes"], "extract should still produce nodes after fallback" def test_extract_parallel_returns_false_on_broken_pool(tmp_path, monkeypatch, capsys): """_extract_parallel must catch BrokenProcessPool internally and return False.""" from concurrent.futures.process import BrokenProcessPool import concurrent.futures from graphify import extract as extract_mod class FakePool: def __init__(self, *a, **kw): pass def __enter__(self): return self def __exit__(self, *a): return False def submit(self, *a, **kw): raise BrokenProcessPool("simulated spawn failure") monkeypatch.setattr( concurrent.futures, "ProcessPoolExecutor", lambda *a, **kw: FakePool() ) uncached = [(0, FIXTURES / "sample.py")] per_file: list = [None] ok = extract_mod._extract_parallel(uncached, per_file, tmp_path, 2, 1) assert ok is False, "function should report failure via return value, not raise" out = capsys.readouterr().out assert "BrokenProcessPool" in out, "user-facing warning must mention the failure" assert "__main__" in out, "warning must hint at the Windows __main__ guard idiom"