diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 4d9fb1f0b..fe748e16f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2775,6 +2775,20 @@ def _has_multiline_error(root) -> bool: return False +def _csharp_bare_call_name(name_node, source: bytes) -> str: + """The callee identifier of a C# call-site name node. + + A plain `identifier` reads as-is; a `generic_name` (`Get`) reads its + identifier child so the type-argument list never leaks into the callee + name (#3406). Falls back to raw text for anything else. + """ + if name_node.type == "generic_name": + for child in name_node.children: + if child.type == "identifier": + return _read_text(child, source) + return _read_text(name_node, source) + + def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: """Resolve a C# type name, whether it was qualified, and its qualifier prefix.""" if node is None: @@ -5362,7 +5376,11 @@ def _extract_generic( mname = fn_node.child_by_field_name("name") recv = fn_node.child_by_field_name("expression") if mname is not None: - callee_name = _read_text(mname, source) + # `recv.Get(...)`: the name field is a + # generic_name; its raw text carries the type-argument + # list, which is not part of the method's identity + # (#3406) — read the bare identifier instead. + callee_name = _csharp_bare_call_name(mname, source) is_member_call = True if recv is not None and recv.type == "identifier": member_receiver = _read_text(recv, source) @@ -5388,6 +5406,13 @@ def _extract_generic( member_receiver = _read_text(fname, source) elif fn_node is not None and fn_node.type == "identifier": callee_name = _read_text(fn_node, source) + elif fn_node is not None and fn_node.type == "generic_name": + # Unqualified generic call `Get(...)` / `Make()`: + # without this arm the raw-text fallback captured + # `Get` verbatim, so the call never matched the + # `.Get()` member and the edge was silently dropped — + # while the non-generic spelling resolved fine (#3406). + callee_name = _csharp_bare_call_name(fn_node, source) else: # Fallback: original name-field / first-named-child scan. name_node = node.child_by_field_name("name") diff --git a/tests/test_csharp_generic_callsites.py b/tests/test_csharp_generic_callsites.py new file mode 100644 index 000000000..7bfe3bc33 --- /dev/null +++ b/tests/test_csharp_generic_callsites.py @@ -0,0 +1,76 @@ +"""Unqualified generic C# calls keep their calls edge (#3406). + +`Get("port")` parses with a `generic_name` function node; the invocation +handler had arms for `member_access_expression` and `identifier` only, so the +raw-text fallback captured `Get` verbatim and the call never matched the +`.Get()` member — while the non-generic spelling of the same call resolved. +`this.Get(...)` failed the same way through the member arm, whose name +field is the same `generic_name`. +""" + +from graphify.extract import extract + +SETTINGS_CS = ( + "namespace Demo\n{\n public class Settings\n {\n" + " public T Get(string key) { return default(T); }\n" + " public string GetRaw(string key) { return key; }\n }\n}\n" +) + +READER_CS = ( + "namespace Demo\n{\n public class Reader : Settings\n {\n" + " public int A() { return Get(\"port\"); }\n" + " public int B() { return this.Get(\"port\"); }\n" + " public string C() { return GetRaw(\"host\"); }\n" + " public string D() { return this.GetRaw(\"host\"); }\n }\n}\n" +) + +LOCAL_CS = ( + "namespace Demo\n{\n public class Local\n {\n" + " public T Make() { return default(T); }\n" + " public int E() { return Make(); }\n }\n}\n" +) + + +def _calls(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "Settings.cs").write_text(SETTINGS_CS, encoding="utf-8") + (tmp_path / "Reader.cs").write_text(READER_CS, encoding="utf-8") + (tmp_path / "Local.cs").write_text(LOCAL_CS, encoding="utf-8") + r = extract(sorted(tmp_path.glob("*.cs")), cache_root=tmp_path) + return {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "calls"} + + +def _has(calls, src_frag, tgt_frag): + return any(src_frag in s and tgt_frag in t for s, t in calls) + + +def test_unqualified_generic_call_resolves_to_base_member(tmp_path, monkeypatch): + """The issue's case 1: `Get("port")` with the target on the base class.""" + calls = _calls(tmp_path, monkeypatch) + assert _has(calls, "reader_a", "settings_get"), sorted(calls) + + +def test_this_qualified_generic_call_resolves(tmp_path, monkeypatch): + """Case 2: `this.Get("port")` — the member arm's generic_name name.""" + calls = _calls(tmp_path, monkeypatch) + assert _has(calls, "reader_b", "settings_get"), sorted(calls) + + +def test_same_class_generic_call_resolves(tmp_path, monkeypatch): + """Case 5: `Make()` with the target on the same class.""" + calls = _calls(tmp_path, monkeypatch) + assert _has(calls, "local_e", "local_make"), sorted(calls) + + +def test_generic_call_never_targets_the_bracketed_text(tmp_path, monkeypatch): + """The old failure shape: no target derived from `Get` raw text.""" + calls = _calls(tmp_path, monkeypatch) + assert not any("<" in t for _, t in calls), sorted(calls) + + +def test_non_generic_controls_unchanged(tmp_path, monkeypatch): + """Cases 3 and 4 resolved before the fix and must keep resolving.""" + calls = _calls(tmp_path, monkeypatch) + assert _has(calls, "reader_c", "settings_getraw"), sorted(calls) + assert _has(calls, "reader_d", "settings_getraw"), sorted(calls)