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) <noreply@anthropic.com>
This commit is contained in:
JensD-gitandClaude Opus 5.5 committed 2026-10-13 15:12:57 +02:00
1 parent e8eb76e797
commit 914dda60cf
5 files changed
+441 -28

No files matched your search

+4 -1
View File
@@ -5365,7 +5365,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"}
+144 -21
View File
@@ -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<T>` 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
@@ -3999,7 +4101,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
@@ -4015,11 +4118,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)
@@ -5285,8 +5390,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()
@@ -5341,6 +5451,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)
@@ -5487,13 +5599,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
@@ -5930,13 +6040,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:
@@ -6470,7 +6586,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",
@@ -6925,9 +7044,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())
@@ -6962,7 +7085,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)
@@ -7532,7 +7655,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":
+6 -3
View File
@@ -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):
+6 -3
View File
@@ -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")
+281
View File
@@ -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<T> { public void Fire() { } }\n"
"public class _Effect<T> { 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")