From eebc406773ecabee9acd50d6ac81b9f56792037c Mon Sep 17 00:00:00 2001 From: safishamsi Date: Thu, 2 Jul 2026 23:25:54 +0100 Subject: [PATCH] feat(extract): C# receiver-typed member-call resolution (#1609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + graphify/extract.py | 265 ++++++++++++++++++++++++++++-- tests/test_csharp_member_calls.py | 143 ++++++++++++++++ 3 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 tests/test_csharp_member_calls.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cfeef7..9457048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +- Feat: C# receiver-typed member-call resolution (#1609, thanks @JensD-git). `recv.Method()` where `recv` is a typed field, property, parameter, or local now resolves to the receiver *type's* method. C# previously had no member-call resolver, so the bare method name matched any same-named method in the corpus — `_server.Save()` silently mis-bound to an unrelated `Cache.Save()` (a wrong edge, not just a missing one), leaving delegation-heavy call graphs blind across typed boundaries. The receiver is now typed from a per-file field/property/param/local table (incl. `var v = new T()`) and resolved with the single-definition god-node guard; `this.M()` binds to the enclosing class and `Type.M()` to the named type. An untypable receiver (e.g. `dynamic`) or a method absent on the type produces no edge — precision over recall, matching the Swift/C++/Python resolvers. - Fix: `graphify cluster-only` now writes `.graphify_analysis.json` alongside `graph.json` (#1617 / #1610, thanks @sanmaxdev). Without it, a re-cluster left a stale/absent sidecar and a later `export html` silently reported "Single community". The sidecar now carries communities/cohesion/gods/surprises/questions, matching the full extract path. - Fix: `.mts` / `.cts` (TypeScript module extensions) are now treated as TypeScript (#1607, thanks @ashmitg). They were missing from the code-extension set and the JS/TS language maps, so `.mts`/`.cts` sources were detected as non-code and silently skipped. - Fix: four TS/JS extractor gaps (#1615, thanks @papinto). Generator functions (`function*`) now register as callables; `namespace`/`module`/`declare module` containers become queryable nodes; and the TS import-equals form (`import x = require("./m")`) now emits an import edge (its module string nests in an `import_require_clause` the direct-child scan missed). diff --git a/graphify/extract.py b/graphify/extract.py index bdd8280..267cf36 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2270,6 +2270,76 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N stack.append(c) +def _csharp_member_type_table(root, source: bytes) -> dict[str, str]: + """Collect ``name -> TypeName`` for C# receiver typing (#1609): class fields, + properties, method parameters, and local variable declarations. + + File-scoped, first-binding-wins (like the C++ table): a field declared once at + class scope is visible to every method's `field.Method()`, and a param/local + shadowing the same name is a conservative approximation graphify already accepts + for receiver typing. Only a resolvable, non-`var` type name is recorded; `var` + without a `new T()` initializer, and predefined/lower-cased primitives, are + skipped (precision over recall — an untypable receiver is left for the resolver + to drop rather than guess). `var v = new T()` is typed from the object-creation. + """ + table: dict[str, str] = {} + + def _typed(type_node) -> str | None: + info = _read_csharp_type_name(type_node, source) + if not info: + return None + name = info[0] + # A genuine C# class name is Pascal-cased; skip predefined primitives + # (int/bool/string) which never own a resolvable method definition here. + return name if name and name[:1].isupper() else None + + def _decl_names(var_decl): + for c in var_decl.children: + if c.type == "variable_declarator": + nm = c.child_by_field_name("name") or next( + (g for g in c.children if g.type == "identifier"), None) + if nm is not None: + yield _read_text(nm, source), c + + def _new_type(declarator) -> str | None: + # `var v = new Server()` — recover the type from the object_creation_expression. + for g in declarator.children: + if g.type == "object_creation_expression": + return _typed(g.child_by_field_name("type")) + return None + + stack = [root] + while stack: + n = stack.pop() + t = n.type + if t in ("field_declaration", "local_declaration_statement"): + vd = next((c for c in n.children if c.type == "variable_declaration"), None) + if vd is not None: + type_node = vd.child_by_field_name("type") + declared = _typed(type_node) + for name, decl in _decl_names(vd): + resolved = declared or _new_type(decl) + if name and resolved and name not in table: + table[name] = resolved + elif t == "property_declaration": + nm = n.child_by_field_name("name") + resolved = _typed(n.child_by_field_name("type")) + if nm is not None and resolved: + pname = _read_text(nm, source) + if pname not in table: + table[pname] = resolved + elif t == "parameter": + nm = n.child_by_field_name("name") + resolved = _typed(n.child_by_field_name("type")) + if nm is not None and resolved: + pname = _read_text(nm, source) + if pname not in table: + table[pname] = resolved + for c in n.children: + stack.append(c) + return table + + def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None: """Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``) in a method body, for receiver typing in the cross-file message-send pass @@ -4776,20 +4846,45 @@ def _extract_generic( callee_name = _read_text(child, source) break elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression": - # C#: try name field, then first named child - name_node = node.child_by_field_name("name") - if name_node: - callee_name = _read_text(name_node, source) + # C#: the invoked function is the `function` field. A member call + # `recv.Method(...)` is a member_access_expression (receiver in its + # `expression` field, method in `name`). Capture a simple-identifier + # or `this` receiver + set is_member_call so the receiver-typed + # resolver (_resolve_csharp_member_calls) can bind it to the + # receiver's declared type. Without this the bare method name matched + # any same-named method in the corpus, silently mis-resolving + # `_server.Save()` to an unrelated `Cache.Save()` (#1609). + fn_node = node.child_by_field_name("function") + if fn_node is not None and fn_node.type == "member_access_expression": + 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) + is_member_call = True + if recv is not None and recv.type == "identifier": + member_receiver = _read_text(recv, source) + elif recv is not None and recv.type == "this_expression": + member_receiver = "this" + elif fn_node is not None and fn_node.type == "identifier": + callee_name = _read_text(fn_node, source) else: - for child in node.children: - if child.is_named: - raw = _read_text(child, source) - if "." in raw: - callee_name = raw.split(".")[-1] - is_member_call = True - else: - callee_name = raw - break + # Fallback: original name-field / first-named-child scan. + name_node = node.child_by_field_name("name") + if name_node: + callee_name = _read_text(name_node, source) + else: + for child in node.children: + if child.is_named: + raw = _read_text(child, source) + if "." in raw: + callee_name = raw.split(".")[-1] + is_member_call = True + parts = raw.split(".") + if len(parts) == 2 and parts[0]: + member_receiver = parts[0] + else: + callee_name = raw + break elif config.ts_module == "tree_sitter_php": # PHP: distinguish call expression subtypes if node.type == "function_call_expression": @@ -4903,7 +4998,18 @@ def _extract_generic( # viewset action delegates to a same-named service action — which would # match `tgt_nid == caller_nid` and silently drop the call (#1446). The # captured receiver is resolved later in _resolve_python_member_calls. - if is_member_call and member_receiver and (member_receiver[:1].isupper() or is_this_field_call): + # C#: ANY member call with a captured receiver defers to the + # receiver-typed resolver — a bare method-name match ignores the + # receiver's declared type and mis-binds to an unrelated same-named + # method (#1609). The receiver may be lowercase (`_server.Save()`), + # so this is broader than the capitalized/this-field Python rule. + _csharp_defer = ( + config.ts_module == "tree_sitter_c_sharp" + and is_member_call and member_receiver + ) + if is_member_call and member_receiver and ( + member_receiver[:1].isupper() or is_this_field_call or _csharp_defer + ): tgt_nid = None else: tgt_nid = label_to_nid.get(callee_name) @@ -4944,6 +5050,11 @@ def _extract_generic( # suffix sets, so a source_file suffix alone can't separate them. if config.ts_module == "tree_sitter_cpp": rc_entry["lang"] = "cpp" + # C#: tag the raw_call so _resolve_csharp_member_calls claims it + # and types the receiver against the file's field/param/local + # type table (#1609). + if config.ts_module == "tree_sitter_c_sharp": + rc_entry["lang"] = "csharp" raw_calls.append(rc_entry) # Indirect dispatch: a function passed BY NAME as a call argument @@ -5284,6 +5395,13 @@ def _extract_generic( result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} + # C#: a file-wide receiver type table (field/property/param/local -> Type) for + # _resolve_csharp_member_calls (#1609). Built from the whole tree, not just + # function bodies, so class-level fields/properties are in scope for every method. + if config.ts_module == "tree_sitter_c_sharp": + cs_table = _csharp_member_type_table(root, source) + if cs_table: + result["csharp_type_table"] = {"path": str_path, "table": cs_table} return result @@ -11589,6 +11707,120 @@ def _resolve_cpp_member_calls( }) +def _resolve_csharp_member_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve C# member calls (``recv.Method()``) to the receiver's declared type + (#1609). + + The shared cross-file pass drops every ``is_member_call`` because a bare method + name collides across the corpus — and for C# an in-file bare match silently + mis-bound ``_server.Save()`` to an unrelated ``Cache.Save()``. The C# extractor + now records each member call's receiver plus a per-file ``name -> Type`` table + (``csharp_type_table``) of fields/properties/params/locals. This pass types the + receiver, then emits an edge ONLY when that type resolves to exactly ONE + definition (the god-node guard); an untypable receiver is skipped (no guess). + + Receiver typing, by precision tier: + * ``this.M()`` — receiver is the caller's own enclosing class -> EXTRACTED. + * ``Type.M()`` (capitalized) — the type is named explicitly in source -> EXTRACTED. + * ``recv.M()`` — ``recv`` typed via the file's field/param/local table -> INFERRED. + + Must run after id-disambiguation so node ids and caller_nids are final. + """ + type_table_by_file: dict[str, dict[str, str]] = {} + for result in per_file: + tt = result.get("csharp_type_table") + if tt and tt.get("path"): + type_table_by_file[tt["path"]] = tt.get("table", {}) + + def _key(label: str) -> str: + return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() + + contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"} + + type_def_nids: dict[str, list[str]] = {} + node_by_id: dict[str, dict] = {} + for n in all_nodes: + node_by_id[n.get("id")] = n + if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n): + type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"]) + + # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type. + # C# owns its methods via `method` edges. + method_index: dict[tuple[str, str], str] = {} + enclosing_type: dict[str, str] = {} + for e in all_edges: + if e.get("relation") != "method": + continue + src, tgt = e.get("source"), e.get("target") + tnode = node_by_id.get(tgt) + if tnode is None: + continue + enclosing_type.setdefault(tgt, src) + method_index[(src, _key(tnode.get("label", "")))] = tgt + + all_raw_calls: list[dict] = [] + for result in per_file: + all_raw_calls.extend(result.get("raw_calls", [])) + + existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges} + for rc in all_raw_calls: + if rc.get("lang") != "csharp" or not rc.get("is_member_call"): + continue + receiver = rc.get("receiver") + callee = rc.get("callee") + caller = rc.get("caller_nid") + if not receiver or not callee or not caller: + continue + src_file = rc.get("source_file", "") + if receiver == "this": + type_nid = enclosing_type.get(caller) + if not type_nid: + continue + type_qualified = True + elif receiver[:1].isupper(): + # Type.M() — the type is named explicitly (also covers a Pascal-cased + # local whose name equals its type, resolved via the table below if the + # explicit-type lookup misses). + type_defs = type_def_nids.get(_key(receiver), []) + if len(type_defs) != 1: + type_name = type_table_by_file.get(src_file, {}).get(receiver) + type_defs = type_def_nids.get(_key(type_name), []) if type_name else [] + if len(type_defs) != 1: + continue + type_nid = type_defs[0] + type_qualified = True + else: + type_name = type_table_by_file.get(src_file, {}).get(receiver) + if not type_name: + continue + type_defs = type_def_nids.get(_key(type_name), []) + if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + continue + type_nid = type_defs[0] + type_qualified = False + method_nid = method_index.get((type_nid, _key(callee))) + if not method_nid: + continue # receiver typed, but the type has no such method — skip + if method_nid == caller or (caller, method_nid) in existing_pairs: + continue + existing_pairs.add((caller, method_nid)) + all_edges.append({ + "source": caller, + "target": method_nid, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED" if type_qualified else "INFERRED", + "confidence_score": 1.0 if type_qualified else 0.8, + "source_file": src_file, + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def _resolve_objc_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -11734,6 +11966,11 @@ register_language_resolver( _resolve_objc_member_calls, ) ) +# C# receiver-typed member-call resolution (#1609): `field/param/local.Method()` +# bound to the receiver's declared type instead of a bare same-named match. +register_language_resolver( + LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls) +) def extract_objc(path: Path) -> dict: diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py new file mode 100644 index 0000000..d83265d --- /dev/null +++ b/tests/test_csharp_member_calls.py @@ -0,0 +1,143 @@ +"""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"