chore: restore original directory structure (project under code-review-graph-main/)
This commit is contained in:
@@ -0,0 +1,4333 @@
|
||||
"""Tests for Go, Rust, Java, C, C++, C#, Ruby, PHP, Kotlin, Swift, Solidity, and Vue parsing."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from code_review_graph.parser import CodeParser
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
class TestGoParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_go.go")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("main.go")) == "go"
|
||||
|
||||
def test_finds_structs_and_interfaces(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
assert "UserRepository" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "NewInMemoryRepo" in names
|
||||
assert "CreateUser" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "errors" in targets
|
||||
assert "fmt" in targets
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 1
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 3
|
||||
|
||||
def test_methods_attached_to_receiver(self):
|
||||
"""Go methods should be attached to their receiver type (#190).
|
||||
|
||||
`func (r *InMemoryRepo) FindByID(...)` should produce a Function node
|
||||
with parent_name='InMemoryRepo' and a CONTAINS edge from the type to
|
||||
the method, so `inheritors_of`/`query_graph` can find methods via the
|
||||
struct they belong to.
|
||||
"""
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
by_name = {f.name: f for f in funcs}
|
||||
assert "FindByID" in by_name
|
||||
assert "Save" in by_name
|
||||
assert by_name["FindByID"].parent_name == "InMemoryRepo"
|
||||
assert by_name["Save"].parent_name == "InMemoryRepo"
|
||||
# Free functions should still have no parent.
|
||||
assert by_name["NewInMemoryRepo"].parent_name is None
|
||||
assert by_name["CreateUser"].parent_name is None
|
||||
|
||||
contains = [(e.source, e.target) for e in self.edges if e.kind == "CONTAINS"]
|
||||
find_by_id_contains = [
|
||||
(s, t) for (s, t) in contains
|
||||
if t.endswith("::InMemoryRepo.FindByID")
|
||||
]
|
||||
save_contains = [
|
||||
(s, t) for (s, t) in contains
|
||||
if t.endswith("::InMemoryRepo.Save")
|
||||
]
|
||||
assert find_by_id_contains, (
|
||||
f"no CONTAINS edge for InMemoryRepo.FindByID in {contains}"
|
||||
)
|
||||
assert save_contains, (
|
||||
f"no CONTAINS edge for InMemoryRepo.Save in {contains}"
|
||||
)
|
||||
# Source of each CONTAINS should be the InMemoryRepo type,
|
||||
# not the file path.
|
||||
assert find_by_id_contains[0][0].endswith("::InMemoryRepo")
|
||||
assert save_contains[0][0].endswith("::InMemoryRepo")
|
||||
|
||||
|
||||
class TestRustParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_rust.rs")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("lib.rs")) == "rust"
|
||||
|
||||
def test_finds_structs_and_traits(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "new" in names
|
||||
assert "create_user" in names
|
||||
assert "find_by_id" in names
|
||||
assert "save" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) >= 1
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 3
|
||||
|
||||
def test_detects_test_attribute(self):
|
||||
tests = [n for n in self.nodes if n.kind == "Test"]
|
||||
names = {t.name for t in tests}
|
||||
assert "new_repo_is_empty" in names
|
||||
assert "create_user_saves_to_repo" in names
|
||||
assert all(t.is_test for t in tests)
|
||||
|
||||
def test_detects_tokio_test_attribute(self):
|
||||
tests = {n.name for n in self.nodes if n.kind == "Test"}
|
||||
assert "async_test_is_detected" in tests
|
||||
|
||||
def test_non_test_functions_not_misclassified(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "create_user" in funcs
|
||||
assert "new" in funcs
|
||||
# `create_user` carries no `#[test]` — must stay Function.
|
||||
for n in self.nodes:
|
||||
if n.name == "create_user":
|
||||
assert not n.is_test
|
||||
|
||||
|
||||
class TestJavaParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "SampleJava.java")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("Main.java")) == "java"
|
||||
|
||||
def test_finds_classes_and_interfaces(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "UserRepository" in names
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
assert "UserService" in names
|
||||
|
||||
def test_finds_methods(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "findById" in names
|
||||
assert "save" in names
|
||||
assert "getUser" in names
|
||||
|
||||
def test_method_names_not_return_types(self):
|
||||
"""Method names must be the actual name, not the return type.
|
||||
|
||||
tree-sitter-java puts type_identifier (return type) before
|
||||
identifier (method name). Without the Java-specific branch in
|
||||
_get_name the generic loop picks up the return type instead.
|
||||
"""
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
# getName()/getEmail() return String — must not be indexed as "String"
|
||||
assert "getName" in names
|
||||
assert "getEmail" in names
|
||||
assert "getId" in names
|
||||
# createUser() returns User — must not be indexed as "User" (the class)
|
||||
assert "createUser" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) >= 2
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
# InMemoryRepo implements UserRepository + CachedRepo extends InMemoryRepo
|
||||
assert len(inherits) >= 2
|
||||
targets = {e.target for e in inherits}
|
||||
assert "UserRepository" in targets
|
||||
assert "InMemoryRepo" in targets
|
||||
|
||||
def test_inheritance_target_is_bare_name(self):
|
||||
"""INHERITS edge target must be the type name, not 'implements Foo'.
|
||||
|
||||
tree-sitter-java wraps extends/implements in superclass and
|
||||
super_interfaces nodes whose .text includes the keyword.
|
||||
Without the Java-specific branch in _get_bases the full text
|
||||
(e.g. 'implements UserRepository') is stored as the edge target.
|
||||
"""
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
# Must have both extends and implements edges to test both paths
|
||||
assert len(inherits) >= 2, (
|
||||
"Expected at least 2 INHERITS edges (extends + implements)"
|
||||
)
|
||||
for e in inherits:
|
||||
assert not e.target.startswith("implements "), (
|
||||
f"INHERITS target should be bare type name, got: {e.target!r}"
|
||||
)
|
||||
assert not e.target.startswith("extends "), (
|
||||
f"INHERITS target should be bare type name, got: {e.target!r}"
|
||||
)
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 3
|
||||
|
||||
|
||||
class TestJavaImportResolution:
|
||||
"""Test that Java imports are resolved to absolute file paths."""
|
||||
|
||||
def test_resolves_project_import(self, tmp_path):
|
||||
"""Import of a project class resolves to its .java file."""
|
||||
# Create a mini Java project with two packages
|
||||
auth = tmp_path / "src/main/java/com/example/auth"
|
||||
auth.mkdir(parents=True)
|
||||
(auth / "User.java").write_text(
|
||||
"package com.example.auth;\npublic class User {}\n"
|
||||
)
|
||||
svc = tmp_path / "src/main/java/com/example/service"
|
||||
svc.mkdir(parents=True)
|
||||
(svc / "App.java").write_text(
|
||||
"package com.example.service;\n"
|
||||
"import com.example.auth.User;\n"
|
||||
"public class App {}\n"
|
||||
)
|
||||
|
||||
parser = CodeParser()
|
||||
_, edges = parser.parse_file(svc / "App.java")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == (auth / "User.java").resolve().as_posix()
|
||||
|
||||
def test_jdk_import_stays_unresolved(self):
|
||||
"""JDK imports have no local file and remain as raw strings."""
|
||||
parser = CodeParser()
|
||||
_, edges = parser.parse_file(FIXTURES / "SampleJava.java")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
# All imports in SampleJava.java are java.util.* (JDK)
|
||||
for e in imports:
|
||||
assert not e.target.endswith(".java"), (
|
||||
f"JDK import should not resolve to a file: {e.target!r}"
|
||||
)
|
||||
|
||||
def test_static_import_resolves_to_class(self, tmp_path):
|
||||
"""Static import of a member resolves to the enclosing class file."""
|
||||
pkg = tmp_path / "src/main/java/com/example/util"
|
||||
pkg.mkdir(parents=True)
|
||||
(pkg / "Helper.java").write_text(
|
||||
"package com.example.util;\n"
|
||||
"public class Helper { public static int MAX = 1; }\n"
|
||||
)
|
||||
app_dir = tmp_path / "src/main/java/com/example/app"
|
||||
app_dir.mkdir(parents=True)
|
||||
(app_dir / "App.java").write_text(
|
||||
"package com.example.app;\n"
|
||||
"import static com.example.util.Helper.MAX;\n"
|
||||
"public class App {}\n"
|
||||
)
|
||||
|
||||
parser = CodeParser()
|
||||
_, edges = parser.parse_file(app_dir / "App.java")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == (pkg / "Helper.java").resolve().as_posix()
|
||||
|
||||
def test_wildcard_import_stays_unresolved(self, tmp_path):
|
||||
"""Wildcard imports cannot resolve to a single file."""
|
||||
app_dir = tmp_path / "src/main/java/com/example"
|
||||
app_dir.mkdir(parents=True)
|
||||
(app_dir / "App.java").write_text(
|
||||
"package com.example;\n"
|
||||
"import java.util.*;\n"
|
||||
"public class App {}\n"
|
||||
)
|
||||
|
||||
parser = CodeParser()
|
||||
_, edges = parser.parse_file(app_dir / "App.java")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == "java.util.*"
|
||||
|
||||
|
||||
class TestCParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.c")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("main.c")) == "c"
|
||||
|
||||
def test_finds_structs(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "print_user" in names
|
||||
assert "main" in names
|
||||
assert "create_user" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "stdio.h" in targets
|
||||
|
||||
|
||||
class TestCppParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.cpp")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("main.cpp")) == "cpp"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Animal" in names
|
||||
assert "Dog" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "greet" in names or "main" in names
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
assert len(inherits) >= 1
|
||||
|
||||
|
||||
class TestHhParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.hh")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("types.hh")) == "cpp"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Shape" in names
|
||||
assert "Circle" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "perimeter" in names
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
assert len(inherits) >= 1
|
||||
|
||||
|
||||
def _has_csharp_parser():
|
||||
try:
|
||||
import tree_sitter_language_pack as tslp
|
||||
tslp.get_parser("csharp")
|
||||
return True
|
||||
except (LookupError, ImportError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed")
|
||||
class TestCSharpParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "Sample.cs")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("Program.cs")) == "csharp"
|
||||
|
||||
def test_finds_classes_and_interfaces(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
|
||||
def test_finds_methods(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "FindById" in names or "Save" in names
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
targets = {e.target for e in inherits}
|
||||
assert "IRepository" in targets
|
||||
assert "InMemoryRepo" in targets
|
||||
assert "System.IDisposable" in targets
|
||||
assert "List<User>" in targets
|
||||
assert all(not e.target.startswith(":") for e in inherits)
|
||||
assert all("," not in e.target for e in inherits)
|
||||
|
||||
def test_inheritance_hard_cases(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
by_source = {}
|
||||
for edge in inherits:
|
||||
by_source.setdefault(edge.source.rsplit("::", 1)[-1], set()).add(
|
||||
edge.target
|
||||
)
|
||||
|
||||
assert by_source.get("AuditedUser") == {"User", "IRepository"}
|
||||
assert by_source.get("TaggedUser") == {"User"}
|
||||
assert "IRepository" in by_source.get("Token", set())
|
||||
assert "System.Collections.Generic.List<User>" in {
|
||||
edge.target for edge in inherits
|
||||
}
|
||||
assert "ConstrainedHolder" not in by_source
|
||||
assert by_source.get("SeededRepo") == {"InMemoryRepo"}
|
||||
assert all(not edge.target.startswith("(") for edge in inherits)
|
||||
assert "Status" not in by_source
|
||||
assert "byte" not in {edge.target for edge in inherits}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("statement", "expected_targets"),
|
||||
[
|
||||
("Ping();", {"Ping"}),
|
||||
("service.Send();", {"Send"}),
|
||||
("service.GetClient().Fetch();", {"GetClient", "Fetch"}),
|
||||
("service?.Notify();", {"Notify"}),
|
||||
],
|
||||
ids=("bare", "member", "chained", "null-conditional"),
|
||||
)
|
||||
def test_finds_calls_and_attributes_them_to_enclosing_method(
|
||||
self, tmp_path, statement, expected_targets,
|
||||
):
|
||||
source_file = tmp_path / "Calls.cs"
|
||||
source_file.write_text(
|
||||
"class Caller\n"
|
||||
"{\n"
|
||||
" void Run()\n"
|
||||
" {\n"
|
||||
f" {statement}\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
_, edges = self.parser.parse_file(source_file)
|
||||
calls = [edge for edge in edges if edge.kind == "CALLS"]
|
||||
call_targets = {
|
||||
edge.target.split("::")[-1].split(".")[-1]: edge
|
||||
for edge in calls
|
||||
}
|
||||
|
||||
assert expected_targets <= call_targets.keys()
|
||||
assert all(
|
||||
call_targets[target].source.endswith("::Caller.Run")
|
||||
for target in expected_targets
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed",
|
||||
)
|
||||
class TestCSharpMethodNames:
|
||||
"""Regression tests for #791: a non-generic C# return type is itself an
|
||||
``identifier``, so ``async Task Foo()`` was named ``Task`` and every such
|
||||
method in a class merged onto one ``qualified_name``.
|
||||
"""
|
||||
|
||||
def _parse(self, source: str, tmp_path):
|
||||
p = tmp_path / "x.cs"
|
||||
p.write_text(source, encoding="utf-8")
|
||||
return CodeParser().parse_file(p)
|
||||
|
||||
def test_non_generic_return_type_is_not_the_name(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"public class Suite {\n"
|
||||
" public async Task Should_do_thing() { }\n"
|
||||
" public async Task Should_do_other_thing() { }\n"
|
||||
" public async Task<int> Returns_generic() { return 1; }\n"
|
||||
" public void PlainVoid() { }\n"
|
||||
" public Suite() { }\n"
|
||||
"}\n",
|
||||
tmp_path,
|
||||
)
|
||||
funcs = [n for n in nodes if n.kind == "Function"]
|
||||
assert {f.name for f in funcs} == {
|
||||
"Should_do_thing",
|
||||
"Should_do_other_thing",
|
||||
"Returns_generic",
|
||||
"PlainVoid",
|
||||
"Suite",
|
||||
}
|
||||
assert len(funcs) == 5
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed",
|
||||
)
|
||||
class TestCSharpAttributes:
|
||||
"""Regression tests for #295 (C# half): C# attributes use
|
||||
``attribute_list`` nodes, not ``modifiers > annotation``, so they need
|
||||
a dedicated capture path. Persisted in ``modifiers`` + ``extra['decorators']``.
|
||||
"""
|
||||
|
||||
def _parse(self, source: str, tmp_path):
|
||||
p = tmp_path / "x.cs"
|
||||
p.write_text(source, encoding="utf-8")
|
||||
return CodeParser().parse_file(p)
|
||||
|
||||
def test_method_attributes_captured(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"namespace Api;\npublic class Ctrl {\n"
|
||||
" [HttpGet(\"/x\")]\n [Authorize]\n"
|
||||
" public void Get() {}\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
get = next(n for n in nodes if n.kind == "Function" and n.name == "Get")
|
||||
assert get.extra.get("decorators") == ["HttpGet", "Authorize"]
|
||||
assert get.modifiers == "HttpGet,Authorize"
|
||||
|
||||
def test_class_attribute_captured(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"namespace Api;\n[ApiController]\npublic class Ctrl {\n"
|
||||
" public void Get() {}\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
ctrl = next(n for n in nodes if n.kind == "Class" and n.name == "Ctrl")
|
||||
assert ctrl.extra.get("decorators") == ["ApiController"]
|
||||
assert ctrl.modifiers == "ApiController"
|
||||
|
||||
def test_unattributed_method_has_none_modifiers(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"namespace Api;\npublic class C {\n public void Plain() {}\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
plain = next(n for n in nodes if n.kind == "Function" and n.name == "Plain")
|
||||
assert plain.modifiers is None
|
||||
assert "decorators" not in plain.extra
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed",
|
||||
)
|
||||
class TestCSharpNamespaceResolution:
|
||||
"""Regression tests for #310: C# ``using X.Y;`` directives carry a
|
||||
namespace string as their ``IMPORTS_FROM.target`` (not a file path), so
|
||||
``importers_of`` returned [] for every .cs file. The fix tags File
|
||||
nodes with their declared namespaces and adds a namespace fallback.
|
||||
"""
|
||||
|
||||
def _write(self, path: Path, source: str) -> None:
|
||||
path.write_text(source, encoding="utf-8")
|
||||
|
||||
def test_file_scoped_namespace_tagged(self, tmp_path):
|
||||
f = tmp_path / "Core.cs"
|
||||
self._write(f, "namespace ACME.Core;\npublic class TaskBoard {}\n")
|
||||
nodes, _ = CodeParser().parse_file(f)
|
||||
file_node = next(n for n in nodes if n.kind == "File")
|
||||
assert file_node.extra.get("csharp_namespaces") == ["ACME.Core"]
|
||||
|
||||
def test_block_namespace_tagged(self, tmp_path):
|
||||
f = tmp_path / "Core.cs"
|
||||
self._write(f, "namespace ACME.Core {\n public class T {}\n}\n")
|
||||
nodes, _ = CodeParser().parse_file(f)
|
||||
file_node = next(n for n in nodes if n.kind == "File")
|
||||
assert file_node.extra.get("csharp_namespaces") == ["ACME.Core"]
|
||||
|
||||
def test_non_csharp_file_has_no_namespace_tag(self, tmp_path):
|
||||
f = tmp_path / "mod.py"
|
||||
self._write(f, "def foo():\n pass\n")
|
||||
nodes, _ = CodeParser().parse_file(f)
|
||||
file_node = next(n for n in nodes if n.kind == "File")
|
||||
assert "csharp_namespaces" not in file_node.extra
|
||||
|
||||
def test_importers_of_resolves_namespace_to_file(self, tmp_path):
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / ".code-review-graph").mkdir()
|
||||
core = tmp_path / "Core.cs"
|
||||
self._write(core, "namespace ACME.Core;\npublic class TaskBoard {}\n")
|
||||
app = tmp_path / "App.cs"
|
||||
self._write(app, "using ACME.Core;\nnamespace ACME.App;\npublic class App {}\n")
|
||||
unrelated = tmp_path / "Unrelated.cs"
|
||||
self._write(
|
||||
unrelated,
|
||||
"using System.Linq;\nnamespace ACME.Other;\npublic class Other {}\n",
|
||||
)
|
||||
|
||||
store = GraphStore(tmp_path / ".code-review-graph" / "graph.db")
|
||||
parser = CodeParser()
|
||||
for path in (core, app, unrelated):
|
||||
nodes, edges = parser.parse_file(path)
|
||||
for n in nodes:
|
||||
store.upsert_node(n)
|
||||
for e in edges:
|
||||
store.upsert_edge(e)
|
||||
store.commit()
|
||||
store.close()
|
||||
|
||||
result = query_graph("importers_of", str(core), repo_root=str(tmp_path))
|
||||
assert result.get("status") == "ok"
|
||||
importers = {r["file"] for r in result.get("results", [])}
|
||||
assert app.as_posix() in importers
|
||||
assert unrelated.as_posix() not in importers
|
||||
|
||||
def test_importers_of_resolves_nested_block_namespace(self, tmp_path):
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / ".code-review-graph").mkdir()
|
||||
core = tmp_path / "Core.cs"
|
||||
self._write(
|
||||
core,
|
||||
"namespace Acme {\n"
|
||||
" namespace Core {\n"
|
||||
" public class TaskBoard {}\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
)
|
||||
app = tmp_path / "App.cs"
|
||||
self._write(
|
||||
app,
|
||||
"using Acme.Core;\n"
|
||||
"namespace Acme.App;\n"
|
||||
"public class App {}\n",
|
||||
)
|
||||
|
||||
store = GraphStore(tmp_path / ".code-review-graph" / "graph.db")
|
||||
parser = CodeParser()
|
||||
for path in (core, app):
|
||||
nodes, edges = parser.parse_file(path)
|
||||
for node in nodes:
|
||||
store.upsert_node(node)
|
||||
for edge in edges:
|
||||
store.upsert_edge(edge)
|
||||
store.commit()
|
||||
store.close()
|
||||
|
||||
result = query_graph("importers_of", str(core), repo_root=str(tmp_path))
|
||||
assert result.get("status") == "ok"
|
||||
importers = {r["file"] for r in result.get("results", [])}
|
||||
assert app.as_posix() in importers
|
||||
|
||||
def test_deep_ast_preserves_nested_namespace_metadata(self, tmp_path):
|
||||
"""Namespace discovery must not recurse through the whole C# AST."""
|
||||
source_file = tmp_path / "Deep.cs"
|
||||
deep_expression = "(" * 1200 + "1" + ")" * 1200
|
||||
self._write(
|
||||
source_file,
|
||||
"namespace Acme {\n"
|
||||
" namespace Core {\n"
|
||||
" public class Calculator {\n"
|
||||
" public int Value() {\n"
|
||||
f" return {deep_expression};\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
)
|
||||
|
||||
try:
|
||||
nodes, _ = CodeParser().parse_file(source_file)
|
||||
except RecursionError:
|
||||
pytest.fail("C# namespace discovery overflowed on a deep expression AST")
|
||||
|
||||
file_node = next(node for node in nodes if node.kind == "File")
|
||||
assert file_node.extra.get("csharp_namespaces") == [
|
||||
"Acme",
|
||||
"Acme.Core",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed",
|
||||
)
|
||||
class TestCSharpReceiverCallResolution:
|
||||
"""Regression tests for #612: C# receiver calls (``Service.StaticCall()``,
|
||||
``obj.InstanceCall()``, ``obj?.ConditionalCall()``) were extracted but every
|
||||
call target stayed a bare unresolved name, so ``callers_of`` marked callers
|
||||
unresolved and ``get_impact_radius`` reported zero impacted nodes/files for
|
||||
the callee's file. These tests run the full build pipeline (``full_build``
|
||||
plus ``run_post_processing``) on a multi-file fixture and assert on
|
||||
built-graph query results, not parse-time output.
|
||||
"""
|
||||
|
||||
SERVICE = (
|
||||
"namespace Acme.Services;\n"
|
||||
"\n"
|
||||
"public class Service\n"
|
||||
"{\n"
|
||||
" public static void StaticCall() { }\n"
|
||||
" public void InstanceCall() { }\n"
|
||||
" public void ConditionalCall() { }\n"
|
||||
"}\n"
|
||||
)
|
||||
CONSUMER = (
|
||||
"using Acme.Services;\n"
|
||||
"\n"
|
||||
"namespace Acme.App;\n"
|
||||
"\n"
|
||||
"public class Consumer\n"
|
||||
"{\n"
|
||||
" public void Run()\n"
|
||||
" {\n"
|
||||
" Service.StaticCall();\n"
|
||||
" var obj = new Service();\n"
|
||||
" obj.InstanceCall();\n"
|
||||
" Service typed = obj;\n"
|
||||
" typed.InstanceCall();\n"
|
||||
" obj?.ConditionalCall();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
# Same-file resolution: two classes in one file, one calling the other.
|
||||
SINGLE = (
|
||||
"namespace Acme.Single;\n"
|
||||
"\n"
|
||||
"public class Widget\n"
|
||||
"{\n"
|
||||
" public static void Spin() { }\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"public class Runner\n"
|
||||
"{\n"
|
||||
" public void Go()\n"
|
||||
" {\n"
|
||||
" Widget.Spin();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
# Decoy classes with identical class/method names in an unrelated
|
||||
# namespace: resolution must use receiver + namespace evidence, not
|
||||
# graph-wide name uniqueness.
|
||||
DECOY = (
|
||||
"namespace Other.Zone;\n"
|
||||
"\n"
|
||||
"public class Widget\n"
|
||||
"{\n"
|
||||
" public static void Spin() { }\n"
|
||||
"}\n"
|
||||
"\n"
|
||||
"public class Service\n"
|
||||
"{\n"
|
||||
" public static void StaticCall() { }\n"
|
||||
" public void InstanceCall() { }\n"
|
||||
" public void ConditionalCall() { }\n"
|
||||
"}\n"
|
||||
)
|
||||
TESTS = (
|
||||
"using Acme.Services;\n"
|
||||
"\n"
|
||||
"namespace Acme.Tests;\n"
|
||||
"\n"
|
||||
"public class ServiceTests\n"
|
||||
"{\n"
|
||||
" public void TestStaticDispatch()\n"
|
||||
" {\n"
|
||||
" Service.StaticCall();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
def _build(self, tmp_path):
|
||||
from unittest.mock import patch
|
||||
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.incremental import full_build
|
||||
from code_review_graph.postprocessing import run_post_processing
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / ".code-review-graph").mkdir()
|
||||
files = {
|
||||
"Service.cs": self.SERVICE,
|
||||
"Consumer.cs": self.CONSUMER,
|
||||
"Single.cs": self.SINGLE,
|
||||
"Decoy.cs": self.DECOY,
|
||||
"ServiceTests.cs": self.TESTS,
|
||||
}
|
||||
for name, content in files.items():
|
||||
(tmp_path / name).write_text(content, encoding="utf-8")
|
||||
store = GraphStore(tmp_path / ".code-review-graph" / "graph.db")
|
||||
with patch(
|
||||
"code_review_graph.incremental.get_all_tracked_files",
|
||||
return_value=sorted(files),
|
||||
):
|
||||
full_build(tmp_path, store)
|
||||
run_post_processing(store)
|
||||
store.close()
|
||||
|
||||
def _call_targets_of(self, tmp_path, caller_suffix):
|
||||
from code_review_graph.graph import GraphStore
|
||||
|
||||
store = GraphStore(tmp_path / ".code-review-graph" / "graph.db")
|
||||
try:
|
||||
rows = store._conn.execute(
|
||||
"SELECT target_qualified FROM edges "
|
||||
"WHERE kind = 'CALLS' AND source_qualified LIKE ?",
|
||||
(f"%::{caller_suffix}",),
|
||||
).fetchall()
|
||||
return {row["target_qualified"] for row in rows}
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_full_build_resolves_receiver_calls_to_canonical_methods(
|
||||
self, tmp_path,
|
||||
):
|
||||
self._build(tmp_path)
|
||||
service = str(tmp_path / "Service.cs")
|
||||
targets = self._call_targets_of(tmp_path, "Consumer.Run")
|
||||
assert f"{service}::Service.StaticCall" in targets
|
||||
assert f"{service}::Service.InstanceCall" in targets
|
||||
assert f"{service}::Service.ConditionalCall" in targets
|
||||
decoy = str(tmp_path / "Decoy.cs")
|
||||
assert not any(t.startswith(f"{decoy}::") for t in targets)
|
||||
|
||||
def test_full_build_resolves_same_file_receiver_call(self, tmp_path):
|
||||
self._build(tmp_path)
|
||||
single = str(tmp_path / "Single.cs")
|
||||
targets = self._call_targets_of(tmp_path, "Runner.Go")
|
||||
assert f"{single}::Widget.Spin" in targets
|
||||
|
||||
def test_callers_of_returns_resolved_caller_after_full_build(self, tmp_path):
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
self._build(tmp_path)
|
||||
service = str(tmp_path / "Service.cs")
|
||||
for method in ("StaticCall", "InstanceCall", "ConditionalCall"):
|
||||
result = query_graph(
|
||||
"callers_of",
|
||||
f"{service}::Service.{method}",
|
||||
repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
run_callers = [
|
||||
r for r in result.get("results", [])
|
||||
if r.get("name") == "Run"
|
||||
]
|
||||
assert run_callers, f"callers_of({method}) missed Consumer.Run"
|
||||
assert all(
|
||||
r.get("target_resolution") != "unresolved"
|
||||
for r in run_callers
|
||||
), f"callers_of({method}) still marks Consumer.Run unresolved"
|
||||
|
||||
def test_impact_radius_of_service_file_reaches_consumer(self, tmp_path):
|
||||
from code_review_graph.tools.query import get_impact_radius
|
||||
|
||||
self._build(tmp_path)
|
||||
result = get_impact_radius(
|
||||
changed_files=["Service.cs"], repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
impacted_names = {
|
||||
n["name"] for n in result.get("impacted_nodes", [])
|
||||
}
|
||||
assert "Run" in impacted_names
|
||||
assert str(tmp_path / "Consumer.cs") in set(
|
||||
result.get("impacted_files", []),
|
||||
)
|
||||
|
||||
def test_impact_radius_of_decoy_file_does_not_reach_consumer(
|
||||
self, tmp_path,
|
||||
):
|
||||
from code_review_graph.tools.query import get_impact_radius
|
||||
|
||||
self._build(tmp_path)
|
||||
result = get_impact_radius(
|
||||
changed_files=["Decoy.cs"], repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
impacted_names = {
|
||||
n["name"] for n in result.get("impacted_nodes", [])
|
||||
}
|
||||
assert "Run" not in impacted_names
|
||||
|
||||
def test_tests_for_finds_test_through_resolved_receiver_call(
|
||||
self, tmp_path,
|
||||
):
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
self._build(tmp_path)
|
||||
service = str(tmp_path / "Service.cs")
|
||||
result = query_graph(
|
||||
"tests_for",
|
||||
f"{service}::Service.StaticCall",
|
||||
repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
test_names = {r.get("name") for r in result.get("results", [])}
|
||||
assert "TestStaticDispatch" in test_names
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not _has_csharp_parser(), reason="csharp tree-sitter grammar not installed",
|
||||
)
|
||||
class TestCSharpNamespaceImpactAndCoverage:
|
||||
"""End-to-end regression tests for #310 / #792.
|
||||
|
||||
C# ``using X.Y;`` directives produce IMPORTS_FROM edges targeting the
|
||||
raw namespace string, never a file path. PR #353 added a namespace
|
||||
fallback for ``importers_of`` only, leaving:
|
||||
|
||||
- ``get_impact_radius`` returning 0 impacted files/nodes for changed
|
||||
.cs files (the impact traversal had no namespace expansion), and
|
||||
- ``tests_for`` / the ``detect_changes`` test-gap detector reporting
|
||||
covered C# code as untested (``_resolve_bare_endpoints`` only accepts
|
||||
file-path import evidence C# never emits).
|
||||
"""
|
||||
|
||||
def _build(self, tmp_path):
|
||||
from code_review_graph.graph import GraphStore
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
(tmp_path / ".code-review-graph").mkdir()
|
||||
core = tmp_path / "Core.cs"
|
||||
core.write_text(
|
||||
"namespace ACME.Core;\n"
|
||||
"public class TaskBoard {\n"
|
||||
" public int CountTasks() { return 0; }\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
app = tmp_path / "App.cs"
|
||||
app.write_text(
|
||||
"using ACME.Core;\n"
|
||||
"namespace ACME.App;\n"
|
||||
"public class App {\n"
|
||||
" public void Run() {\n"
|
||||
" var b = new TaskBoard();\n"
|
||||
" b.CountTasks();\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# NUnit-style test whose name does NOT match any naming convention
|
||||
# (no Test prefix on the method), so coverage must come from the
|
||||
# resolved TESTED_BY edge rather than the name-based fallback.
|
||||
tests = tmp_path / "TaskBoardTests.cs"
|
||||
tests.write_text(
|
||||
"using NUnit.Framework;\n"
|
||||
"using ACME.Core;\n"
|
||||
"namespace ACME.Core.Tests;\n"
|
||||
"[TestFixture]\n"
|
||||
"public class TaskBoardTests {\n"
|
||||
" [Test]\n"
|
||||
" public void CountTasks_ReturnsZero() {\n"
|
||||
" var board = new TaskBoard();\n"
|
||||
" var n = board.CountTasks();\n"
|
||||
" }\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
unrelated = tmp_path / "Unrelated.cs"
|
||||
unrelated.write_text(
|
||||
"using System.Linq;\n"
|
||||
"namespace ACME.Other;\n"
|
||||
"public class Other {}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
store = GraphStore(tmp_path / ".code-review-graph" / "graph.db")
|
||||
parser = CodeParser()
|
||||
for path in (core, app, tests, unrelated):
|
||||
nodes, edges = parser.parse_file(path)
|
||||
for n in nodes:
|
||||
store.upsert_node(n)
|
||||
for e in edges:
|
||||
store.upsert_edge(e)
|
||||
store.commit()
|
||||
# Same bare-endpoint resolution the build/postprocess pipeline runs.
|
||||
store.resolve_bare_call_targets()
|
||||
store.resolve_bare_tested_by_sources()
|
||||
return store, core, app, tests, unrelated
|
||||
|
||||
def test_impact_radius_sql_reaches_csharp_importers(self, tmp_path):
|
||||
store, core, app, tests, unrelated = self._build(tmp_path)
|
||||
try:
|
||||
impact = store.get_impact_radius_sql([str(core)])
|
||||
assert str(app) in impact["impacted_files"]
|
||||
assert str(tests) in impact["impacted_files"]
|
||||
assert str(unrelated) not in impact["impacted_files"]
|
||||
assert impact["total_impacted"] > 0
|
||||
# The namespace string itself must never surface as a node.
|
||||
impacted_qns = {n.qualified_name for n in impact["impacted_nodes"]}
|
||||
assert "ACME.Core" not in impacted_qns
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_impact_radius_networkx_reaches_csharp_importers(self, tmp_path):
|
||||
store, core, app, tests, unrelated = self._build(tmp_path)
|
||||
try:
|
||||
impact = store._get_impact_radius_networkx([str(core)])
|
||||
assert str(app) in impact["impacted_files"]
|
||||
assert str(tests) in impact["impacted_files"]
|
||||
assert str(unrelated) not in impact["impacted_files"]
|
||||
impacted_qns = {n.qualified_name for n in impact["impacted_nodes"]}
|
||||
assert "ACME.Core" not in impacted_qns
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_importer_appears_in_both_importers_of_and_impact(self, tmp_path):
|
||||
"""Owner acceptance for #310: the same importer must appear in both
|
||||
``importers_of`` and the public ``get_impact_radius`` results."""
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
store, core, app, _tests, _unrelated = self._build(tmp_path)
|
||||
try:
|
||||
result = query_graph(
|
||||
"importers_of", str(core), repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
importers = {r["file"] for r in result.get("results", [])}
|
||||
assert str(app) in importers
|
||||
|
||||
impact = store.get_impact_radius([str(core)])
|
||||
assert str(app) in impact["impacted_files"]
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_bare_tested_by_source_resolves_via_namespace_evidence(self, tmp_path):
|
||||
store, core, _app, tests, _unrelated = self._build(tmp_path)
|
||||
try:
|
||||
method_qn = f"{core}::TaskBoard.CountTasks"
|
||||
test_qn = f"{tests}::TaskBoardTests.CountTasks_ReturnsZero"
|
||||
tested_by = [
|
||||
e for e in store.get_edges_by_source(method_qn)
|
||||
if e.kind == "TESTED_BY"
|
||||
]
|
||||
assert [e.target_qualified for e in tested_by] == [test_qn]
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
def test_tests_for_finds_csharp_test_via_edge(self, tmp_path):
|
||||
from code_review_graph.tools.query import query_graph
|
||||
|
||||
store, core, _app, tests, _unrelated = self._build(tmp_path)
|
||||
store.close()
|
||||
result = query_graph(
|
||||
"tests_for",
|
||||
f"{core}::TaskBoard.CountTasks",
|
||||
repo_root=str(tmp_path),
|
||||
)
|
||||
assert result.get("status") == "ok"
|
||||
found = {
|
||||
r["qualified_name"]: r for r in result.get("results", [])
|
||||
}
|
||||
test_qn = f"{tests}::TaskBoardTests.CountTasks_ReturnsZero"
|
||||
assert test_qn in found
|
||||
# Must come from the resolved TESTED_BY edge, not name matching.
|
||||
assert found[test_qn].get("inferred_by") != "naming_convention"
|
||||
|
||||
def test_detect_changes_does_not_report_covered_method_untested(self, tmp_path):
|
||||
from code_review_graph.changes import analyze_changes
|
||||
|
||||
store, core, _app, _tests, _unrelated = self._build(tmp_path)
|
||||
try:
|
||||
result = analyze_changes(store, [str(core)])
|
||||
gap_names = {g["name"] for g in result["test_gaps"]}
|
||||
assert "CountTasks" not in gap_names
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class TestRubyParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.rb")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("app.rb")) == "ruby"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names or "UserRepository" in names
|
||||
|
||||
def test_finds_methods(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "initialize" in names or "find_by_id" in names or "save" in names
|
||||
|
||||
def test_finds_calls(self):
|
||||
"""Ruby method calls must produce CALLS edges.
|
||||
|
||||
Ruby's grammar uses the same ``call`` node type for both
|
||||
``require`` and ordinary method invocation, so the dispatcher must
|
||||
not treat every ``call`` as an import. Paren calls (``save(user)``),
|
||||
command calls (``puts ...``) and member calls (``User.new`` /
|
||||
``@users.size``) are all captured. Bare implicit-self calls with no
|
||||
parens (e.g. a lone ``helper``) parse as ``identifier`` rather than
|
||||
``call`` and are intentionally not covered here.
|
||||
"""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 1
|
||||
|
||||
targets = {e.target for e in calls}
|
||||
target_names = {t.split("::")[-1].split(".")[-1] for t in targets}
|
||||
|
||||
# Paren, command and member calls are all captured.
|
||||
assert "save" in target_names
|
||||
assert "puts" in target_names
|
||||
assert "new" in target_names
|
||||
assert "size" in target_names
|
||||
|
||||
# A same-class call resolves to the defining method node, not a bare
|
||||
# name, so callers_of/callees_of work within a file.
|
||||
assert any(t.endswith("sample.rb::UserRepository.save") for t in targets)
|
||||
|
||||
# Calls are attributed to their enclosing method.
|
||||
create_user_targets = {
|
||||
e.target for e in calls
|
||||
if e.source.endswith("UserRepository.create_user")
|
||||
}
|
||||
assert any(t.endswith("UserRepository.save") for t in create_user_targets)
|
||||
assert any(t.endswith("new") for t in create_user_targets)
|
||||
|
||||
|
||||
class TestPHPParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.php")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("index.php")) == "php"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names or "InMemoryRepo" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert len(names) > 0
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
target_names = {t.split("::")[-1].split(".")[-1] for t in targets}
|
||||
|
||||
run_queries_targets = {
|
||||
e.target for e in calls if e.source.endswith("::ExtendedRepo.runQueries")
|
||||
}
|
||||
|
||||
# Plain function calls
|
||||
assert "sqlQuery" in target_names
|
||||
assert "xl" in target_names
|
||||
assert "text" in target_names
|
||||
|
||||
# Member and nullsafe method calls
|
||||
assert "execute" in target_names
|
||||
assert "search" in target_names
|
||||
|
||||
# Scoped/static calls
|
||||
assert any(
|
||||
target.endswith("sample.php::QueryUtils.fetchRecords")
|
||||
for target in targets
|
||||
)
|
||||
assert any(
|
||||
target.endswith("sample.php::EncounterService.create")
|
||||
for target in targets
|
||||
)
|
||||
assert any(t.endswith("__construct") for t in run_queries_targets)
|
||||
assert any(t.endswith("factory") for t in run_queries_targets)
|
||||
|
||||
# Global namespaced calls should normalize to a stable name
|
||||
assert "dirname" in target_names
|
||||
|
||||
def test_finds_extended_php_types_bases_and_object_creation(self):
|
||||
source = b"""<?php
|
||||
trait Auditable {}
|
||||
enum Status: string { case Active = 'active'; }
|
||||
interface Contract {}
|
||||
|
||||
class Service extends \\Framework\\Base implements Contract, \\Other\\Marker {
|
||||
public function run(): void {
|
||||
$worker = new \\App\\Worker();
|
||||
$worker->save();
|
||||
Service::factory();
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
nodes, edges = self.parser.parse_bytes(Path("extended.php"), source)
|
||||
|
||||
class_names = {node.name for node in nodes if node.kind == "Class"}
|
||||
assert {"Auditable", "Status", "Contract", "Service"} <= class_names
|
||||
|
||||
inherited = {edge.target for edge in edges if edge.kind == "INHERITS"}
|
||||
assert "\\Framework\\Base" in inherited
|
||||
assert "Contract" in inherited
|
||||
assert "\\Other\\Marker" in inherited
|
||||
|
||||
calls = {edge.target for edge in edges if edge.kind == "CALLS"}
|
||||
assert "App\\Worker" in calls
|
||||
# Existing PHP call formatting must stay unchanged.
|
||||
assert "save" in calls
|
||||
assert "Service::factory" in calls
|
||||
|
||||
|
||||
class TestPHPTestAnnotations:
|
||||
"""Regression tests for #693: PHP test methods were only recognised by
|
||||
the ``test_`` name prefix. Neither PHPUnit's legacy ``/** @test */``
|
||||
docblock tag nor the PHP 8 ``#[Test]`` attribute was detected, so those
|
||||
methods were misclassified as production ``Function`` nodes. Mirrors the
|
||||
C# attribute fix from #295: PHP attributes need their own capture path
|
||||
(``attribute_list > attribute_group > attribute``, one level deeper than
|
||||
C#'s), and the docblock tag needs a separate preceding-sibling check.
|
||||
"""
|
||||
|
||||
def _parse(self, tmp_path):
|
||||
p = tmp_path / "tests" / "ExampleTest.php"
|
||||
p.parent.mkdir()
|
||||
p.write_text(
|
||||
"<?php\n"
|
||||
"namespace Tests;\n\n"
|
||||
"use PHPUnit\\Framework\\TestCase;\n"
|
||||
"use PHPUnit\\Framework\\Attributes\\Test;\n\n"
|
||||
"use PHPUnit\\Framework\\Attributes\\Test as UnitTest;\n"
|
||||
"use PHPUnit\\Framework\\Attributes\\DataProvider;\n\n"
|
||||
"use App\\Attributes\\Test as OtherTest;\n\n"
|
||||
"class ExampleTest extends TestCase\n"
|
||||
"{\n"
|
||||
" public function test_prefixed_method_should_be_detected(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" public function testItAddsTwoNumbers(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" /** @test */\n"
|
||||
" public function docblock_annotated_method(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[Test]\n"
|
||||
" public function php8_attribute_annotated_method(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[\\PHPUnit\\Framework\\Attributes\\Test]\n"
|
||||
" public function qualified_attribute_method(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[UnitTest]\n"
|
||||
" public function aliased_attribute_method(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[DataProvider('rows'), Test]\n"
|
||||
" public function grouped_attribute_method(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[\\App\\Attributes\\Test]\n"
|
||||
" public function unrelated_qualified_attribute(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" #[OtherTest]\n"
|
||||
" public function unrelated_aliased_attribute(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" /** @test-case is documentation, not a PHPUnit tag. */\n"
|
||||
" public function documented_helper(): void\n"
|
||||
" {\n"
|
||||
" }\n\n"
|
||||
" public function helperNotATest(): void\n"
|
||||
" {\n"
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"function testDatabaseAvailable(): void\n"
|
||||
"{\n"
|
||||
"}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return CodeParser().parse_file(p)
|
||||
|
||||
def test_name_prefix_still_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "test_prefixed_method_should_be_detected")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_phpunit_camel_case_name_prefix_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "testItAddsTwoNumbers")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_phpunit_name_prefix_does_not_mark_top_level_function(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "testDatabaseAvailable")
|
||||
assert m.kind == "Function"
|
||||
assert m.is_test is False
|
||||
|
||||
def test_docblock_annotation_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "docblock_annotated_method")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_php8_attribute_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "php8_attribute_annotated_method")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
assert m.extra.get("decorators") == ["Test"]
|
||||
|
||||
def test_qualified_php8_attribute_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "qualified_attribute_method")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_aliased_php8_attribute_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "aliased_attribute_method")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_grouped_php8_attribute_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "grouped_attribute_method")
|
||||
assert m.kind == "Test"
|
||||
assert m.is_test is True
|
||||
|
||||
def test_unrelated_qualified_attribute_is_not_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(
|
||||
n for n in nodes if n.name == "unrelated_qualified_attribute"
|
||||
)
|
||||
assert m.kind == "Function"
|
||||
assert m.is_test is False
|
||||
|
||||
def test_unrelated_aliased_attribute_is_not_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(
|
||||
n for n in nodes if n.name == "unrelated_aliased_attribute"
|
||||
)
|
||||
assert m.kind == "Function"
|
||||
assert m.is_test is False
|
||||
|
||||
def test_similar_docblock_tag_is_not_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "documented_helper")
|
||||
assert m.kind == "Function"
|
||||
assert m.is_test is False
|
||||
|
||||
def test_plain_method_not_detected(self, tmp_path):
|
||||
nodes, _ = self._parse(tmp_path)
|
||||
m = next(n for n in nodes if n.name == "helperNotATest")
|
||||
assert m.kind == "Function"
|
||||
assert m.is_test is False
|
||||
|
||||
|
||||
class TestPHPImportResolution:
|
||||
"""PHP ``use`` imports resolve to absolute file paths (PSR-4 layout)."""
|
||||
|
||||
def test_resolves_project_import(self, tmp_path):
|
||||
"""``use`` of a project class resolves to its .php file."""
|
||||
entity = tmp_path / "src/App/Domain/Entity"
|
||||
entity.mkdir(parents=True)
|
||||
(entity / "Job.php").write_text(
|
||||
"<?php\nnamespace App\\Domain\\Entity;\nclass Job {}\n"
|
||||
)
|
||||
svc = tmp_path / "src/App/Service"
|
||||
svc.mkdir(parents=True)
|
||||
(svc / "MatchService.php").write_text(
|
||||
"<?php\nnamespace App\\Service;\n"
|
||||
"use App\\Domain\\Entity\\Job;\n"
|
||||
"class MatchService {}\n"
|
||||
)
|
||||
|
||||
parser = CodeParser(tmp_path)
|
||||
_, edges = parser.parse_file(svc / "MatchService.php")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == (entity / "Job.php").resolve().as_posix()
|
||||
|
||||
def test_vendor_import_stays_unresolved(self, tmp_path):
|
||||
"""A class with no local file stays as the bare FQN, not a raw
|
||||
``use ...;`` statement and not a fake path."""
|
||||
svc = tmp_path / "src/App/Service"
|
||||
svc.mkdir(parents=True)
|
||||
(svc / "Logger.php").write_text(
|
||||
"<?php\nnamespace App\\Service;\n"
|
||||
"use Psr\\Log\\LoggerInterface;\n"
|
||||
"class Logger {}\n"
|
||||
)
|
||||
parser = CodeParser()
|
||||
_, edges = parser.parse_file(svc / "Logger.php")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == "Psr\\Log\\LoggerInterface"
|
||||
assert not imports[0].target.endswith(".php")
|
||||
|
||||
def test_aliased_import_records_fqn_not_alias(self, tmp_path):
|
||||
"""``use A\\B\\C as D`` records the FQN A\\B\\C, ignoring the alias."""
|
||||
contact = tmp_path / "src/App/Domain/Embedded"
|
||||
contact.mkdir(parents=True)
|
||||
(contact / "Contact.php").write_text(
|
||||
"<?php\nnamespace App\\Domain\\Embedded;\nclass Contact {}\n"
|
||||
)
|
||||
job = tmp_path / "src/App/Domain/Entity"
|
||||
job.mkdir(parents=True)
|
||||
(job / "Job.php").write_text(
|
||||
"<?php\nnamespace App\\Domain\\Entity;\n"
|
||||
"use App\\Domain\\Embedded\\Contact as ContactEmbedded;\n"
|
||||
"class Job {}\n"
|
||||
)
|
||||
parser = CodeParser(tmp_path)
|
||||
_, edges = parser.parse_file(job / "Job.php")
|
||||
imports = [e for e in edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) == 1
|
||||
assert imports[0].target == (contact / "Contact.php").resolve().as_posix()
|
||||
|
||||
def test_grouped_use_expands_to_multiple_imports(self, tmp_path):
|
||||
"""``use App\\Domain\\{Entity\\Job, Model\\Status}`` -> two imports,
|
||||
each prefixed with the group namespace and resolved independently."""
|
||||
base = tmp_path / "src/App/Domain"
|
||||
(base / "Entity").mkdir(parents=True)
|
||||
(base / "Model").mkdir(parents=True)
|
||||
(base / "Entity/Job.php").write_text(
|
||||
"<?php\nnamespace App\\Domain\\Entity;\nclass Job {}\n"
|
||||
)
|
||||
(base / "Model/Status.php").write_text(
|
||||
"<?php\nnamespace App\\Domain\\Model;\nclass Status {}\n"
|
||||
)
|
||||
consumer = tmp_path / "src/App/Service"
|
||||
consumer.mkdir(parents=True)
|
||||
(consumer / "C.php").write_text(
|
||||
"<?php\nnamespace App\\Service;\n"
|
||||
"use App\\Domain\\{Entity\\Job, Model\\Status};\n"
|
||||
"class C {}\n"
|
||||
)
|
||||
parser = CodeParser(tmp_path)
|
||||
_, edges = parser.parse_file(consumer / "C.php")
|
||||
targets = {e.target for e in edges if e.kind == "IMPORTS_FROM"}
|
||||
assert (base / "Entity/Job.php").resolve().as_posix() in targets
|
||||
assert (base / "Model/Status.php").resolve().as_posix() in targets
|
||||
assert len(targets) == 2
|
||||
|
||||
|
||||
class TestKotlinParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.kt")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("Main.kt")) == "kotlin"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names or "InMemoryRepo" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "createUser" in names or "findById" in names or "save" in names
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {c.target for c in calls}
|
||||
# Simple call: println(...)
|
||||
assert "println" in targets
|
||||
# Method call: repo.save(user)
|
||||
assert any("save" in t for t in targets)
|
||||
|
||||
|
||||
class TestKotlinAnnotations:
|
||||
"""Regression tests for #295: Kotlin nodes must persist annotation
|
||||
metadata in both ``modifiers`` (comma-joined string) and
|
||||
``extra['decorators']`` (list) so consumers can filter queries like
|
||||
"show me all @Composable functions" or "find @HiltViewModel classes".
|
||||
"""
|
||||
|
||||
def _parse(self, source: str, tmp_path):
|
||||
p = tmp_path / "x.kt"
|
||||
p.write_text(source, encoding="utf-8")
|
||||
return CodeParser().parse_file(p)
|
||||
|
||||
def test_hilt_viewmodel_annotation_on_class(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"package com.example\n@HiltViewModel\nclass MyVM {\n fun noop() {}\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
vm = next(n for n in nodes if n.kind == "Class" and n.name == "MyVM")
|
||||
assert vm.modifiers == "HiltViewModel"
|
||||
assert vm.extra.get("decorators") == ["HiltViewModel"]
|
||||
|
||||
def test_composable_annotation_on_function(self, tmp_path):
|
||||
nodes, _ = self._parse(
|
||||
"package com.example\n@Composable\nfun Greeting(n: String) {\n"
|
||||
" println(n)\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
fn = next(n for n in nodes if n.kind == "Function" and n.name == "Greeting")
|
||||
assert fn.modifiers == "Composable"
|
||||
assert fn.extra.get("decorators") == ["Composable"]
|
||||
|
||||
def test_unannotated_function_has_none_modifiers(self, tmp_path):
|
||||
"""Guard: adding annotation support must not leak an empty string
|
||||
or empty list onto unannotated nodes."""
|
||||
nodes, _ = self._parse(
|
||||
"package com.example\nfun bare() { println(1) }\n", tmp_path,
|
||||
)
|
||||
fn = next(n for n in nodes if n.kind == "Function" and n.name == "bare")
|
||||
assert fn.modifiers is None
|
||||
assert "decorators" not in fn.extra
|
||||
|
||||
def test_test_annotation_still_triggers_test_kind(self, tmp_path):
|
||||
"""Guard: annotation persistence must not break the pre-existing
|
||||
@Test -> Test-kind promotion."""
|
||||
nodes, _ = self._parse(
|
||||
"package com.example\nclass T {\n @Test\n fun testX() { println(1) }\n}\n",
|
||||
tmp_path,
|
||||
)
|
||||
t = next(n for n in nodes if n.kind == "Test" and n.name == "testX")
|
||||
assert t.extra.get("decorators") == ["Test"]
|
||||
|
||||
|
||||
class TestSwiftParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.swift")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("App.swift")) == "swift"
|
||||
|
||||
def test_finds_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "createUser" in names or "findById" in names or "save" in names
|
||||
|
||||
def test_finds_enum(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Direction" in names
|
||||
|
||||
def test_finds_actor(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "DataStore" in names
|
||||
|
||||
def test_finds_extension(self):
|
||||
"""Extensions should be detected and linked to the extended type."""
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
# Extension of InMemoryRepo should produce a Class node named InMemoryRepo
|
||||
# with swift_kind == "extension"
|
||||
ext_nodes = [c for c in classes if c.extra.get("swift_kind") == "extension"]
|
||||
assert len(ext_nodes) >= 1
|
||||
assert ext_nodes[0].name == "InMemoryRepo"
|
||||
|
||||
def test_finds_protocol(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "UserRepository" in names
|
||||
|
||||
def test_swift_kind_extra(self):
|
||||
"""Each Swift type should have the correct swift_kind in extra."""
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert classes["User"].extra.get("swift_kind") == "struct"
|
||||
assert classes["Direction"].extra.get("swift_kind") == "enum"
|
||||
assert classes["DataStore"].extra.get("swift_kind") == "actor"
|
||||
assert classes["UserRepository"].extra.get("swift_kind") == "protocol"
|
||||
# InMemoryRepo appears twice (class + extension); check at least one is "class"
|
||||
repo_nodes = [n for n in self.nodes if n.kind == "Class" and n.name == "InMemoryRepo"]
|
||||
kinds = {n.extra.get("swift_kind") for n in repo_nodes}
|
||||
assert "class" in kinds
|
||||
assert "extension" in kinds
|
||||
|
||||
def test_inheritance_edges(self):
|
||||
"""Swift inheritance / conformance should produce INHERITS edges."""
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
targets = {e.target for e in inherits}
|
||||
# InMemoryRepo: UserRepository
|
||||
assert "UserRepository" in targets
|
||||
# Direction: String
|
||||
assert "String" in targets
|
||||
# extension InMemoryRepo: CustomStringConvertible
|
||||
assert "CustomStringConvertible" in targets
|
||||
|
||||
def test_finds_initializers(self):
|
||||
"""`init` / `convenience init` are Function nodes on their own type."""
|
||||
inits = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.name == "init" and n.parent_name == "InMemoryRepo"
|
||||
]
|
||||
assert len(inits) == 2
|
||||
|
||||
def test_finds_deinitializer(self):
|
||||
funcs = {(n.name, n.parent_name) for n in self.nodes if n.kind == "Function"}
|
||||
assert ("deinit", "InMemoryRepo") in funcs
|
||||
|
||||
def test_finds_subscript(self):
|
||||
"""`subscript` is named after its keyword, not its return type."""
|
||||
funcs = {(n.name, n.parent_name) for n in self.nodes if n.kind == "Function"}
|
||||
assert ("subscript", "InMemoryRepo") in funcs
|
||||
assert not any(n.name == "User" for n in self.nodes if n.kind == "Function")
|
||||
|
||||
def test_initializer_body_calls_attributed_to_declaration(self):
|
||||
"""Calls inside init/deinit/subscript belong to that declaration, not the file."""
|
||||
calls = {
|
||||
(e.source.rsplit("::", 1)[-1], e.target.rsplit("::", 1)[-1])
|
||||
for e in self.edges if e.kind == "CALLS"
|
||||
}
|
||||
# init(seed:) calls save(user); convenience init() delegates to self.init
|
||||
assert ("InMemoryRepo.init", "InMemoryRepo.save") in calls
|
||||
assert ("InMemoryRepo.init", "InMemoryRepo.init") in calls
|
||||
assert ("InMemoryRepo.deinit", "removeAll") in calls
|
||||
assert ("InMemoryRepo.subscript", "InMemoryRepo.findById") in calls
|
||||
# Previously these landed on the File node, making blast radius file-wide.
|
||||
assert not any(src.endswith("sample.swift") for src, _ in calls)
|
||||
|
||||
|
||||
class TestScalaParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.scala")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("Main.scala")) == "scala"
|
||||
|
||||
def test_finds_classes_traits_objects(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Repository" in names
|
||||
assert "User" in names
|
||||
assert "InMemoryRepo" in names
|
||||
assert "UserService" in names
|
||||
assert "Color" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "findById" in names
|
||||
assert "save" in names
|
||||
assert "createUser" in names
|
||||
assert "getUser" in names
|
||||
assert "apply" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "scala.util.Try" in targets
|
||||
assert "scala.collection.mutable" in targets
|
||||
assert "scala.collection.mutable.HashMap" in targets
|
||||
assert "scala.collection.mutable.ListBuffer" in targets
|
||||
assert "scala.concurrent.*" in targets
|
||||
assert len(imports) >= 3
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
targets = {e.target for e in inherits}
|
||||
assert "Repository" in targets
|
||||
assert "Serializable" in targets
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 3
|
||||
|
||||
|
||||
class TestSolidityParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sol")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("Vault.sol")) == "solidity"
|
||||
|
||||
def test_finds_contracts_interfaces_libraries(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "StakingVault" in names
|
||||
assert "BoostedPool" in names
|
||||
assert "IStakingPool" in names
|
||||
assert "RewardMath" in names
|
||||
|
||||
def test_finds_structs(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "StakerPosition" in names
|
||||
|
||||
def test_finds_enums(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "PoolStatus" in names
|
||||
|
||||
def test_finds_custom_errors(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "InsufficientStake" in names
|
||||
assert "PoolNotActive" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "stake" in names
|
||||
assert "unstake" in names
|
||||
assert "stakedBalance" in names
|
||||
assert "pendingBonus" in names
|
||||
|
||||
def test_finds_constructors(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
constructors = [f for f in funcs if f.name == "constructor"]
|
||||
assert len(constructors) == 2 # StakingVault + BoostedPool
|
||||
|
||||
def test_finds_modifiers(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "nonZero" in names
|
||||
assert "whenPoolActive" in names
|
||||
|
||||
def test_finds_events(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "Staked" in names
|
||||
assert "Unstaked" in names
|
||||
assert "BonusClaimed" in names
|
||||
|
||||
def test_finds_file_level_events(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
# file-level events declared outside any contract
|
||||
assert "Staked" in names or "Unstaked" in names
|
||||
|
||||
def test_finds_user_defined_value_types(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Price" in names
|
||||
assert "PositionId" in names
|
||||
|
||||
def test_finds_file_level_constants(self):
|
||||
constants = [
|
||||
n for n in self.nodes
|
||||
if n.extra.get("solidity_kind") == "constant"
|
||||
]
|
||||
names = {c.name for c in constants}
|
||||
assert "MAX_SUPPLY" in names
|
||||
assert "ZERO_ADDRESS" in names
|
||||
|
||||
def test_finds_free_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
free = [f for f in funcs if f.name == "protocolFee"]
|
||||
assert len(free) == 1
|
||||
assert free[0].parent_name is None
|
||||
|
||||
def test_finds_using_directive(self):
|
||||
depends = [e for e in self.edges if e.kind == "DEPENDS_ON"]
|
||||
targets = {e.target for e in depends}
|
||||
assert "RewardMath" in targets
|
||||
|
||||
def test_finds_selective_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol" in targets
|
||||
|
||||
def test_finds_state_variables(self):
|
||||
state_vars = [
|
||||
n for n in self.nodes
|
||||
if n.extra.get("solidity_kind") == "state_variable"
|
||||
]
|
||||
names = {v.name for v in state_vars}
|
||||
assert "stakes" in names
|
||||
assert "totalStaked" in names
|
||||
assert "guardian" in names
|
||||
assert "status" in names
|
||||
assert "MIN_STAKE" in names
|
||||
assert "launchTime" in names
|
||||
assert "bonusRate" in names
|
||||
assert "assetPrice" in names
|
||||
|
||||
def test_state_variable_types(self):
|
||||
state_vars = {
|
||||
n.name: n for n in self.nodes
|
||||
if n.extra.get("solidity_kind") == "state_variable"
|
||||
}
|
||||
assert state_vars["totalStaked"].return_type == "uint256"
|
||||
assert state_vars["guardian"].return_type == "address"
|
||||
assert state_vars["stakes"].modifiers == "public"
|
||||
|
||||
def test_finds_receive_and_fallback(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "receive" in names
|
||||
assert "fallback" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "@openzeppelin/contracts/token/ERC20/ERC20.sol" in targets
|
||||
assert "@openzeppelin/contracts/access/Ownable.sol" in targets
|
||||
|
||||
def test_finds_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
pairs = {(e.source.split("::")[-1], e.target) for e in inherits}
|
||||
assert ("StakingVault", "ERC20") in pairs
|
||||
assert ("StakingVault", "Ownable") in pairs
|
||||
assert ("StakingVault", "IStakingPool") in pairs
|
||||
assert ("BoostedPool", "StakingVault") in pairs
|
||||
|
||||
def test_finds_function_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target.split("::")[-1] if "::" in e.target else e.target for e in calls}
|
||||
assert "require" in targets
|
||||
assert "_mint" in targets
|
||||
assert "_burn" in targets
|
||||
assert "pendingBonus" in targets or "BoostedPool.pendingBonus" in targets
|
||||
|
||||
def test_finds_emit_edges(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
# Targets may be qualified (e.g. "file::BoostedPool.BonusClaimed")
|
||||
target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
|
||||
assert "Staked" in target_basenames
|
||||
assert "Unstaked" in target_basenames
|
||||
assert "BonusClaimed" in target_basenames
|
||||
|
||||
def test_finds_modifier_invocations(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
# Extract (source_basename, target_basename) to handle qualified names
|
||||
target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
|
||||
assert "nonZero" in target_basenames
|
||||
assert "whenPoolActive" in target_basenames
|
||||
|
||||
def test_finds_constructor_modifier_invocations(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
target_basenames = {e.target.split("::")[-1].split(".")[-1] for e in calls}
|
||||
assert "ERC20" in target_basenames
|
||||
assert "Ownable" in target_basenames
|
||||
assert "StakingVault" in target_basenames
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target.split("::")[-1] for e in contains}
|
||||
assert "StakingVault" in targets
|
||||
assert "StakingVault.stake" in targets
|
||||
assert "StakingVault.stakes" in targets
|
||||
assert "StakingVault.Staked" not in targets # Staked is file-level
|
||||
assert "BoostedPool.claimBonus" in targets
|
||||
|
||||
def test_extracts_params(self):
|
||||
funcs = {
|
||||
n.name: n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "RewardMath"
|
||||
}
|
||||
assert funcs["mulPrecise"].params == "(uint256 a, uint256 b)"
|
||||
|
||||
def test_extracts_return_type(self):
|
||||
funcs = {
|
||||
n.name: n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "RewardMath"
|
||||
}
|
||||
assert "uint256" in funcs["mulPrecise"].return_type
|
||||
|
||||
|
||||
class TestVueParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("App.vue")) == "vue"
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "increment" in names
|
||||
assert "onSelectUser" in names
|
||||
assert "fetchUsers" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "vue" in targets
|
||||
assert "./UserList.vue" in targets
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 3
|
||||
|
||||
def test_nodes_have_vue_language(self):
|
||||
for node in self.nodes:
|
||||
assert node.language == "vue"
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 1
|
||||
|
||||
|
||||
class TestRParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.R")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("script.r")) == "r"
|
||||
assert self.parser.detect_language(Path("script.R")) == "r"
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function" and n.parent_name is None]
|
||||
names = {f.name for f in funcs}
|
||||
assert "add" in names
|
||||
assert "multiply" in names
|
||||
assert "process_data" in names
|
||||
|
||||
def test_finds_s4_classes(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "MyClass" in names
|
||||
|
||||
def test_finds_class_methods(self):
|
||||
methods = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "MyClass"
|
||||
]
|
||||
names = {m.name for m in methods}
|
||||
assert "greet" in names
|
||||
assert "get_age" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "dplyr" in targets
|
||||
assert "ggplot2" in targets
|
||||
assert "utils.R" in targets
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert "dplyr::filter" in targets
|
||||
assert "dplyr::summarize" in targets
|
||||
|
||||
def test_finds_params(self):
|
||||
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
|
||||
assert funcs["add"].params is not None
|
||||
assert "x" in funcs["add"].params
|
||||
assert "y" in funcs["add"].params
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target.split("::")[-1] for e in contains}
|
||||
assert "add" in targets
|
||||
assert "multiply" in targets
|
||||
assert "MyClass" in targets
|
||||
assert "MyClass.greet" in targets
|
||||
|
||||
def test_detects_test_functions(self):
|
||||
parser = CodeParser()
|
||||
nodes, _edges = parser.parse_file(FIXTURES / "test_sample.R")
|
||||
file_node = [n for n in nodes if n.kind == "File"][0]
|
||||
assert file_node.is_test is True
|
||||
test_funcs = [n for n in nodes if n.is_test and n.kind == "Test"]
|
||||
names = {f.name for f in test_funcs}
|
||||
assert "test_add" in names
|
||||
|
||||
|
||||
class TestPerlParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.pl")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("script.pl")) == "perl"
|
||||
assert self.parser.detect_language(Path("Module.pm")) == "perl"
|
||||
assert self.parser.detect_language(Path("test.t")) == "perl"
|
||||
|
||||
def test_finds_packages(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Animal" in names
|
||||
assert "Dog" in names
|
||||
|
||||
def test_finds_subroutines(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "new" in names
|
||||
assert "speak" in names
|
||||
assert "fetch" in names
|
||||
assert "bark" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) >= 1
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert any(t == "speak" or t.endswith("::speak") for t in targets) # $self->speak() — method_call_expression
|
||||
assert "bless" in targets # ambiguous_function_call_expression
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 3
|
||||
|
||||
|
||||
class TestXSParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.xs")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("MyModule.xs")) == "c"
|
||||
|
||||
def test_finds_structs(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "Point" in names
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "_add" in names
|
||||
assert "compute_distance" in names
|
||||
|
||||
def test_finds_includes(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "XSUB.h" in targets
|
||||
assert "string.h" in targets
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert any(t == "_add" or t.endswith("::_add") for t in targets)
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 3
|
||||
|
||||
|
||||
class TestLuaParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.lua")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("init.lua")) == "lua"
|
||||
assert self.parser.detect_language(Path("config.lua")) == "lua"
|
||||
|
||||
def test_finds_top_level_functions(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "greet" in names
|
||||
assert "helper" in names
|
||||
assert "process_animals" in names
|
||||
|
||||
def test_finds_variable_assigned_functions(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "transform" in names
|
||||
assert "validate" in names
|
||||
|
||||
def test_finds_dot_syntax_methods(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Animal"
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "new" in names
|
||||
|
||||
def test_finds_colon_syntax_methods(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Animal"
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "speak" in names
|
||||
assert "rename" in names
|
||||
|
||||
def test_finds_inherited_table_methods(self):
|
||||
dog_funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind in ("Function", "Test") and n.parent_name == "Dog"
|
||||
]
|
||||
names = {f.name for f in dog_funcs}
|
||||
assert "new" in names
|
||||
assert "fetch" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "cjson" in targets
|
||||
assert "lib.utils" in targets
|
||||
assert "logging" in targets
|
||||
assert len(imports) == 3
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert "print" in targets
|
||||
assert "setmetatable" in targets
|
||||
assert "assert" in targets
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target.split("::")[-1] for e in contains}
|
||||
assert "greet" in targets
|
||||
assert "helper" in targets
|
||||
assert "Animal.new" in targets
|
||||
assert "Animal.speak" in targets
|
||||
assert "Dog.fetch" in targets
|
||||
|
||||
def test_method_parent_names(self):
|
||||
funcs = {
|
||||
(n.name, n.parent_name) for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is not None
|
||||
}
|
||||
assert ("new", "Animal") in funcs
|
||||
assert ("speak", "Animal") in funcs
|
||||
assert ("rename", "Animal") in funcs
|
||||
assert ("new", "Dog") in funcs
|
||||
assert ("fetch", "Dog") in funcs
|
||||
|
||||
def test_detects_test_functions(self):
|
||||
tests = [n for n in self.nodes if n.kind == "Test"]
|
||||
names = {t.name for t in tests}
|
||||
assert "test_greet" in names
|
||||
assert "test_animal_speak" in names
|
||||
assert "test_dog_fetch" in names
|
||||
assert len(tests) == 3
|
||||
|
||||
def test_extracts_params(self):
|
||||
funcs = {n.name: n for n in self.nodes if n.kind == "Function"}
|
||||
assert funcs["greet"].params is not None
|
||||
assert "name" in funcs["greet"].params
|
||||
# Animal.new has (name, sound)
|
||||
animal_new = [
|
||||
n for n in self.nodes
|
||||
if n.name == "new" and n.parent_name == "Animal"
|
||||
][0]
|
||||
assert animal_new.params is not None
|
||||
assert "name" in animal_new.params
|
||||
assert "sound" in animal_new.params
|
||||
|
||||
def test_nodes_have_lua_language(self):
|
||||
for node in self.nodes:
|
||||
assert node.language == "lua"
|
||||
|
||||
def test_calls_inside_methods(self):
|
||||
"""Verify that calls inside methods have correct source qualified names."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
sources = {e.source.split("::")[-1] for e in calls}
|
||||
assert "Dog.fetch" in sources # Dog:fetch calls self:speak and print
|
||||
assert "Animal.speak" in sources # Animal:speak calls log:info
|
||||
|
||||
|
||||
class TestLuauParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.luau")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("init.luau")) == "luau"
|
||||
assert self.parser.detect_language(Path("module.luau")) == "luau"
|
||||
|
||||
def test_finds_type_aliases(self):
|
||||
types = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {t.name for t in types}
|
||||
assert "Vector3" in names
|
||||
assert "Callback" in names
|
||||
|
||||
def test_finds_top_level_functions(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "greet" in names
|
||||
assert "add" in names
|
||||
assert "process_animals" in names
|
||||
|
||||
def test_finds_variable_assigned_functions(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "transform" in names
|
||||
|
||||
def test_finds_dot_syntax_methods(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Animal"
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "new" in names
|
||||
|
||||
def test_finds_colon_syntax_methods(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Animal"
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "speak" in names
|
||||
assert "rename" in names
|
||||
|
||||
def test_finds_inherited_table_methods(self):
|
||||
dog_funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind in ("Function", "Test") and n.parent_name == "Dog"
|
||||
]
|
||||
names = {f.name for f in dog_funcs}
|
||||
assert "new" in names
|
||||
assert "fetch" in names
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "lib.utils" in targets
|
||||
assert "logging" in targets
|
||||
assert len(imports) >= 2
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert "print" in targets
|
||||
assert "setmetatable" in targets
|
||||
assert "assert" in targets
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target.split("::")[-1] for e in contains}
|
||||
assert "greet" in targets
|
||||
assert "add" in targets
|
||||
assert "Animal.new" in targets
|
||||
assert "Animal.speak" in targets
|
||||
assert "Dog.fetch" in targets
|
||||
|
||||
def test_method_parent_names(self):
|
||||
funcs = {
|
||||
(n.name, n.parent_name) for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is not None
|
||||
}
|
||||
assert ("new", "Animal") in funcs
|
||||
assert ("speak", "Animal") in funcs
|
||||
assert ("rename", "Animal") in funcs
|
||||
assert ("new", "Dog") in funcs
|
||||
assert ("fetch", "Dog") in funcs
|
||||
|
||||
def test_detects_test_functions(self):
|
||||
tests = [n for n in self.nodes if n.kind == "Test"]
|
||||
names = {t.name for t in tests}
|
||||
assert "test_greet" in names
|
||||
assert "test_animal_speak" in names
|
||||
assert "test_dog_fetch" in names
|
||||
assert len(tests) == 3
|
||||
|
||||
def test_nodes_have_luau_language(self):
|
||||
for node in self.nodes:
|
||||
assert node.language == "luau"
|
||||
|
||||
def test_calls_inside_methods(self):
|
||||
"""Verify that calls inside methods have correct source qualified names."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
sources = {e.source.split("::")[-1] for e in calls}
|
||||
assert "Dog.fetch" in sources
|
||||
assert "Animal.speak" in sources
|
||||
|
||||
|
||||
class TestObjectiveCParsing:
|
||||
"""Objective-C parser — closes #88."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.m")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("foo.m")) == "objc"
|
||||
|
||||
def test_nodes_have_objc_language(self):
|
||||
for n in self.nodes:
|
||||
assert n.language == "objc"
|
||||
|
||||
def test_finds_class(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
# Both @interface and @implementation produce Class nodes; that's
|
||||
# fine because they upsert to the same qualified name in the store.
|
||||
names = {c.name for c in classes}
|
||||
assert "Calculator" in names
|
||||
|
||||
def test_finds_instance_and_class_methods(self):
|
||||
funcs = {
|
||||
(n.name, n.parent_name) for n in self.nodes if n.kind == "Function"
|
||||
}
|
||||
assert ("add", "Calculator") in funcs
|
||||
assert ("reset", "Calculator") in funcs
|
||||
assert ("logResult", "Calculator") in funcs
|
||||
assert ("sharedCalculator", "Calculator") in funcs
|
||||
|
||||
def test_finds_c_main(self):
|
||||
"""Top-level C-style main() must be extracted via the
|
||||
function_declarator pattern that C/C++ already use (#88)."""
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
main_fn = next((f for f in funcs if f.name == "main"), None)
|
||||
assert main_fn is not None
|
||||
assert main_fn.parent_name is None # top-level, not attached to a class
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
# Angle-bracket system headers and quoted user headers both arrive
|
||||
# as preproc_include in tree-sitter-objc.
|
||||
assert any("Foundation" in t for t in targets)
|
||||
assert any("Logger" in t for t in targets)
|
||||
|
||||
def test_extracts_message_expression_calls(self):
|
||||
"""Objective-C uses [receiver method:args] for method calls; these
|
||||
must produce CALLS edges (#88)."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = [e.target for e in calls]
|
||||
# Internal [self logResult:sum] should resolve to Calculator.logResult
|
||||
assert any(t.endswith("::Calculator.logResult") for t in targets)
|
||||
# [Calculator sharedCalculator] from main should also resolve
|
||||
assert any(t.endswith("::Calculator.sharedCalculator") for t in targets)
|
||||
# External NSLog(...) call_expression should be captured too
|
||||
assert "NSLog" in targets
|
||||
|
||||
|
||||
class TestBashParsing:
|
||||
"""Bash/Shell parser — closes #197."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sh")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("build.sh")) == "bash"
|
||||
assert self.parser.detect_language(Path("build.bash")) == "bash"
|
||||
assert self.parser.detect_language(Path("run.zsh")) == "bash"
|
||||
# Regression for #235 — Korn shell (.ksh) should parse as bash.
|
||||
assert self.parser.detect_language(Path("legacy.ksh")) == "bash"
|
||||
|
||||
def test_ksh_extension_parses_as_bash(self, tmp_path):
|
||||
"""Regression for #235: a real .ksh file is parsed through the bash
|
||||
grammar end-to-end and produces the same structural nodes/edges
|
||||
as an equivalent .sh file."""
|
||||
fixture_source = (FIXTURES / "sample.sh").read_text(encoding="utf-8")
|
||||
ksh_copy = tmp_path / "legacy.ksh"
|
||||
ksh_copy.write_text(fixture_source, encoding="utf-8")
|
||||
|
||||
ksh_nodes, ksh_edges = self.parser.parse_file(ksh_copy)
|
||||
|
||||
# Language tagging: every node must be "bash".
|
||||
assert ksh_nodes, "parser produced zero nodes for .ksh file"
|
||||
for n in ksh_nodes:
|
||||
assert n.language == "bash"
|
||||
|
||||
# Same function set as the .sh fixture.
|
||||
ksh_funcs = {n.name for n in ksh_nodes if n.kind == "Function"}
|
||||
sh_funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert ksh_funcs == sh_funcs, (
|
||||
f".ksh and .sh produced different function sets: "
|
||||
f"sh-only={sh_funcs - ksh_funcs}, ksh-only={ksh_funcs - sh_funcs}"
|
||||
)
|
||||
|
||||
# Same structural-edge totals by kind.
|
||||
def by_kind(edges):
|
||||
counts: dict[str, int] = {}
|
||||
for e in edges:
|
||||
counts[e.kind] = counts.get(e.kind, 0) + 1
|
||||
return counts
|
||||
assert by_kind(ksh_edges) == by_kind(self.edges)
|
||||
|
||||
def test_nodes_have_bash_language(self):
|
||||
for n in self.nodes:
|
||||
assert n.language == "bash"
|
||||
|
||||
def test_finds_functions(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "log_info" in funcs
|
||||
assert "log_error" in funcs
|
||||
assert "ensure_dir" in funcs
|
||||
assert "cleanup" in funcs
|
||||
assert "main" in funcs
|
||||
|
||||
def test_functions_have_no_parent(self):
|
||||
"""Bash has no classes so every function should be top-level."""
|
||||
for n in self.nodes:
|
||||
if n.kind == "Function":
|
||||
assert n.parent_name is None
|
||||
|
||||
def test_source_creates_import_edge(self):
|
||||
"""`source ./lib.sh` / `. ./config.sh` should produce IMPORTS_FROM
|
||||
edges (#197)."""
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
assert len(imports) >= 2
|
||||
targets = [e.target for e in imports]
|
||||
# sample_lib.sh exists on disk so should be resolved to an absolute path
|
||||
assert any(t.endswith("sample_lib.sh") for t in targets)
|
||||
# sample_config.sh doesn't exist; unresolved path is kept as-is
|
||||
assert any("sample_config.sh" in t for t in targets)
|
||||
|
||||
def test_command_invocations_create_call_edges(self):
|
||||
"""Each `command` node inside a function body should become a
|
||||
CALLS edge keyed on its command_name (#197)."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
# Built-ins and external commands kept as bare names
|
||||
assert "echo" in targets
|
||||
assert "mkdir" in targets
|
||||
# Internal function calls should resolve to qualified names
|
||||
assert any(t.endswith("::log_info") for t in targets)
|
||||
assert any(t.endswith("::ensure_dir") for t in targets)
|
||||
assert any(t.endswith("::cleanup") for t in targets)
|
||||
|
||||
def test_main_calls_resolve_to_internal_functions(self):
|
||||
"""main() should have CALLS edges to log_info, ensure_dir, and cleanup."""
|
||||
calls = [
|
||||
e for e in self.edges
|
||||
if e.kind == "CALLS" and e.source.endswith("::main")
|
||||
]
|
||||
call_targets = {e.target for e in calls}
|
||||
assert any(t.endswith("::log_info") for t in call_targets)
|
||||
assert any(t.endswith("::ensure_dir") for t in call_targets)
|
||||
assert any(t.endswith("::cleanup") for t in call_targets)
|
||||
|
||||
|
||||
class TestElixirParsing:
|
||||
"""Elixir parser — closes #112."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.ex")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("lib.ex")) == "elixir"
|
||||
assert self.parser.detect_language(Path("script.exs")) == "elixir"
|
||||
|
||||
def test_nodes_have_elixir_language(self):
|
||||
for n in self.nodes:
|
||||
assert n.language == "elixir"
|
||||
|
||||
def test_modules_become_classes(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "Calculator" in classes
|
||||
assert "MathHelpers" in classes
|
||||
|
||||
def test_def_defp_produce_functions_with_parent_module(self):
|
||||
funcs = {
|
||||
(n.name, n.parent_name) for n in self.nodes if n.kind == "Function"
|
||||
}
|
||||
# public defs
|
||||
assert ("add", "Calculator") in funcs
|
||||
assert ("subtract", "Calculator") in funcs
|
||||
assert ("compute", "Calculator") in funcs
|
||||
assert ("double", "MathHelpers") in funcs
|
||||
assert ("triple", "MathHelpers") in funcs
|
||||
# private defp
|
||||
assert ("log", "Calculator") in funcs
|
||||
|
||||
def test_alias_import_require_produce_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = [e.target for e in imports]
|
||||
# alias Calculator, import Calculator, require Logger
|
||||
assert targets.count("Calculator") >= 2
|
||||
assert "Logger" in targets
|
||||
|
||||
def test_internal_calls_resolve_to_qualified_names(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
# Calculator.compute calls add() and log() — both inside Calculator
|
||||
assert any(t.endswith("::Calculator.add") for t in targets)
|
||||
assert any(t.endswith("::Calculator.log") for t in targets)
|
||||
# MathHelpers.double calls Calculator.compute
|
||||
assert any(t.endswith("::Calculator.compute") for t in targets)
|
||||
# MathHelpers.triple calls double() — within the same module
|
||||
assert any(t.endswith("::MathHelpers.double") for t in targets)
|
||||
|
||||
def test_contains_edges_wire_module_to_functions(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
# Each function should be CONTAINS-linked to its parent module
|
||||
function_targets = {
|
||||
e.target for e in contains
|
||||
if "::" in e.source and "Calculator" in e.source
|
||||
}
|
||||
assert any(t.endswith("::Calculator.add") for t in function_targets)
|
||||
assert any(t.endswith("::Calculator.compute") for t in function_targets)
|
||||
|
||||
|
||||
class TestGDScriptParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.gd")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("player.gd")) == "gdscript"
|
||||
assert self.parser.detect_language(Path("globals/manager.gd")) == "gdscript"
|
||||
|
||||
def test_finds_class_name_statement(self):
|
||||
"""File-level ``class_name X`` declaration becomes a Class node."""
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "SampleManager" in classes
|
||||
|
||||
def test_finds_inner_class(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "Item" in classes
|
||||
|
||||
def test_finds_top_level_functions(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
for expected in ("_ready", "_load_items", "get_item", "helper"):
|
||||
assert expected in names, f"missing top-level function {expected}"
|
||||
|
||||
def test_finds_inner_class_methods(self):
|
||||
"""Methods defined inside ``class Inner:`` should attach to the inner class."""
|
||||
inner_funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Item"
|
||||
]
|
||||
names = {f.name for f in inner_funcs}
|
||||
assert "promote" in names
|
||||
|
||||
def test_finds_extends_as_import(self):
|
||||
"""``extends Node`` is the GDScript analogue of an import — parent class."""
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "Node" in targets, f"expected Node in imports, got {targets}"
|
||||
|
||||
def test_finds_direct_calls(self):
|
||||
"""Bare calls (``range(...)``, ``_load_items()``) produce CALLS edges."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert "range" in targets
|
||||
|
||||
def test_finds_attribute_calls(self):
|
||||
"""``obj.method(...)`` calls live inside ``attribute`` nodes as ``attribute_call``."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
# timer.start(), items.append(item), item_added.emit(item)
|
||||
assert "start" in targets
|
||||
assert "append" in targets
|
||||
assert "emit" in targets
|
||||
|
||||
def test_internal_calls_resolve_to_qualified_names(self):
|
||||
"""A bare ``_load_items()`` call inside _ready should resolve to the
|
||||
same-file function's qualified name."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert any(t.endswith("::_load_items") for t in targets), (
|
||||
f"expected ::_load_items in call targets, got {targets}"
|
||||
)
|
||||
|
||||
def test_contains_edges_wire_classes_and_functions(self):
|
||||
contains = [(e.source, e.target) for e in self.edges if e.kind == "CONTAINS"]
|
||||
# File CONTAINS the top-level Class and Function nodes.
|
||||
file_contains = {t for s, t in contains if not s.endswith(".gd::Item")
|
||||
and not s.endswith(".gd::SampleManager")}
|
||||
assert any(t.endswith("::SampleManager") for t in file_contains)
|
||||
assert any(t.endswith("::Item") for t in file_contains)
|
||||
assert any(t.endswith("::_ready") for t in file_contains)
|
||||
# Inner class CONTAINS its method.
|
||||
item_contains = {t for s, t in contains if s.endswith("::Item")}
|
||||
assert any(t.endswith("::Item.promote") for t in item_contains)
|
||||
|
||||
class TestJuliaParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.jl")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("foo.jl")) == "julia"
|
||||
|
||||
def test_finds_module(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "SampleModule" in classes
|
||||
|
||||
def test_finds_structs(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "Dog" in classes
|
||||
assert "MutablePoint" in classes
|
||||
|
||||
def test_finds_abstract_types(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "AbstractAnimal" in classes
|
||||
|
||||
def test_struct_inheritance(self):
|
||||
inherits = [e for e in self.edges if e.kind == "INHERITS"]
|
||||
# Dog's qualified source is file::SampleModule.Dog; we only care
|
||||
# about the trailing struct name and the target.
|
||||
pairs = {
|
||||
(e.source.split("::")[-1].split(".")[-1], e.target)
|
||||
for e in inherits
|
||||
}
|
||||
assert ("Dog", "AbstractAnimal") in pairs
|
||||
|
||||
def test_finds_long_form_functions(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "greet" in funcs
|
||||
assert "outer" in funcs
|
||||
assert "inner" in funcs
|
||||
assert "process" in funcs
|
||||
assert "show" in funcs
|
||||
|
||||
def test_finds_short_form_functions(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "add" in funcs
|
||||
assert "square" in funcs
|
||||
|
||||
def test_finds_macros(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "sayhello" in funcs
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "LinearAlgebra" in targets
|
||||
assert "JSON" in targets
|
||||
|
||||
def test_finds_selective_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "Statistics.mean" in targets or "Statistics" in targets
|
||||
assert "Statistics.std" in targets or "Statistics" in targets
|
||||
|
||||
def test_finds_base_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "Base.show" in targets or "Base" in targets
|
||||
assert "Base.print" in targets or "Base" in targets
|
||||
|
||||
def test_finds_include(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert any("utils.jl" in t for t in targets)
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert len(calls) >= 1
|
||||
|
||||
def test_finds_contains(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
assert len(contains) >= 3
|
||||
|
||||
def test_finds_exports(self):
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and e.extra
|
||||
and e.extra.get("julia_export")
|
||||
]
|
||||
# Targets may be resolved to qualified names (file::SampleModule.greet)
|
||||
# if the exported symbol is defined locally; otherwise they stay bare.
|
||||
trailing = {e.target.split(".")[-1] for e in refs}
|
||||
assert "greet" in trailing
|
||||
assert "Dog" in trailing
|
||||
assert "process" in trailing
|
||||
|
||||
def test_finds_testsets(self):
|
||||
tests = [n for n in self.nodes if n.kind == "Test"]
|
||||
assert any("Arithmetic" in t.name for t in tests)
|
||||
|
||||
def test_nested_function_parent(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
# The CONTAINS edge for inner should originate from outer, and
|
||||
# its qualified target should carry `outer.inner` in the name.
|
||||
assert any(
|
||||
e.source.endswith("outer")
|
||||
and e.target.endswith("outer.inner")
|
||||
for e in contains
|
||||
)
|
||||
|
||||
def test_qualified_function_name(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
# function Base.show(...) -> name is "show", not "Base.show"
|
||||
assert "show" in funcs
|
||||
assert "Base.show" not in funcs
|
||||
|
||||
def test_nodes_have_julia_language(self):
|
||||
nameable = [n for n in self.nodes if n.kind in ("Class", "Function", "Test")]
|
||||
assert all(n.language == "julia" for n in nameable)
|
||||
assert len(nameable) >= 5
|
||||
|
||||
def test_finds_enum_type(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
by_name = {c.name: c for c in classes}
|
||||
assert "Color" in by_name
|
||||
assert by_name["Color"].extra.get("julia_kind") == "enum"
|
||||
|
||||
def test_finds_enum_variants(self):
|
||||
variants = {
|
||||
n.name for n in self.nodes
|
||||
if n.kind == "Function"
|
||||
and (n.extra or {}).get("julia_kind") == "enum_variant"
|
||||
}
|
||||
assert {"RED", "BLUE", "GREEN"} <= variants
|
||||
|
||||
def test_enum_variants_contained_by_type(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
# Color -> RED, BLUE, GREEN
|
||||
variants_under_color = {
|
||||
e.target.split(".")[-1]
|
||||
for e in contains
|
||||
if e.source.endswith("Color")
|
||||
}
|
||||
assert {"RED", "BLUE", "GREEN"} <= variants_under_color
|
||||
|
||||
def test_finds_public_symbols(self):
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and e.extra
|
||||
and e.extra.get("julia_public")
|
||||
]
|
||||
trailing = {e.target.split(".")[-1] for e in refs}
|
||||
assert "square" in trailing
|
||||
assert "add" in trailing
|
||||
|
||||
def test_qualified_function_references_base(self):
|
||||
refs = [e for e in self.edges if e.kind == "REFERENCES"]
|
||||
# function Base.show(...) should emit a REFERENCES edge to Base
|
||||
assert any(
|
||||
"show" in e.source and e.target == "Base"
|
||||
for e in refs
|
||||
)
|
||||
|
||||
class TestRescriptParser:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.res")
|
||||
|
||||
def test_detects_language_for_res_and_resi(self):
|
||||
assert self.parser.detect_language(Path("lib.res")) == "rescript"
|
||||
assert self.parser.detect_language(Path("lib.resi")) == "rescript"
|
||||
|
||||
def test_file_node(self):
|
||||
files = [n for n in self.nodes if n.kind == "File"]
|
||||
assert len(files) == 1
|
||||
assert files[0].language == "rescript"
|
||||
assert files[0].extra.get("rescript_interface") is not True
|
||||
|
||||
def test_finds_top_level_modules(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert {"User", "App", "Validator"}.issubset(names)
|
||||
|
||||
def test_nested_module_has_parent(self):
|
||||
validator = next(
|
||||
n for n in self.nodes if n.kind == "Class" and n.name == "Validator"
|
||||
)
|
||||
assert validator.parent_name == "User"
|
||||
|
||||
def test_finds_top_level_lets(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "main" in names
|
||||
assert "defaultTimeout" in names
|
||||
assert "fact" in names
|
||||
assert "helper" in names
|
||||
|
||||
def test_let_inside_let_body_is_not_top_level(self):
|
||||
# `let u = ...` inside App.start should NOT appear as a Function node.
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "u" not in names
|
||||
assert "valid" not in names
|
||||
assert "n" not in names
|
||||
|
||||
def test_external_binding_extracted(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
by_name = {f.name: f for f in funcs}
|
||||
assert "readFile" in by_name
|
||||
assert by_name["readFile"].extra.get("rescript_external") is True
|
||||
|
||||
def test_module_attr_creates_import_edge(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "fs" in targets
|
||||
|
||||
def test_open_and_include_create_import_edges(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "Belt" in targets
|
||||
assert "Js.Promise" in targets
|
||||
|
||||
def test_types_extracted(self):
|
||||
types = [n for n in self.nodes if n.kind == "Type"]
|
||||
names = {t.name for t in types}
|
||||
assert {"status", "result", "t", "config"}.intersection(names)
|
||||
|
||||
def test_member_let_has_parent_module(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
by_name = {f.name: f for f in funcs}
|
||||
assert by_name["greet"].parent_name == "User"
|
||||
assert by_name["isAdult"].parent_name == "Validator"
|
||||
assert by_name["start"].parent_name == "App"
|
||||
|
||||
def test_calls_attributed_to_enclosing_let(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
sources = {e.source for e in calls}
|
||||
targets = {e.target for e in calls}
|
||||
assert any(s.endswith("::App.start") for s in sources)
|
||||
assert "User.make" in targets or any(
|
||||
t.endswith("::User.make") for t in targets
|
||||
)
|
||||
|
||||
def test_contains_edges_wire_module_to_members(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target for e in contains}
|
||||
assert any(t.endswith("::User.greet") for t in targets)
|
||||
assert any(t.endswith("::Validator.isAdult") for t in targets)
|
||||
|
||||
def test_nodes_have_rescript_language(self):
|
||||
non_file = [n for n in self.nodes if n.kind != "File"]
|
||||
assert all(n.language == "rescript" for n in non_file)
|
||||
|
||||
|
||||
class TestRescriptInterfaceParser:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.resi")
|
||||
|
||||
def test_file_flagged_as_interface(self):
|
||||
file_node = next(n for n in self.nodes if n.kind == "File")
|
||||
assert file_node.extra.get("rescript_interface") is True
|
||||
|
||||
def test_modules_extracted_from_interface(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "User" in names
|
||||
assert "App" in names
|
||||
assert "Validator" in names
|
||||
|
||||
def test_signatures_extracted_without_bodies(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
# Top-level and module-member signatures should both appear.
|
||||
assert "defaultTimeout" in names
|
||||
assert "fact" in names
|
||||
assert "make" in names
|
||||
assert "greet" in names
|
||||
assert "isAdult" in names
|
||||
assert "start" in names
|
||||
|
||||
def test_external_signature_extracted(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
by_name = {f.name: f for f in funcs}
|
||||
assert "readFile" in by_name
|
||||
assert by_name["readFile"].extra.get("rescript_external") is True
|
||||
|
||||
def test_no_calls_extracted_from_interface(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
assert calls == []
|
||||
|
||||
|
||||
class TestRescriptEdgeCases:
|
||||
"""Bug-fix tests: IMPORTS_FROM dedup, JS binding tag, JSX, module alias."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.res")
|
||||
|
||||
def test_duplicate_open_produces_single_import_edge(self):
|
||||
# sample.res has `open Belt` twice — should emit only one edge.
|
||||
belt_edges = [
|
||||
e for e in self.edges
|
||||
if e.kind == "IMPORTS_FROM" and e.target == "Belt"
|
||||
]
|
||||
assert len(belt_edges) == 1
|
||||
|
||||
def test_module_alias_emits_import_edge(self):
|
||||
# `module IntMap = Belt.Map.Int` → IMPORTS_FROM Belt.Map.Int
|
||||
aliases = [
|
||||
e for e in self.edges
|
||||
if e.extra.get("rescript_import_kind") == "module_alias"
|
||||
]
|
||||
assert any(e.target == "Belt.Map.Int" for e in aliases)
|
||||
assert any(e.extra.get("alias_name") == "IntMap" for e in aliases)
|
||||
|
||||
def test_module_alias_is_not_treated_as_block_module(self):
|
||||
# IntMap is an alias — should NOT appear as a Class node.
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "IntMap" not in names
|
||||
|
||||
def test_js_binding_module_is_tagged(self):
|
||||
text_encoder = next(
|
||||
n for n in self.nodes if n.kind == "Class" and n.name == "TextEncoder"
|
||||
)
|
||||
assert text_encoder.extra.get("rescript_kind") == "js_binding"
|
||||
|
||||
def test_regular_module_keeps_module_tag(self):
|
||||
user = next(
|
||||
n for n in self.nodes if n.kind == "Class" and n.name == "User"
|
||||
)
|
||||
assert user.extra.get("rescript_kind") == "module"
|
||||
|
||||
def test_jsx_emits_import_and_call_edges(self):
|
||||
jsx_imports = [
|
||||
e for e in self.edges
|
||||
if e.extra.get("rescript_import_kind") == "jsx"
|
||||
]
|
||||
jsx_targets = {e.target for e in jsx_imports}
|
||||
assert "Layout" in jsx_targets
|
||||
assert "User" in jsx_targets
|
||||
assert "AnalyticsFilterUi" in jsx_targets
|
||||
|
||||
jsx_calls = [
|
||||
e for e in self.edges
|
||||
if e.kind == "CALLS"
|
||||
and e.extra.get("rescript_call_kind") == "jsx"
|
||||
]
|
||||
call_targets = {e.target for e in jsx_calls}
|
||||
assert "User.Badge" in call_targets
|
||||
assert "AnalyticsFilterUi.Filter" in call_targets
|
||||
|
||||
def test_jsx_call_attributed_to_enclosing_let(self):
|
||||
jsx_calls = [
|
||||
e for e in self.edges
|
||||
if e.kind == "CALLS"
|
||||
and e.extra.get("rescript_call_kind") == "jsx"
|
||||
]
|
||||
assert all(e.source.endswith("::render") for e in jsx_calls)
|
||||
|
||||
|
||||
class TestRescriptCrossModuleResolver:
|
||||
"""Integration test for the cross-module resolver post-pass."""
|
||||
|
||||
def _build(self, tmp_path):
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.incremental import full_build
|
||||
|
||||
(tmp_path / ".git").mkdir()
|
||||
|
||||
(tmp_path / "LogicUtils.res").write_text(
|
||||
"let safeParse = (s) => s\n"
|
||||
"let trim = (s) => s\n"
|
||||
)
|
||||
(tmp_path / "CurrencyFormatUtils.res").write_text(
|
||||
"let format = (n) => n\n"
|
||||
)
|
||||
(tmp_path / "Caller.res").write_text(
|
||||
"open CurrencyFormatUtils\n"
|
||||
"let run = () => {\n"
|
||||
" let a = LogicUtils.safeParse(\"x\")\n"
|
||||
" let b = LogicUtils.safeParse(\"y\")\n"
|
||||
" let c = format(12.0)\n"
|
||||
" let d = <Layout name=\"hi\" />\n"
|
||||
" (a, b, c, d)\n"
|
||||
"}\n"
|
||||
)
|
||||
(tmp_path / "Layout.res").write_text(
|
||||
"let make = (~name) => name\n"
|
||||
)
|
||||
|
||||
store = GraphStore(tmp_path / "graph.db")
|
||||
result = full_build(tmp_path, store)
|
||||
return store, result
|
||||
|
||||
def test_qualified_call_resolves_to_canonical_node(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
cur = store._conn.cursor()
|
||||
rows = cur.execute(
|
||||
"SELECT target_qualified FROM edges "
|
||||
"WHERE kind='CALLS' AND source_qualified LIKE '%Caller.res::run'"
|
||||
).fetchall()
|
||||
targets = {r["target_qualified"] for r in rows}
|
||||
# Both LogicUtils.safeParse callsites should now point to the canonical
|
||||
# node path, not the bare `LogicUtils.safeParse` string.
|
||||
assert any(
|
||||
t.endswith("LogicUtils.res::safeParse") for t in targets
|
||||
), f"no canonical resolution in {targets}"
|
||||
assert not any(t == "LogicUtils.safeParse" for t in targets)
|
||||
|
||||
def test_callers_of_canonical_node_finds_both_sites(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
# Two calls to safeParse from the same caller — both should survive
|
||||
# as separate edges pointing to the canonical node.
|
||||
cur = store._conn.cursor()
|
||||
count = cur.execute(
|
||||
"SELECT COUNT(*) as c FROM edges "
|
||||
"WHERE kind='CALLS' "
|
||||
"AND target_qualified LIKE '%LogicUtils.res::safeParse'"
|
||||
).fetchone()["c"]
|
||||
assert count == 2
|
||||
|
||||
def test_bare_call_resolves_via_open_directive(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
cur = store._conn.cursor()
|
||||
rows = cur.execute(
|
||||
"SELECT target_qualified FROM edges WHERE kind='CALLS' "
|
||||
"AND target_qualified LIKE '%CurrencyFormatUtils.res::format'"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
|
||||
def test_imports_from_rewrites_to_file_path(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
cur = store._conn.cursor()
|
||||
rows = cur.execute(
|
||||
"SELECT target_qualified FROM edges WHERE kind='IMPORTS_FROM' "
|
||||
"AND file_path LIKE '%Caller.res'"
|
||||
).fetchall()
|
||||
targets = {r["target_qualified"] for r in rows}
|
||||
# `open CurrencyFormatUtils` and `<Layout />` should both resolve
|
||||
# to file paths.
|
||||
assert any(t.endswith("CurrencyFormatUtils.res") for t in targets)
|
||||
assert any(t.endswith("Layout.res") for t in targets)
|
||||
|
||||
def test_resolver_stats_in_build_result(self, tmp_path):
|
||||
_, result = self._build(tmp_path)
|
||||
stats = result["rescript_resolution"]
|
||||
assert stats["files_indexed"] == 4
|
||||
assert stats["calls_resolved"] >= 3
|
||||
assert stats["imports_resolved"] >= 2
|
||||
|
||||
def test_resolver_is_idempotent(self, tmp_path):
|
||||
from code_review_graph.rescript_resolver import (
|
||||
resolve_rescript_cross_module,
|
||||
)
|
||||
store, _ = self._build(tmp_path)
|
||||
second = resolve_rescript_cross_module(store)
|
||||
# Second run should find nothing new — all already resolved.
|
||||
assert second["calls_resolved"] == 0
|
||||
assert second["imports_resolved"] == 0
|
||||
|
||||
class TestNixParsing:
|
||||
"""Flake-aware Nix parser — see the Nix language-support epic."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
# Parse the flake-shaped fixture as if its basename were ``flake.nix``
|
||||
# so the ``inputs.*.url`` branch of _extract_nix_constructs fires.
|
||||
flake_bytes = (FIXTURES / "sample.nix").read_bytes()
|
||||
self.flake_path = FIXTURES / "flake.nix"
|
||||
self.flake_nodes, self.flake_edges = self.parser.parse_bytes(
|
||||
self.flake_path, flake_bytes,
|
||||
)
|
||||
# The non-flake fixture retains its actual path; it's used to verify
|
||||
# the flake-input branch does *not* fire on non-flake files.
|
||||
module_path = FIXTURES / "sample_module.nix"
|
||||
self.module_nodes, self.module_edges = self.parser.parse_file(module_path)
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("flake.nix")) == "nix"
|
||||
assert self.parser.detect_language(Path("modules/foo.nix")) == "nix"
|
||||
|
||||
def test_nodes_have_nix_language(self):
|
||||
for n in self.flake_nodes:
|
||||
assert n.language == "nix"
|
||||
for n in self.module_nodes:
|
||||
assert n.language == "nix"
|
||||
|
||||
def test_top_level_bindings_become_functions(self):
|
||||
funcs = {n.name for n in self.flake_nodes if n.kind == "Function"}
|
||||
# Top-level bindings from sample.nix (flake-shaped).
|
||||
assert "description" in funcs
|
||||
assert "inputs" in funcs
|
||||
assert "outputs" in funcs
|
||||
# Nested bindings flattened to dotted names.
|
||||
assert "packages.default" in funcs
|
||||
assert "devShells.default" in funcs
|
||||
|
||||
def test_flake_inputs_produce_import_edges(self):
|
||||
targets = {
|
||||
e.target for e in self.flake_edges if e.kind == "IMPORTS_FROM"
|
||||
}
|
||||
assert "github:NixOS/nixpkgs/nixos-unstable" in targets
|
||||
assert "github:numtide/flake-utils" in targets
|
||||
|
||||
def test_import_and_callpackage_produce_import_edges(self):
|
||||
targets = {
|
||||
e.target for e in self.flake_edges if e.kind == "IMPORTS_FROM"
|
||||
}
|
||||
# callPackage ./default.nix and import ./shell.nix. Relative paths
|
||||
# are resolved against the caller's directory when possible; since
|
||||
# neither file exists alongside the fixture, the raw relative
|
||||
# path is preserved.
|
||||
assert "./default.nix" in targets
|
||||
assert "./shell.nix" in targets
|
||||
|
||||
def test_non_flake_file_has_no_input_edges(self):
|
||||
# ``sample_module.nix`` is not named ``flake.nix``, so the
|
||||
# inputs.*.url branch must not fire — no github:-prefixed targets.
|
||||
targets = [
|
||||
e.target for e in self.module_edges if e.kind == "IMPORTS_FROM"
|
||||
]
|
||||
assert not any(t.startswith("github:") for t in targets)
|
||||
# The import ./foo.nix inside the `let` body still produces an edge.
|
||||
assert any("foo.nix" in t for t in targets)
|
||||
|
||||
def test_contains_edges_wire_file_to_top_level_bindings(self):
|
||||
file_path = self.flake_path.as_posix()
|
||||
contains_targets = {
|
||||
e.target for e in self.flake_edges
|
||||
if e.kind == "CONTAINS" and e.source == file_path
|
||||
}
|
||||
# Each top-level binding should be CONTAINS-linked from the file.
|
||||
for name in ("description", "inputs", "outputs"):
|
||||
qualified = f"{file_path}::{name}"
|
||||
assert qualified in contains_targets, (
|
||||
f"missing CONTAINS edge for {qualified}"
|
||||
)
|
||||
|
||||
|
||||
class TestSpringDIParsing:
|
||||
"""Tests for Spring DI annotation detection and INJECTS edge generation."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "SpringDI.java")
|
||||
|
||||
def test_detects_spring_stereotype_on_repository(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "JpaOrderRepository" in classes
|
||||
assert classes["JpaOrderRepository"].extra.get("spring_stereotype") == "Repository"
|
||||
|
||||
def test_detects_spring_stereotype_on_service(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "NotificationService" in classes
|
||||
assert classes["NotificationService"].extra.get("spring_stereotype") == "Service"
|
||||
assert "OrderService" in classes
|
||||
assert classes["OrderService"].extra.get("spring_stereotype") == "Service"
|
||||
|
||||
def test_detects_spring_stereotype_on_configuration(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "AppConfig" in classes
|
||||
assert classes["AppConfig"].extra.get("spring_stereotype") == "Configuration"
|
||||
|
||||
def test_no_stereotype_on_plain_interface(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "OrderRepository" in classes
|
||||
assert "spring_stereotype" not in classes["OrderRepository"].extra
|
||||
|
||||
def test_spring_annotations_list_stored(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
annotations = classes["OrderService"].extra.get("spring_annotations", [])
|
||||
assert "Service" in annotations
|
||||
assert "RequiredArgsConstructor" in annotations
|
||||
|
||||
def test_autowired_field_injection_edge(self):
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
# NotificationService has @Autowired OrderRepository field
|
||||
field_edges = [e for e in injects if e.extra.get("injection_type") == "field"]
|
||||
targets = {e.target for e in field_edges}
|
||||
assert "OrderRepository" in targets
|
||||
|
||||
def test_autowired_field_source_is_class(self):
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"
|
||||
and e.extra.get("injection_type") == "field"]
|
||||
sources = {e.source for e in injects}
|
||||
assert any("NotificationService" in s for s in sources)
|
||||
|
||||
def test_lombok_required_args_constructor_injection(self):
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
lombok_edges = [e for e in injects
|
||||
if e.extra.get("injection_type") == "constructor_lombok"]
|
||||
targets = {e.target for e in lombok_edges}
|
||||
# OrderService has two final injected fields
|
||||
assert "OrderRepository" in targets
|
||||
assert "NotificationService" in targets
|
||||
|
||||
def test_static_final_field_not_injected(self):
|
||||
"""static final String TAG should NOT produce an INJECTS edge."""
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
targets = {e.target for e in injects}
|
||||
assert "String" not in targets
|
||||
|
||||
def test_explicit_autowired_constructor_injection(self):
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
ctor_edges = [e for e in injects
|
||||
if e.extra.get("injection_type") == "constructor"]
|
||||
targets = {e.target for e in ctor_edges}
|
||||
# AuditLogger has @Autowired constructor with OrderRepository param
|
||||
assert "OrderRepository" in targets
|
||||
|
||||
def test_autowired_constructor_source_is_class(self):
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"
|
||||
and e.extra.get("injection_type") == "constructor"]
|
||||
sources = {e.source for e in injects}
|
||||
assert any("AuditLogger" in s for s in sources)
|
||||
|
||||
def test_total_injects_edge_count(self):
|
||||
"""Sanity check: total INJECTS edges matches known injection points."""
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
# NotificationService: 1 field
|
||||
# OrderService: 2 lombok (orderRepository + notificationService)
|
||||
# AuditLogger: 1 constructor
|
||||
assert len(injects) >= 4
|
||||
|
||||
def test_field_name_stored_in_injects_extra(self):
|
||||
"""INJECTS edges must carry extra.field_name for the resolver."""
|
||||
injects = [e for e in self.edges if e.kind == "INJECTS"]
|
||||
names = {e.extra.get("field_name") for e in injects}
|
||||
# @Autowired field in NotificationService
|
||||
assert "orderRepository" in names
|
||||
# @RequiredArgsConstructor final fields in OrderService
|
||||
assert "orderRepository" in names
|
||||
assert "notificationService" in names
|
||||
# @Autowired constructor param in AuditLogger
|
||||
assert "orderRepository" in names
|
||||
|
||||
def test_java_method_call_target_is_method_not_receiver(self):
|
||||
"""Java receiver.method() must emit CALLS with method as target, not receiver."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
# placeOrder calls orderRepository.save() — target must end in "save"
|
||||
# (possibly qualified to "::OrderRepository.save" if same-file resolution kicks in)
|
||||
assert any("save" in t for t in targets), f"expected 'save' in targets, got {targets}"
|
||||
# receiver variable names must NOT appear as CALLS targets
|
||||
assert "orderRepository" not in targets
|
||||
assert "notificationService" not in targets
|
||||
|
||||
def test_java_receiver_stored_in_calls_extra(self):
|
||||
"""CALLS edges for Java method calls must carry extra.receiver."""
|
||||
calls = [e for e in self.edges if e.kind == "CALLS" and e.extra.get("receiver")]
|
||||
receivers = {e.extra["receiver"] for e in calls}
|
||||
assert "orderRepository" in receivers or "notificationService" in receivers
|
||||
|
||||
|
||||
class TestSpringDIResolver:
|
||||
"""Integration tests for the Spring DI post-build resolver."""
|
||||
|
||||
def _build(self, tmp_path):
|
||||
"""Build a mini Spring repo and run the resolver."""
|
||||
pkg = tmp_path / "src/main/java/com/example"
|
||||
pkg.mkdir(parents=True)
|
||||
|
||||
(pkg / "OrderRepository.java").write_text(
|
||||
"package com.example;\n"
|
||||
"public interface OrderRepository {\n"
|
||||
" void save(Order o);\n"
|
||||
"}\n"
|
||||
)
|
||||
(pkg / "JpaOrderRepository.java").write_text(
|
||||
"package com.example;\n"
|
||||
"import org.springframework.stereotype.Repository;\n"
|
||||
"@Repository\n"
|
||||
"public class JpaOrderRepository implements OrderRepository {\n"
|
||||
" public void save(Order o) {}\n"
|
||||
"}\n"
|
||||
)
|
||||
(pkg / "OrderService.java").write_text(
|
||||
"package com.example;\n"
|
||||
"import org.springframework.stereotype.Service;\n"
|
||||
"import lombok.RequiredArgsConstructor;\n"
|
||||
"@Service\n"
|
||||
"@RequiredArgsConstructor\n"
|
||||
"public class OrderService {\n"
|
||||
" private final OrderRepository orderRepository;\n"
|
||||
" public void place(Order o) {\n"
|
||||
" orderRepository.save(o);\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.incremental import full_build
|
||||
from code_review_graph.postprocessing import run_post_processing
|
||||
|
||||
store = GraphStore(str(tmp_path / "graph.db"))
|
||||
result = full_build(tmp_path, store)
|
||||
run_post_processing(store)
|
||||
return store, result
|
||||
|
||||
def test_resolver_runs_and_reports(self, tmp_path):
|
||||
_, result = self._build(tmp_path)
|
||||
stats = result.get("spring_resolution")
|
||||
assert stats is not None
|
||||
assert stats["files_indexed"] > 0
|
||||
|
||||
def test_calls_resolved_through_field(self, tmp_path):
|
||||
store, result = self._build(tmp_path)
|
||||
stats = result.get("spring_resolution", {})
|
||||
assert stats.get("calls_resolved", 0) >= 1
|
||||
|
||||
def test_resolved_target_includes_method_name(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
cur = store._conn.cursor()
|
||||
rows = cur.execute(
|
||||
"SELECT target_qualified FROM edges WHERE kind='CALLS' "
|
||||
"AND extra LIKE '%spring_resolved%'"
|
||||
).fetchall()
|
||||
assert rows, "Expected at least one spring-resolved CALLS edge"
|
||||
for (target,) in rows:
|
||||
assert "." in target or "::" in target, (
|
||||
f"Resolved target should contain type.method or ::, got: {target!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestTemporalParsing:
|
||||
"""Tests for Temporal @WorkflowInterface / @ActivityInterface detection."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "TemporalWorkflow.java")
|
||||
|
||||
def test_workflow_interface_gets_temporal_role(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "OrderWorkflow" in classes
|
||||
assert classes["OrderWorkflow"].extra.get("temporal_role") == "workflow_interface"
|
||||
|
||||
def test_activity_interface_gets_temporal_role(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "PaymentActivity" in classes
|
||||
assert classes["PaymentActivity"].extra.get("temporal_role") == "activity_interface"
|
||||
assert "ShippingActivity" in classes
|
||||
assert classes["ShippingActivity"].extra.get("temporal_role") == "activity_interface"
|
||||
|
||||
def test_impl_class_has_no_temporal_role(self):
|
||||
classes = {n.name: n for n in self.nodes if n.kind == "Class"}
|
||||
assert "OrderWorkflowImpl" in classes
|
||||
assert "temporal_role" not in classes["OrderWorkflowImpl"].extra
|
||||
|
||||
def test_temporal_stub_edges_emitted_for_activity_fields(self):
|
||||
stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
|
||||
targets = {e.target for e in stubs}
|
||||
assert "PaymentActivity" in targets
|
||||
assert "ShippingActivity" in targets
|
||||
|
||||
def test_temporal_stub_field_name_stored(self):
|
||||
stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
|
||||
field_names = {e.extra.get("field_name") for e in stubs}
|
||||
assert "paymentActivity" in field_names
|
||||
assert "shippingActivity" in field_names
|
||||
|
||||
def test_static_field_not_in_temporal_stubs(self):
|
||||
stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
|
||||
field_names = {e.extra.get("field_name") for e in stubs}
|
||||
assert "TAG" not in field_names
|
||||
|
||||
def test_temporal_stub_source_is_workflow_impl(self):
|
||||
stubs = [e for e in self.edges if e.kind == "TEMPORAL_STUB"]
|
||||
sources = {e.source for e in stubs}
|
||||
assert any("OrderWorkflowImpl" in s for s in sources)
|
||||
|
||||
def test_workflow_method_annotation_stored_on_method(self):
|
||||
interface_methods = [
|
||||
n for n in self.nodes if n.kind == "Function" and n.parent_name == "OrderWorkflow"
|
||||
]
|
||||
names = {n.name: n for n in interface_methods}
|
||||
assert "processOrder" in names
|
||||
assert names["processOrder"].extra.get("temporal_role") == "workflowmethod"
|
||||
|
||||
def test_signal_method_annotation_stored(self):
|
||||
interface_methods = [
|
||||
n for n in self.nodes if n.kind == "Function" and n.parent_name == "OrderWorkflow"
|
||||
]
|
||||
names = {n.name: n for n in interface_methods}
|
||||
assert "cancelOrder" in names
|
||||
assert names["cancelOrder"].extra.get("temporal_role") == "signalmethod"
|
||||
|
||||
def test_activity_method_annotation_stored(self):
|
||||
activity_methods = [
|
||||
n for n in self.nodes if n.kind == "Function" and n.parent_name == "PaymentActivity"
|
||||
]
|
||||
names = {n.name: n for n in activity_methods}
|
||||
assert "chargeCard" in names
|
||||
assert names["chargeCard"].extra.get("temporal_role") == "activitymethod"
|
||||
|
||||
|
||||
class TestTemporalResolver:
|
||||
"""Integration tests for the Temporal post-build call resolver."""
|
||||
|
||||
def _build(self, tmp_path):
|
||||
pkg = tmp_path / "src/main/java/com/example"
|
||||
pkg.mkdir(parents=True)
|
||||
|
||||
(pkg / "PaymentActivity.java").write_text(
|
||||
"package com.example;\n"
|
||||
"import io.temporal.activity.ActivityInterface;\n"
|
||||
"import io.temporal.activity.ActivityMethod;\n"
|
||||
"@ActivityInterface\n"
|
||||
"public interface PaymentActivity {\n"
|
||||
" @ActivityMethod\n"
|
||||
" boolean charge(String orderId);\n"
|
||||
"}\n"
|
||||
)
|
||||
(pkg / "PaymentActivityImpl.java").write_text(
|
||||
"package com.example;\n"
|
||||
"public class PaymentActivityImpl implements PaymentActivity {\n"
|
||||
" public boolean charge(String orderId) { return true; }\n"
|
||||
"}\n"
|
||||
)
|
||||
(pkg / "OrderWorkflowImpl.java").write_text(
|
||||
"package com.example;\n"
|
||||
"public class OrderWorkflowImpl {\n"
|
||||
" private PaymentActivity paymentActivity;\n"
|
||||
" public String process(String id) {\n"
|
||||
" return paymentActivity.charge(id) ? \"OK\" : \"FAIL\";\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
)
|
||||
|
||||
from code_review_graph.graph import GraphStore
|
||||
from code_review_graph.incremental import full_build
|
||||
|
||||
store = GraphStore(str(tmp_path / "graph.db"))
|
||||
result = full_build(tmp_path, store)
|
||||
return store, result
|
||||
|
||||
def test_temporal_resolver_runs_and_reports(self, tmp_path):
|
||||
_, result = self._build(tmp_path)
|
||||
stats = result.get("temporal_resolution")
|
||||
assert stats is not None
|
||||
assert stats["files_indexed"] > 0
|
||||
|
||||
def test_calls_resolved_through_activity_stub(self, tmp_path):
|
||||
_, result = self._build(tmp_path)
|
||||
stats = result.get("temporal_resolution", {})
|
||||
assert stats.get("calls_resolved", 0) >= 1
|
||||
|
||||
def test_resolved_target_is_fully_qualified(self, tmp_path):
|
||||
store, _ = self._build(tmp_path)
|
||||
rows = store._conn.execute(
|
||||
"SELECT target_qualified FROM edges WHERE kind='CALLS' "
|
||||
"AND extra LIKE '%temporal_resolved%'"
|
||||
).fetchall()
|
||||
assert rows, "Expected at least one temporal-resolved CALLS edge"
|
||||
for (target,) in rows:
|
||||
assert "." in target or "::" in target, (
|
||||
f"Resolved target should be qualified, got: {target!r}"
|
||||
)
|
||||
|
||||
def test_resolved_target_is_concrete_impl_not_interface(self, tmp_path):
|
||||
# paymentActivity.charge(...) has a single implementor, so it must
|
||||
# resolve to PaymentActivityImpl.charge, not the interface method
|
||||
# PaymentActivity.charge. Regression: implementors was keyed by the
|
||||
# bare interface name but looked up by the qualified name, so the
|
||||
# unique-implementor branch was dead and every stub call resolved to
|
||||
# the interface.
|
||||
store, _ = self._build(tmp_path)
|
||||
rows = store._conn.execute(
|
||||
"SELECT target_qualified FROM edges WHERE kind='CALLS' "
|
||||
"AND extra LIKE '%temporal_resolved%'"
|
||||
).fetchall()
|
||||
targets = [t for (t,) in rows]
|
||||
assert targets, "Expected at least one temporal-resolved CALLS edge"
|
||||
assert any(t.endswith("PaymentActivityImpl.charge") for t in targets), (
|
||||
f"Expected resolution to the concrete impl, got: {targets!r}"
|
||||
)
|
||||
assert not any(t.endswith("PaymentActivity.charge") for t in targets), (
|
||||
f"Should not resolve to the interface method, got: {targets!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestKafkaParsing:
|
||||
"""Tests for Kafka CONSUMES / PRODUCES edge detection."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "KafkaPatterns.java")
|
||||
|
||||
def test_kafka_listener_annotation_emits_consumes_edge(self):
|
||||
consumes = [e for e in self.edges if e.kind == "CONSUMES"]
|
||||
targets = {e.target for e in consumes}
|
||||
assert "kafka:order-events" in targets
|
||||
|
||||
def test_kafka_listener_multiple_topics(self):
|
||||
consumes = [e for e in self.edges if e.kind == "CONSUMES"]
|
||||
targets = {e.target for e in consumes}
|
||||
assert "kafka:order-dlq" in targets
|
||||
assert "kafka:order-retry" in targets
|
||||
|
||||
def test_kafka_listener_topic_in_extra(self):
|
||||
consumes = [e for e in self.edges if e.kind == "CONSUMES"
|
||||
and e.target == "kafka:order-events"]
|
||||
assert consumes
|
||||
assert consumes[0].extra.get("topic") == "order-events"
|
||||
|
||||
def test_kafka_template_field_emits_produces_edge(self):
|
||||
produces = [e for e in self.edges if e.kind == "PRODUCES"]
|
||||
sources = {e.source for e in produces}
|
||||
assert any("NotificationProducer" in s for s in sources)
|
||||
|
||||
def test_kafka_receiver_field_emits_consumes_edge(self):
|
||||
consumes = [e for e in self.edges if e.kind == "CONSUMES"]
|
||||
sources = {e.source for e in consumes}
|
||||
assert any("ReactiveOrderConsumer" in s for s in sources)
|
||||
|
||||
def test_kafka_receiver_message_type_stored(self):
|
||||
consumes = [e for e in self.edges if e.kind == "CONSUMES"
|
||||
and "ReactiveOrderConsumer" in e.source]
|
||||
assert consumes
|
||||
assert consumes[0].extra.get("message_type") == "OrderEvent"
|
||||
|
||||
def test_kafka_operations_field_emits_produces_edge(self):
|
||||
produces = [e for e in self.edges if e.kind == "PRODUCES"]
|
||||
sources = {e.source for e in produces}
|
||||
assert any("ReactiveOrderConsumer" in s for s in sources)
|
||||
|
||||
def test_static_field_not_in_kafka_edges(self):
|
||||
all_kafka = [e for e in self.edges if e.kind in ("CONSUMES", "PRODUCES")]
|
||||
field_names = {e.extra.get("field_name") for e in all_kafka}
|
||||
assert "TOPIC" not in field_names
|
||||
|
||||
def test_no_kafka_edges_for_plain_class(self):
|
||||
# OrderEvent (plain class, no Kafka) should not appear as a source
|
||||
kafka = [e for e in self.edges if e.kind in ("CONSUMES", "PRODUCES")]
|
||||
bare_sources = {e.source.split("::")[-1].split(".")[0] for e in kafka}
|
||||
assert "OrderEvent" not in bare_sources
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verilog / SystemVerilog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _has_verilog_parser():
|
||||
try:
|
||||
import tree_sitter_language_pack as tslp
|
||||
tslp.get_parser("verilog")
|
||||
return True
|
||||
except (LookupError, ImportError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_verilog_parser(), reason="verilog tree-sitter grammar not installed")
|
||||
class TestVerilogParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sv")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("top.sv")) == "verilog"
|
||||
assert self.parser.detect_language(Path("pkg.svh")) == "verilog"
|
||||
assert self.parser.detect_language(Path("cpu.v")) == "verilog"
|
||||
assert self.parser.detect_language(Path("header.vh")) == "verilog"
|
||||
|
||||
def test_finds_modules(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "FIFOController" in names
|
||||
assert "Adder" in names
|
||||
|
||||
def test_finds_interfaces(self):
|
||||
classes = [n for n in self.nodes if n.kind == "Class"]
|
||||
names = {c.name for c in classes}
|
||||
assert "BusIf" in names
|
||||
|
||||
def test_finds_tasks(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "do_write" in names
|
||||
|
||||
def test_finds_functions_in_module(self):
|
||||
funcs = [n for n in self.nodes if n.kind == "Function"]
|
||||
names = {f.name for f in funcs}
|
||||
assert "is_full" in names
|
||||
|
||||
def test_task_and_function_parent_is_module(self):
|
||||
funcs = {f.name: f for f in self.nodes if f.kind == "Function"}
|
||||
assert funcs["do_write"].parent_name == "FIFOController"
|
||||
assert funcs["is_full"].parent_name == "FIFOController"
|
||||
|
||||
def test_finds_package_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
assert "utils_pkg" in targets
|
||||
assert "arith_pkg" in targets
|
||||
|
||||
def test_module_instantiation_creates_call_edge(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
targets = {e.target for e in calls}
|
||||
assert any("Adder" in t for t in targets)
|
||||
|
||||
def test_module_instantiation_caller_is_enclosing_module(self):
|
||||
# module_instantiation CALLS must be attributed to the containing
|
||||
# module, not a function — Verilog-specific fallback in _extract_calls.
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
adder_calls = [e for e in calls if "Adder" in e.target]
|
||||
assert adder_calls, "Expected a CALLS edge for Adder instantiation"
|
||||
assert any("FIFOController" in e.source for e in adder_calls)
|
||||
|
||||
def test_file_node_language(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert len(file_nodes) == 1
|
||||
assert file_nodes[0].language == "verilog"
|
||||
|
||||
class TestSQLParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.sql")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("schema.sql")) == "sql"
|
||||
|
||||
def test_file_node(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert len(file_nodes) == 1
|
||||
assert file_nodes[0].language == "sql"
|
||||
|
||||
def test_finds_tables(self):
|
||||
tables = [n for n in self.nodes if n.kind == "Class" and n.extra.get("sql_kind") == "table"]
|
||||
names = {t.name for t in tables}
|
||||
assert "users" in names
|
||||
assert "orders" in names
|
||||
|
||||
def test_finds_view(self):
|
||||
views = [n for n in self.nodes if n.kind == "Class" and n.extra.get("sql_kind") == "view"]
|
||||
names = {v.name for v in views}
|
||||
assert "active_orders" in names
|
||||
|
||||
def test_finds_function(self):
|
||||
funcs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.extra.get("sql_kind") == "function"
|
||||
]
|
||||
names = {f.name for f in funcs}
|
||||
assert "get_user_total" in names
|
||||
|
||||
def test_finds_procedure(self):
|
||||
procs = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.extra.get("sql_kind") == "procedure"
|
||||
]
|
||||
names = {p.name for p in procs}
|
||||
assert "archive_old_orders" in names
|
||||
|
||||
def test_contains_edges(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target.split("::")[-1] for e in contains}
|
||||
assert "users" in targets
|
||||
assert "orders" in targets
|
||||
assert "active_orders" in targets
|
||||
assert "get_user_total" in targets
|
||||
assert "archive_old_orders" in targets
|
||||
|
||||
def test_table_reference_edges(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
# active_orders view and archive procedure both reference orders/users
|
||||
assert "orders" in targets or "users" in targets
|
||||
class TestZigParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.fixture = FIXTURES / "sample_zig.zig"
|
||||
self.nodes, self.edges = self.parser.parse_file(self.fixture)
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("main.zig")) == "zig"
|
||||
|
||||
def test_finds_top_level_functions(self):
|
||||
funcs = {
|
||||
n.name for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name is None
|
||||
}
|
||||
assert {"main", "helper"} <= funcs
|
||||
|
||||
def test_finds_struct_methods(self):
|
||||
methods = {
|
||||
n.name for n in self.nodes
|
||||
if n.kind == "Function" and n.parent_name == "Point"
|
||||
}
|
||||
assert {"init", "distance"} <= methods
|
||||
|
||||
def test_finds_struct_enum_union_classes(self):
|
||||
classes = {
|
||||
n.name: n.extra.get("zig_kind") for n in self.nodes
|
||||
if n.kind == "Class"
|
||||
}
|
||||
assert classes.get("Point") == "struct"
|
||||
assert classes.get("Color") == "enum"
|
||||
assert classes.get("Shape") == "union"
|
||||
|
||||
def test_finds_imports(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = {e.target for e in imports}
|
||||
# std stays unresolved (no relative .zig path); util resolves to
|
||||
# the absolute fixture path.
|
||||
assert "std" in targets
|
||||
assert any(
|
||||
t.endswith("sample_zig_util.zig") and t != "./sample_zig_util.zig"
|
||||
for t in targets
|
||||
)
|
||||
|
||||
def test_finds_calls(self):
|
||||
calls = [e for e in self.edges if e.kind == "CALLS"]
|
||||
# Bare callees (std.debug.print, expect, util.noop) keep their final
|
||||
# identifier as the target; same-file helper resolves to the
|
||||
# qualified name via _resolve_call_targets.
|
||||
bare_targets = {e.target.split("::")[-1] for e in calls}
|
||||
assert "print" in bare_targets
|
||||
assert "expect" in bare_targets
|
||||
assert "helper" in bare_targets
|
||||
|
||||
def test_builtin_calls_emitted(self):
|
||||
# @intCast inside Point.distance should produce a CALLS edge
|
||||
# whose target is the builtin name (with the leading @).
|
||||
targets = {e.target for e in self.edges if e.kind == "CALLS"}
|
||||
assert "@intCast" in targets
|
||||
|
||||
def test_at_import_is_not_a_call(self):
|
||||
# @import is modelled as IMPORTS_FROM only — never as CALLS, so
|
||||
# it doesn't pollute the call graph.
|
||||
targets = {e.target for e in self.edges if e.kind == "CALLS"}
|
||||
assert "@import" not in targets
|
||||
|
||||
def test_test_block_creates_test_node(self):
|
||||
tests = [n for n in self.nodes if n.kind == "Test"]
|
||||
assert len(tests) == 1
|
||||
assert tests[0].name.startswith("test:helper increments@L")
|
||||
assert tests[0].is_test is True
|
||||
|
||||
def test_in_source_test_emits_tested_by_outside_test_path(self):
|
||||
path = Path("src/math.zig")
|
||||
nodes, edges = self.parser.parse_bytes(
|
||||
path,
|
||||
b"fn increment(x: i32) i32 { return x + 1; }\n"
|
||||
b'test "increment" { try expect(increment(1) == 2); }\n',
|
||||
)
|
||||
|
||||
file_node = next(n for n in nodes if n.kind == "File")
|
||||
test_node = next(n for n in nodes if n.kind == "Test")
|
||||
function_node = next(
|
||||
n for n in nodes if n.kind == "Function" and n.name == "increment"
|
||||
)
|
||||
test_qname = self.parser._qualify(
|
||||
test_node.name, test_node.file_path, test_node.parent_name,
|
||||
)
|
||||
function_qname = self.parser._qualify(
|
||||
function_node.name, function_node.file_path, function_node.parent_name,
|
||||
)
|
||||
|
||||
assert file_node.is_test is False
|
||||
assert any(
|
||||
edge.kind == "CALLS"
|
||||
and edge.source == test_qname
|
||||
and edge.target == function_qname
|
||||
for edge in edges
|
||||
)
|
||||
assert any(
|
||||
edge.kind == "TESTED_BY"
|
||||
and edge.source == function_qname
|
||||
and edge.target == test_qname
|
||||
for edge in edges
|
||||
)
|
||||
|
||||
def test_calls_inside_methods_have_qualified_source(self):
|
||||
# Point.distance calls helper(...) — the source should be the
|
||||
# qualified Point.distance name, not the bare file path.
|
||||
sources = {
|
||||
e.source.split("::")[-1] for e in self.edges
|
||||
if e.kind == "CALLS"
|
||||
}
|
||||
assert "Point.distance" in sources
|
||||
|
||||
def test_nodes_have_zig_language(self):
|
||||
for node in self.nodes:
|
||||
assert node.language == "zig"
|
||||
|
||||
class TestHCLParsing:
|
||||
"""HCL / Terraform parser — closes #199."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(FIXTURES / "sample.tf")
|
||||
|
||||
def test_detects_language(self):
|
||||
assert self.parser.detect_language(Path("main.tf")) == "hcl"
|
||||
assert self.parser.detect_language(Path("config.hcl")) == "hcl"
|
||||
|
||||
def test_nodes_have_hcl_language(self):
|
||||
for n in self.nodes:
|
||||
assert n.language == "hcl"
|
||||
|
||||
def test_file_node(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert len(file_nodes) == 1
|
||||
assert file_nodes[0].name.endswith("sample.tf")
|
||||
|
||||
def test_finds_resources(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "resource.aws_vpc.main" in classes
|
||||
assert "resource.aws_instance.web" in classes
|
||||
assert "resource.aws_subnet.main" in classes
|
||||
|
||||
def test_finds_data_sources(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "data.aws_ami.ubuntu" in classes
|
||||
|
||||
def test_finds_modules(self):
|
||||
classes = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "module.security" in classes
|
||||
|
||||
def test_finds_variables(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "var.region" in funcs
|
||||
assert "var.instance_type" in funcs
|
||||
|
||||
def test_finds_outputs(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "output.instance_ip" in funcs
|
||||
assert "output.vpc_id" in funcs
|
||||
|
||||
def test_finds_locals(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "local.name_prefix" in funcs
|
||||
assert "local.full_name" in funcs
|
||||
|
||||
def test_finds_provider(self):
|
||||
funcs = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "provider.aws" in funcs
|
||||
|
||||
def test_hcl_type_extra_metadata(self):
|
||||
by_name = {n.name: n for n in self.nodes if n.kind != "File"}
|
||||
assert by_name["resource.aws_vpc.main"].extra["hcl_type"] == "resource"
|
||||
assert by_name["data.aws_ami.ubuntu"].extra["hcl_type"] == "data"
|
||||
assert by_name["module.security"].extra["hcl_type"] == "module"
|
||||
assert by_name["var.region"].extra["hcl_type"] == "variable"
|
||||
assert by_name["output.instance_ip"].extra["hcl_type"] == "output"
|
||||
assert by_name["local.name_prefix"].extra["hcl_type"] == "local"
|
||||
assert by_name["provider.aws"].extra["hcl_type"] == "provider"
|
||||
|
||||
def test_module_source_creates_import_edge(self):
|
||||
imports = [e for e in self.edges if e.kind == "IMPORTS_FROM"]
|
||||
targets = [e.target for e in imports]
|
||||
assert any("modules/security" in t for t in targets)
|
||||
|
||||
def test_contains_edges(self):
|
||||
contains = [e for e in self.edges if e.kind == "CONTAINS"]
|
||||
targets = {e.target for e in contains}
|
||||
# All non-File nodes should be contained by the file
|
||||
for n in self.nodes:
|
||||
if n.kind != "File":
|
||||
qn = f"{n.file_path}::{n.name}"
|
||||
assert qn in targets, f"missing CONTAINS for {n.name}"
|
||||
|
||||
def test_resource_references_variable(self):
|
||||
"""resource.aws_instance.web references var.instance_type."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_instance.web" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.instance_type" in t for t in targets)
|
||||
|
||||
def test_resource_references_other_resource(self):
|
||||
"""resource.aws_instance.web references resource.aws_subnet.main."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_instance.web" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_subnet.main" in t for t in targets)
|
||||
|
||||
def test_resource_references_data_source(self):
|
||||
"""resource.aws_instance.web references data.aws_ami.ubuntu."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_instance.web" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("data.aws_ami.ubuntu" in t for t in targets)
|
||||
|
||||
def test_output_references_resource(self):
|
||||
"""output.instance_ip references resource.aws_instance.web."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "output.instance_ip" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_instance.web" in t for t in targets)
|
||||
|
||||
def test_module_references_resource(self):
|
||||
"""module.security references resource.aws_vpc.main."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "module.security" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_vpc.main" in t for t in targets)
|
||||
|
||||
def test_provider_references_variable(self):
|
||||
"""provider.aws references var.region."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "provider.aws" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.region" in t for t in targets)
|
||||
|
||||
def test_terraform_block_skipped(self):
|
||||
"""terraform {} block should not produce any nodes."""
|
||||
names = {n.name for n in self.nodes if n.kind != "File"}
|
||||
assert not any(name.startswith("terraform") for name in names)
|
||||
|
||||
def test_resource_references_local(self):
|
||||
"""resource.aws_vpc.main references local.full_name."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_vpc.main" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("local.full_name" in t for t in targets)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Variable references inside function call arguments
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_count_with_function_extracts_var_ref(self):
|
||||
"""length(var.subnet_ids) in count — var.subnet_ids must be extracted."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_instance.fleet" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.subnet_ids" in t for t in targets), (
|
||||
f"Expected var.subnet_ids in refs from fleet; got {targets}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Block-local meta-argument iterators must not produce REFERENCES edges
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_each_value_produces_no_spurious_edge(self):
|
||||
"""each.value.id should not produce any REFERENCES edge."""
|
||||
each_edges = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES" and "each" in e.target
|
||||
]
|
||||
assert each_edges == [], (
|
||||
f"Spurious 'each' REFERENCES edges: {[e.target for e in each_edges]}"
|
||||
)
|
||||
|
||||
def test_count_index_produces_no_spurious_edge(self):
|
||||
"""count.index should not produce any REFERENCES edge."""
|
||||
count_edges = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES" and "count" in e.target
|
||||
]
|
||||
assert count_edges == [], (
|
||||
f"Spurious 'count' REFERENCES edges: {[e.target for e in count_edges]}"
|
||||
)
|
||||
|
||||
def test_path_module_produces_no_edge(self):
|
||||
"""path.module must not produce a REFERENCES edge."""
|
||||
path_edges = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES" and "path" in e.target
|
||||
]
|
||||
assert path_edges == [], (
|
||||
f"Spurious 'path' REFERENCES edges: {[e.target for e in path_edges]}"
|
||||
)
|
||||
|
||||
def test_terraform_workspace_produces_no_edge(self):
|
||||
"""terraform.workspace must not produce a REFERENCES edge."""
|
||||
tf_edges = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and e.target.rsplit("::", 1)[-1].startswith("terraform")
|
||||
]
|
||||
assert tf_edges == [], (
|
||||
f"Spurious 'terraform' REFERENCES edges: {[e.target for e in tf_edges]}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Resource-to-resource for_each chaining
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_for_each_resource_chaining(self):
|
||||
"""for_each = aws_vpc.main emits REFERENCES to resource.aws_vpc.main."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_internet_gateway.gw" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_vpc.main" in t for t in targets), (
|
||||
f"Expected resource.aws_vpc.main in refs from gw; got {targets}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Variable references inside template string interpolations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_template_interpolation_extracts_var_ref(self):
|
||||
"""\"${var.region}-static-assets\" must produce a REFERENCES edge to var.region."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_s3_bucket.static" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.region" in t for t in targets), (
|
||||
f"Expected var.region in refs from static bucket; got {targets}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Nested block and dynamic block references
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_lifecycle_replace_triggered_by(self):
|
||||
"""lifecycle { replace_triggered_by = [...] } must emit REFERENCES edges."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_autoscaling_group.web" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_launch_template.web" in t for t in targets), (
|
||||
f"Expected resource.aws_launch_template.web in refs from asg.web; got {targets}"
|
||||
)
|
||||
|
||||
def test_dynamic_block_for_each_ref(self):
|
||||
"""dynamic block: for_each = var.ingress_rules must produce REFERENCES edge."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_security_group.main" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.ingress_rules" in t for t in targets), (
|
||||
f"Expected var.ingress_rules in refs from sg.main; got {targets}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dynamic block iterator scope
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_dynamic_block_iterator_no_spurious_edge(self):
|
||||
"""Iterator variables from dynamic blocks must not produce REFERENCES edges.
|
||||
|
||||
Covers: ingress (existing fixture), setting (default iterator),
|
||||
srv (custom iterator=), origin_group and origin (nested dynamic).
|
||||
"""
|
||||
iterator_names = ("ingress", "setting", "srv", "origin_group", "origin")
|
||||
spurious = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and any(f"resource.{name}." in e.target for name in iterator_names)
|
||||
]
|
||||
assert spurious == [], (
|
||||
f"Spurious iterator REFERENCES edges: {[e.target for e in spurious]}"
|
||||
)
|
||||
|
||||
def test_dynamic_block_default_iterator_for_each_extracted(self):
|
||||
"""for_each = var.settings inside dynamic block must produce a REFERENCES edge."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_elastic_beanstalk_environment.tfenvtest" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.settings" in t for t in targets), (
|
||||
f"Expected var.settings in refs from tfenvtest; got {targets}"
|
||||
)
|
||||
|
||||
def test_dynamic_block_resource_ref_alongside_iterator(self):
|
||||
"""Non-iterator attribute refs must still be extracted from the same block.
|
||||
|
||||
aws_elastic_beanstalk_environment.tfenvtest references both
|
||||
var.settings (via for_each) and aws_elastic_beanstalk_application.tftest
|
||||
(via application = <resource>.name) while also containing a 'setting'
|
||||
iterator. Both real refs must survive.
|
||||
"""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_elastic_beanstalk_environment.tfenvtest" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("resource.aws_elastic_beanstalk_application.tftest" in t for t in targets), (
|
||||
f"Expected aws_elastic_beanstalk_application.tftest ref; got {targets}"
|
||||
)
|
||||
|
||||
def test_dynamic_block_custom_iterator_for_each_extracted(self):
|
||||
"""for_each = var.server_list with iterator = srv must still extract var.server_list."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_lb_listener_rule.hosts" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.server_list" in t for t in targets), (
|
||||
f"Expected var.server_list in refs from aws_lb_listener_rule.hosts; got {targets}"
|
||||
)
|
||||
|
||||
def test_nested_dynamic_outer_for_each_extracted(self):
|
||||
"""Outer dynamic for_each = var.load_balancer_origin_groups must be extracted."""
|
||||
refs = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.aws_cloudfront_distribution.cdn" in e.source
|
||||
]
|
||||
targets = {e.target for e in refs}
|
||||
assert any("var.load_balancer_origin_groups" in t for t in targets), (
|
||||
f"Expected var.load_balancer_origin_groups in refs from cdn; got {targets}"
|
||||
)
|
||||
|
||||
def test_nested_dynamic_inner_iterator_refs_suppressed(self):
|
||||
"""Inner dynamic for_each = origin_group.value.origins must produce NO edge.
|
||||
|
||||
origin_group is an iterator variable from the outer dynamic block;
|
||||
treating it as a resource type would emit a spurious
|
||||
resource.origin_group.value edge.
|
||||
"""
|
||||
spurious = [
|
||||
e for e in self.edges
|
||||
if e.kind == "REFERENCES"
|
||||
and "resource.origin_group." in e.target
|
||||
]
|
||||
assert spurious == [], (
|
||||
f"Spurious origin_group REFERENCES edges: {[e.target for e in spurious]}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ansible YAML parsing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
try:
|
||||
import yaml as _yaml_check # noqa: F401
|
||||
_YAML_AVAILABLE = True
|
||||
except ImportError:
|
||||
_YAML_AVAILABLE = False
|
||||
|
||||
_ANSIBLE_SKIP = pytest.mark.skipif(not _YAML_AVAILABLE, reason="pyyaml not installed")
|
||||
|
||||
_PLAYBOOK = FIXTURES / "playbooks" / "sample_ansible_playbook.yml"
|
||||
_TASKS_FILE = FIXTURES / "tasks" / "sample_ansible_tasks.yml"
|
||||
_META_FILE = FIXTURES / "roles" / "myrole" / "meta" / "main.yml"
|
||||
|
||||
|
||||
@_ANSIBLE_SKIP
|
||||
class TestAnsiblePlaybookParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(_PLAYBOOK)
|
||||
|
||||
def test_detects_language_ansible_paths(self):
|
||||
p = self.parser
|
||||
assert p.detect_language(Path("playbooks/site.yml")) == "ansible"
|
||||
assert p.detect_language(Path("roles/web/tasks/main.yml")) == "ansible"
|
||||
assert p.detect_language(Path("handlers/main.yml")) == "ansible"
|
||||
assert p.detect_language(Path("config/settings.yml")) == "yaml"
|
||||
|
||||
def test_file_node_created(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert len(file_nodes) == 1
|
||||
assert file_nodes[0].language == "ansible"
|
||||
|
||||
def test_finds_plays_as_class_nodes(self):
|
||||
play_names = {n.name for n in self.nodes if n.kind == "Class"}
|
||||
assert "Configure web servers" in play_names
|
||||
assert "Configure database servers" in play_names
|
||||
|
||||
def test_plays_have_ansible_kind_extra(self):
|
||||
plays = [n for n in self.nodes if n.kind == "Class"]
|
||||
assert plays, "expected at least one play"
|
||||
for p in plays:
|
||||
assert p.extra.get("ansible_kind") == "play"
|
||||
|
||||
def test_import_playbook_produces_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "base-setup.yml" in targets
|
||||
|
||||
def test_pre_task_extracted(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Verify connectivity" in func_names
|
||||
|
||||
def test_post_task_extracted(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Smoke test" in func_names
|
||||
|
||||
def test_finds_tasks_as_function_nodes(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Install packages" in func_names
|
||||
assert "Deploy config" in func_names
|
||||
assert "Run deploy tasks" in func_names
|
||||
|
||||
def test_fqcn_module_stored_in_extra(self):
|
||||
task = next(
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.name == "Verify connectivity"
|
||||
)
|
||||
assert task.extra.get("ansible_module") == "ansible.builtin.wait_for_connection"
|
||||
|
||||
def test_finds_handlers(self):
|
||||
handlers = [
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.extra.get("ansible_kind") == "handler"
|
||||
]
|
||||
handler_names = {h.name for h in handlers}
|
||||
assert "restart app" in handler_names
|
||||
assert "restart db" in handler_names
|
||||
|
||||
def test_handler_listen_stored(self):
|
||||
handler = next(
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.name == "restart app"
|
||||
)
|
||||
assert handler.extra.get("ansible_listen") == "app restarted"
|
||||
|
||||
def test_notify_scalar_produces_calls(self):
|
||||
calls = {e.target for e in self.edges if e.kind == "CALLS"}
|
||||
assert any(target.endswith("::Configure web servers.restart app") for target in calls)
|
||||
|
||||
def test_notify_list_produces_multiple_calls(self):
|
||||
calls = {e.target for e in self.edges if e.kind == "CALLS"}
|
||||
assert any(target.endswith("::Configure database servers.restart db") for target in calls)
|
||||
assert any(target.endswith("::Configure database servers.run migrations") for target in calls)
|
||||
|
||||
def test_include_tasks_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "deploy.yml" in targets
|
||||
|
||||
def test_import_role_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "security" in targets
|
||||
|
||||
def test_roles_list_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "common" in targets
|
||||
assert "nginx" in targets
|
||||
|
||||
def test_vars_files_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "vars/common.yml" in targets
|
||||
|
||||
def test_block_tasks_extracted(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Run migration script" in func_names
|
||||
assert "Verify migration" in func_names
|
||||
|
||||
def test_rescue_tasks_extracted(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Log migration failure" in func_names
|
||||
|
||||
def test_block_tasks_parented_to_play(self):
|
||||
block_task = next(
|
||||
n for n in self.nodes
|
||||
if n.kind == "Function" and n.name == "Run migration script"
|
||||
)
|
||||
assert block_task.parent_name == "Configure web servers"
|
||||
|
||||
def test_file_contains_plays(self):
|
||||
file_path_str = str(_PLAYBOOK)
|
||||
file_contains = {e.target for e in self.edges
|
||||
if e.kind == "CONTAINS" and e.source == file_path_str}
|
||||
assert any("Configure web servers" in t for t in file_contains)
|
||||
|
||||
def test_line_numbers_positive(self):
|
||||
for n in self.nodes:
|
||||
assert n.line_start > 0, f"{n.name} has line_start={n.line_start}"
|
||||
assert n.line_end >= n.line_start, f"{n.name} has bad line range"
|
||||
|
||||
def test_all_nodes_language_ansible(self):
|
||||
for n in self.nodes:
|
||||
assert n.language == "ansible", f"{n.name} has language={n.language!r}"
|
||||
|
||||
|
||||
@_ANSIBLE_SKIP
|
||||
class TestAnsibleTasksParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(_TASKS_FILE)
|
||||
|
||||
def test_file_language_ansible(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert file_nodes[0].language == "ansible"
|
||||
|
||||
def test_named_tasks_found(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert "Create app user" in func_names
|
||||
assert "Clone repository" in func_names
|
||||
assert "Install requirements" in func_names
|
||||
|
||||
def test_nameless_task_fallback_name(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
fallbacks = [n for n in func_names if "@line" in n and "package" in n.lower()]
|
||||
assert fallbacks, "expected a fallback-named task for the nameless package task"
|
||||
|
||||
def test_loop_key_not_misidentified_as_module(self):
|
||||
func_names = {n.name for n in self.nodes if n.kind == "Function"}
|
||||
assert not any(n.startswith("loop@") or n.startswith("with_") for n in func_names)
|
||||
|
||||
def test_fqcn_include_role_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "shared_config" in targets
|
||||
|
||||
def test_import_tasks_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "deploy_steps.yml" in targets
|
||||
|
||||
def test_include_vars_imports_from(self):
|
||||
targets = {e.target for e in self.edges if e.kind == "IMPORTS_FROM"}
|
||||
assert "env_vars.yml" in targets
|
||||
|
||||
def test_file_contains_tasks(self):
|
||||
file_path_str = str(_TASKS_FILE)
|
||||
sources = {e.source for e in self.edges if e.kind == "CONTAINS"}
|
||||
assert file_path_str in sources
|
||||
|
||||
def test_tasks_have_no_parent_play(self):
|
||||
for n in self.nodes:
|
||||
if n.kind == "Function":
|
||||
assert n.parent_name is None, f"{n.name} should have no parent_play"
|
||||
|
||||
|
||||
@_ANSIBLE_SKIP
|
||||
class TestAnsibleMetaParsing:
|
||||
def setup_method(self):
|
||||
self.parser = CodeParser()
|
||||
self.nodes, self.edges = self.parser.parse_file(_META_FILE)
|
||||
|
||||
def test_file_language_ansible(self):
|
||||
file_nodes = [n for n in self.nodes if n.kind == "File"]
|
||||
assert file_nodes[0].language == "ansible"
|
||||
|
||||
def test_depends_on_bare_string(self):
|
||||
dep_targets = {e.target for e in self.edges if e.kind == "DEPENDS_ON"}
|
||||
assert "common" in dep_targets
|
||||
|
||||
def test_depends_on_role_key(self):
|
||||
dep_targets = {e.target for e in self.edges if e.kind == "DEPENDS_ON"}
|
||||
assert "nginx" in dep_targets
|
||||
|
||||
def test_depends_on_name_key_collections(self):
|
||||
dep_targets = {e.target for e in self.edges if e.kind == "DEPENDS_ON"}
|
||||
assert "security.hardening" in dep_targets
|
||||
Reference in New Issue
Block a user