From 8825ce8531663e55c39a06b7cc8a834fef6b63d0 Mon Sep 17 00:00:00 2001 From: vitali87 Date: Mon, 13 Jul 2026 01:27:43 +0400 Subject: [PATCH] feat(csharp): register operators, conversion operators, and finalizers as member nodes --- codebase_rag/constants/ast_csharp.py | 10 ++++++ codebase_rag/dead_code.py | 15 ++++++++ codebase_rag/language_spec.py | 30 +++++----------- codebase_rag/parsers/csharp/utils.py | 35 ++++++++++++++---- codebase_rag/parsers/utils.py | 9 +++++ codebase_rag/tests/test_csharp_dead_code.py | 16 +++++++++ codebase_rag/tests/test_csharp_definitions.py | 36 +++++++++++++++++++ uv.lock | 2 +- 8 files changed, 124 insertions(+), 29 deletions(-) diff --git a/codebase_rag/constants/ast_csharp.py b/codebase_rag/constants/ast_csharp.py index 87a812b41..349f5dc14 100644 --- a/codebase_rag/constants/ast_csharp.py +++ b/codebase_rag/constants/ast_csharp.py @@ -34,6 +34,16 @@ TS_CSHARP_CONVERSION_OPERATOR_DECLARATION = "conversion_operator_declaration" TS_CSHARP_PROPERTY_DECLARATION = "property_declaration" +# (H) Members whose registered leaf name is synthesized (no usable `name` field, +# (H) or one that collides), routed through csharp.utils.synthesize_method_name. +CSHARP_SYNTHESIZED_NAME_TYPES = frozenset( + { + TS_CSHARP_OPERATOR_DECLARATION, + TS_CSHARP_CONVERSION_OPERATOR_DECLARATION, + TS_CSHARP_DESTRUCTOR_DECLARATION, + } +) + # (H) Base spec: `class C : Base, IShape` / `interface I : IOther` / # (H) `enum E : byte`. A single base_list lumps the base class and interfaces # (H) together (unlike Java's separate superclass/super_interfaces clauses), so diff --git a/codebase_rag/dead_code.py b/codebase_rag/dead_code.py index 6b2b950df..1d5254b43 100644 --- a/codebase_rag/dead_code.py +++ b/codebase_rag/dead_code.py @@ -125,6 +125,17 @@ def _is_csharp_dispose_root(name: str, is_method: bool, path: str) -> bool: ) +def _is_csharp_operator_or_finalizer_root(name: str, path: str) -> bool: + # (H) An operator overload is invoked by operator SYNTAX (`a + b`) and a + # (H) finalizer (`~Foo`) by the GC -- never a named call the graph sees -- so + # (H) both are reachability roots on a .cs file (cf. the C++ operator root). + # (H) The synthesized leaf carries the `operator_`/`~` prefix. + return path.endswith(cs.EXT_CS) and ( + name.startswith(cs.TS_CSHARP_OPERATOR_NAME_PREFIX) + or name.startswith(cs.TS_CSHARP_DESTRUCTOR_NAME_PREFIX) + ) + + def _matches_test_path(path: str, patterns: tuple[str, ...]) -> bool: # (H) Match test-path patterns against a leading-slash-normalized path so a dir # (H) pattern like `/tests/` also matches a ROOT `tests/` dir (Rust integration @@ -283,6 +294,10 @@ def dead_code_from_graph( str(props.get(cs.KEY_PATH, "")), ): roots.add(qn) + elif _is_csharp_operator_or_finalizer_root( + leaf, str(props.get(cs.KEY_PATH, "")) + ): + roots.add(qn) elif any(qn.endswith(entry) for entry in config.entry_points): roots.add(qn) elif config.include_tests and _matches_test_path( diff --git a/codebase_rag/language_spec.py b/codebase_rag/language_spec.py index 8a9a886f3..c9109f365 100644 --- a/codebase_rag/language_spec.py +++ b/codebase_rag/language_spec.py @@ -173,28 +173,14 @@ def _csharp_get_name(node: Node) -> str | None: if name_node and name_node.text: return name_node.text.decode(cs.ENCODING_UTF8) return None - # (H) Operators expose no `name` field; synthesize a stable qn segment from - # (H) the operator symbol (or conversion target type) so the node still gets - # (H) a qn instead of being dropped. - if node.type == cs.TS_CSHARP_OPERATOR_DECLARATION: - op_node = node.child_by_field_name(cs.TS_CSHARP_FIELD_OPERATOR) - if op_node and op_node.text: - return cs.TS_CSHARP_OPERATOR_NAME_PREFIX + op_node.text.decode( - cs.ENCODING_UTF8 - ) - return None - if node.type == cs.TS_CSHARP_CONVERSION_OPERATOR_DECLARATION: - type_node = node.child_by_field_name(cs.TS_CSHARP_FIELD_TYPE) - if type_node and type_node.text: - return cs.TS_CSHARP_OPERATOR_NAME_PREFIX + type_node.text.decode( - cs.ENCODING_UTF8 - ) - return None - # (H) A ctor and dtor both take the type's name; prefix `~` on the dtor so - # (H) `Foo()` and `~Foo()` don't collapse onto one qn. - if node.type == cs.TS_CSHARP_DESTRUCTOR_DECLARATION: - base = _generic_get_name(node) - return cs.TS_CSHARP_DESTRUCTOR_NAME_PREFIX + base if base else None + # (H) Operators expose no `name` field and a destructor's `name` collides + # (H) with the constructor; delegate to the shared synthesizer so the FQN + # (H) scope walk and the registered node qn agree. Local import avoids a + # (H) module-load cycle (csharp.utils -> parsers.utils). + if node.type in cs.CSHARP_SYNTHESIZED_NAME_TYPES: + from .parsers.csharp import utils as csharp_utils + + return csharp_utils.synthesize_method_name(node) return _generic_get_name(node) diff --git a/codebase_rag/parsers/csharp/utils.py b/codebase_rag/parsers/csharp/utils.py index 54fc75da2..04c26ebe0 100644 --- a/codebase_rag/parsers/csharp/utils.py +++ b/codebase_rag/parsers/csharp/utils.py @@ -74,11 +74,34 @@ def build_field_type_map(class_node: Node) -> dict[str, str]: return fields -def extract_method_signature(method_node: Node) -> tuple[str | None, list[str]]: - # (H) (method name, parameter type names). The name is the `name` field, the - # (H) same leaf ingest_method registers, so the signatured qn stays consistent. - # (H) Operators/destructors have no `name` field -> (None, ...), and the - # (H) caller leaves them with their bare synthesized name. +def synthesize_method_name(method_node: Node) -> str | None: + # (H) The registered leaf name for a C# member. Operators expose no `name` + # (H) field, so synthesize `operator_` (binary/unary operators) or + # (H) `operator_` (conversion operators). A destructor HAS a + # (H) `name` field equal to the type name, which would collide with the + # (H) constructor, so prefix `~`. Everything else uses the plain `name` leaf. + # (H) Kept identical to _csharp_get_name so the FQN scope walk and the node + # (H) qn agree. + if method_node.type == cs.TS_CSHARP_OPERATOR_DECLARATION: + op_node = method_node.child_by_field_name(cs.TS_CSHARP_FIELD_OPERATOR) + symbol = safe_decode_text(op_node) if op_node and op_node.text else None + return cs.TS_CSHARP_OPERATOR_NAME_PREFIX + symbol if symbol else None + if method_node.type == cs.TS_CSHARP_CONVERSION_OPERATOR_DECLARATION: + type_node = method_node.child_by_field_name(cs.TS_CSHARP_FIELD_TYPE) + target = safe_decode_text(type_node) if type_node and type_node.text else None + return cs.TS_CSHARP_OPERATOR_NAME_PREFIX + target if target else None name_node = method_node.child_by_field_name(cs.FIELD_NAME) name = safe_decode_text(name_node) if name_node and name_node.text else None - return name, extract_parameter_type_names(method_node) + if name and method_node.type == cs.TS_CSHARP_DESTRUCTOR_DECLARATION: + return cs.TS_CSHARP_DESTRUCTOR_NAME_PREFIX + name + return name + + +def extract_method_signature(method_node: Node) -> tuple[str | None, list[str]]: + # (H) (method name, parameter type names). The name matches the leaf + # (H) ingest_method registers (synthesized for operators/destructors), so the + # (H) signatured qn stays consistent. Overloaded operators (`operator +` on + # (H) two operand types) still get distinct qns via the parameter signature. + return synthesize_method_name(method_node), extract_parameter_type_names( + method_node + ) diff --git a/codebase_rag/parsers/utils.py b/codebase_rag/parsers/utils.py index c26c2cc22..f2958d38b 100644 --- a/codebase_rag/parsers/utils.py +++ b/codebase_rag/parsers/utils.py @@ -705,6 +705,15 @@ def ingest_method( method_name = cpp_utils.extract_function_name(method_node) if not method_name: return None + elif language == cs.SupportedLanguage.CSHARP: + # (H) Operators expose no `name` field (they would be dropped) and a + # (H) destructor's `name` field collides with the constructor; synthesize + # (H) the leaf so both register with the same name the FQN walk uses. + from .csharp import utils as csharp_utils + + method_name = csharp_utils.synthesize_method_name(method_node) + if not method_name: + return None elif (method_name_node := method_node.child_by_field_name(cs.FIELD_NAME)) is None: # (H) A JS/TS class-field arrow / fn-expr (`helper = () => ...`) has no name # (H) field on the function node; take the binding name from the enclosing diff --git a/codebase_rag/tests/test_csharp_dead_code.py b/codebase_rag/tests/test_csharp_dead_code.py index 636f0bded..06bbdf52d 100644 --- a/codebase_rag/tests/test_csharp_dead_code.py +++ b/codebase_rag/tests/test_csharp_dead_code.py @@ -64,3 +64,19 @@ def test_attribute_and_dispose_methods_are_roots() -> None: assert "proj.Svc.Get" not in dead assert "proj.Svc.Bracketed" not in dead assert "proj.Svc.Dispose" not in dead + + +def test_operator_and_finalizer_are_roots() -> None: + # (H) An operator overload is invoked by operator syntax (`a + b`) and a + # (H) finalizer (`~Svc`) by the GC, never a named call the graph sees, so + # (H) neither may be reported dead despite having no incoming CALLS. + nodes = [ + _method("proj.Svc.operator_+(int, int)", "operator_+"), + _method("proj.Svc.~Svc", "~Svc"), + _method("proj.Svc.Helper", "Helper"), + ] + dead = _dead(FakeIngestor(nodes, [])) + + assert "proj.Svc.Helper" in dead + assert "proj.Svc.operator_+(int, int)" not in dead + assert "proj.Svc.~Svc" not in dead diff --git a/codebase_rag/tests/test_csharp_definitions.py b/codebase_rag/tests/test_csharp_definitions.py index b121f4420..762fc50a5 100644 --- a/codebase_rag/tests/test_csharp_definitions.py +++ b/codebase_rag/tests/test_csharp_definitions.py @@ -98,6 +98,42 @@ def test_members_generics_and_operators( assert _endswith_any(members, "N.Box.Size") # (H) Constructor is named after the type; destructor is distinct from it. assert _endswith_any(members, "N.Box.Box(T)") + # (H) Operators and destructors must register as members too. The operator + # (H) has no `name` field (synthesize `operator_` + signature so + # (H) overloaded operators stay distinct); the destructor's identifier + # (H) collides with the ctor unless prefixed with `~`. + assert _endswith_any(members, "N.Box.operator_+(Box, Box)") + assert _endswith_any(members, "N.Box.~Box") + + +def test_operator_overloads_and_conversions_are_distinct( + csharp_project: Path, mock_ingestor: MagicMock +) -> None: + (csharp_project / "Ops.cs").write_text( + """ +namespace N; +public struct Vec { + public int X; + public static Vec operator +(Vec a, Vec b) => a; + public static Vec operator +(Vec a, int b) => a; + public static explicit operator int(Vec v) => v.X; + public static implicit operator string(Vec v) => ""; +} +""", + encoding="utf-8", + ) + run_updater(csharp_project, mock_ingestor, skip_if_missing=SKIP) + + members = get_node_names(mock_ingestor, NodeType.METHOD) | get_node_names( + mock_ingestor, NodeType.FUNCTION + ) + # (H) Two `operator +` overloads differ only by parameter type, so the + # (H) signature must keep them as two nodes (not one @line-suffixed collision). + assert _endswith_any(members, "N.Vec.operator_+(Vec, Vec)") + assert _endswith_any(members, "N.Vec.operator_+(Vec, int)") + # (H) Conversion operators are named by their target type. + assert _endswith_any(members, "N.Vec.operator_int(Vec)") + assert _endswith_any(members, "N.Vec.operator_string(Vec)") def test_nested_types(csharp_project: Path, mock_ingestor: MagicMock) -> None: 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" },