Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions code_review_graph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2432,7 +2432,8 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]:
return None # method child not found

# Simple call: func_name(args)
if first.type == "identifier":
# Kotlin uses "simple_identifier" instead of "identifier".
if first.type in ("identifier", "simple_identifier"):
return first.text.decode("utf-8", errors="replace")

# Perl: function_call_expression / ambiguous_function_call_expression
Expand All @@ -2450,18 +2451,50 @@ def _get_call_name(self, node, language: str, source: bytes) -> Optional[str]:
return None

# Method call: obj.method(args)
# Only emit CALLS for self/cls/this/super receivers -- external method
# calls (session.execute(), data.get()) are unresolvable without type
# inference and create noise in the call graph.
# Kotlin uses "navigation_expression" for member access (obj.method).
member_types = (
"attribute", "member_expression",
"field_expression", "selector_expression",
"navigation_expression",
)
if first.type in member_types:
# Check receiver (first child) -- only allow self/cls/this/super.
receiver = first.children[0] if first.children else None
if receiver is None:
return None
is_self_call = (
receiver.type in ("self", "this", "super")
or (
receiver.type in ("identifier", "simple_identifier")
and receiver.text.decode("utf-8", errors="replace")
in ("self", "cls", "this", "super")
)
# Python super().method() -- receiver is call(identifier:"super")
or (
receiver.type == "call"
and receiver.children
and receiver.children[0].type == "identifier"
and receiver.children[0].text == b"super"
)
)
if not is_self_call:
return None

# Get the rightmost identifier (the method name)
# Kotlin navigation_expression uses navigation_suffix > simple_identifier.
for child in reversed(first.children):
if child.type in (
"identifier", "property_identifier", "field_identifier",
"field_name",
"field_name", "simple_identifier",
):
return child.text.decode("utf-8", errors="replace")
if child.type == "navigation_suffix":
for sub in child.children:
if sub.type == "simple_identifier":
return sub.text.decode("utf-8", errors="replace")
return first.text.decode("utf-8", errors="replace")

# Scoped call (e.g., Rust path::func())
Expand Down
14 changes: 12 additions & 2 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def test_finds_imports(self):

def test_finds_calls(self):
calls = [e for e in self.edges if e.kind == "CALLS"]
assert len(calls) >= 3
assert len(calls) >= 2


class TestJavaParsing:
Expand Down Expand Up @@ -110,7 +110,9 @@ def test_finds_inheritance(self):

def test_finds_calls(self):
calls = [e for e in self.edges if e.kind == "CALLS"]
assert len(calls) >= 3
# Java fixture only has external method calls (repo.save, users.put, etc.)
# and new expressions -- no simple function calls or this.method() calls
assert len(calls) >= 0


class TestCParsing:
Expand Down Expand Up @@ -249,6 +251,14 @@ def test_finds_functions(self):
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
# External method call repo.save(user) is filtered out
assert "save" not in targets


class TestSwiftParsing:
def setup_method(self):
Expand Down
85 changes: 68 additions & 17 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,13 @@ def test_parse_python_calls(self):
nodes, edges = self.parser.parse_file(FIXTURES / "sample_python.py")
calls = [e for e in edges if e.kind == "CALLS"]
call_targets = {e.target for e in calls}
# _resolve_call_targets qualifies same-file definitions
# self._validate_token() resolves within the class
assert any("_validate_token" in t for t in call_targets)
assert any("authenticate" in t for t in call_targets)
# service.authenticate() is an external method call -- filtered out
assert not any(
t.endswith("authenticate") for t in call_targets
if "::" in t and "self" not in t
)

def test_parse_typescript_file(self):
nodes, edges = self.parser.parse_file(FIXTURES / "sample_typescript.ts")
Expand Down Expand Up @@ -142,6 +146,57 @@ def test_multiple_calls_to_same_function(self):
lines = {e.line for e in calls}
assert len(lines) == 2 # distinct line numbers

def test_method_call_filtering_python_self(self):
"""self.method() should emit a CALLS edge."""
_, edges = self.parser.parse_bytes(
Path("/test/app.py"),
b"class C:\n def helper(self): pass\n"
b" def main(self):\n self.helper()\n",
)
calls = [e for e in edges if e.kind == "CALLS"]
assert any("helper" in c.target for c in calls)

def test_method_call_filtering_python_external(self):
"""obj.method() should NOT emit a CALLS edge (unresolvable)."""
_, edges = self.parser.parse_bytes(
Path("/test/app.py"),
b"def main():\n response.json()\n data.get('k')\n",
)
calls = [e for e in edges if e.kind == "CALLS"]
targets = {c.target for c in calls}
assert "json" not in targets
assert "get" not in targets

def test_method_call_filtering_python_super(self):
"""super().method() should emit a CALLS edge."""
_, edges = self.parser.parse_bytes(
Path("/test/app.py"),
b"class C:\n def save(self):\n super().save()\n",
)
calls = [e for e in edges if e.kind == "CALLS"]
assert any("save" in c.target for c in calls)

def test_method_call_filtering_ts_this(self):
"""this.method() should emit a CALLS edge in TS."""
_, edges = self.parser.parse_bytes(
Path("/test/app.ts"),
b"class C {\n helper() {}\n"
b" main() { this.helper(); }\n}\n",
)
calls = [e for e in edges if e.kind == "CALLS"]
assert any("helper" in c.target for c in calls)

def test_method_call_filtering_ts_external(self):
"""obj.method() should NOT emit a CALLS edge in TS."""
_, edges = self.parser.parse_bytes(
Path("/test/app.ts"),
b"function main() { response.json(); data.get('k'); }\n",
)
calls = [e for e in edges if e.kind == "CALLS"]
targets = {c.target for c in calls}
assert "json" not in targets
assert "get" not in targets

def test_parse_nonexistent_file(self):
nodes, edges = self.parser.parse_file(Path("/nonexistent/file.py"))
assert nodes == []
Expand Down Expand Up @@ -226,9 +281,10 @@ def test_parse_vue_calls(self):
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
calls = [e for e in edges if e.kind == "CALLS"]
call_targets = {e.target for e in calls}
assert "log" in call_targets or "console.log" in call_targets or any(
"log" in t for t in call_targets
)
# fetch() is a simple function call, should be present
assert "fetch" in call_targets
# console.log() is an external method call, should be filtered
assert "log" not in call_targets

def test_parse_vue_contains_edges(self):
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vue.vue")
Expand Down Expand Up @@ -403,24 +459,19 @@ def test_vitest_contains_edges(self):
assert describe_qualified & contains_sources

def test_vitest_calls_edges(self):
"""Calls inside test blocks should produce CALLS edges."""
"""External method calls (service.findById) should be filtered out."""
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts")
calls = [e for e in edges if e.kind == "CALLS"]
assert len(calls) >= 1
test_names = {n.name for n in nodes if n.kind == "Test"}
file_path = str(FIXTURES / "sample_vitest.test.ts")
test_qualified = {f"{file_path}::{name}" for name in test_names}
call_sources = {e.source for e in calls}
assert call_sources & test_qualified
# service.findById() is an external method call -- should not produce a CALLS edge
assert not any("findById" in c.target for c in calls)

def test_vitest_tested_by_edges(self):
"""TESTED_BY edges should be generated from test calls to production code."""
"""TESTED_BY edges need direct function calls (not method calls on locals)."""
nodes, edges = self.parser.parse_file(FIXTURES / "sample_vitest.test.ts")
tested_by = [e for e in edges if e.kind == "TESTED_BY"]
assert len(tested_by) >= 1, (
f"Expected TESTED_BY edges, got none. "
f"All edges: {[(e.kind, e.source, e.target) for e in edges]}"
)
# The fixture only has new X() and service.findById() -- no direct function calls
# from tests, so no TESTED_BY edges are expected.
assert len(tested_by) == 0

def test_non_test_file_describe_not_special(self):
"""describe() in a non-test file should NOT create Test nodes."""
Expand Down
Loading