From 931ca2269a9d609e713bf97e643f9c309c1d4d16 Mon Sep 17 00:00:00 2001 From: JensD-git <299119159+JensD-git@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:12:57 +0200 Subject: [PATCH] fix(csharp): keep names that differ only by `_` or case apart, in ids and in member-call keys (#4415) make_id strips leading/trailing underscores and casefolds, while C# is case-sensitive and spells private members `_Name`. In one file, `Action()` / `_Action()`, `Size` / `_Size`, `Load()` / `load()` and the types `Pair` / `_Pair` minted one id; add_node kept the first declaration and dropped the second, and the dropped method's calls were credited to the survivor. Apply the rule of #3302 to C#: a pre-scan groups the declared names of a file (types; per type its methods, properties and enum members) that would share an id, and _underscore_salted_nid (renamed from the Python helper, unchanged) keeps the plain id on the one name without a leading underscore and salts the rest. In-file type references and base lists go through the same rule. A name without a colliding sibling keeps its id. The C# member-call resolver keyed names by lowercased alphanumerics, so `this.Put()` bound to `_Put()` on a nearer base, and `Token` / `TOKEN` were one type key. The key now keeps `_` and case. Fixes #4415. Co-Authored-By: Claude Opus 5.5 (1M context) (cherry picked from commit 914dda60cf72bad3a5d4fbfc5a4c4125f10bbdb0) --- graphify/extract.py | 5 +- graphify/extractors/engine.py | 165 ++++++++++-- tests/test_csharp_enum_members.py | 9 +- tests/test_csharp_member_nodes.py | 9 +- tests/test_csharp_underscore_collisions.py | 281 +++++++++++++++++++++ 5 files changed, 441 insertions(+), 28 deletions(-) create mode 100644 tests/test_csharp_underscore_collisions.py diff --git a/graphify/extract.py b/graphify/extract.py index 3dd97f731..6f08bb7ed 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5367,7 +5367,10 @@ def _resolve_csharp_member_calls( Must run after id-disambiguation so node ids and caller_nids are final. """ def _key(label: str) -> str: - return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + # Case and `_` are part of a C# name: `Put` and `_Put` are two + # methods, so `this.Put()` must not bind to `_Put` (#4415). Only the + # label decoration (`.Put()`) goes. + return re.sub(r"\W+", "", str(label)) contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index aa736e993..75eb5a378 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -168,6 +168,107 @@ def _csharp_pre_scan_interfaces(root_node, source: bytes) -> set[str]: stack.extend(n.children) return out +_CSHARP_TYPE_DECLARATIONS = frozenset({ + "class_declaration", + "interface_declaration", + "enum_declaration", + "struct_declaration", + "record_declaration", +}) + + +def _csharp_type_name_for_id(node, class_name: str) -> str: + """A C# type's name as its node id spells it: the arity folds in (#4249). + + `Effect` and `Effect` are different types, so the generic one is named + the way C# metadata names it (`` Effect`1 ``); arity 0 is the bare name. + """ + arity = sum( + 1 for c in node.children if c.type == "type_parameter_list" + for _p in c.children if _p.type in ("type_parameter", "identifier") + ) + return f"{class_name}`{arity}" if arity else class_name + + +def _csharp_pre_scan_name_collisions( + root_node, source: bytes, stem: str +) -> tuple[dict[str, set[str]], dict[str, set[str]]]: + """Pre-scan a C# file for declared names that collapse to one node id. + + C# is case-sensitive and `_Name` is the usual spelling of a private member, + but ``ids.py:make_id`` strips leading/trailing underscores and casefolds, so + `Action()` / `_Action()`, `Size` / `_Size`, `Load()` / `load()` and the types + `Pair` / `_Pair` each mint ONE id. ``add_node`` keeps the first declaration + and drops the second; the dropped method's calls are credited to the + survivor, and a type's members hang off the other type (#4415). This is + the Python #3302 collision in C#; the outcome is the same rule + (``_underscore_salted_nid``). + + Returns ``(type_groups, member_groups)``, each ``{plain_nid: {raw names}}`` + for the ids two or more distinct names would share. Types are keyed the way + the walk mints them (``_make_id(stem, namespace, name_for_id)``), members + (methods, properties, enum members) by ``_make_id(type_nid, name)`` where + ``type_nid`` is the type's FINAL id, already salted when the type itself + collides. Same-file partial declarations of one type share an id, so their + members are grouped together. Overloads are one name, never a collision. + """ + type_names: dict[str, set[str]] = {} + member_names: dict[str, set[str]] = {} + type_decls: list[tuple[object, str, str]] = [] + + def _visit(n, ns: list[str]) -> None: + # Types are declared only at file level, in namespace and type bodies + # and in #if wrappers around them, so only those are entered: the + # recursion depth is the nesting depth of declarations, never of + # expressions. + scoped = list(ns) + for c in n.children: + if c.type == "namespace_declaration": + ns_name = _csharp_namespace_name(c, source) + body = c.child_by_field_name("body") + if body is not None: + _visit(body, scoped + [ns_name] if ns_name else scoped) + elif c.type == "file_scoped_namespace_declaration": + # Applies to the rest of the file, as in the walk. + ns_name = _csharp_namespace_name(c, source) + if ns_name: + scoped = scoped + [ns_name] + elif c.type in _CSHARP_TYPE_DECLARATIONS: + name_node = c.child_by_field_name("name") + name = _read_text(name_node, source) if name_node is not None else "" + if name: + plain = _make_id(stem, ".".join(scoped), _csharp_type_name_for_id(c, name)) + type_names.setdefault(plain, set()).add(name) + type_decls.append((c, plain, name)) + # Nested types take the namespace, not the enclosing type (ids + # omit the outer type), so they join the same groups. + _visit(c, scoped) + elif c.type == "declaration_list" or c.type.startswith("preproc_"): + _visit(c, scoped) + + _visit(root_node, []) + type_groups = {nid: names for nid, names in type_names.items() if len(names) >= 2} + + def _members(n, type_nid: str) -> None: + for c in n.children: + if c.type in ("method_declaration", "property_declaration", + "enum_member_declaration"): + name_node = c.child_by_field_name("name") + name = _read_text(name_node, source) if name_node is not None else "" + if name: + member_names.setdefault(_make_id(type_nid, name), set()).add(name) + elif c.type in ("declaration_list", "enum_member_declaration_list") or ( + c.type.startswith("preproc_") + ): + # The body, and #if/#region wrappers the walk sees through (#2631). + _members(c, type_nid) + + for decl, plain, name in type_decls: + _members(decl, _underscore_salted_nid(plain, name, type_groups)) + member_groups = {nid: names for nid, names in member_names.items() if len(names) >= 2} + return type_groups, member_groups + + def _csharp_classify_base(name: str, interface_names: set[str]) -> str: """`implements` if the base name is an interface (declared or by I-prefix convention), else `inherits`.""" if name in interface_names: @@ -1189,10 +1290,11 @@ def _python_pre_scan_underscore_collisions(root_node, source: bytes, stem: str) return {nid: names for nid, names in groups.items() if len(names) >= 2} -def _python_underscore_salted_nid(plain_nid: str, name: str, groups: dict[str, set[str]]) -> str: - """Resolve a Python function/method's real node id against the collision map. +def _underscore_salted_nid(plain_nid: str, name: str, groups: dict[str, set[str]]) -> str: + """Resolve a declaration's real node id against the collision map. - A name with no leading underscore at all is "public" per PEP 8 convention. + Shared by the Python and C# pre-scans (#3302, #4415). A name with no + leading underscore at all is "public" per PEP 8 convention. When a collision group has exactly one public member, that member keeps the plain id — cross-file/documentation references overwhelmingly target the public name, and keeping it stable means an incremental rebuild that adds or @@ -4102,7 +4204,8 @@ def _csharp_namespace_name(node, source: bytes) -> str: def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, - walk_fn, namespace_stack: list[str], scope_stack: list[str]) -> bool: + walk_fn, namespace_stack: list[str], scope_stack: list[str], + member_groups: dict[str, set[str]] | None = None) -> bool: """Handle C# namespaces, enum members, and transparent class-member wrappers.""" if node.type == "enum_member_declaration" and parent_class_nid: # `enum_declaration` is in C#'s class_types, so the enum type is a node @@ -4118,11 +4221,13 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: if not member_name: return True line = node.start_point[0] + 1 - member_nid = _make_id(parent_class_nid, member_name) # C# is case-sensitive, so `enum E { Value, value }` is legal, but the id - # recipe casefolds — both members normalize to one id. Emitting the - # second would hang a duplicate edge on the first member's node, so the - # first declaration keeps it (same guard as the property nodes in #3006). + # recipe casefolds — both members normalize to one id. The pre-scan + # salts such a pair apart (#4415); the guard stays for anything else + # that lands on one id. + member_nid = _underscore_salted_nid( + _make_id(parent_class_nid, member_name), member_name, member_groups or {} + ) if member_nid not in seen_ids: add_node_fn(member_nid, member_name, line) add_edge_fn(parent_class_nid, member_nid, "case_of", line) @@ -5388,8 +5493,13 @@ def _extract_generic( csharp_method_returns: dict[str, dict[str, list]] = {} csharp_interface_names: set[str] = set() + csharp_type_groups: dict[str, set[str]] = {} + csharp_member_groups: dict[str, set[str]] = {} if config.ts_module == "tree_sitter_c_sharp": csharp_interface_names = _csharp_pre_scan_interfaces(root, source) + csharp_type_groups, csharp_member_groups = _csharp_pre_scan_name_collisions( + root, source, stem + ) swift_protocol_names: set[str] = set() swift_class_names: set[str] = set() @@ -5444,6 +5554,8 @@ def _extract_generic( def ensure_named_node(name: str, line: int) -> str: nid = _make_id(stem, ".".join(namespace_stack), name) + if config.ts_module == "tree_sitter_c_sharp": + nid = _underscore_salted_nid(nid, name, csharp_type_groups) if nid in seen_ids: return nid nid = _make_id(name) @@ -5590,13 +5702,11 @@ def _extract_generic( # matching how C# metadata itself names a generic type # (`` Effect`1 ``), so each arity gets its own node; the # label is untouched; arity 0 keeps today's id exactly. - _csharp_arity = sum( - 1 for c in node.children if c.type == "type_parameter_list" - for _p in c.children if _p.type in ("type_parameter", "identifier") - ) - if _csharp_arity: - class_name_for_id = f"{class_name}`{_csharp_arity}" + class_name_for_id = _csharp_type_name_for_id(node, class_name) class_nid = _make_id(stem, ".".join(namespace_stack), class_name_for_id) + if config.ts_module == "tree_sitter_c_sharp": + # `Pair` / `_Pair` in one file share an id otherwise (#4415). + class_nid = _underscore_salted_nid(class_nid, class_name, csharp_type_groups) if config.ts_module == "tree_sitter_python" and parent_class_nid: class_nid = _make_id(parent_class_nid, class_name) line = node.start_point[0] + 1 @@ -6033,13 +6143,19 @@ def _extract_generic( ) base_nid = None if base_arity: - _arity_nid = _make_id( - stem, ".".join(namespace_stack), f"{base}`{base_arity}" + _arity_nid = _underscore_salted_nid( + _make_id( + stem, ".".join(namespace_stack), f"{base}`{base_arity}" + ), + base, csharp_type_groups, ) if _arity_nid in seen_ids: base_nid = _arity_nid if base_nid is None: - base_nid = _make_id(stem, ".".join(namespace_stack), base) + base_nid = _underscore_salted_nid( + _make_id(stem, ".".join(namespace_stack), base), + base, csharp_type_groups, + ) if base_nid not in seen_ids: base_nid = _make_id(base) if base_nid not in seen_ids: @@ -6577,7 +6693,10 @@ def _extract_generic( property_name = _read_text(prop_node_name, source) if property_name: property_line = node.start_point[0] + 1 - property_nid = _make_id(parent_class_nid, property_name) + property_nid = _underscore_salted_nid( + _make_id(parent_class_nid, property_name), + property_name, csharp_member_groups, + ) if property_nid not in seen_ids: add_node(property_nid, property_name, property_line) add_edge(parent_class_nid, property_nid, "defines", @@ -7036,9 +7155,13 @@ def _extract_generic( if parent_class_nid: func_nid = _make_id(parent_class_nid, sanitized_name) if config.ts_module == "tree_sitter_python": - func_nid = _python_underscore_salted_nid( + func_nid = _underscore_salted_nid( func_nid, sanitized_name, python_underscore_groups ) + elif config.ts_module == "tree_sitter_c_sharp": + func_nid = _underscore_salted_nid( + func_nid, sanitized_name, csharp_member_groups + ) ruby_method_metadata = None if ruby_method_kind is not None: kinds = ruby_method_kinds.setdefault(func_nid, set()) @@ -7073,7 +7196,7 @@ def _extract_generic( else: func_nid = _make_id(stem, sanitized_name) if config.ts_module == "tree_sitter_python": - func_nid = _python_underscore_salted_nid( + func_nid = _underscore_salted_nid( func_nid, sanitized_name, python_underscore_groups ) add_node(func_nid, f"{func_name}()", line) @@ -7643,7 +7766,7 @@ def _extract_generic( if _csharp_extra_walk(node, source, file_nid, stem, str_path, nodes, edges, seen_ids, function_bodies, parent_class_nid, add_node, add_edge, walk, - namespace_stack, scope_stack): + namespace_stack, scope_stack, csharp_member_groups): return if config.ts_module == "tree_sitter_swift": diff --git a/tests/test_csharp_enum_members.py b/tests/test_csharp_enum_members.py index 839f1169e..fafa65548 100644 --- a/tests/test_csharp_enum_members.py +++ b/tests/test_csharp_enum_members.py @@ -76,15 +76,18 @@ def test_an_explicit_value_does_not_change_the_member(tmp_path): def test_case_only_sibling_members_do_not_duplicate_an_edge(tmp_path): - # Legal C#, and one id after normalization. The first declaration keeps the - # node rather than a second edge hanging off it. + # Legal C#, and one id after normalization. Each member gets its own node + # (#4415) with its own edge, rather than a second edge hanging off the + # first member's node. case_of, r = _extract(tmp_path, {"S.cs": ( "public enum Odd {\n" " Value,\n" " value\n" "}\n" )}) - assert len(case_of) == 1 + odd = _find(r, "Odd") + assert case_of == {(odd, _find(r, "Value")), (odd, _find(r, "value"))} + assert _find(r, "Value") != _find(r, "value") def test_a_namespaced_enum_still_emits_members(tmp_path): diff --git a/tests/test_csharp_member_nodes.py b/tests/test_csharp_member_nodes.py index 6bb96a482..ffa3018e9 100644 --- a/tests/test_csharp_member_nodes.py +++ b/tests/test_csharp_member_nodes.py @@ -146,12 +146,15 @@ def test_methods_are_still_methods(tmp_path): def test_case_only_sibling_properties_do_not_duplicate_an_edge(tmp_path): - # Legal C#, and one id after normalization. One node, one edge, rather than a - # second edge hung on the first property's node. + # Legal C#, and one id after normalization. Each property gets its own node + # (#4415) and one edge, rather than a second edge hung on the first + # property's node. defines, r = _extract(tmp_path, {"S.cs": ( "public class Odd {\n" " public int Count { get; set; }\n" " public int count { get; set; }\n" "}\n" )}) - assert len(defines) == 1 + odd = _find(r, "Odd") + assert defines == {(odd, _find(r, "Count")), (odd, _find(r, "count"))} + assert _find(r, "Count") != _find(r, "count") diff --git a/tests/test_csharp_underscore_collisions.py b/tests/test_csharp_underscore_collisions.py new file mode 100644 index 000000000..de23f0596 --- /dev/null +++ b/tests/test_csharp_underscore_collisions.py @@ -0,0 +1,281 @@ +"""C# names that differ only by `_` or case keep their own nodes (#4415). + +``ids.py:make_id`` strips leading/trailing underscores and casefolds, so in C# +`Action()` / `_Action()`, `Size` / `_Size`, `Load()` / `load()` and the types +`Pair` / `_Pair` each minted ONE id. ``add_node`` kept the first declaration +and dropped the second: the dropped method's calls were credited to the +survivor, and a dropped type's members hung off the other type. This is the +Python collision of #3302; C# gets the same rule: a group with exactly one +name without a leading underscore keeps the plain id on it, every other name +is salted. + +Separately, the C# member-call resolver keyed methods by a lowercased +alphanumeric form of the name, so `this.Put()` bound to `_Put()` on a nearer +base class instead of `Put()` further up. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + + +def _ids(r, label): + return [n["id"] for n in r["nodes"] if n["label"] == label and n.get("source_file")] + + +def _one(r, label): + ids = _ids(r, label) + assert len(ids) == 1, (label, ids) + return ids[0] + + +def _edges(r, relation): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def test_underscore_method_pair_keeps_both_methods(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Widget {\n" + " public bool Action() { return true; }\n" + " protected bool _Action() { return Action(); }\n" + "}\n" + )}) + widget, action, private = _one(r, "Widget"), _one(r, ".Action()"), _one(r, "._Action()") + assert action != private + assert {(widget, action), (widget, private)} <= _edges(r, "method") + # The private method's call is its own, not a self-loop on the public one. + assert (private, action) in _edges(r, "calls") + assert (action, action) not in _edges(r, "calls") + + +def test_public_name_keeps_the_id_it_has_without_the_sibling(tmp_path): + solo = _extract(tmp_path / "a", {"S.cs": ( + "public class Widget { public bool Action() { return true; } }\n" + )}) + pair = _extract(tmp_path / "b", {"S.cs": ( + "public class Widget {\n" + " protected bool _Action() { return true; }\n" + " public bool Action() { return true; }\n" + "}\n" + )}) + # Adding or removing the private sibling re-points nothing. + assert _one(pair, ".Action()") == _one(solo, ".Action()") + + +def test_ids_do_not_depend_on_declaration_order(tmp_path): + first = _extract(tmp_path / "a", {"S.cs": ( + "public class Widget {\n" + " public bool Action() { return true; }\n" + " protected bool _Action() { return true; }\n" + "}\n" + )}) + second = _extract(tmp_path / "b", {"S.cs": ( + "public class Widget {\n" + " protected bool _Action() { return true; }\n" + " public bool Action() { return true; }\n" + "}\n" + )}) + for label in (".Action()", "._Action()"): + assert _one(first, label) == _one(second, label) + + +def test_underscore_property_pair_keeps_both_properties(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Host {\n" + " public int Size { get; set; }\n" + " private int _Size { get; set; }\n" + "}\n" + )}) + host = _one(r, "Host") + assert {(host, _one(r, "Size")), (host, _one(r, "_Size"))} <= _edges(r, "defines") + + +def test_case_only_method_pair_keeps_both_methods(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Host {\n" + " public void Load() { }\n" + " public void load() { }\n" + "}\n" + )}) + assert _one(r, ".Load()") != _one(r, ".load()") + + +def test_overloads_stay_one_node(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Host {\n" + " public void Save() { }\n" + " public void Save(int x) { }\n" + "}\n" + )}) + _one(r, ".Save()") + + +def test_member_behind_preprocessor_directive_is_counted(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Host {\n" + " public void Run() { }\n" + "#if DEBUG\n" + " private void _Run() { }\n" + "#endif\n" + "}\n" + )}) + assert _one(r, ".Run()") != _one(r, "._Run()") + + +def test_same_file_partial_halves_share_the_group(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public partial class Host { public void Foo() { } }\n" + "public partial class Host { private void _Foo() { } }\n" + )}) + assert _one(r, ".Foo()") != _one(r, "._Foo()") + + +def test_underscore_type_pair_keeps_both_types_and_their_members(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "namespace Demo {\n" + " public class Pair { public void Go() { } }\n" + " public class _Pair { public void Stop() { } }\n" + "}\n" + )}) + pair, private = _one(r, "Pair"), _one(r, "_Pair") + assert pair != private + method = _edges(r, "method") + assert (pair, _one(r, ".Go()")) in method + assert (private, _one(r, ".Stop()")) in method + + +def test_type_behind_file_level_preprocessor_directive_is_counted(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "namespace Demo {\n" + "#if NET8\n" + " public class _Pair { public void Stop() { } }\n" + "#endif\n" + " public class Pair { public void Go() { } }\n" + "}\n" + )}) + assert _one(r, "Pair") != _one(r, "_Pair") + + +def test_underscore_generic_type_pair_keeps_both_types(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Effect { public void Fire() { } }\n" + "public class _Effect { public void Fire() { } }\n" + )}) + assert _one(r, "Effect") != _one(r, "_Effect") + assert len(_ids(r, ".Fire()")) == 2 + + +def test_underscore_nested_type_pair_keeps_both_types(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Host {\n" + " public class Item { }\n" + " private class _Item { }\n" + "}\n" + )}) + assert _one(r, "Item") != _one(r, "_Item") + + +def test_bases_in_another_file_reach_the_right_type(tmp_path): + r = _extract(tmp_path, { + "A.cs": ( + "namespace Demo {\n" + " public class _Pair { public void Stop() { } }\n" + " public class Pair { public void Go() { } }\n" + "}\n" + ), + "B.cs": ( + "namespace Demo {\n" + " public class Z : _Pair { public void M() { this.Stop(); } }\n" + " public class Y : Pair { public void N() { this.Go(); } }\n" + "}\n" + ), + }) + inherits = _edges(r, "inherits") + assert (_one(r, "Z"), _one(r, "_Pair")) in inherits + assert (_one(r, "Y"), _one(r, "Pair")) in inherits + calls = _edges(r, "calls") + assert (_one(r, ".M()"), _one(r, ".Stop()")) in calls + assert (_one(r, ".N()"), _one(r, ".Go()")) in calls + + +def test_base_in_the_same_file_reaches_the_right_type(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class _Pair { }\n" + "public class Pair { }\n" + "public class Z : _Pair { }\n" + "public class Y : Pair { }\n" + )}) + inherits = _edges(r, "inherits") + assert (_one(r, "Z"), _one(r, "_Pair")) in inherits + assert (_one(r, "Y"), _one(r, "Pair")) in inherits + + +def test_member_call_does_not_bind_to_an_underscore_sibling_on_a_nearer_base(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class BaseStore {\n" + " public virtual object Get(string n) { return null; }\n" + " public virtual bool Put(string n, object v) { return true; }\n" + "}\n" + "public class CachedStore : BaseStore {\n" + " protected object _Get(string n) { return null; }\n" + " protected bool _Put(string n, object v) { return true; }\n" + "}\n" + "public class Widget : CachedStore {\n" + " public void Consume(Widget other) {\n" + " this.Put(\"A\", 1);\n" + " other.Get(\"B\");\n" + " }\n" + "}\n" + )}) + calls = _edges(r, "calls") + consume = _one(r, ".Consume()") + assert (consume, _one(r, ".Put()")) in calls + assert (consume, _one(r, ".Get()")) in calls + assert (consume, _one(r, "._Put()")) not in calls + assert (consume, _one(r, "._Get()")) not in calls + + +def test_member_call_across_partial_halves_binds_by_exact_name(tmp_path): + r = _extract(tmp_path, { + "H1.cs": "public partial class Host { public void Foo() { } }\n", + "H2.cs": ( + "public partial class Host {\n" + " private void _Foo() { this.Foo(); }\n" + " public void Bar() { this._Foo(); }\n" + "}\n" + ), + }) + calls = _edges(r, "calls") + assert (_one(r, "._Foo()"), _one(r, ".Foo()")) in calls + assert (_one(r, ".Bar()"), _one(r, "._Foo()")) in calls + + +def test_base_call_reaches_the_underscore_method(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class Widget {\n" + " public virtual bool Action() { return true; }\n" + " protected virtual bool _Action() { return true; }\n" + "}\n" + "public class SubWidget : Widget {\n" + " protected override bool _Action() { return base._Action(); }\n" + "}\n" + )}) + sub, base = sorted(_ids(r, "._Action()"), key=lambda nid: "subwidget" not in nid) + assert (sub, base) in _edges(r, "calls") + assert (sub, _one(r, ".Action()")) not in _edges(r, "calls")