diff --git a/graphify/extract.py b/graphify/extract.py index 95ab7d5ea..13a625317 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index c356912de..b6dc672c0 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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": diff --git a/graphify/symbol_resolution.py b/graphify/symbol_resolution.py index 3b014c369..5114c6f28 100644 --- a/graphify/symbol_resolution.py +++ b/graphify/symbol_resolution.py @@ -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. diff --git a/tests/test_csharp_enum_members.py b/tests/test_csharp_enum_members.py index 43620ccf2..839f1169e 100644 --- a/tests/test_csharp_enum_members.py +++ b/tests/test_csharp_enum_members.py @@ -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