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 d81b90199..d46fe4fec 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]] 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] @@ -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 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 837865d4e..1cecdb5d7 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", "csharp_extension_methods", ) @@ -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 @@ -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 {} ) @@ -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: @@ -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 @@ -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 @@ -446,6 +451,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}.")] @@ -453,12 +463,46 @@ def _type_name_to_qn(self, type_name: str, module_qn: str) -> str | None: 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}" diff --git a/codebase_rag/parsers/definition_processor.py b/codebase_rag/parsers/definition_processor.py index 62fe4c56c..e2756d688 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) 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 diff --git a/codebase_rag/parsers/factory.py b/codebase_rag/parsers/factory.py index 4870fd2d2..008895b40 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, csharp_extension_methods=self.definition_processor.csharp_extension_methods, ) return self._type_inference diff --git a/codebase_rag/parsers/type_inference.py b/codebase_rag/parsers/type_inference.py index 8aa0839dc..8c258866f 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", "csharp_extension_methods", "_java_type_inference", "_csharp_type_inference", @@ -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 @@ -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. @@ -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 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..0a025daa1 --- /dev/null +++ b/codebase_rag/tests/test_csharp_partial_classes.py @@ -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