From 9410e356cb6ca47562e892b0794b749a69fb0c66 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 13 Jul 2026 02:27:16 +0400 Subject: [PATCH 1/3] feat(csharp): unify partial-class parts for member and base resolution --- codebase_rag/constants/ast_csharp.py | 4 + codebase_rag/parsers/class_ingest/mixin.py | 11 +++ codebase_rag/parsers/csharp/type_inference.py | 42 +++++++-- codebase_rag/parsers/definition_processor.py | 8 ++ codebase_rag/parsers/factory.py | 1 + codebase_rag/parsers/type_inference.py | 9 ++ .../tests/test_csharp_partial_classes.py | 87 +++++++++++++++++++ uv.lock | 2 +- 8 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 codebase_rag/tests/test_csharp_partial_classes.py diff --git a/codebase_rag/constants/ast_csharp.py b/codebase_rag/constants/ast_csharp.py index 87a812b41..a6a400935 100644 --- a/codebase_rag/constants/ast_csharp.py +++ b/codebase_rag/constants/ast_csharp.py @@ -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. diff --git a/codebase_rag/parsers/class_ingest/mixin.py b/codebase_rag/parsers/class_ingest/mixin.py index 3f3c15f1d..ef251040b 100644 --- a/codebase_rag/parsers/class_ingest/mixin.py +++ b/codebase_rag/parsers/class_ingest/mixin.py @@ -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]] class_field_guard_inner: dict[str, dict[str, str]] method_return_types: dict[str, str] interface_implementers: dict[str, set[str]] @@ -783,6 +785,15 @@ 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 (by namespace-qualified name) into one + # (H) shared list so a typed receiver resolves members and bases from + # (H) any part, not just the one it happened to bind to. + 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 self._ingest_class_methods( member_node, class_qn, diff --git a/codebase_rag/parsers/csharp/type_inference.py b/codebase_rag/parsers/csharp/type_inference.py index 0103876ef..524d0e6a2 100644 --- a/codebase_rag/parsers/csharp/type_inference.py +++ b/codebase_rag/parsers/csharp/type_inference.py @@ -57,6 +57,7 @@ class CSharpTypeInferenceEngine: "class_inheritance", "simple_name_lookup", "class_field_types", + "csharp_partial_groups", ) def __init__( @@ -71,6 +72,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, ): self.import_processor = import_processor self.function_registry = function_registry @@ -82,6 +84,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 {} + ) # (H) --- variable/field/parameter type map ------------------------------- @@ -280,6 +285,11 @@ 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}.")] @@ -287,6 +297,18 @@ def _type_name_to_qn(self, type_name: str, module_qn: str) -> str | None: return same_module[0] return None + 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 + def _find_method( self, class_qn: str, method_name: str, arg_count: int ) -> str | None: @@ -295,12 +317,20 @@ def _find_method( # (H) (`Base.Foo(int, int)`) is not lost to a wrong-arity same-name method # (H) on the derived class (`Derived.Foo(int)`). Only when no arity matches # (H) anywhere does the nearest lone same-name method (params-array / - # (H) optional args) stand in. - if resolved := self._find_method_by_arity( - class_qn, method_name, arg_count, set() - ): - return resolved - return self._find_method_by_name(class_qn, method_name, set()) + # (H) optional args) stand in. For a partial class the search spans every + # (H) part (and each part's bases), so a member/base on another part binds. + roots = self.csharp_partial_groups.get(class_qn) or [class_qn] + seen: set[str] = set() + for root in roots: + if resolved := self._find_method_by_arity( + root, method_name, arg_count, seen + ): + return resolved + seen = set() + for root in roots: + 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}" diff --git a/codebase_rag/parsers/definition_processor.py b/codebase_rag/parsers/definition_processor.py index b3f51277a..c46021ecf 100644 --- a/codebase_rag/parsers/definition_processor.py +++ b/codebase_rag/parsers/definition_processor.py @@ -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) {class_qn: {field_name: inner_type}} for Rust guard-container fields # (H) (`state: Mutex` -> {"state": "State"}). The field map above keeps # (H) the WRAPPER; this inner is applied only when a receiver chain reaches a diff --git a/codebase_rag/parsers/factory.py b/codebase_rag/parsers/factory.py index 1df4e0178..641af9008 100644 --- a/codebase_rag/parsers/factory.py +++ b/codebase_rag/parsers/factory.py @@ -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, ) return self._type_inference diff --git a/codebase_rag/parsers/type_inference.py b/codebase_rag/parsers/type_inference.py index 2b3676745..aa96e07ea 100644 --- a/codebase_rag/parsers/type_inference.py +++ b/codebase_rag/parsers/type_inference.py @@ -37,6 +37,7 @@ class TypeInferenceEngine: "class_field_types", "class_field_guard_inner", "method_return_types", + "csharp_partial_groups", "_java_type_inference", "_csharp_type_inference", "_lua_type_inference", @@ -61,6 +62,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, ): self.import_processor = import_processor self.function_registry = function_registry @@ -90,6 +92,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 {} + ) self._java_type_inference: JavaTypeInferenceEngine | None = None self._csharp_type_inference: CSharpTypeInferenceEngine | None = None @@ -148,6 +156,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, ) return self._csharp_type_inference diff --git a/codebase_rag/tests/test_csharp_partial_classes.py b/codebase_rag/tests/test_csharp_partial_classes.py new file mode 100644 index 000000000..941f33b21 --- /dev/null +++ b/codebase_rag/tests/test_csharp_partial_classes.py @@ -0,0 +1,87 @@ +# (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 diff --git a/uv.lock b/uv.lock index e890876c6..8c538ba6d 100644 --- a/uv.lock +++ b/uv.lock @@ -534,7 +534,7 @@ wheels = [ [[package]] name = "code-graph-rag" -version = "0.0.315" +version = "0.0.316" source = { editable = "." } dependencies = [ { name = "click" }, From 5f3ee0bf561f139f54c16c5767c512b973e39f10 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 13 Jul 2026 02:48:20 +0400 Subject: [PATCH 2/3] fix(csharp): resolve fields declared on another partial-class part --- codebase_rag/parsers/csharp/type_inference.py | 6 +++-- .../tests/test_csharp_partial_classes.py | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/codebase_rag/parsers/csharp/type_inference.py b/codebase_rag/parsers/csharp/type_inference.py index 524d0e6a2..9cbcd7b83 100644 --- a/codebase_rag/parsers/csharp/type_inference.py +++ b/codebase_rag/parsers/csharp/type_inference.py @@ -173,10 +173,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: diff --git a/codebase_rag/tests/test_csharp_partial_classes.py b/codebase_rag/tests/test_csharp_partial_classes.py index 941f33b21..b143de576 100644 --- a/codebase_rag/tests/test_csharp_partial_classes.py +++ b/codebase_rag/tests/test_csharp_partial_classes.py @@ -85,3 +85,29 @@ def test_typed_receiver_binds_inherited_via_other_part( # (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 From aee1d76e3aec6ab91417de4e3070c8a93430b050 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 13 Jul 2026 02:58:27 +0400 Subject: [PATCH 3/3] fix(csharp): scope partial-class grouping to the declaring directory to avoid cross-project merges --- codebase_rag/parsers/class_ingest/mixin.py | 19 +++++++++--- .../tests/test_csharp_partial_classes.py | 31 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/codebase_rag/parsers/class_ingest/mixin.py b/codebase_rag/parsers/class_ingest/mixin.py index ef251040b..f6047f5c5 100644 --- a/codebase_rag/parsers/class_ingest/mixin.py +++ b/codebase_rag/parsers/class_ingest/mixin.py @@ -786,11 +786,22 @@ def _process_class_node( 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 (by namespace-qualified name) into one - # (H) shared list so a typed receiver resolves members and bases from - # (H) any part, not just the one it happened to bind to. + # (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: - key = class_qn[len(module_qn) + 1 :] + 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 diff --git a/codebase_rag/tests/test_csharp_partial_classes.py b/codebase_rag/tests/test_csharp_partial_classes.py index b143de576..0a025daa1 100644 --- a/codebase_rag/tests/test_csharp_partial_classes.py +++ b/codebase_rag/tests/test_csharp_partial_classes.py @@ -111,3 +111,34 @@ def test_field_typed_receiver_sees_field_on_other_part( # (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