chore: restore original directory structure (project under code-review-graph-main/)
This commit is contained in:
@@ -0,0 +1,2491 @@
|
||||
"""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 <script setup>
|
||||
funcs = [n for n in nodes if n.kind == "Function"]
|
||||
func_names = {f.name for f in funcs}
|
||||
assert "increment" in func_names
|
||||
assert "onSelectUser" in func_names
|
||||
assert "fetchUsers" in func_names
|
||||
|
||||
def test_parse_vue_imports(self):
|
||||
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
import_targets = {e.target for e in imports}
|
||||
assert "vue" in import_targets
|
||||
assert "./UserList.vue" in import_targets
|
||||
|
||||
def test_parse_vue_calls(self):
|
||||
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
calls = [e for e in edges if e.kind == "CALLS"]
|
||||
call_targets = {e.target for e in calls}
|
||||
assert "log" in call_targets or "console.log" in call_targets or any(
|
||||
"log" in t for t in call_targets
|
||||
)
|
||||
|
||||
def test_parse_vue_contains_edges(self):
|
||||
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
contains = [e for e in edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 1
|
||||
|
||||
def test_parse_vue_line_numbers_offset(self):
|
||||
"""Line numbers should be offset to reflect position in the .vue file."""
|
||||
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
funcs = [n for n in nodes if n.kind == "Function" and n.name == "increment"]
|
||||
assert len(funcs) == 1
|
||||
# increment() is on line 22 of the .vue file (inside <script setup> starting at line 9)
|
||||
assert funcs[0].line_start > 9
|
||||
|
||||
def test_parse_vue_nodes_have_vue_language(self):
|
||||
"""All extracted nodes from Vue SFC should have language='vue'."""
|
||||
nodes, _ = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
for node in nodes:
|
||||
assert node.language == "vue"
|
||||
|
||||
def test_parse_vue_empty_script(self):
|
||||
"""Vue file with no script block should still produce a File node."""
|
||||
source = b"<template><div>Hello</div></template>\n"
|
||||
path = Path("empty_script.vue")
|
||||
nodes, edges = self.parser.parse_bytes(path, source)
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0].kind == "File"
|
||||
|
||||
def test_parse_vue_js_default(self):
|
||||
"""Vue file without lang attr should parse script as JavaScript."""
|
||||
source = (
|
||||
b"<script>\n"
|
||||
b"export default {\n"
|
||||
b" methods: {\n"
|
||||
b" greet() { return 'hi' }\n"
|
||||
b" }\n"
|
||||
b"}\n"
|
||||
b"</script>\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 <section><MarkdownMsg text={value} /></section>;\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 <section><div /><span /></section>;\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 <UI.MarkdownMsg text={value} />;\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 <UI.MarkdownMsg text={value} />;\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 <UI.Messages.MarkdownMsg text={value} />;\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 <div />; }\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 <MarkdownMsg text={value} />;\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 <div />; }\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 <Msg text={value} />;\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 <div />; }\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 <MarkdownMsg text={value} />;\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 <div>{text}</div>; }\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"
|
||||
" <MarkdownMsg text='a' />\n"
|
||||
" <MarkdownMsg text='b' />\n"
|
||||
" <MarkdownMsg text='c' />\n"
|
||||
" </>\n"
|
||||
" );\n"
|
||||
"}\n\n"
|
||||
"export function AIPanel() {\n"
|
||||
" return (\n"
|
||||
" <>\n"
|
||||
" <MarkdownMsg text='d' />\n"
|
||||
" <MarkdownMsg text='e' />\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 <div>{text}</div>; }\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 <Msg text='a' />;\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"<App />;\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 = <non-function>`` 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<string, Severity> = 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<string, Severity>.
|
||||
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<T> { 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<Payload> {\n"
|
||||
" value = { value: 'x' };\n"
|
||||
"}\n\n"
|
||||
"export interface StringBox extends Box<string> {}\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
|
||||
)
|
||||
Reference in New Issue
Block a user