fix(csharp): never let a call bind to a same-named enum member (#4254)

Exclude case_of targets from the per-file label map and apply the cross-file
_member_nids exclusion to all C# calls (not only new X()), so a bare call like
Delegate(t) no longer binds to a same-named enum case. Methods are method/
contains targets, not defines, so real call targets survive; fail-closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ayushcodes10andClaude Opus 4.8 authored and safishamsi committed 2026-10-09 19:19:01 +01:00
1 parent 41b2a7f6f9
commit 230bbd2d92
4 files changed
+104 -4

No files matched your search

+7 -1
View File
@@ -8993,7 +8993,13 @@ def extract(
candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
if rc.get("csharp_new"):
if rc.get("csharp_new") or rc.get("lang") == "csharp":
# An enum member or a field/property is never a valid call target
# either — not just never what `new X()` constructs. A bare call
# whose name happens to collide with a same-named enum case (a
# delegate-typed field invoked next to an enum sharing its name,
# #4245) bound to the case instead of getting no edge/deferring to
# raw_calls resolution that could reject it on its own grounds.
candidates = [c for c in candidates if c not in _member_nids]
if not candidates:
continue
+13
View File
@@ -6949,10 +6949,23 @@ def _extract_generic(
# so a `self:other()` call in its body can be rewritten to the sibling method's
# table-qualified label and resolved (#3991).
lua_self_table: dict[str, str] = {}
# An enum member can never be the target of a call in any language that
# reaches this shared map (Java/Kotlin/Swift/C#/Scala/C++/PHP all emit a
# node per case with a `case_of` edge). Mirrors the exclusion
# _build_csharp_type_def_index already applies for TYPE lookup (#3795);
# without it here too, a bare call whose name happens to match a
# same-named enum case (`Delegate(t)` invoking a delegate-typed field
# next to `enum Kind { ..., Delegate }`) silently bound to the case
# instead of getting no edge (#4245).
_case_of_targets = {
e.get("target") for e in edges if e.get("relation") == "case_of"
}
for n in nodes:
nid_to_sf[n["id"]] = str(n.get("source_file") or "")
if n.get("type") == "namespace":
continue
if n["id"] in _case_of_targets:
continue
raw = n["label"]
normalised = raw.strip("()").lstrip(".")
if config.ts_module == "tree_sitter_lua":
+22 -3
View File
@@ -55,9 +55,26 @@ def node_is_resolvable_symbol(node: dict[str, Any]) -> bool:
return bool(normalise_callable_label(label))
def build_label_index(nodes: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Build label -> node id list for conservative cross-file resolution."""
def build_label_index(
nodes: list[dict[str, Any]],
edges: "list[dict[str, Any]] | None" = None,
) -> dict[str, list[str]]:
"""Build label -> node id list for conservative cross-file resolution.
An enum member can never be the target of a call, in any language that
reaches this shared index — but it is a node like any other, and its
label can collide with an unrelated bare name (a delegate-typed field
named the same as one of its own enum's cases, #4245). When *edges* is
given, a node that is the target of a `case_of` edge is excluded, the
same way `_build_csharp_type_def_index` already excludes it from TYPE
lookup (#3795).
"""
case_of_targets: set[str] = set()
if edges is not None:
case_of_targets = {
e.get("target") for e in edges if isinstance(e, dict) and e.get("relation") == "case_of"
}
index: dict[str, list[str]] = {}
for node in nodes:
if not node_is_resolvable_symbol(node):
@@ -65,6 +82,8 @@ def build_label_index(nodes: list[dict[str, Any]]) -> dict[str, list[str]]:
node_id = node.get("id")
if not node_id:
continue
if node_id in case_of_targets:
continue
key = normalise_callable_label(str(node.get("label", "")))
if not key:
continue
@@ -319,7 +338,7 @@ def resolve_cross_file_raw_calls(
- emitted edges are INFERRED because the raw call alone is not import proof.
"""
label_index = build_label_index(all_nodes)
label_index = build_label_index(all_nodes, all_edges)
known_pairs = existing_edge_pairs(all_edges)
# nid -> source_file, for the shared god-node tie-breakers (#1553) so a
# same-named test mock no longer erases a real cross-file call.
+62
View File
@@ -161,3 +161,65 @@ def test_a_property_and_an_enum_member_sharing_a_name_stay_separate(tmp_path):
enum_member = next(t for s, t in case_of if s == _find(r, "Mode"))
class_member = next(t for s, t in defines if s == _find(r, "Widget"))
assert enum_member != class_member
# ── #4245: a call must never bind to a same-named enum member ──────────────
def test_a_bare_call_to_a_delegate_field_does_not_bind_to_a_same_named_enum_case(tmp_path):
"""Invoking a delegate-typed field whose name collides with one of its own
enum's cases must get no edge to the case — an enum member can never be a
call target."""
case_of, r = _extract(tmp_path, {"Eval.cs": (
"namespace Demo {\n"
" public delegate float Curve(float t);\n"
" public class Eval {\n"
" public enum Kind { Linear, Delegate }\n"
" public Kind Mode;\n"
" public Curve Delegate;\n"
" public float Evaluate(float t) {\n"
" switch (Mode) {\n"
" case Kind.Delegate: return Delegate != null ? Delegate(t) : t;\n"
" default: return t;\n"
" }\n"
" }\n"
" }\n"
"}\n"
)})
# Pin the member named "Delegate" specifically (Kind also has "Linear"); the
# collision is only exercised when the asserted-against node is that member.
kind_id = _find(r, "Kind")
_label = {n["id"]: (n.get("label") or "").strip(".()") for n in r["nodes"]}
delegate_member = next(
t for s, t in case_of if s == kind_id and _label.get(t) == "Delegate"
)
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
assert not any(tgt == delegate_member for _src, tgt in calls), (
f"a call must never land on the enum member, got: {calls}"
)
def test_element_access_member_call_still_binds_to_the_real_method_not_an_enum_case(tmp_path):
"""Control: `Handles[i].Pause()` must still resolve to the real `Pause()`
method on `Handle`, not to an unrelated same-named enum member elsewhere
in the corpus — the fix must not make a genuine call unresolved."""
case_of, r = _extract(tmp_path, {"Handles.cs": (
"namespace Demo {\n"
" public enum State { Pause, Play }\n"
" public class Handle { public void Pause() { } }\n"
" public class Injector {\n"
" public Handle[] Handles;\n"
" public void StopAll() {\n"
" for (int i = 0; i < Handles.Length; i++) { Handles[i].Pause(); }\n"
" }\n"
" }\n"
"}\n"
)})
pause_member = next(t for s, t in case_of if s == _find(r, "State"))
pause_method = next(
n["id"] for n in r["nodes"]
if n["label"] == ".Pause()"
)
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
stopall = _find(r, ".StopAll()")
assert (stopall, pause_method) in calls
assert (stopall, pause_member) not in calls