mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 23:49:14 +00:00
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:
1 parent
41b2a7f6f9
commit
230bbd2d92
4 files changed
+104
-4
No files matched your search
+7
-1
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user