Skip to content
Merged
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
4 changes: 4 additions & 0 deletions codebase_rag/constants/ast_csharp.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@
# (H) (`override`) from an explicit `new` hide (which must not become OVERRIDES).
TS_CSHARP_MODIFIER = "modifier"
TS_CSHARP_MODIFIER_OVERRIDE = "override"
# (H) A type split across files carries `partial` on every part; parts with the
# (H) same namespace-qualified name are one logical type, unified for member and
# (H) base resolution (see csharp_partial_groups).
TS_CSHARP_MODIFIER_PARTIAL = "partial"

# (H) Visibility modifiers that make a type/member external API surface (seed
# (H) dead-code roots). `protected internal` is two separate modifier children.
Expand Down
22 changes: 22 additions & 0 deletions codebase_rag/parsers/class_ingest/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ class ClassIngestMixin:
java_anon_overrides: list[tuple[str, str, str, str]]
csharp_methods: set[str]
csharp_override_methods: set[str]
csharp_partial_groups: dict[str, list[str]]
_csharp_partial_index: dict[str, list[str]]
csharp_extension_methods: dict[str, list[tuple[str, str, str]]]
class_field_guard_inner: dict[str, dict[str, str]]
method_return_types: dict[str, str]
Expand Down Expand Up @@ -792,6 +794,26 @@ def _process_class_node(
# (H) class_inheritance over these per-class maps).
if field_types := csharp_utils.build_field_type_map(member_node):
self.class_field_types[class_qn] = field_types
# (H) A `partial` type is split across files into N path-distinct
# (H) nodes; group the parts into one shared list so a typed receiver
# (H) resolves members and bases from any part. The key is the
# (H) declaring DIRECTORY (module_qn minus the file stem) plus the
# (H) namespace-qualified name, NOT the bare namespace name: two
# (H) independent projects that both declare `N.Widget` live in
# (H) different directories and must not be merged across assembly
# (H) boundaries. Parts in different directories of one project fall
# (H) back to generic resolution (safe under-merge) rather than risk a
# (H) cross-project wrong edge.
if cs.TS_CSHARP_MODIFIER_PARTIAL in modifiers:
directory = (
module_qn.rsplit(cs.SEPARATOR_DOT, 1)[0]
if cs.SEPARATOR_DOT in module_qn
else module_qn
)
key = f"{directory}{cs.SEPARATOR_DOT}{class_qn[len(module_qn) + 1 :]}"
group = self._csharp_partial_index.setdefault(key, [])
group.append(class_qn)
self.csharp_partial_groups[class_qn] = group
Comment on lines +807 to +816

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If a C# class is defined in the global namespace (i.e., outside of any namespace declaration), module_qn will be empty (""). In this case, len(module_qn) + 1 evaluates to 1, causing class_qn[1:] to slice off the first character of the class name. This can lead to incorrect grouping of unrelated classes (for example, Widget and Fidget would both resolve to the key "idget" and be grouped together as parts of the same partial class). Checking if module_qn is non-empty before slicing prevents this issue.

Suggested change
if cs.TS_CSHARP_MODIFIER_PARTIAL in modifiers:
key = class_qn[len(module_qn) + 1 :]
group = self._csharp_partial_index.setdefault(key, [])
group.append(class_qn)
self.csharp_partial_groups[class_qn] = group
if cs.TS_CSHARP_MODIFIER_PARTIAL in modifiers:
key = class_qn[len(module_qn) + 1 :] if module_qn else class_qn
group = self._csharp_partial_index.setdefault(key, [])
group.append(class_qn)
self.csharp_partial_groups[class_qn] = group

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push. The partial-group key is now the declaring DIRECTORY (module_qn minus the file stem) plus the namespace-qualified name, so two independent projects that both declare N.Widget live in different directories and are not merged across the assembly boundary. Parts in different directories of one project fall back to generic resolution (safe under-merge) rather than risk a cross-project wrong edge. Added test_same_name_partial_in_different_projects_not_merged (two project dirs + a decoy so the generic fallback can't mask it; verified RED before, GREEN after).

self._ingest_class_methods(
member_node,
class_qn,
Expand Down
70 changes: 57 additions & 13 deletions codebase_rag/parsers/csharp/type_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ class CSharpTypeInferenceEngine:
"class_inheritance",
"simple_name_lookup",
"class_field_types",
"csharp_partial_groups",
"csharp_extension_methods",
)

Expand All @@ -72,6 +73,7 @@ def __init__(
class_inheritance: dict[str, list[str]],
simple_name_lookup: SimpleNameLookup,
class_field_types: dict[str, dict[str, str]],
csharp_partial_groups: dict[str, list[str]] | None = None,
csharp_extension_methods: dict[str, list[tuple[str, str, str]]] | None = None,
):
self.import_processor = import_processor
Expand All @@ -84,6 +86,9 @@ def __init__(
self.class_inheritance = class_inheritance
self.simple_name_lookup = simple_name_lookup
self.class_field_types = class_field_types
self.csharp_partial_groups = (
csharp_partial_groups if csharp_partial_groups is not None else {}
)
self.csharp_extension_methods = (
csharp_extension_methods if csharp_extension_methods is not None else {}
)
Expand Down Expand Up @@ -173,10 +178,12 @@ def _infer_initializer_type(self, declarator: Node) -> str | None:
def _field_type(self, class_qn: str, field_name: str) -> str | None:
# (H) The declared type of `field_name` on class_qn or any base class,
# (H) read from the per-class maps recorded at ingestion (so it reaches a
# (H) field inherited from a base in another file). BFS with a visited
# (H) field inherited from a base in another file). Seed the BFS with every
# (H) partial part of the class so a field declared on ANOTHER part
# (H) (`helper` on P1, used in a method on P2) is found. BFS with a visited
# (H) guard so a malformed inheritance cycle cannot loop.
seen: set[str] = set()
queue = deque([class_qn])
queue = deque(self.csharp_partial_groups.get(class_qn) or [class_qn])
while queue:
current = queue.popleft()
if current in seen:
Expand Down Expand Up @@ -216,8 +223,8 @@ def resolve_csharp_method_call(
# (H) would bind `c.Foo(1)` to a lone `C.Foo()` and never reach the
# (H) arity-correct `static Foo(this C, int)` extension.
if receiver_class_qn is not None:
if arity_hit := self._find_method_by_arity(
receiver_class_qn, method_name, arg_count, set()
if arity_hit := self._find_arity_across_parts(
receiver_class_qn, method_name, arg_count
):
return cs.NodeLabel.METHOD.value, arity_hit
# (H) An extension method (`static M(this T x, ...)` on an unrelated static
Expand All @@ -233,9 +240,7 @@ def resolve_csharp_method_call(
):
return cs.NodeLabel.METHOD.value, ext
if receiver_class_qn is not None:
if name_hit := self._find_method_by_name(
receiver_class_qn, method_name, set()
):
if name_hit := self._find_name_across_parts(receiver_class_qn, method_name):
return cs.NodeLabel.METHOD.value, name_hit
return None

Expand Down Expand Up @@ -446,19 +451,58 @@ def _type_name_to_qn(self, type_name: str, module_qn: str) -> str | None:
if len(candidates) == 1:
return candidates[0]
if len(candidates) > 1:
# (H) Several candidates that are all parts of ONE partial class are a
# (H) single logical type, not a real ambiguity; return one part (method
# (H) resolution then spans the whole group).
if part := self._single_partial_group_member(candidates):
return part
# (H) Prefer a candidate in the calling file's module; ambiguity across
# (H) unrelated files is left unresolved rather than guessed.
same_module = [q for q in candidates if q.startswith(f"{module_qn}.")]
if len(same_module) == 1:
return same_module[0]
return None

# (H) An exact-arity match ANYWHERE up the hierarchy (_find_method_by_arity)
# (H) wins before any same-name fallback, so an inherited correct-arity
# (H) overload (`Base.Foo(int, int)`) is not lost to a wrong-arity same-name
# (H) method (`Derived.Foo(int)`). resolve_csharp_method_call sequences the
# (H) two around the extension-method lookup so an arity-correct extension is
# (H) preferred over a lone-same-name instance fallback.
def _single_partial_group_member(self, candidates: list[str]) -> str | None:
# (H) If every candidate belongs to the SAME partial-class group, they are
# (H) one logical type; return its lexicographically-first part (stable
# (H) across runs). A candidate outside the group (its group is None) means
# (H) a genuine cross-type ambiguity, left unresolved.
group = self.csharp_partial_groups.get(candidates[0])
if group is None:
return None
if all(self.csharp_partial_groups.get(c) is group for c in candidates):
return min(group)
return None

# (H) An exact-arity match ANYWHERE up the hierarchy (_find_arity_across_parts)
# (H) wins before any same-name fallback, so an inherited correct-arity overload
# (H) (`Base.Foo(int, int)`) is not lost to a wrong-arity same-name method
# (H) (`Derived.Foo(int)`). resolve_csharp_method_call sequences the two around
# (H) the extension-method lookup so an arity-correct extension is preferred over
# (H) a lone-same-name instance fallback. Both phases span every part of a
# (H) partial class (and each part's bases), so a member/base on another part
# (H) binds.
def _partial_roots(self, class_qn: str) -> list[str]:
return self.csharp_partial_groups.get(class_qn) or [class_qn]

def _find_arity_across_parts(
self, class_qn: str, method_name: str, arg_count: int
) -> str | None:
seen: set[str] = set()
for root in self._partial_roots(class_qn):
if resolved := self._find_method_by_arity(
root, method_name, arg_count, seen
):
return resolved
return None

def _find_name_across_parts(self, class_qn: str, method_name: str) -> str | None:
seen: set[str] = set()
for root in self._partial_roots(class_qn):
if resolved := self._find_method_by_name(root, method_name, seen):
return resolved
return None

def _direct_same_name_methods(self, class_qn: str, method_name: str) -> list[str]:
prefix = f"{class_qn}{cs.SEPARATOR_DOT}"
Expand Down
8 changes: 8 additions & 0 deletions codebase_rag/parsers/definition_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ def __init__(
# (H) interface implementations need no modifier and are unaffected.
self.csharp_methods: set[str] = set()
self.csharp_override_methods: set[str] = set()
# (H) C# partial-class unification. A `partial` type split across files
# (H) becomes N path-distinct Class nodes; parts sharing a
# (H) namespace-qualified name are one logical type. Each part qn maps to
# (H) the SHARED list of all its part qns (grows as parts are ingested),
# (H) so member/base resolution on any part spans the whole group. The
# (H) private index groups parts by their namespace-qualified key.
self.csharp_partial_groups: dict[str, list[str]] = {}
self._csharp_partial_index: dict[str, list[str]] = {}
# (H) C# extension methods indexed for call binding: {method simple name:
# (H) [(method_qn, receiver_type_simple_name)]}. `s.WordCount()` resolves
# (H) to a `static WordCount(this string s)` whose receiver type matches
Expand Down
1 change: 1 addition & 0 deletions codebase_rag/parsers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ def type_inference(self) -> TypeInferenceEngine:
class_field_types=self.definition_processor.class_field_types,
class_field_guard_inner=self.definition_processor.class_field_guard_inner,
method_return_types=self.definition_processor.method_return_types,
csharp_partial_groups=self.definition_processor.csharp_partial_groups,
csharp_extension_methods=self.definition_processor.csharp_extension_methods,
)
return self._type_inference
Expand Down
9 changes: 9 additions & 0 deletions codebase_rag/parsers/type_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class TypeInferenceEngine:
"class_field_types",
"class_field_guard_inner",
"method_return_types",
"csharp_partial_groups",
"csharp_extension_methods",
"_java_type_inference",
"_csharp_type_inference",
Expand All @@ -62,6 +63,7 @@ def __init__(
class_field_types: dict[str, dict[str, str]] | None = None,
class_field_guard_inner: dict[str, dict[str, str]] | None = None,
method_return_types: dict[str, str] | None = None,
csharp_partial_groups: dict[str, list[str]] | None = None,
csharp_extension_methods: dict[str, list[tuple[str, str, str]]] | None = None,
):
self.import_processor = import_processor
Expand Down Expand Up @@ -92,6 +94,12 @@ def __init__(
self.method_return_types = (
method_return_types if method_return_types is not None else {}
)
# (H) Shared reference (as with class_field_types): C# partial-class part
# (H) groups, populated during ingestion and read by the C# resolver to
# (H) span all parts of a split type.
self.csharp_partial_groups = (
csharp_partial_groups if csharp_partial_groups is not None else {}
)
# (H) Shared reference (as with class_field_types): C# extension-method
# (H) index, populated during ingestion and read by the C# resolver's
# (H) receiver-binding fallback.
Expand Down Expand Up @@ -156,6 +164,7 @@ def csharp_type_inference(self) -> CSharpTypeInferenceEngine:
class_inheritance=self.class_inheritance,
simple_name_lookup=self.simple_name_lookup,
class_field_types=self.class_field_types,
csharp_partial_groups=self.csharp_partial_groups,
csharp_extension_methods=self.csharp_extension_methods,
)
return self._csharp_type_inference
Expand Down
144 changes: 144 additions & 0 deletions codebase_rag/tests/test_csharp_partial_classes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# (H) C# Phase 3 tail: partial-class member unification. A class split across
# (H) files with `partial` is one logical type; a typed receiver must resolve to
# (H) members and bases declared on ANY part, not just the receiver's own part.
# (H) (Unique-name calls already resolve via the generic fallback; these tests
# (H) use decoys so only cross-part typed resolution can bind them.)
from pathlib import Path
from unittest.mock import MagicMock

import pytest

from codebase_rag.tests.conftest import get_relationships, run_updater

SKIP = "c_sharp"


@pytest.fixture
def csharp_project(temp_repo: Path) -> Path:
project = temp_repo / "csharp_partial"
project.mkdir()
return project


def _call_targets(mock_ingestor: MagicMock) -> set[str]:
return {c.args[2][2] for c in get_relationships(mock_ingestor, "CALLS")}


def test_typed_receiver_binds_member_on_other_part(
csharp_project: Path, mock_ingestor: MagicMock
) -> None:
(csharp_project / "A.cs").write_text(
"""
namespace N;
public partial class Widget { public void Alpha() { } }
public class Other { public void Beta() { } }
""",
encoding="utf-8",
)
(csharp_project / "B.cs").write_text(
"namespace N;\npublic partial class Widget { public void Beta() { } }\n",
encoding="utf-8",
)
(csharp_project / "App.cs").write_text(
"namespace N;\npublic class App { public void Run() { var w = new Widget(); w.Beta(); } }\n",
encoding="utf-8",
)
run_updater(csharp_project, mock_ingestor, skip_if_missing=SKIP)

targets = _call_targets(mock_ingestor)
# (H) `Beta` exists on both Widget (part B) and Other, so the generic
# (H) name-only resolver is ambiguous and drops it. Only typing `w` to the
# (H) Widget partial group and finding Beta on part B binds it correctly --
# (H) and it must be Widget's Beta, never Other's.
assert any(t.endswith(".Widget.Beta") for t in targets), targets
assert not any(t.endswith(".Other.Beta") for t in targets), targets


def test_typed_receiver_binds_inherited_via_other_part(
csharp_project: Path, mock_ingestor: MagicMock
) -> None:
(csharp_project / "Base.cs").write_text(
"""
namespace N;
public class Base { public void Ping() { } }
public class Decoy { public void Ping() { } }
""",
encoding="utf-8",
)
(csharp_project / "P1.cs").write_text(
"namespace N;\npublic partial class Widget : Base { }\n",
encoding="utf-8",
)
(csharp_project / "P2.cs").write_text(
"namespace N;\npublic partial class Widget { public void Own() { } }\n",
encoding="utf-8",
)
(csharp_project / "App.cs").write_text(
"namespace N;\npublic class App { public void Run() { var w = new Widget(); w.Ping(); } }\n",
encoding="utf-8",
)
run_updater(csharp_project, mock_ingestor, skip_if_missing=SKIP)

targets = _call_targets(mock_ingestor)
# (H) `Ping` is inherited through the base declared on part 1, but the
# (H) receiver may resolve to part 2; spanning the partial group reaches the
# (H) base. The Decoy.Ping makes the generic fallback ambiguous.
assert any(t.endswith(".Base.Ping") for t in targets), targets
assert not any(t.endswith(".Decoy.Ping") for t in targets), targets


def test_field_typed_receiver_sees_field_on_other_part(
csharp_project: Path, mock_ingestor: MagicMock
) -> None:
(csharp_project / "F1.cs").write_text(
"""
namespace N;
public class Helper { public void Run() { } }
public class Decoy { public void Run() { } }
public partial class Widget { private Helper helper; }
""",
encoding="utf-8",
)
(csharp_project / "F2.cs").write_text(
"namespace N;\npublic partial class Widget { public void Use() { helper.Run(); } }\n",
encoding="utf-8",
)
run_updater(csharp_project, mock_ingestor, skip_if_missing=SKIP)

targets = _call_targets(mock_ingestor)
# (H) `helper` is a field declared on the OTHER part; typing it requires the
# (H) field lookup to span the partial group. The Decoy.Run makes the generic
# (H) fallback ambiguous, so only the field-typed receiver can bind Helper.Run.
assert any(t.endswith(".Helper.Run") for t in targets), targets
assert not any(t.endswith(".Decoy.Run") for t in targets), targets


def test_same_name_partial_in_different_projects_not_merged(
csharp_project: Path, mock_ingestor: MagicMock
) -> None:
# (H) Two independent projects (directories) both declare `partial class
# (H) Widget` in namespace N. They are DIFFERENT types; grouping them would
# (H) resolve a call in one project to the other's member across the assembly
# (H) boundary. The group key is directory-scoped so they stay separate.
(csharp_project / "proj1").mkdir()
(csharp_project / "proj2").mkdir()
(csharp_project / "proj1" / "A.cs").write_text(
"""
namespace N;
public partial class Widget { public void Alpha() { } }
public class Decoy { public void Beta() { } }
public class App { public void Run() { var w = new Widget(); w.Beta(); } }
""",
encoding="utf-8",
)
(csharp_project / "proj2" / "B.cs").write_text(
"namespace N;\npublic partial class Widget { public void Beta() { } }\n",
encoding="utf-8",
)
run_updater(csharp_project, mock_ingestor, skip_if_missing=SKIP)

targets = _call_targets(mock_ingestor)
# (H) proj1's Widget has no Beta, so any `Widget.Beta` edge would be proj2's,
# (H) reached across the project boundary. (Decoy.Beta blocks the generic
# (H) fallback, so only the partial-group path could produce it.)
assert not any(t.endswith(".Widget.Beta") for t in targets), targets
Loading