Files
graphify/tests/test_csharp_member_calls.py
T
safishamsiandClaude Opus 4.8 eebc406773 feat(extract): C# receiver-typed member-call resolution (#1609)
C# had no member-call resolver (unlike Swift/Python/Ruby/TS/C++/ObjC), so
`recv.Method()` fell back to a bare method-name match against label_to_nid —
which, under ambiguity, silently mis-bound `_server.Save()` to an unrelated
`Cache.Save()`. That's a WRONG edge, not just a missing one, and it left
delegation-heavy C# call graphs (wrappers, service layers) blind across typed
member/param boundaries.

Mirrors the C++ #1547 pattern:
- capture the member_access_expression receiver (simple identifier or `this`)
  into member_receiver and set is_member_call in the C# invocation branch;
- defer ALL C# member calls with a receiver to the resolver (tgt_nid = None) so
  the bare in-file match can't fire, and emit a raw_call tagged lang="csharp";
- _csharp_member_type_table: file-wide name -> Type from fields, properties,
  parameters, and locals (incl. `var v = new T()`), first-binding-wins;
- _resolve_csharp_member_calls: `this` -> enclosing class (EXTRACTED),
  capitalized -> the named type (EXTRACTED), else the receiver's table type
  (INFERRED), each gated by the single-definition guard; no method on the type
  -> no edge. Registered for .cs.

Verified: the ambiguous `_server.Save()` now resolves to Server.Save and NOT
Cache.Save; field/param/local/this/Type.static/cross-file all resolve; dynamic
receiver and absent-method emit nothing; unqualified calls unregressed. 8 new
tests, full suite 2841.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:25:54 +01:00

144 lines
5.2 KiB
Python

"""C# receiver-typed member-call resolution (#1609).
`recv.Method()` where `recv` is a typed field / property / parameter / local must
resolve to the receiver TYPE's method — not a bare same-named match. Before this,
C# had no member-call resolver: the bare method name matched any same-named method
in the corpus, so `_server.Save()` silently mis-bound to an unrelated `Cache.Save()`
(a WRONG edge, not just a missing one). Resolution is by receiver type with the
single-definition god-node guard; an untypable receiver produces no edge.
"""
from __future__ import annotations
import os
from pathlib import Path
from graphify.extract import extract
def _calls(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)
r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache")
finally:
os.chdir(old)
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
return calls, r
_AMBIG = {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
"public class Repo {\n"
" private Server _server = new Server();\n"
" public bool Commit() { return _server.Save(); }\n"
"}\n"
)
}
def _find(r, label, id_contains):
return next(n["id"] for n in r["nodes"]
if n["label"] == label and id_contains in n["id"])
def test_field_receiver_resolves_to_declared_type_not_bare_match(tmp_path):
calls, r = _calls(tmp_path, _AMBIG)
commit = _find(r, ".Commit()", "commit")
server_save = _find(r, ".Save()", "server")
cache_save = _find(r, ".Save()", "cache")
assert (commit, server_save) in calls, "field.Method() must resolve to the field's type"
assert (commit, cache_save) not in calls, "must NOT mis-bind to an unrelated same-named method"
def test_parameter_receiver_resolves(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
"public class Svc { public static bool Copy(Server server) { return server.Save(); } }\n"
)
})
assert any("copy" in s and "server_save" in t for s, t in calls)
assert not any("copy" in s and "cache_save" in t for s, t in calls)
def test_local_var_receiver_resolves(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class R {\n"
" public bool A() { Server s = new Server(); return s.Save(); }\n"
" public bool B() { var v = new Server(); return v.Save(); }\n"
"}\n"
)
})
assert any("_r_a" in s and "server_save" in t for s, t in calls), "explicit-typed local"
assert any("_r_b" in s and "server_save" in t for s, t in calls), "var = new T() local"
def test_cross_file_receiver_resolves(tmp_path):
calls, r = _calls(tmp_path, {
"Server.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
),
"Repo.cs": (
"public class Repo { private Server _s = new Server(); "
"public bool Commit() { return _s.Save(); } }\n"
),
})
assert any("commit" in s and "server_save" in t for s, t in calls)
assert not any("commit" in s and "cache_save" in t for s, t in calls)
def test_this_and_static_receivers(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Util { public static int F() => 1; }\n"
"public class R {\n"
" public bool A() { return this.B(); }\n"
" public bool B() => true;\n"
" public int G() { return Util.F(); }\n"
"}\n"
)
})
assert any("_r_a" in s and "_r_b" in t for s, t in calls), "this.B() -> R.B"
assert any("_r_g" in s and "util_f" in t for s, t in calls), "Util.F() -> Util.F"
def test_untyped_receiver_emits_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class R { public bool C(dynamic x) { return x.Save(); } }\n"
)
})
assert not any("save" in t.lower() for _s, t in calls), "dynamic receiver must not resolve"
def test_method_absent_on_type_emits_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class R { private Server _s = new Server(); "
"public bool C() { return _s.Missing(); } }\n"
)
})
assert not any("_r_c" in s and "save" in t.lower() for s, t in calls)
def test_unqualified_call_still_resolves(tmp_path):
calls, r = _calls(tmp_path, {
"S.cs": (
"public class R { public bool A() { Helper(); return true; } "
"private void Helper() {} }\n"
)
})
assert any("_r_a" in s and "helper" in t for s, t in calls), "no regression on unqualified calls"