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
109 changes: 109 additions & 0 deletions code_review_graph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,72 @@ def _is_test_function(
return False


def _modifier_annotation_names(node) -> list[str]:
"""Return annotation names from a ``modifiers`` child of *node*.

Covers Java/Kotlin/C# where annotations live inside a ``modifiers``
node as ``annotation`` / ``marker_annotation`` children. The leading
``@`` is stripped. See: #295
"""
names: list[str] = []
for sub in node.children:
if sub.type == "modifiers":
for mod in sub.children:
if mod.type in ("annotation", "marker_annotation"):
text = mod.text.decode("utf-8", errors="replace")
names.append(text.lstrip("@").strip())
return names


def _csharp_attribute_names(node) -> list[str]:
"""Return C# attribute names from ``attribute_list`` children of *node*.

C# attributes (``[HttpGet]``, ``[Authorize]``, ``[ApiController]``) are
``attribute_list`` nodes, each wrapping one or more ``attribute`` nodes
whose first ``identifier`` is the attribute name. The bracket wrapper
and any argument list are dropped. See: #295
"""
names: list[str] = []
for sub in node.children:
if sub.type != "attribute_list":
continue
for attr in sub.children:
if attr.type != "attribute":
continue
for ident in attr.children:
if ident.type in ("identifier", "qualified_name"):
names.append(ident.text.decode("utf-8", errors="replace").strip())
break
return names


def _csharp_namespaces(root_node) -> list[str]:
"""Return all namespaces declared in a C# compilation unit.

Handles both the block form (``namespace_declaration``) and the C# 10+
file-scoped form (``file_scoped_namespace_declaration``). A single file
may declare multiple namespaces; all are returned in source order.
See: #310
"""
namespaces: list[str] = []

def _walk(node) -> None:
if node.type in (
"namespace_declaration", "file_scoped_namespace_declaration",
):
for c in node.children:
if c.type in ("qualified_name", "identifier"):
text = c.text.decode("utf-8", errors="replace").strip()
if text:
namespaces.append(text)
break
for c in node.children:
_walk(c)

_walk(root_node)
return namespaces


def file_hash(path: Path) -> str:
"""SHA-256 hash of file contents."""
return hashlib.sha256(path.read_bytes()).hexdigest()
Expand Down Expand Up @@ -1000,6 +1066,14 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E

# File node
test_file = _is_test_file(file_path_str)
file_extra: dict = {}
# C#: record the namespace(s) this file declares so query-time
# fallbacks can resolve namespace-form IMPORTS_FROM targets (from
# `using X.Y;` directives) back to the declaring file. See: #310
if language == "csharp":
ns_list = _csharp_namespaces(tree.root_node)
if ns_list:
file_extra["csharp_namespaces"] = ns_list
nodes.append(NodeInfo(
kind="File",
name=file_path_str,
Expand All @@ -1008,6 +1082,7 @@ def parse_bytes(self, path: Path, source: bytes) -> tuple[list[NodeInfo], list[E
line_end=source.count(b"\n") + 1,
language=language,
is_test=test_file,
extra=file_extra,
))

# Pre-scan for import mappings and defined names
Expand Down Expand Up @@ -4296,6 +4371,23 @@ def _extract_classes(
role = "workflow_interface" if is_wf else "activity_interface"
extra["temporal_role"] = role

# Class-level annotation persistence for all annotation-bearing
# languages. Kotlin (@HiltViewModel, @AndroidEntryPoint) and C#
# ([ApiController], [Route]) lost this metadata entirely; Java reuses
# the list already gathered above. Stored in ``modifiers`` (string)
# and ``extra["decorators"]`` (list). See: #295
if language == "java":
class_decorators = list(class_annotations)
else:
class_decorators = _modifier_annotation_names(child)
if language == "csharp":
class_decorators.extend(_csharp_attribute_names(child))
class_modifiers: Optional[str] = (
",".join(class_decorators) if class_decorators else None
)
if class_decorators and "decorators" not in extra:
extra["decorators"] = class_decorators

node = NodeInfo(
kind="Class",
name=name,
Expand All @@ -4304,6 +4396,7 @@ def _extract_classes(
line_end=child.end_point[0] + 1,
language=language,
parent_name=enclosing_class,
modifiers=class_modifiers,
extra=extra,
)
nodes.append(node)
Expand Down Expand Up @@ -4412,6 +4505,12 @@ def _extract_functions(
inner = inner[:-1]
deco_list.append(inner.strip())
sib = sib.prev_sibling
# C#: attributes use `attribute_list` child nodes ([HttpGet],
# [Authorize]) rather than `modifiers > annotation`. Capture the
# attribute name from each `attribute_list > attribute > identifier`.
# See: #295
if language == "csharp":
deco_list.extend(_csharp_attribute_names(child))
if deco_list:
decorators = tuple(deco_list)

Expand Down Expand Up @@ -4445,6 +4544,15 @@ def _extract_functions(
child, name, enclosing_class, file_path, edges,
)

# Persist annotations/decorators so consumers can filter on them
# (e.g. "show me all @Composable functions"). Stored in BOTH
# ``modifiers`` (comma-joined string) and ``extra["decorators"]``
# (list) — merged into the existing method_extra dict rather than a
# separate one. See: #295
modifiers_str: Optional[str] = ",".join(deco_list) if deco_list else None
if deco_list:
method_extra["decorators"] = list(deco_list)

node = NodeInfo(
kind=kind,
name=name,
Expand All @@ -4455,6 +4563,7 @@ def _extract_functions(
parent_name=parent_name,
params=params,
return_type=ret_type,
modifiers=modifiers_str,
is_test=is_test,
extra=method_extra,
)
Expand Down
26 changes: 26 additions & 0 deletions code_review_graph/tools/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,32 @@ def query_graph(
"file": e.file_path,
})
edges_out.append(edge_to_dict(e))
# C# fallback: `using X.Y;` directives produce IMPORTS_FROM edges
# whose target is the raw namespace string, not a file path, so
# the path lookup above misses them. Resolve the target file's
# declared namespace(s) and also search edges by namespace.
# See: #310
if node is not None and node.language == "csharp":
declared_ns: list[str] = []
for n in store.get_nodes_by_file(node.file_path):
if n.kind == "File":
declared_ns = list(
n.extra.get("csharp_namespaces", []) or []
)
break
seen_sources = {r.get("importer") for r in results}
for ns in declared_ns:
for e in store.get_edges_by_target(ns):
if e.kind != "IMPORTS_FROM":
continue
if e.source_qualified in seen_sources:
continue
results.append({
"importer": e.source_qualified,
"file": e.file_path,
})
edges_out.append(edge_to_dict(e))
seen_sources.add(e.source_qualified)

elif pattern == "children_of":
for e in store.get_edges_by_source(qn):
Expand Down
165 changes: 165 additions & 0 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,119 @@ def test_finds_methods(self):
assert "FindById" in names or "Save" in names


@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 str(app) in importers
assert str(unrelated) not in importers


class TestRubyParsing:
def setup_method(self):
self.parser = CodeParser()
Expand Down Expand Up @@ -482,6 +595,58 @@ def test_finds_calls(self):
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()
Expand Down
Loading