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>
This commit is contained in:
safishamsi
2026-07-02 23:25:54 +01:00
co-authored by Claude Opus 4.8
parent 41ce87e442
commit eebc406773
3 changed files with 395 additions and 14 deletions
+1
View File
@@ -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).
+251 -14
View File
@@ -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:
+143
View File
@@ -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"