Skip to content
Draft
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
50 changes: 49 additions & 1 deletion code_review_graph/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ class EdgeInfo:
"csharp": [
"class_declaration", "interface_declaration",
"enum_declaration", "struct_declaration",
"record_declaration", "record_struct_declaration",
],
"ruby": ["class", "module"],
"r": [], # Classes detected via call pattern-matching, not AST node types
Expand Down Expand Up @@ -6085,7 +6086,54 @@ def _get_bases(self, node, language: str, source: bytes) -> list[str]:
for ident in sub.children:
if ident.type in ("type_identifier", "generic_type"):
bases.append(ident.text.decode("utf-8", errors="replace"))
elif language in ("csharp", "kotlin"):
elif language == "csharp":
# C#: class / record / struct / interface declarations carry a
# `base_list` child wrapping the `: Base, IFace, ...` clause. Its
# `.text` includes the leading colon and commas, so iterate the
# *named* base-type entries and take each one's bare text. Using
# is_named (rather than a fixed node-type allowlist) keeps this
# robust across tree-sitter-c-sharp versions, which variously emit
# identifier / qualified_name / generic_name / alias_qualified_name
# / type for a base entry. Punctuation (':' and ',') is unnamed and
# skipped. Generic parameter constraints (`where T : Base`) live in
# a sibling clause, not base_list, so they are never captured.
# Without this, the shared handler below looked for
# superclass/super_interfaces nodes the C# grammar never emits,
# so C# types produced zero INHERITS edges and inheritors_of /
# get_impact_radius returned empty for .cs files.
#
# Two things the base_list contains that are NOT base types:
# * `enum E : byte` — the base_list holds the enum's *underlying
# type*, not a base. Enums never inherit, so emit nothing.
# * `class C(int x) : Base(x)` — the primary-constructor arguments
# `(x)` appear as an `argument_list` sibling of the base type
# (and inside `primary_constructor_base_type` for records).
if node.type == "enum_declaration":
return bases
for child in node.children:
if child.type != "base_list":
continue
for sub in child.children:
if not sub.is_named or sub.type == "argument_list":
continue
if sub.type == "primary_constructor_base_type":
# positional record: `record R(...) : Base(args)` —
# keep the type, drop the constructor argument_list.
type_node = sub.child_by_field_name("type")
if type_node is not None:
bases.append(
type_node.text.decode("utf-8", errors="replace")
)
else:
for t in sub.children:
if t.is_named and t.type != "argument_list":
bases.append(
t.text.decode("utf-8", errors="replace")
)
break
continue
bases.append(sub.text.decode("utf-8", errors="replace"))
elif language == "kotlin":
# Look for superclass/interfaces in extends/implements clauses
for child in node.children:
if child.type in (
Expand Down
68 changes: 68 additions & 0 deletions tests/fixtures/Sample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,72 @@ public User GetUser(int id)
return _repo.FindById(id);
}
}

// Inheritance coverage for INHERITS edges (base_list clause).
// Extends a base class AND implements an interface (both bare identifiers).
public class CachedRepo : InMemoryRepo, IRepository
{
public new User FindById(int id)
{
return base.FindById(id);
}
}

// Qualified base type name (qualified_name node).
public class DisposableService : System.IDisposable
{
public void Dispose() { }
}

// Generic base type name (generic_name node).
public class UserList : List<User>
{
}

// Nested-qualified generic base (qualified generic_name).
public class ScopedUserList : System.Collections.Generic.List<User>
{
}

// Generic type parameter constraint — `where T : IRepository` is NOT a base
// and must NOT produce an INHERITS edge. ConstrainedHolder itself has no base.
public class ConstrainedHolder<T> where T : IRepository
{
public T Value { get; set; }
}

// record with a base class + interface (record_declaration must be parsed
// as a class-like node so its base_list is reached).
public record AuditedUser : User, IRepository
{
public User FindById(int id) { return null; }
public void Save(User user) { }
}

// positional record with a primary-constructor base (drop the (args)).
public record TaggedUser(int Id, string Tag) : User
{
}

// struct implementing an interface.
public struct Token : IRepository
{
public User FindById(int id) { return null; }
public void Save(User user) { }
}

// primary-constructor class: the base ctor args `(seed)` appear as an
// argument_list sibling in base_list and must NOT become a base target.
public class SeededRepo(int seed) : InMemoryRepo
{
public int Seed { get; } = seed;
}

// enum with an underlying type — `: byte` is the storage type, NOT a base.
// Must produce no INHERITS edge.
public enum Status : byte
{
Active,
Closed,
}
}
68 changes: 68 additions & 0 deletions tests/test_multilang.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,74 @@ def test_finds_methods(self):
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"]
# InMemoryRepo : IRepository, CachedRepo : InMemoryRepo, IRepository
assert len(inherits) >= 3
targets = {e.target for e in inherits}
assert "IRepository" in targets
assert "InMemoryRepo" in targets

def test_inheritance_target_is_bare_name(self):
"""INHERITS target must be the bare type name, not ': Foo' or 'Foo, Bar'.

tree-sitter-csharp wraps the `: Base, IFace` clause in a base_list
node whose .text includes the colon and commas. Without the
csharp-specific branch in _get_bases the whole clause is stored as
the edge target, so inheritors_of / get_impact_radius miss .cs files.
Qualified (System.IDisposable) and generic (List<User>) base names
must be preserved.
"""
inherits = [e for e in self.edges if e.kind == "INHERITS"]
targets = {e.target for e in inherits}
assert "System.IDisposable" in targets # qualified_name preserved
assert "List<User>" in targets # generic_name preserved
for e in inherits:
assert not e.target.startswith(":"), (
f"INHERITS target should be a bare type name, got: {e.target!r}"
)
assert "," not in e.target, (
f"INHERITS target should be a single type, got: {e.target!r}"
)

def test_inheritance_hard_cases(self):
"""Records, structs and nested-qualified generics reach _get_bases, and
generic constraints (`where T : Base`) do NOT produce edges.
"""
inherits = [e for e in self.edges if e.kind == "INHERITS"]
by_source = {}
for e in inherits:
by_source.setdefault(e.source.rsplit("::", 1)[-1], set()).add(e.target)

# record AuditedUser : User, IRepository (record_declaration parsed)
assert by_source.get("AuditedUser") == {"User", "IRepository"}, by_source.get(
"AuditedUser"
)
# positional record TaggedUser(...) : User (drop the primary-ctor args)
assert by_source.get("TaggedUser") == {"User"}, by_source.get("TaggedUser")
# struct Token : IRepository
assert "IRepository" in by_source.get("Token", set())
# nested-qualified generic base preserved verbatim
assert "System.Collections.Generic.List<User>" in {
e.target for e in inherits
}
# `where T : IRepository` is a constraint, not a base — no edge for it.
assert "ConstrainedHolder" not in by_source, (
"generic constraint must not produce an INHERITS edge"
)
# primary-constructor class: `: InMemoryRepo` is the base; the ctor
# args `(seed)` must NOT leak in as a bogus '(seed)' target.
assert by_source.get("SeededRepo") == {"InMemoryRepo"}, by_source.get(
"SeededRepo"
)
for e in inherits:
assert not e.target.startswith("("), (
f"primary-ctor args must not become a base target: {e.target!r}"
)
# enum underlying type (`enum Status : byte`) is not inheritance.
assert "Status" not in by_source, "enum underlying type must not inherit"
assert "byte" not in {e.target for e in inherits}


class TestRubyParsing:
def setup_method(self):
Expand Down