"""Tests for the Tree-sitter parser module.""" import tempfile from pathlib import Path from code_review_graph.graph import GraphStore from code_review_graph.incremental import full_build from code_review_graph.parser import CodeParser FIXTURES = Path(__file__).parent / "fixtures" class TestCodeParser: def setup_method(self): self.parser = CodeParser() def test_detect_language_python(self): assert self.parser.detect_language(Path("foo.py")) == "python" def test_detect_language_typescript(self): assert self.parser.detect_language(Path("foo.ts")) == "typescript" def test_detect_language_unknown(self): assert self.parser.detect_language(Path("foo.txt")) is None # --- Shebang detection for extension-less Unix scripts (#237) --- def _write_shebang_file(self, tmp_path: Path, name: str, content: str) -> Path: """Helper: write an extension-less file with ``content`` and return its path.""" p = tmp_path / name p.write_text(content, encoding="utf-8") return p def test_detect_shebang_bin_bash(self, tmp_path): p = self._write_shebang_file( tmp_path, "deploy", "#!/bin/bash\nfoo() { echo hi; }\n", ) assert self.parser.detect_language(p) == "bash" def test_detect_shebang_bin_sh_routed_to_bash(self, tmp_path): """/bin/sh scripts are parsed through the bash grammar.""" p = self._write_shebang_file( tmp_path, "install-hook", "#!/bin/sh\necho hello\n", ) assert self.parser.detect_language(p) == "bash" def test_detect_shebang_env_bash(self, tmp_path): p = self._write_shebang_file( tmp_path, "runner", "#!/usr/bin/env bash\nfoo() { echo hi; }\n", ) assert self.parser.detect_language(p) == "bash" def test_detect_shebang_env_python3(self, tmp_path): p = self._write_shebang_file( tmp_path, "myapp", "#!/usr/bin/env python3\ndef main():\n pass\n", ) assert self.parser.detect_language(p) == "python" def test_detect_shebang_direct_python(self, tmp_path): p = self._write_shebang_file( tmp_path, "tool", "#!/usr/bin/python3\nprint('hi')\n", ) assert self.parser.detect_language(p) == "python" def test_detect_shebang_node(self, tmp_path): p = self._write_shebang_file( tmp_path, "cli", "#!/usr/bin/env node\nconsole.log(1);\n", ) assert self.parser.detect_language(p) == "javascript" def test_detect_shebang_env_dash_s_flag(self, tmp_path): """``#!/usr/bin/env -S node --flag`` (Linux -S) resolves to the interpreter.""" p = self._write_shebang_file( tmp_path, "esm-tool", "#!/usr/bin/env -S node --experimental-vm-modules\n" "console.log('esm');\n", ) assert self.parser.detect_language(p) == "javascript" def test_detect_shebang_ruby(self, tmp_path): p = self._write_shebang_file( tmp_path, "rake-task", "#!/usr/bin/env ruby\nputs 1\n", ) assert self.parser.detect_language(p) == "ruby" def test_detect_shebang_perl(self, tmp_path): p = self._write_shebang_file( tmp_path, "cgi-script", "#!/usr/bin/env perl\nprint 1;\n", ) assert self.parser.detect_language(p) == "perl" def test_detect_shebang_with_trailing_flags(self, tmp_path): """``#!/bin/bash -e`` still maps to bash (flags ignored).""" p = self._write_shebang_file( tmp_path, "strict", "#!/bin/bash -e\nfoo() { echo hi; }\n", ) assert self.parser.detect_language(p) == "bash" def test_detect_shebang_missing_returns_none(self, tmp_path): """Extension-less text files without a shebang return None, not bash.""" p = self._write_shebang_file( tmp_path, "README", "# just a readme, no shebang\nsome content\n", ) assert self.parser.detect_language(p) is None def test_detect_shebang_empty_file_returns_none(self, tmp_path): p = tmp_path / "EMPTY" p.write_bytes(b"") assert self.parser.detect_language(p) is None def test_detect_shebang_binary_content_returns_none(self, tmp_path): """A garbage-byte first line that happens not to start with ``#!`` must not raise and must return None.""" p = tmp_path / "binary-blob" p.write_bytes(b"\x00\x01\x02\x03 garbage bytes not a shebang\n") assert self.parser.detect_language(p) is None def test_detect_shebang_unknown_interpreter_returns_none(self, tmp_path): """A valid shebang to an interpreter we don't route is treated as 'unknown language' — same as an unmapped extension.""" p = self._write_shebang_file( tmp_path, "ocaml-script", "#!/usr/bin/env ocaml\nlet x = 1\n", ) assert self.parser.detect_language(p) is None def test_detect_shebang_does_not_override_extension(self, tmp_path): """A file with a known extension must still use extension-based detection, even if its first line is a misleading shebang.""" p = tmp_path / "script.py" p.write_text("#!/bin/bash\nprint('hi')\n", encoding="utf-8") # .py wins over the bash shebang — non-intuitive-looking content # in a .py file must not fool the detector. assert self.parser.detect_language(p) == "python" def test_parse_shebang_script_produces_function_nodes(self, tmp_path): """End-to-end regression: an extension-less bash script is not only detected but also fully parsed into structural nodes via parse_file. """ script = ( "#!/usr/bin/env bash\n" "greet() {\n" ' echo "hi $1"\n' "}\n" "main() {\n" " greet world\n" "}\n" "main\n" ) p = self._write_shebang_file(tmp_path, "deploy", script) nodes, edges = self.parser.parse_file(p) # We at least got the File node plus both functions. assert len(nodes) >= 3 funcs = [n for n in nodes if n.kind == "Function"] func_names = {f.name for f in funcs} assert "greet" in func_names assert "main" in func_names for n in nodes: assert n.language == "bash" def test_parse_bytes_shebang_language_from_snapshot_not_disk(self, tmp_path): """Regression for #746: ``parse_bytes`` must derive the language from the byte snapshot it was given, not from a re-read of the file. Simulates a save racing the indexer: an editor's truncate+rewrite save has just emptied the extension-less script on disk while the indexer parses its complete snapshot. If the shebang probe re-reads the disk it sees an empty file, detects no language, and a complete snapshot parses to zero nodes — stored under the snapshot's (final) file hash. """ p = self._write_shebang_file( tmp_path, "tool", "#!/usr/bin/env python3\n\ndef damaged():\n return 1\n", ) snapshot = p.read_bytes() p.write_bytes(b"") # the racing save has truncated the file nodes, _ = self.parser.parse_bytes(p, snapshot) func_names = {n.name for n in nodes if n.kind == "Function"} assert "damaged" in func_names for n in nodes: assert n.language == "python" def test_detect_language_uses_provided_source_over_disk(self, tmp_path): """With pre-read source bytes, shebang detection must not touch disk.""" p = tmp_path / "tool" p.write_bytes(b"") # on-disk content is mid-save (empty) source = b"#!/usr/bin/env python3\nprint(1)\n" assert self.parser.detect_language(p, source) == "python" def test_detect_language_without_source_still_probes_disk(self, tmp_path): """Path-only callers (file filters) keep the on-disk shebang probe.""" p = self._write_shebang_file( tmp_path, "runner", "#!/usr/bin/env bash\necho hi\n", ) assert self.parser.detect_language(p) == "bash" def test_parse_python_file(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py") # Should have File node file_nodes = [n for n in nodes if n.kind == "File"] assert len(file_nodes) == 1 # Should find classes classes = [n for n in nodes if n.kind == "Class"] class_names = {c.name for c in classes} assert "BaseService" in class_names assert "AuthService" in class_names # Should find functions funcs = [n for n in nodes if n.kind == "Function"] func_names = {f.name for f in funcs} assert "__init__" in func_names assert "authenticate" in func_names assert "create_auth_service" in func_names assert "process_request" in func_names def test_parse_python_class_decorators_persisted(self): """Stacked Python class decorators reach downstream metadata consumers.""" from code_review_graph.flows import _has_framework_decorator source = b""" @Component(\"widget-card\") @dataclass(frozen=True) class Widget: pass class Plain: pass """ nodes, _ = self.parser.parse_bytes(Path("models.py"), source) widget = next(node for node in nodes if node.name == "Widget") plain = next(node for node in nodes if node.name == "Plain") expected = ["Component(\"widget-card\")", "dataclass(frozen=True)"] assert widget.kind == "Class" assert widget.modifiers == ",".join(expected) assert widget.extra["decorators"] == expected assert _has_framework_decorator(widget) assert plain.modifiers is None assert "decorators" not in plain.extra def test_parse_python_edges(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py") edge_kinds = {e.kind for e in edges} assert "CONTAINS" in edge_kinds assert "IMPORTS_FROM" in edge_kinds assert "CALLS" in edge_kinds # Should detect inheritance inherits = [e for e in edges if e.kind == "INHERITS"] assert len(inherits) >= 1 assert any("AuthService" in e.source and "BaseService" in e.target for e in inherits) def test_parse_python_imports(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py") imports = [e for e in edges if e.kind == "IMPORTS_FROM"] import_targets = {e.target for e in imports} assert "os" in import_targets assert "pathlib" in import_targets def test_parse_python_calls(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py") calls = [e for e in edges if e.kind == "CALLS"] call_targets = {e.target for e in calls} # _resolve_call_targets qualifies same-file definitions assert any("_validate_token" in t for t in call_targets) assert any("authenticate" in t for t in call_targets) def test_parse_typescript_file(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_typescript.ts") classes = [n for n in nodes if n.kind == "Class"] class_names = {c.name for c in classes} assert "UserRepository" in class_names assert "UserService" in class_names funcs = [n for n in nodes if n.kind == "Function"] func_names = {f.name for f in funcs} assert "findById" in func_names or "handleGetUser" in func_names def test_parse_test_file(self): nodes, edges = self.parser.parse_file(FIXTURES / "test_sample.py") # Test functions should be detected tests = [n for n in nodes if n.kind == "Test"] test_names = {t.name for t in tests} assert "test_authenticate_valid" in test_names assert "test_process_request_ok" in test_names def test_calls_edge_same_file_resolution(self): """Call targets defined in the same file should be qualified.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py") calls = [e for e in edges if e.kind == "CALLS"] file_path = (FIXTURES / "sample_python.py").as_posix() # create_auth_service() calls AuthService() — a class defined in the same file auth_service_calls = [ e for e in calls if e.target == f"{file_path}::AuthService" ] assert len(auth_service_calls) >= 1 def test_calls_edge_cross_file_resolution(self): """Call targets imported from another file should resolve to that file's qualified name.""" _, edges = self.parser.parse_file(FIXTURES / "caller_example.py") calls = [e for e in edges if e.kind == "CALLS"] sample_path = (FIXTURES / "sample_python.py").resolve().as_posix() # setup_and_run() calls create_auth_service(), imported from sample_python resolved_calls = [ e for e in calls if e.target == f"{sample_path}::create_auth_service" ] assert len(resolved_calls) == 1 def test_same_file_calls_resolved(self): """Same-file call targets should be resolved to qualified names.""" _, edges = self.parser.parse_file(FIXTURES / "sample_python.py") calls = [e for e in edges if e.kind == "CALLS"] # _validate_token is defined in the same file, so it should be qualified resolved_calls = [e for e in calls if "_validate_token" in e.target and "::" in e.target] assert len(resolved_calls) >= 1 def test_calls_edge_decorated_function_resolution(self): """Decorated functions should be in defined_names and resolvable as call targets.""" _, edges = self.parser.parse_file(FIXTURES / "sample_python.py") calls = [e for e in edges if e.kind == "CALLS"] file_path = (FIXTURES / "sample_python.py").as_posix() # guarded_process() calls process_request() — both in the same file, # but guarded_process is wrapped in a decorated_definition node resolved = [e for e in calls if e.target == f"{file_path}::process_request" and "guarded_process" in e.source] assert len(resolved) == 1 def test_multiple_calls_to_same_function(self): """Multiple calls to the same function on different lines should each produce an edge.""" _, edges = self.parser.parse_file(FIXTURES / "multi_call_example.py") calls = [e for e in edges if e.kind == "CALLS" and "_internal_request" in e.target] assert len(calls) == 2 lines = {e.line for e in calls} assert len(lines) == 2 # distinct line numbers def test_module_scope_calls_attributed_to_file(self): """Module-scope calls (script glue, top-level code) emit CALLS edges attributed to the File node, so callees aren't flagged as dead by find_dead_code. Regression test: prior to this fix, _extract_calls dropped the edge entirely when enclosing_func was None, leaving notebooks, CLI scripts, and top-level entry points with zero outgoing CALLS edges. """ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write( "def helper():\n" " return 42\n" "\n" "# Module-scope call — no enclosing function\n" "result = helper()\n" ) tmp = Path(f.name) try: _, edges = self.parser.parse_file(tmp) calls = [e for e in edges if e.kind == "CALLS"] module_scope_calls = [e for e in calls if e.source == tmp.as_posix()] assert any( "helper" in e.target for e in module_scope_calls ), f"Expected module-scope CALLS edge to helper(); got: {[(e.source, e.target) for e in calls]}" finally: tmp.unlink() def test_module_scope_calls_in_notebook(self): """Notebook code cells are entirely module-scope — every call inside them should produce a CALLS edge attributed to the .ipynb File node.""" import json notebook = { "cells": [ { "cell_type": "code", "source": [ "from helper_module import do_work\n", "do_work()\n", ], }, ], "metadata": {"language_info": {"name": "python"}}, "nbformat": 4, "nbformat_minor": 5, } with tempfile.NamedTemporaryFile(mode="w", suffix=".ipynb", delete=False) as f: json.dump(notebook, f) tmp = Path(f.name) try: _, edges = self.parser.parse_file(tmp) calls = [e for e in edges if e.kind == "CALLS"] assert any( "do_work" in e.target and e.source == tmp.as_posix() for e in calls ), f"Expected notebook CALLS edge to do_work(); got: {[(e.source, e.target) for e in calls]}" finally: tmp.unlink() def test_parse_nonexistent_file(self): nodes, edges = self.parser.parse_file(Path("/nonexistent/file.py")) assert nodes == [] assert edges == [] def test_parse_unsupported_extension(self): nodes, edges = self.parser.parse_file(Path("readme.txt")) assert nodes == [] assert edges == [] def test_tested_by_edges_generated(self): """Test files should produce TESTED_BY edges when tests call production code.""" nodes, edges = self.parser.parse_file(FIXTURES / "test_sample.py") tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1 def test_tested_by_edge_direction(self): """Regression for #515: TESTED_BY must point production -> test. Producer-side guard. Reads naturally as "X is tested by Y": source = production code, target = the test that covers it. Consumer-side queries (tests_for, get_transitive_tests, test-gap detection, flow criticality, dead-code) were fixed in #515 to match this canonical direction. Without this assertion the parser could silently flip the direction and every consumer test would still pass against the inverted edges. """ nodes, edges = self.parser.parse_file(FIXTURES / "test_sample.py") tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1, "fixture should yield at least one TESTED_BY edge" test_file = (FIXTURES / "test_sample.py").as_posix() test_qualified = { f"{test_file}::{n.name}" for n in nodes if n.kind == "Test" } assert test_qualified, "fixture should yield at least one Test node" for edge in tested_by: assert edge.target in test_qualified, ( f"TESTED_BY edge has wrong direction: target={edge.target!r} " f"is not a Test node from {test_file}. " f"Expected target in {sorted(test_qualified)}. " f"Edge: kind={edge.kind} source={edge.source} target={edge.target}" ) assert edge.source not in test_qualified, ( f"TESTED_BY edge points test -> test: " f"{edge.source} -> {edge.target}" ) def test_recursion_depth_guard(self): """Parser should not crash on deeply nested code.""" # Generate Python code with many nested functions (> _MAX_AST_DEPTH) depth = 200 lines = [] for i in range(depth): indent = " " * i lines.append(f"{indent}def func_{i}():") lines.append(" " * depth + "pass") source = "\n".join(lines).encode("utf-8") import tempfile with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: f.write(source) f.flush() path = Path(f.name) try: # Should NOT raise RecursionError nodes, edges = self.parser.parse_bytes(path, source) # We should get some functions but not all 200 due to depth cap funcs = [n for n in nodes if n.kind == "Function"] assert len(funcs) > 0 assert len(funcs) < depth # capped by _MAX_AST_DEPTH finally: path.unlink(missing_ok=True) def test_module_file_cache_bounded(self): """Module file cache should not grow unboundedly.""" parser = CodeParser() # Fill the cache up to the limit for i in range(parser._MODULE_CACHE_MAX + 100): parser._module_file_cache[f"key_{i}"] = f"/path/to/mod_{i}.py" # Trigger a resolve which should clear the cache parser._resolve_module_to_file("os", "/test/file.py", "python") assert len(parser._module_file_cache) <= parser._MODULE_CACHE_MAX # --- Vue SFC tests --- def test_detect_language_vue(self): assert self.parser.detect_language(Path("App.vue")) == "vue" def test_parse_vue_file(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue") # Should have File node with language=vue file_nodes = [n for n in nodes if n.kind == "File"] assert len(file_nodes) == 1 assert file_nodes[0].language == "vue" # Should find functions from \n" ) path = Path("js_default.vue") nodes, edges = self.parser.parse_bytes(path, source) funcs = [n for n in nodes if n.kind == "Function"] func_names = {f.name for f in funcs} assert "greet" in func_names # --- Dart tests --- def test_detect_language_dart(self): assert self.parser.detect_language(Path("main.dart")) == "dart" def test_parse_dart_file(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") file_nodes = [n for n in nodes if n.kind == "File"] assert len(file_nodes) == 1 assert file_nodes[0].language == "dart" classes = [n for n in nodes if n.kind == "Class"] class_names = {c.name for c in classes} assert "Animal" in class_names assert "Dog" in class_names assert "SwimmingMixin" in class_names assert "PetType" in class_names funcs = [n for n in nodes if n.kind == "Function"] func_names = {f.name for f in funcs} assert "speak" in func_names assert "fetch" in func_names assert "_run" in func_names assert "create" in func_names assert "createDog" in func_names assert "swim" in func_names def test_parse_dart_imports(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") imports = [e for e in edges if e.kind == "IMPORTS_FROM"] import_targets = {e.target for e in imports} assert "dart:async" in import_targets assert "package:flutter/material.dart" in import_targets def test_parse_dart_inheritance(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") inherits = [e for e in edges if e.kind == "INHERITS"] assert any("Dog" in e.source and "Animal" in e.target for e in inherits) assert any("Dog" in e.source and "SwimmingMixin" in e.target for e in inherits) def test_parse_dart_contains_edges(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") contains = [e for e in edges if e.kind == "CONTAINS"] # File should contain top-level classes and functions file_path = (FIXTURES / "sample.dart").as_posix() file_contains = [e for e in contains if e.source == file_path] assert len(file_contains) >= 1 # Dog class should contain its methods dog_contains = [e for e in contains if "Dog" in e.source] dog_targets = {e.target for e in dog_contains} assert any("speak" in t for t in dog_targets) assert any("fetch" in t for t in dog_targets) def test_parse_dart_method_parent(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") funcs = [n for n in nodes if n.kind == "Function"] # Both Animal and Dog define speak(); check Dog's specifically dog_speak = next( (f for f in funcs if f.name == "speak" and f.parent_name == "Dog"), None, ) assert dog_speak is not None def test_parse_dart_top_level_function_no_parent(self): nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") funcs = [n for n in nodes if n.kind == "Function"] create_dog = next((f for f in funcs if f.name == "createDog"), None) assert create_dog is not None assert create_dog.parent_name is None def test_parse_dart_call_edges(self): """Dart CALLS extraction (#87 bug 1). tree-sitter-dart doesn't wrap calls in a single ``call_expression`` node so the parser has a Dart-specific walker that detects ``identifier + selector > argument_part`` patterns. Verify we capture builtin calls (``print``), constructor calls (``Dog(...)``), and internal method calls (``_run()``). """ nodes, edges = self.parser.parse_file(FIXTURES / "sample.dart") calls = [e for e in edges if e.kind == "CALLS"] assert calls, "expected at least one CALLS edge for Dart" targets = [e.target for e in calls] # Builtin print is called at least twice in sample.dart assert sum(1 for t in targets if t == "print") >= 2 # _run() is called inside Dog.fetch(); the call target should # either be the bare name "_run" or a qualified form ending in # "::Dog._run" once the call resolver has run. assert any(t == "_run" or t.endswith("::Dog._run") for t in targets), ( f"expected _run() call, got targets: {targets}" ) # Dog(name) constructor call from createDog() — target may be # bare "Dog" or qualified "...::Dog". assert any(t == "Dog" or t.endswith("::Dog") for t in targets), ( f"expected Dog() constructor call, got targets: {targets}" ) # --- tsconfig alias resolution --- def test_tsconfig_alias_resolution(self): """Alias imports should resolve to absolute file paths.""" nodes, edges = self.parser.parse_file(FIXTURES / "alias_importer.ts") imports = [e for e in edges if e.kind == "IMPORTS_FROM"] resolved_imports = [e for e in imports if e.target.endswith("utils.ts")] assert len(resolved_imports) >= 1, ( f"Expected resolved alias import, got targets: {[e.target for e in imports]}" ) def test_tsconfig_missing_gracefully_handled(self): """Files without a tsconfig should still parse without errors.""" import os import tempfile with tempfile.TemporaryDirectory() as tmp_dir: tmp_path = os.path.join(tmp_dir, "no_tsconfig_file.ts") with open(tmp_path, "w") as f: f.write('import { foo } from "@/bar";\nexport const x = 1;\n') nodes, edges = self.parser.parse_file(Path(tmp_path)) imports = [e for e in edges if e.kind == "IMPORTS_FROM"] assert any("@/bar" in e.target for e in imports) # --- Vitest/Jest test detection --- def test_vitest_test_detection(self): """Vitest describe/it/test calls should produce Test nodes.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts") tests = [n for n in nodes if n.kind == "Test"] test_names = {t.name for t in tests} assert any(n.startswith("describe") or n.startswith("describe:") for n in test_names), ( f"Expected describe Test node, got: {test_names}" ) assert any(n.startswith("it:") or n.startswith("test:") for n in test_names), ( f"Expected it/test Test node, got: {test_names}" ) def test_vitest_contains_edges(self): """describe Test nodes should CONTAIN it/test Test nodes.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts") describe_nodes = [ n for n in nodes if n.kind == "Test" and (n.name.startswith("describe") or n.name.startswith("describe:")) ] assert len(describe_nodes) >= 1 it_tests = [ n for n in nodes if n.kind == "Test" and (n.name.startswith("it:") or n.name.startswith("test:")) ] assert len(it_tests) >= 2 file_path = (FIXTURES / "sample_vitest.test.ts").as_posix() describe_qualified = {f"{file_path}::{n.name}" for n in describe_nodes} contains_sources = {e.source for e in edges if e.kind == "CONTAINS"} assert describe_qualified & contains_sources def test_vitest_calls_edges(self): """Calls inside test blocks should produce CALLS edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts") calls = [e for e in edges if e.kind == "CALLS"] assert len(calls) >= 1 test_names = {n.name for n in nodes if n.kind == "Test"} file_path = (FIXTURES / "sample_vitest.test.ts").as_posix() test_qualified = {f"{file_path}::{name}" for name in test_names} call_sources = {e.source for e in calls} assert call_sources & test_qualified def test_vitest_tested_by_edges(self): """TESTED_BY edges should be generated from test calls to production code.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts") tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1, ( f"Expected TESTED_BY edges, got none. " f"All edges: {[(e.kind, e.source, e.target) for e in edges]}" ) # --- Python callback REFERENCES (#363) --- # Functions passed as bare-identifier arguments (executor.submit(fn), # filter(fn, xs), map(fn, xs), df.apply(fn), ...) should produce # REFERENCES edges so dead-code detection does not flag them as unused. # Pre-fix: only the JS/TS `arguments` node type triggered the # _ref_from_arguments dispatcher; Python's `argument_list` was ignored. def test_python_callback_references_emitted(self): """A function passed as a bare identifier to another call should produce a REFERENCES edge from the calling function to it.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_callback_refs.py") refs = [e for e in edges if e.kind == "REFERENCES"] ref_target_names = {e.target.rsplit("::", 1)[-1] for e in refs} for callback in ("executor_callback", "filter_callback", "map_callback"): assert callback in ref_target_names, ( f"Expected REFERENCES edge to {callback}, got targets: " f"{ref_target_names}" ) def test_python_callback_references_not_treated_as_dead(self): """End-to-end: with REFERENCES edges in place, find_dead_code should not flag callback functions as dead.""" from code_review_graph.graph import GraphStore from code_review_graph.refactor import find_dead_code with tempfile.TemporaryDirectory() as tmp_dir: db_path = Path(tmp_dir) / "graph.db" store = GraphStore(db_path) try: nodes, edges = self.parser.parse_file( FIXTURES / "sample_callback_refs.py" ) store.store_file_nodes_edges( str(FIXTURES / "sample_callback_refs.py"), nodes, edges, "", ) dead = find_dead_code(store) dead_names = {d["name"] for d in dead} for callback in ( "executor_callback", "filter_callback", "map_callback", ): assert callback not in dead_names, ( f"{callback} was flagged as dead but is used as a " f"callback. Dead names: {dead_names}" ) finally: store.close() # --- Bun test detection (regression: bun:test uses identical runner names) --- def test_bun_test_detection(self): """A .test.ts file importing from 'bun:test' should produce Test nodes.""" nodes, _ = self.parser.parse_file(FIXTURES / "sample_bun.test.ts") tests = [n for n in nodes if n.kind == "Test"] test_names = {t.name for t in tests} assert any(n.startswith("describe") or n.startswith("describe:") for n in test_names), ( f"Expected describe Test node, got: {test_names}" ) assert any(n.startswith("it:") or n.startswith("test:") for n in test_names), ( f"Expected it/test Test node, got: {test_names}" ) def test_bun_tested_by_edges(self): """TESTED_BY edges should be generated from bun tests to production code.""" _, edges = self.parser.parse_file(FIXTURES / "sample_bun.test.ts") tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1, ( f"Expected TESTED_BY edges, got none. " f"All edges: {[(e.kind, e.source, e.target) for e in edges]}" ) # --- __tests__/ directory recognition (Jest convention) --- # Consistency fix: flows.py and refactor.py already recognize __tests__/ # but parser.py did not, so files there did not produce Test nodes. def test_jest_tests_dir_detected_as_test_file(self): """A file under __tests__/ should be classified as a test file even when the filename itself has no .test./.spec. marker.""" from code_review_graph.parser import _is_test_file assert _is_test_file("src/__tests__/UserService.ts") assert _is_test_file("src\\__tests__\\UserService.ts") # Negative: __tests__ as a substring without path separators must not match assert not _is_test_file("my__tests__notdir.ts") def test_jest_tests_dir_produces_test_nodes(self): """A vitest-style file under __tests__/ should yield Test nodes and TESTED_BY edges, the same as a *.test.ts file.""" fixture_path = FIXTURES / "__tests__" / "UserService.ts" fixture_code = fixture_path.read_text(encoding="utf-8") with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / "src" / "__tests__" / "UserService.ts" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(fixture_code, encoding="utf-8") nodes, edges = self.parser.parse_file(path) tests = [n for n in nodes if n.kind == "Test"] test_names = {t.name for t in tests} assert any(n.startswith("describe") or n.startswith("describe:") for n in test_names), ( f"Expected describe Test node, got: {test_names}" ) tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1, ( f"Expected TESTED_BY edges from __tests__/ file, got none. " f"Edges: {[(e.kind, e.source, e.target) for e in edges]}" ) # --- Mocha TDD interface (suite/test) --- # Mocha's TDD UI uses `suite()` instead of `describe()`. The `test()` # function is already recognized; this verifies `suite()` is too. def test_mocha_tdd_suite_produces_test_nodes(self): """A *.test.ts file using `suite()` should produce Test nodes and TESTED_BY edges, the same as a describe()-based file.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_mocha.test.ts") tests = [n for n in nodes if n.kind == "Test"] test_names = {t.name for t in tests} assert any(n.startswith("suite") or n.startswith("suite:") for n in test_names), ( f"Expected suite Test node, got: {test_names}" ) assert any(n.startswith("test:") for n in test_names), ( f"Expected test Test node, got: {test_names}" ) tested_by = [e for e in edges if e.kind == "TESTED_BY"] assert len(tested_by) >= 1, ( f"Expected TESTED_BY edges, got none. " f"Edges: {[(e.kind, e.source, e.target) for e in edges]}" ) def test_non_test_file_describe_not_special(self): """describe() in a non-test file should NOT create Test nodes.""" import tempfile code = ( b'function describe(name, fn) { fn(); }\n' b'describe("test", () => { console.log("hello"); });\n' ) with tempfile.NamedTemporaryFile(suffix=".ts", delete=False, prefix="regular_") as f: f.write(code) tmp_path = Path(f.name) try: nodes, edges = self.parser.parse_file(tmp_path) tests = [n for n in nodes if n.kind == "Test"] assert len(tests) == 0, ( f"Non-test file should not have Test nodes, got: {[t.name for t in tests]}" ) finally: tmp_path.unlink(missing_ok=True) # --- JSX component CALLS tests --- def test_tsx_jsx_component_invocation_creates_call_edge(self): source = ( b"import MarkdownMsg from './MarkdownMsg';\n\n" b"export function BookWorkspace() {\n" b" return
;\n" b"}\n" ) path = FIXTURES / "BookWorkspace.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = f"{(FIXTURES / 'MarkdownMsg.tsx').resolve().as_posix()}::MarkdownMsg" jsx_calls = [ e for e in calls if e.source == f"{path.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_tsx_intrinsic_dom_elements_do_not_create_call_edges(self): source = ( b"export function BookWorkspace() {\n" b" return
;\n" b"}\n" ) path = FIXTURES / "BookWorkspace.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] assert calls == [] def test_tsx_member_component_invocation_creates_unqualified_call_edge(self): source = ( b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) path = FIXTURES / "BookWorkspace.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] jsx_calls = [ e for e in calls if e.source == f"{path.as_posix()}::BookWorkspace" and e.target == "MarkdownMsg" ] assert len(jsx_calls) == 1 def test_tsx_namespace_import_component_invocation_resolves_to_module_file(self): source = ( b"import * as UI from './MarkdownMsg';\n\n" b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) path = FIXTURES / "BookWorkspace.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = f"{(FIXTURES / 'MarkdownMsg.tsx').resolve().as_posix()}::MarkdownMsg" jsx_calls = [ e for e in calls if e.source == f"{path.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_tsx_nested_member_component_invocation_resolves_namespace_root(self): source = ( b"import * as UI from './MarkdownMsg';\n\n" b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) path = FIXTURES / "BookWorkspace.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = f"{(FIXTURES / 'MarkdownMsg.tsx').resolve().as_posix()}::MarkdownMsg" jsx_calls = [ e for e in calls if e.source == f"{path.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_tsx_barrel_reexport_resolves_component_to_origin_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) (root / "components").mkdir() (root / "components" / "MarkdownMsg.tsx").write_text( "export function MarkdownMsg() { return
; }\n", encoding="utf-8", ) (root / "components" / "index.ts").write_text( "export { MarkdownMsg } from './MarkdownMsg';\n", encoding="utf-8", ) consumer = root / "BookWorkspace.tsx" source = ( b"import { MarkdownMsg } from './components';\n\n" b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) _, edges = self.parser.parse_bytes(consumer, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = ( f"{(root / 'components' / 'MarkdownMsg.tsx').resolve().as_posix()}" "::MarkdownMsg" ) jsx_calls = [ e for e in calls if e.source == f"{consumer.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_tsx_barrel_aliased_reexport_resolves_component_to_origin_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) (root / "components").mkdir() (root / "components" / "MarkdownMsg.tsx").write_text( "export function MarkdownMsg() { return
; }\n", encoding="utf-8", ) (root / "components" / "index.ts").write_text( "export { MarkdownMsg as Msg } from './MarkdownMsg';\n", encoding="utf-8", ) consumer = root / "BookWorkspace.tsx" source = ( b"import { Msg } from './components';\n\n" b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) _, edges = self.parser.parse_bytes(consumer, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = ( f"{(root / 'components' / 'MarkdownMsg.tsx').resolve().as_posix()}" "::MarkdownMsg" ) jsx_calls = [ e for e in calls if e.source == f"{consumer.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_tsx_barrel_star_reexport_resolves_component_to_origin_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) (root / "components").mkdir() (root / "components" / "MarkdownMsg.tsx").write_text( "export function MarkdownMsg() { return
; }\n", encoding="utf-8", ) (root / "components" / "index.ts").write_text( "export * from './MarkdownMsg';\n", encoding="utf-8", ) consumer = root / "BookWorkspace.tsx" source = ( b"import { MarkdownMsg } from './components';\n\n" b"export function BookWorkspace() {\n" b" return ;\n" b"}\n" ) _, edges = self.parser.parse_bytes(consumer, source) calls = [e for e in edges if e.kind == "CALLS"] expected_target = ( f"{(root / 'components' / 'MarkdownMsg.tsx').resolve().as_posix()}" "::MarkdownMsg" ) jsx_calls = [ e for e in calls if e.source == f"{consumer.as_posix()}::BookWorkspace" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_grimoire_style_jsx_fixture_tracks_all_component_call_sites(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) components = root / "components" components.mkdir() (components / "MarkdownMsg.jsx").write_text( "export function MarkdownMsg({ text }) { return
{text}
; }\n", encoding="utf-8", ) (components / "index.js").write_text( "export { MarkdownMsg } from './MarkdownMsg';\n", encoding="utf-8", ) consumer = root / "BookWorkspace.jsx" consumer.write_text( "import { MarkdownMsg } from './components';\n\n" "export function BookDashboard() {\n" " return (\n" " <>\n" " \n" " \n" " \n" " \n" " );\n" "}\n\n" "export function AIPanel() {\n" " return (\n" " <>\n" " \n" " \n" " \n" " );\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(consumer) expected_target = ( f"{(components / 'MarkdownMsg.jsx').resolve().as_posix()}::MarkdownMsg" ) jsx_calls = [ e for e in edges if e.kind == "CALLS" and e.target == expected_target ] by_source = {} for edge in jsx_calls: by_source[edge.source] = by_source.get(edge.source, 0) + 1 assert by_source == { f"{consumer.as_posix()}::BookDashboard": 3, f"{consumer.as_posix()}::AIPanel": 2, } def test_nested_barrel_chain_resolves_component_to_origin_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) messages = root / "components" / "messages" messages.mkdir(parents=True) (messages / "MarkdownMsg.jsx").write_text( "export function MarkdownMsg({ text }) { return
{text}
; }\n", encoding="utf-8", ) (messages / "index.js").write_text( "export { MarkdownMsg } from './MarkdownMsg';\n", encoding="utf-8", ) (root / "components" / "index.js").write_text( "export { MarkdownMsg as Msg } from './messages';\n", encoding="utf-8", ) consumer = root / "BookWorkspace.jsx" consumer.write_text( "import { Msg } from './components';\n\n" "export function BookDashboard() {\n" " return ;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(consumer) expected_target = ( f"{(messages / 'MarkdownMsg.jsx').resolve().as_posix()}::MarkdownMsg" ) jsx_calls = [ e for e in edges if e.kind == "CALLS" and e.source == f"{consumer.as_posix()}::BookDashboard" and e.target == expected_target ] assert len(jsx_calls) == 1 def test_junit_annotation_marks_test(self): """Java @Test annotation should mark functions as tests.""" nodes, _ = self.parser.parse_bytes( Path("/src/MyTest.java"), b"class MyTest {\n" b" @Test\n" b" void verifyBehavior() { }\n" b" void helperMethod() { }\n" b"}\n", ) test_nodes = [n for n in nodes if n.is_test] test_names = {n.name for n in test_nodes} assert "verifyBehavior" in test_names assert "helperMethod" not in test_names def test_kotlin_test_annotation_marks_test(self): """Kotlin @Test annotation should mark functions as tests.""" nodes, _ = self.parser.parse_bytes( Path("/src/SampleTest.kt"), b"class SampleTest {\n" b" @Test fun checkResult() { }\n" b" fun setup() { }\n" b"}\n", ) test_nodes = [n for n in nodes if n.is_test] test_names = {n.name for n in test_nodes} assert "checkResult" in test_names assert "setup" not in test_names def test_detects_test_functions(self): """Functions with test-like names should be marked is_test=True.""" nodes, _ = self.parser.parse_bytes( Path("/src/test_example.py"), b"def test_something(): pass\n" b"def helper(): pass\n", ) test_nodes = [n for n in nodes if n.is_test] test_names = {n.name for n in test_nodes} assert "test_something" in test_names assert "helper" not in test_names def test_c_dead_guard_if0_omits_dead_edges(self): """CALLS edges inside ``#if 0`` / ``#elif 0`` blocks in C are never emitted, including when the block wraps a whole function. Calls in the ``#else`` / ``#elif`` branches of ``#if 0`` are live and must be kept. Python's ast-based detector cannot reach C, so this is handled by the tree-sitter dead-guard walk.""" _nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.c", ) calls = [e for e in edges if e.kind == "CALLS"] def hits(name): return [e for e in calls if e.target.split("::")[-1] == name] # live_helper: emitted (no guard) assert len(hits("live_helper")) == 1 # dead_in_if0: NOT emitted (#if 0 consequence) assert hits("dead_in_if0") == [] # live_in_else: emitted (#else of #if 0 is live) assert len(hits("live_in_else")) == 1 # dead_in_elifblock: NOT emitted (#if 0 consequence, elif form) assert hits("dead_in_elifblock") == [] # live_in_elif: emitted (#elif of #if 0 is live). Regression # guard: a detector excluding only preproc_else marks it dead. assert len(hits("live_in_elif")) == 1 # dead_in_wrapped: NOT emitted. The call sits in a function that # is itself inside #if 0 -- the scope-agnostic preprocessor walk # must not stop at the function_definition. assert hits("dead_in_wrapped") == [] # live_in_if1: emitted (#if 1 branch is taken) assert len(hits("live_in_if1")) == 1 # dead_in_elif0: NOT emitted (#elif 0 consequence is dead) assert hits("dead_in_elif0") == [] # Total: exactly 4 live edges assert len(calls) == 4 def test_go_dead_guard_if_false_omits_dead_edges(self): """CALLS edges inside ``if false`` blocks in Go are never emitted. Go's ``if_statement`` and ``false`` literal are detected by the tree-sitter dead-guard walk. Else branches and ``if true`` stay live.""" _nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.go", ) calls = [e for e in edges if e.kind == "CALLS"] def hits(name): return [e for e in calls if e.target.split("::")[-1] == name] # live_helper: emitted (no guard) assert len(hits("live_helper")) == 1 # dead_false_call: NOT emitted (if false consequence in caller) assert hits("dead_false_call") == [] # dead_in_consequence: NOT emitted (if false consequence) assert hits("dead_in_consequence") == [] # live_in_else: emitted (else branch of if false) assert len(hits("live_in_else")) == 1 # live_final_else: emitted (inside else branch, nested if) assert len(hits("live_final_else")) == 1 # live_in_wrapped: emitted (func def is at module scope, not # inside if false -- Go forbids func decl in if blocks) assert len(hits("live_in_wrapped")) == 1 # some_condition: emitted (called in else branch, nested if) assert len(hits("some_condition")) == 1 # live_in_if_true: emitted (if true is NOT a dead guard) assert len(hits("live_in_if_true")) == 1 # Total: exactly 6 live edges assert len(calls) == 6 def test_ts_dead_guard_if_false_omits_dead_edges(self): """CALLS edges inside ``if (false)`` / ``if (0)`` blocks in TypeScript are never emitted. The condition is wrapped in a ``parenthesized_expression`` that must be unwrapped, and the ``0`` literal uses node type ``number``. Else branches and ``if (true)`` are live.""" _nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.ts", ) calls = [e for e in edges if e.kind == "CALLS"] def hits(name): return [e for e in calls if e.target.split("::")[-1] == name] # live_helper: emitted (no guard) assert len(hits("live_helper")) == 1 # dead_false_call: NOT emitted (if (false) consequence) assert hits("dead_false_call") == [] # dead_zero_call: NOT emitted (if (0) consequence) assert hits("dead_zero_call") == [] # dead_in_consequence: NOT emitted (if (false) consequence) assert hits("dead_in_consequence") == [] # live_in_else: emitted (else branch of if (false)) assert len(hits("live_in_else")) == 1 # live_final_else: emitted (else-if chain, live branch) assert len(hits("live_final_else")) == 1 # live_in_if_true: emitted (if (true) is NOT a dead guard) assert len(hits("live_in_if_true")) == 1 # some_condition: emitted (called in else-if condition) assert len(hits("some_condition")) == 1 # Total: exactly 5 live edges assert len(calls) == 5 def test_dead_guard_covers_declarations_nested_in_dead_branch(self): """A function or class declared inside a dead branch is never evaluated, so calls in its body are dead. This matches what the Python ast path does for a ``def``/``class`` under ``if False:``; the walk must not stop at a declaration boundary. JS/TS class declarations are not hoisted, so no reachable symbol is lost.""" with tempfile.TemporaryDirectory() as tmp: src = Path(tmp) / "nested.ts" src.write_text( "function caller() {\n" " if (false) {\n" " function inner_fn() { dead_in_fn(); }\n" " class Inner { method() { dead_in_class(); } }\n" " }\n" " live_after();\n" "}\n" "function sibling() { live_sibling(); }\n", encoding="utf-8", ) _nodes, edges = self.parser.parse_file(src) targets = { e.target.split("::")[-1] for e in edges if e.kind == "CALLS" } # Dead: declared inside the never-evaluated branch. assert "dead_in_fn" not in targets assert "dead_in_class" not in targets # Live: the guard must not leak past the branch it belongs to. assert "live_after" in targets assert "live_sibling" in targets def test_dead_guard_calls_absent_from_graph_store(self): """End-to-end: build a real graph from each non-Python fixture and confirm the consumer-facing store never reports a dead-branch call target. Mirrors the Python store-level check in test_python_reachability.py for C/Go/TS.""" cases = [ ( "sample_dead_guard.c", {"dead_in_if0", "dead_in_wrapped", "dead_in_elif0", "dead_in_elifblock"}, {"live_helper", "live_in_else", "live_in_elif", "live_in_if1"}, ), ( "sample_dead_guard.go", {"dead_false_call", "dead_in_consequence"}, {"live_helper", "live_in_else", "some_condition"}, ), ( "sample_dead_guard.ts", {"dead_false_call", "dead_zero_call", "dead_in_consequence"}, {"live_helper", "live_in_else", "live_in_if_true"}, ), ] for fixture, dead, live in cases: nodes, edges = self.parser.parse_file(FIXTURES / fixture) with tempfile.NamedTemporaryFile( suffix=".db", delete=False, ) as handle: db_path = handle.name try: with GraphStore(db_path) as store: for node in nodes: store.upsert_node(node) for edge in edges: store.upsert_edge(edge) store.commit() targets = { t.split("::")[-1] for t in store.get_all_call_targets() } finally: Path(db_path).unlink(missing_ok=True) for name in dead: assert name not in targets, ( f"{fixture}: dead target {name} leaked into the store" ) for name in live: assert name in targets, ( f"{fixture}: live target {name} missing from the store" ) class TestDeadGuardHelpers: """Direct unit tests for dead-guard helper functions. The bot flagged ``_node_is_in_child``, ``_is_statically_false_condition`` and ``_is_in_static_dead_guard`` as untested. The behaviour-level tests above exercise them through ``parse_file()``, but these tests call them directly with tree-sitter nodes so every branch is provably hit. """ @staticmethod def _parse(lang, source): """Parse *source* and return (root, source_bytes).""" import tree_sitter_language_pack as tsp tree = tsp.get_parser(lang).parse(source) return tree.root_node, source @staticmethod def _find(node, node_type): """Return the first descendant of *node* with the given type.""" if node.type == node_type: return node for child in node.children: found = TestDeadGuardHelpers._find(child, node_type) if found is not None: return found return None @staticmethod def _find_call(node, name): """Return the first ``call_expression`` whose function is *name*.""" if node.type == "call_expression": func = node.child_by_field_name("function") if func is not None and func.text == name: return node for child in node.children: found = TestDeadGuardHelpers._find_call(child, name) if found is not None: return found return None # --- _node_is_in_child --- def test_node_is_in_child_direct(self): """A call directly inside a block is a descendant.""" from code_review_graph.parser import _node_is_in_child root, _ = self._parse("go", b"func f() { g() }") block = self._find(root, "block") call = self._find(root, "call_expression") assert _node_is_in_child(call, block) is True def test_node_is_in_child_nested(self): """A call 3 levels deep is still a descendant.""" from code_review_graph.parser import _node_is_in_child root, _ = self._parse("go", b"func f() { if true { g() } }") outer_block = self._find(root, "block") call = self._find_call(root, b"g") assert _node_is_in_child(call, outer_block) is True def test_node_is_in_child_sibling(self): """A call in the else branch is NOT a descendant of the consequence block.""" from code_review_graph.parser import _node_is_in_child root, _ = self._parse("go", b"func f() { if false { a() } else { b() } }") if_stmt = self._find(root, "if_statement") consequence = if_stmt.child_by_field_name("consequence") call_b = self._find_call(root, b"b") assert _node_is_in_child(call_b, consequence) is False def test_node_is_in_child_self(self): """A node is a descendant of itself.""" from code_review_graph.parser import _node_is_in_child root, _ = self._parse("go", b"func f() { g() }") block = self._find(root, "block") assert _node_is_in_child(block, block) is True def test_node_is_in_child_root(self): """A module-level call is NOT inside an if consequence.""" from code_review_graph.parser import _node_is_in_child root, _ = self._parse("go", b"func f() { g() }\nfunc h() { if false { i() } }") if_stmt = self._find(root, "if_statement") assert if_stmt is not None, "if_statement not found in parse tree" consequence = if_stmt.child_by_field_name("consequence") assert consequence is not None, "consequence field not found" call_g = self._find_call(root, b"g") assert _node_is_in_child(call_g, consequence) is False # --- _is_statically_false_condition --- def test_false_literal(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("go", b"func f() { if false { g() } }") cond = self._find(root, "false") assert _is_statically_false_condition(cond) is True def test_number_zero(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("typescript", b"f(); if (0) { g(); }") cond = self._find(root, "number") assert _is_statically_false_condition(cond) is True def test_parenthesized_false(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("typescript", b"f(); if ((false)) { g(); }") cond = self._find(root, "parenthesized_expression") assert _is_statically_false_condition(cond) is True def test_true_literal(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("go", b"func f() { if true { g() } }") cond = self._find(root, "true") assert _is_statically_false_condition(cond) is False def test_number_one(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("typescript", b"f(); if (1) { g(); }") cond = self._find(root, "number") assert _is_statically_false_condition(cond) is False def test_variable_condition(self): from code_review_graph.parser import _is_statically_false_condition root, _ = self._parse("go", b"func f() { if x { g() } }") cond = self._find(root, "identifier") assert _is_statically_false_condition(cond) is False # --- _is_in_static_dead_guard --- def test_go_if_false_dead(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("go", b"func f() { if false { g() } }") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is True def test_go_else_branch_live(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("go", b"func f() { if false { a() } else { b() } }") call_b = self._find_call(root, b"b") assert _is_in_static_dead_guard(call_b) is False def test_ts_if_false_dead(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("typescript", b"function f() { if (false) { g(); } }") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is True def test_ts_if_zero_dead(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("typescript", b"function f() { if (0) { g(); } }") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is True def test_ts_if_true_live(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("typescript", b"function f() { if (true) { g(); } }") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is False def test_c_if0_dead(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("c", b"void f() {\n#if 0\ng();\n#endif\n}\n") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is True def test_c_else_live(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse( "c", b"void f() {\n#if 0\na();\n#else\nb();\n#endif\n}\n" ) call_b = self._find_call(root, b"b") assert _is_in_static_dead_guard(call_b) is False def test_c_if1_live(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("c", b"void f() {\n#if 1\ng();\n#endif\n}\n") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is False def test_no_guard_live(self): from code_review_graph.parser import _is_in_static_dead_guard root, _ = self._parse("go", b"func f() { g() }") call = self._find_call(root, b"g") assert _is_in_static_dead_guard(call) is False # --- _extract_calls integration --- def test_extract_calls_skips_dead_go(self): """_extract_calls returns True (skip) for a dead Go call.""" self.parser = CodeParser() nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.go", ) dead = [ e for e in edges if e.kind == "CALLS" and e.target.split("::")[-1] == "dead_false_call" ] assert dead == [] def test_extract_calls_skips_dead_ts(self): """_extract_calls returns True (skip) for a dead TS call.""" self.parser = CodeParser() nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.ts", ) dead = [ e for e in edges if e.kind == "CALLS" and e.target.split("::")[-1] == "dead_false_call" ] assert dead == [] def test_extract_calls_skips_dead_c(self): """_extract_calls returns True (skip) for a dead C call.""" self.parser = CodeParser() nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.c", ) dead = [ e for e in edges if e.kind == "CALLS" and e.target.split("::")[-1] == "dead_in_if0" ] assert dead == [] def test_extract_calls_keeps_live(self): """_extract_calls returns False (keep) for a live call.""" self.parser = CodeParser() nodes, edges = self.parser.parse_file( FIXTURES / "sample_dead_guard.go", ) live = [ e for e in edges if e.kind == "CALLS" and e.target.split("::")[-1] == "live_helper" ] assert len(live) == 1 class TestValueReferences: """Tests for REFERENCES edge extraction from function-as-value patterns.""" def setup_method(self): self.parser = CodeParser() def test_ts_object_literal_function_values(self): """Object literal values that are function identifiers emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} # handleCreate, handleUpdate, handleDelete are values in the handlers object assert "handleCreate" in ref_targets_bare assert "handleUpdate" in ref_targets_bare assert "handleDelete" in ref_targets_bare def test_ts_shorthand_property_references(self): """Shorthand properties like { validateInput } emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} assert "validateInput" in ref_targets_bare assert "processData" in ref_targets_bare def test_ts_array_function_elements(self): """Array elements that are function identifiers emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} # pipeline = [validateInput, processData, formatOutput] assert "formatOutput" in ref_targets_bare def test_ts_callback_argument_reference(self): """Function identifiers passed as arguments emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} # register(handleCreate) in dispatch function assert "handleCreate" in ref_targets_bare def test_ts_property_assignment_reference(self): """Property assignment RHS identifiers emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} # dynamicHandlers['format'] = formatOutput assert "formatOutput" in ref_targets_bare def test_python_dict_function_values(self): """Python dict values that are function identifiers emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.py") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} assert "handle_create" in ref_targets_bare assert "handle_update" in ref_targets_bare assert "handle_delete" in ref_targets_bare def test_python_list_function_elements(self): """Python list elements that are function identifiers emit REFERENCES edges.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.py") refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets_bare = {e.target.split("::")[-1] for e in refs} # pipeline = [validate_input, process_data, format_output] assert "validate_input" in ref_targets_bare assert "process_data" in ref_targets_bare assert "format_output" in ref_targets_bare def test_references_have_correct_source(self): """REFERENCES edges should have the enclosing function as source.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] # The register(handleCreate) call is inside 'dispatch' dispatch_refs = [ e for e in refs if "dispatch" in e.source and "handleCreate" in e.target ] assert len(dispatch_refs) >= 1 def test_no_references_for_unknown_identifiers(self): """Identifiers not in defined_names or import_map should NOT emit REFERENCES.""" nodes, edges = self.parser.parse_bytes( Path("/test/example.ts"), b"function outer() {\n" b" const map = { key: unknownFunc };\n" b"}\n", ) refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets = {e.target for e in refs} assert "unknownFunc" not in ref_targets def test_no_references_for_constants(self): """All-uppercase identifiers should NOT emit REFERENCES (likely constants).""" nodes, edges = self.parser.parse_bytes( Path("/test/example.ts"), b"const MAX_SIZE = 100;\n" b"function outer() {\n" b" const arr = [MAX_SIZE];\n" b"}\n", ) refs = [e for e in edges if e.kind == "REFERENCES"] ref_targets = {e.target for e in refs} assert "MAX_SIZE" not in ref_targets def test_resolve_references_targets(self): """REFERENCES edges should have resolved (qualified) targets for local funcs.""" nodes, edges = self.parser.parse_file(FIXTURES / "sample_map_dispatch.ts") refs = [e for e in edges if e.kind == "REFERENCES"] file_path = (FIXTURES / "sample_map_dispatch.ts").as_posix() # At least some targets should be fully qualified qualified_refs = [e for e in refs if "::" in e.target] assert len(qualified_refs) > 0 class TestModuleScopeCalls: """Module-scope calls (no enclosing function) must attribute to the File node. Previously these edges were silently dropped, causing ``find_dead_code`` to flag CLI entrypoints, notebook-helper functions, and top-level JSX renders as dead. The fix emits a CALLS edge with ``source = file_path`` (the File node's qualified name). """ def setup_method(self): self.parser = CodeParser() def test_python_top_level_call_attributes_to_file(self): source = ( b"def worker():\n" b" return 1\n" b"\n" b"worker()\n" ) path = FIXTURES / "module_scope_py.py" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] top_level = [ e for e in calls if e.source == path.as_posix() and e.target.endswith("worker") ] assert len(top_level) == 1 # Edge originates at the call site (line 4), not the def (line 1). assert top_level[0].line == 4 def test_python_if_main_block_call_attributes_to_file(self): source = ( b"def run_job():\n" b" return 1\n" b"\n" b"if __name__ == '__main__':\n" b" run_job()\n" ) path = FIXTURES / "module_scope_cli.py" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] top_level = [ e for e in calls if e.source == path.as_posix() and e.target.endswith("run_job") ] assert len(top_level) == 1 # Edge originates inside the `if __name__` block (line 5). assert top_level[0].line == 5 def test_tsx_top_level_jsx_render_attributes_to_file(self): # Bare top-level JSX expression statement exercises the # _extract_jsx_child path specifically (not a value-reference # fallback from the `const element = ...` assignment). source = ( b"import App from './App';\n" b"\n" b";\n" ) path = FIXTURES / "module_scope_entry.tsx" _, edges = self.parser.parse_bytes(path, source) calls = [e for e in edges if e.kind == "CALLS"] top_level = [ e for e in calls if e.source == path.as_posix() and e.target.endswith("App") ] assert len(top_level) == 1 # Edge originates at the JSX site (line 3), not the import (line 1). assert top_level[0].line == 3 def test_r_top_level_call_attributes_to_file(self): # R scripts are overwhelmingly module-scope by convention; this is # the highest-leverage language for the fix after Python. source = ( b"worker <- function() {\n" b" 1\n" b"}\n" b"\n" b"worker()\n" ) path = FIXTURES / "module_scope_sample.R" _, edges = self.parser.parse_bytes(path, source) top_level = [ e for e in edges if e.kind == "CALLS" and e.source == path.as_posix() and e.target.endswith("worker") ] assert len(top_level) == 1 def test_elixir_top_level_dotted_call_attributes_to_file(self): # `.exs` scripts and mix tasks commonly have module-scope `IO.puts`, # which is what the parser comment explicitly calls out. source = b'IO.puts("hello")\n' path = FIXTURES / "module_scope_script.exs" _, edges = self.parser.parse_bytes(path, source) top_level = [ e for e in edges if e.kind == "CALLS" and e.source == path.as_posix() and e.target.endswith("puts") ] assert len(top_level) == 1 def test_cpp_scoped_method_names(self, tmp_path): """C++ scoped method definitions must extract the leaf method name, not the return-type identifier. Regression: previously ``Ret Class::method()`` indexed as ``Ret`` (return type) and ``void Class::method()`` was silently dropped because _get_name() fell through to the generic identifier loop, which did not recognise qualified_identifier, destructor_name, or operator_name nodes inside function_declarator. """ src = b""" void PlaybackExtension::resetStateForPool() {} quint64 PlaybackExtension::startTimestamp() const { return 0; } PlaybackExtension::~PlaybackExtension() {} ~PlaybackExtension() {} bool operator==(const A& a, const B& b) { return true; } bool MyClass::operator<(const MyClass& o) const { return true; } void foo() {} int SnapshotController::getHandleIndex() { return 0; } bool PlaybackWidget::AllocateResourceStrategy::allocateExtensionResource(int i) { return true; } void A::B::C::deep() {} ExtensionID PlaybackExtension::ID() const { return {}; } """ p = tmp_path / "x.cpp" p.write_bytes(src) nodes, _ = self.parser.parse_file(p) names = [n.name for n in nodes if n.kind == "Function"] assert names == [ "resetStateForPool", "startTimestamp", "~PlaybackExtension", "~PlaybackExtension", "operator==", "operator<", "foo", "getHandleIndex", "allocateExtensionResource", "deep", "ID", ] class TestCppScopedFunctionName: """Regression tests for C++ scoped function name extraction. See: https://github.com/tirth8205/code-review-graph/issues/395 """ def test_scoped_function_with_type_identifier_return(self, tmp_path): """bufferlist OSDService::get_inc_map(...) should extract 'get_inc_map'.""" src = tmp_path / "osd_service.cpp" src.write_text( "bufferlist OSDService::get_inc_map(epoch_t e) {\n" " bufferlist bl;\n" " return bl;\n" "}\n" ) p = CodeParser() nodes, _ = p.parse_file(src) fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "get_inc_map" def test_scoped_function_with_qualified_return(self, tmp_path): """std::string OSDMap::get_pool_name(...) should extract 'get_pool_name'.""" src = tmp_path / "osd_map.cpp" src.write_text( "std::string OSDMap::get_pool_name(int64_t pool_id) const {\n" ' return "";\n' "}\n" ) p = CodeParser() nodes, _ = p.parse_file(src) fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "get_pool_name" def test_scoped_function_with_primitive_return_still_works(self, tmp_path): """int OSD::handle_osd_map(...) was already correct; verify no regression.""" src = tmp_path / "osd.cpp" src.write_text( "int OSD::handle_osd_map(MOSDMap *m) {\n" " return 0;\n" "}\n" ) p = CodeParser() nodes, _ = p.parse_file(src) fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "handle_osd_map" def test_unscoped_function_with_type_identifier_return(self, tmp_path): """static std::string _make_key(...) should extract '_make_key'.""" src = tmp_path / "util.cpp" src.write_text( "static std::string _make_key(const std::string& prefix) {\n" " return prefix;\n" "}\n" ) p = CodeParser() nodes, _ = p.parse_file(src) fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "_make_key" def test_scoped_function_string_return(self, tmp_path): """string RGWDedupProcessor::get_obj_fingerprint(...) should extract the method name.""" src = tmp_path / "rgw_dedup.cpp" src.write_text( "string RGWDedupProcessor::get_obj_fingerprint(const rgw_obj& obj) {\n" ' return "";\n' "}\n" ) p = CodeParser() nodes, _ = p.parse_file(src) fns = [n for n in nodes if n.kind == "Function"] assert len(fns) == 1 assert fns[0].name == "get_obj_fingerprint" class TestJsMemberAssignedFunctions: """Member-assigned function expressions in JS/TS. ``obj.method = function () {}`` / ``Foo.prototype.bar = () => {}`` are the prototype- and module-augmentation patterns that Express, Koa and many older JS libraries use for their entire public API. Only ``const x = fn`` (variable_declarator) and class fields were captured before, so these definitions produced no Function node at all. """ def setup_method(self): self.parser = CodeParser() def test_js_object_method_assignment_captured(self): nodes, _ = self.parser.parse_bytes( Path("/test/application.js"), b"app.handle = function handle(req, res, next) {\n" b" next();\n" b"};\n", ) fns = {n.name for n in nodes if n.kind == "Function"} assert "app.handle" in fns def test_js_arrow_member_assignment_captured(self): nodes, _ = self.parser.parse_bytes( Path("/test/router.js"), b"router.dispatch = (req, res) => {\n" b" return res;\n" b"};\n", ) fns = {n.name for n in nodes if n.kind == "Function"} assert "router.dispatch" in fns def test_ts_prototype_assignment_captured(self): nodes, _ = self.parser.parse_bytes( Path("/test/proto.ts"), b"Router.prototype.handle = function (req: Request): void {\n" b" this.stack.forEach((layer) => layer.handle(req));\n" b"};\n", ) fns = {n.name for n in nodes if n.kind == "Function"} assert "Router.prototype.handle" in fns def test_member_function_qualified_name_and_contains(self): """Qualified name is ``file::obj.method`` and a CONTAINS edge links it.""" path = Path("/test/application.js") nodes, edges = self.parser.parse_bytes( path, b"app.handle = function handle(req, res) {};\n", ) contains = [ e for e in edges if e.kind == "CONTAINS" and e.target == f"{path.as_posix()}::app.handle" ] assert len(contains) == 1 assert contains[0].source == path.as_posix() def test_non_function_member_assignment_not_captured(self): """``obj.prop = `` must not create a Function node.""" nodes, _ = self.parser.parse_bytes( Path("/test/config.js"), b"app.settings = { trust_proxy: false };\n" b"app.locals = {};\n", ) fns = {n.name for n in nodes if n.kind == "Function"} assert "app.settings" not in fns assert "app.locals" not in fns def test_function_local_member_assignments_are_not_module_definitions(self): """Sibling local assignments must not collide as ``file::x.run``.""" path = Path("/test/local_assignments.js") nodes, edges = self.parser.parse_bytes( path, b"function a() { x.run = function () {}; }\n" b"function b() { x.run = function () {}; }\n", ) functions = [n for n in nodes if n.kind == "Function"] assert {n.name for n in functions} == {"a", "b"} assert all(n.name != "x.run" for n in functions) assert all( not (e.kind == "CONTAINS" and e.target == f"{path.as_posix()}::x.run") for e in edges ) def test_sibling_top_level_blocks_do_not_share_member_identity(self): """Block-local objects must not collapse into one module definition.""" path = Path("/test/block_assignments.js") nodes, edges = self.parser.parse_bytes( path, b"{ const x = {}; x.run = function () {}; }\n" b"{ const x = {}; x.run = function () {}; }\n", ) functions = [n for n in nodes if n.kind == "Function"] assert all(n.name != "x.run" for n in functions) assert all( not (e.kind == "CONTAINS" and e.target == f"{path.as_posix()}::x.run") for e in edges ) def test_dynamic_receiver_assignment_is_not_a_stable_definition(self): """A fresh object returned by a call has no stable member identity.""" path = Path("/test/dynamic_assignment.js") nodes, edges = self.parser.parse_bytes( path, b"factory().handle = function () {};\n", ) functions = [n for n in nodes if n.kind == "Function"] assert all(n.name != "factory().handle" for n in functions) assert all( not ( e.kind == "CONTAINS" and e.target == f"{path.as_posix()}::factory().handle" ) for e in edges ) def test_dynamic_receiver_call_does_not_resolve_as_static_member(self): """Separate factory calls must not be linked as one member.""" path = Path("/test/dynamic_call.js") _, edges = self.parser.parse_bytes( path, b"factory().handle = function () {};\n" b"function start() { factory().handle(); }\n", ) calls = [ e for e in edges if e.kind == "CALLS" and e.source == f"{path.as_posix()}::start" ] assert len(calls) == 2 handle_call = next(e for e in calls if e.target == "handle") assert "member_call" not in handle_call.extra def test_member_function_body_calls_still_attributed(self): """Calls inside a member-assigned function attribute to that function.""" path = Path("/test/application.js") _, edges = self.parser.parse_bytes( path, b"function helper() { return 1; }\n" b"app.handle = function handle() {\n" b" helper();\n" b"};\n", ) calls = [ e for e in edges if e.kind == "CALLS" and e.source == f"{path.as_posix()}::app.handle" and e.target.endswith("helper") ] assert len(calls) == 1 def test_member_call_resolves_to_member_assigned_function(self): """A static member call resolves to its same-file member definition.""" path = Path("/test/application.js") _, edges = self.parser.parse_bytes( path, b"app.handle = function () {};\n" b"function start() { app.handle(); }\n", ) calls = [ e for e in edges if e.kind == "CALLS" and e.source == f"{path.as_posix()}::start" ] assert len(calls) == 1 assert calls[0].target == f"{path.as_posix()}::app.handle" def test_optional_member_call_resolves_to_member_assigned_function(self): """Optional chaining retains the same static member-call target.""" path = Path("/test/application.js") _, edges = self.parser.parse_bytes( path, b"app.handle = function () {};\n" b"function start() { app?.handle(); }\n", ) calls = [ e for e in edges if e.kind == "CALLS" and e.source == f"{path.as_posix()}::start" ] assert len(calls) == 1 assert calls[0].target == f"{path.as_posix()}::app.handle" def test_member_assignment_survives_full_build_with_resolved_caller( self, tmp_path: Path, monkeypatch, ) -> None: """The definition and resolved call persist through a real graph build.""" monkeypatch.setenv("CRG_SERIAL_PARSE", "1") source = tmp_path / "application.js" source.write_text( "app.handle = function () { return 1; };\n" "function start() { return app.handle(); }\n", encoding="utf-8", ) member_qn = f"{source.as_posix()}::app.handle" caller_qn = f"{source.as_posix()}::start" with GraphStore(tmp_path / "graph.db") as store: built = full_build(tmp_path, store) assert built["errors"] == [] member = store.get_node(member_qn) callers = [ edge for edge in store.get_edges_by_target(member_qn) if edge.kind == "CALLS" ] assert member is not None assert member.kind == "Function" assert member.name == "app.handle" assert len(callers) == 1 assert callers[0].source_qualified == caller_qn class TestTypeScriptTypeDeclarations: """TS interfaces / type aliases / enums are graph nodes, and type positions are dependencies. Before this, ``_CLASS_TYPES`` covered only ``class_declaration`` for TS, so a types-only module produced zero symbol nodes and its blast radius collapsed to whole-file ``IMPORTS_FROM`` fan-out. Java/C#/PHP already indexed ``interface_declaration``. See: #737 """ def setup_method(self): self.parser = CodeParser() def _project(self, root: Path) -> tuple[Path, Path]: types = root / "types.ts" types.write_text( "export interface Finding {\n" " id: string;\n" "}\n\n" "export type Verdict = 'ok' | 'bad';\n\n" "export enum Severity {\n" " Low,\n" " High,\n" "}\n", encoding="utf-8", ) use = root / "use.ts" use.write_text( "import { Finding, Verdict, Severity } from './types';\n\n" "export function summarize(items: Finding[]): Verdict {\n" " const cache: Map = new Map();\n" " return cache.size ? 'bad' : 'ok';\n" "}\n", encoding="utf-8", ) return types, use def test_interface_type_alias_and_enum_become_nodes(self): with tempfile.TemporaryDirectory() as tmp_dir: types, _ = self._project(Path(tmp_dir)) nodes, _ = self.parser.parse_file(types) names = {n.name for n in nodes if n.kind == "Class"} assert {"Finding", "Verdict", "Severity"} <= names def test_declaration_name_is_not_a_reference_to_itself(self): with tempfile.TemporaryDirectory() as tmp_dir: types, _ = self._project(Path(tmp_dir)) _, edges = self.parser.parse_file(types) refs = [e for e in edges if e.kind == "REFERENCES"] assert not [e for e in refs if e.source == e.target] def test_type_annotation_emits_reference_to_the_declaring_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) types, use = self._project(root) _, edges = self.parser.parse_file(use) refs = { (e.source, e.target) for e in edges if e.kind == "REFERENCES" } summarize = f"{use.as_posix()}::summarize" assert (summarize, f"{types.resolve().as_posix()}::Finding") in refs assert (summarize, f"{types.resolve().as_posix()}::Verdict") in refs def test_aliased_type_import_resolves_to_exported_symbol(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) types, _ = self._project(root) use = root / "aliased.ts" use.write_text( "import type { Finding as ImportedFinding } from './types';\n\n" "export function summarize(item: ImportedFinding): string {\n" " return item.id;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(use) refs = { (edge.source, edge.target) for edge in edges if edge.kind == "REFERENCES" } assert ( f"{use.as_posix()}::summarize", f"{types.resolve().as_posix()}::Finding", ) in refs assert not any( target == f"{types.resolve().as_posix()}::ImportedFinding" for _, target in refs ) def test_type_argument_inside_a_generic_is_a_reference(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) types, use = self._project(root) _, edges = self.parser.parse_file(use) # Severity appears only as Map. assert any( e.kind == "REFERENCES" and e.source == f"{use.as_posix()}::summarize" and e.target == f"{types.resolve().as_posix()}::Severity" for e in edges ) def test_unknown_and_builtin_types_do_not_emit_references(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) _, use = self._project(root) _, edges = self.parser.parse_file(use) bare = {e.target.split("::")[-1] for e in edges if e.kind == "REFERENCES"} # Neither a predefined type nor an unimported global becomes an edge. assert "string" not in bare assert "Map" not in bare def test_interface_member_attributes_to_the_interface_not_the_file(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) types, _ = self._project(root) wrapper = root / "wrapper.ts" wrapper.write_text( "import { Verdict } from './types';\n\n" "export interface Wrapper {\n" " nested: Verdict;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(wrapper) assert any( e.kind == "REFERENCES" and e.source == f"{wrapper.as_posix()}::Wrapper" and e.target == f"{types.resolve().as_posix()}::Verdict" for e in edges ) def test_class_heritage_emits_inherits_edges(self): """`class C extends B implements I` nests its clauses under class_heritage, so scanning only direct children found no bases at all. """ with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) base = root / "base.ts" base.write_text( "export class Base {}\n" "export interface Findable { id: string }\n", encoding="utf-8", ) impl = root / "impl.ts" impl.write_text( "import { Base, Findable } from './base';\n\n" "export class Impl extends Base implements Findable {\n" " id = 'x';\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(impl) inherits = { (e.source, e.target) for e in edges if e.kind == "INHERITS" } assert (f"{impl.as_posix()}::Impl", "Base") in inherits assert (f"{impl.as_posix()}::Impl", "Findable") in inherits def test_interface_extends_emits_inherits_edge(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) base = root / "base.ts" base.write_text("export interface Findable { id: string }\n", encoding="utf-8") wrapper = root / "wrapper.ts" wrapper.write_text( "import { Findable } from './base';\n\n" "export interface Wrapper extends Findable {\n" " extra: string;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(wrapper) inherits = {(e.source, e.target) for e in edges if e.kind == "INHERITS"} assert (f"{wrapper.as_posix()}::Wrapper", "Findable") in inherits def test_heritage_does_not_double_emit_a_reference(self): """A base is already an INHERITS edge; it must not also be REFERENCES.""" with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) base = root / "base.ts" base.write_text("export interface Findable { id: string }\n", encoding="utf-8") wrapper = root / "wrapper.ts" wrapper.write_text( "import { Findable } from './base';\n\n" "export interface Wrapper extends Findable {\n" " extra: string;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(wrapper) bare = {e.target.split("::")[-1] for e in edges if e.kind == "REFERENCES"} assert "Findable" not in bare def test_generic_heritage_does_not_double_emit_a_reference(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) base = root / "base.ts" base.write_text( "export interface Box { value: T }\n" "export interface Payload { value: string }\n", encoding="utf-8", ) wrapper = root / "wrapper.ts" wrapper.write_text( "import { Box, Payload } from './base';\n\n" "export class BoxImpl implements Box {\n" " value = { value: 'x' };\n" "}\n\n" "export interface StringBox extends Box {}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(wrapper) inherits = [ edge for edge in edges if edge.kind == "INHERITS" and edge.target == "Box" ] references = [ edge for edge in edges if edge.kind == "REFERENCES" and edge.target == f"{base.resolve().as_posix()}::Box" ] assert len(inherits) == 2 assert references == [] assert any( edge.kind == "REFERENCES" and edge.target == f"{base.resolve().as_posix()}::Payload" for edge in edges ) def test_tsx_type_positions_are_also_covered(self): with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) types, _ = self._project(root) panel = root / "Panel.tsx" panel.write_text( "import { Finding } from './types';\n\n" "export function Panel({ finding }: { finding: Finding }) {\n" " return null;\n" "}\n", encoding="utf-8", ) _, edges = self.parser.parse_file(panel) assert any( e.kind == "REFERENCES" and e.source == f"{panel.as_posix()}::Panel" and e.target == f"{types.resolve().as_posix()}::Finding" for e in edges )