Skip to content

Commit ab03cb5

Browse files
fix(csharp): resolve a member access to the receiver type's property (#3528)
`db.Users` inside `db.Users.Where(...)` was dropped: the invocation branch keeps only a simple receiver, so every ORM query site behind a DbSet<T> property reached _resolve_csharp_member_calls with receiver=None and the property node (#3006) had no inbound edge but its own type's `defines`. Record a non-call member_access_expression on a simple receiver (recv / this / base / this.field / Type) as a raw_calls entry stamped is_member_access, typed from the same scoped receiver table the member calls use, and bind it in _resolve_csharp_member_calls through the same this/base/Type/typed tiers to a property_index built from `defines` edges to .cs nodes — a `uses` edge at the access line, EXTRACTED when the type is named in source and INFERRED when the receiver is typed. Never parked: parked entries are cross-repo call candidates (#3152). The receiver-capture arms of the invocation branch, including the cast arm from #3797 (`((IStore)x)` / `(x as IStore)`), become _csharp_member_receiver, shared by both sites, so a property read through a cast resolves on the cast type the same way a call does.
1 parent 6478eb7 commit ab03cb5

4 files changed

Lines changed: 557 additions & 54 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ Intra-class call binding across five languages, new language-structure coverage,
273273

274274
## 0.9.61 (2026-09-12)
275275

276+
- Fix: a C# member access on a typed receiver (`db.Users` in `db.Users.Where(...)`, `this.Count`, `Config.Instance`) now resolves to the receiver type's property node as a `uses` edge, the way `recv.Method()` resolves to its method — so the ORM query sites behind a `DbSet<T>` property, whose table name never appears in source, are reachable from the property (#3528).
276277
- Fix: `graphify.serve` now imports cleanly on Python 3.12 and 3.13. The `chinese` extra pins `jieba-py` from 3.12 onward (0.9.60 mistakenly kept the old `jieba` until 3.14, and its invalid regex escapes are a hard error on 3.12+), and the jieba import now suppresses the tokenizer's `SyntaxWarning` regardless of message or line so it never escalates under `-W error`.
277278
- Fix: the git hook's rebuild-root guard now rejects a symlink-loop or dangling `.graphify_root` on Python 3.13, whose `Path.resolve()` no longer raises on a loop — the saved root must resolve to a real directory inside the repo before it is adopted.
278279

‎graphify/extract.py‎

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4707,6 +4707,13 @@ def _resolve_csharp_member_calls(
47074707
``inherits`` chain; a chain containing an unresolvable (out-of-corpus) base
47084708
poisons the lookup — the method may live there, so no edge is emitted.
47094709
4710+
A member ACCESS (``db.Users``, ``this.Count``, ``Config.Instance`` — a raw
4711+
call stamped ``is_member_access``, #3528) is typed by the same tiers and
4712+
bound to the receiver type's property node (#3006) instead of a method,
4713+
as a ``uses`` edge. That is what makes the ORM query sites behind a
4714+
``DbSet<T>`` property reachable: the table name never appears in source,
4715+
only the property does.
4716+
47104717
Must run after id-disambiguation so node ids and caller_nids are final.
47114718
"""
47124719
def _key(label: str) -> str:
@@ -4740,6 +4747,23 @@ def _key(label: str) -> str:
47404747
enclosing_type.setdefault(tgt, src)
47414748
method_index[(src, _key(tnode.get("label", "")))] = tgt
47424749

4750+
# (type_node_id, property_key) -> property_node_id, the member-access twin
4751+
# of method_index (#3528). A C# property is the target of a `defines` edge
4752+
# from its type (#3006); C++ data members ride the same relation, so keep
4753+
# to targets declared in a .cs file — a receiver typed by bare-name
4754+
# fallback must not reach a same-named C++ member.
4755+
property_index: dict[tuple[str, str], str] = {}
4756+
for e in all_edges:
4757+
if e.get("relation") != "defines":
4758+
continue
4759+
src, tgt = e.get("source"), e.get("target")
4760+
if not (isinstance(src, str) and isinstance(tgt, str)):
4761+
continue
4762+
tnode = node_by_id.get(tgt)
4763+
if tnode is None or not str(tnode.get("source_file", "")).endswith(".cs"):
4764+
continue
4765+
property_index[(src, _key(tnode.get("label", "")))] = tgt
4766+
47434767
# Base-class chain from `inherits` edges (C# files only). The type-reference
47444768
# pass has already re-pointed each resolvable base to its real definition and
47454769
# left unresolvable ones on dangling sourceless stubs — a stub target marks
@@ -4764,13 +4788,16 @@ def _key(label: str) -> str:
47644788
if tgt not in bucket:
47654789
bucket.append(tgt)
47664790

4767-
def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
4768-
"""The method's definition on the type or its resolvable base chain.
4791+
def _member_on_type_or_bases(
4792+
index: dict[tuple[str, str], str], type_nid: str, callee_key: str
4793+
) -> str | None:
4794+
"""The member's definition on the type or its resolvable base chain.
47694795
4770-
A type that declares the method directly wins (overrides shadow the
4771-
base). Otherwise walk `inherits` upward; an unresolved base anywhere the
4772-
walk actually reaches poisons the lookup (no edge), as does anything
4773-
other than exactly one declaration found.
4796+
``index`` is method_index for a call and property_index for a member
4797+
access. A type that declares the member directly wins (overrides
4798+
shadow the base). Otherwise walk `inherits` upward; an unresolved base
4799+
anywhere the walk actually reaches poisons the lookup (no edge), as
4800+
does anything other than exactly one declaration found.
47744801
"""
47754802
hits: set[str] = set()
47764803
seen: set[str] = set()
@@ -4780,9 +4807,9 @@ def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
47804807
if nid in seen:
47814808
continue
47824809
seen.add(nid)
4783-
method_nid = method_index.get((nid, callee_key))
4784-
if method_nid:
4785-
hits.add(method_nid)
4810+
member_nid = index.get((nid, callee_key))
4811+
if member_nid:
4812+
hits.add(member_nid)
47864813
continue # an override shadows anything above it
47874814
if nid in unresolved_base:
47884815
return None # the method may live on the out-of-corpus base
@@ -4837,6 +4864,11 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -
48374864
caller = rc.get("caller_nid")
48384865
if not receiver or not callee or not caller:
48394866
continue
4867+
# A member access (`db.Users`, #3528) types its receiver exactly like a
4868+
# call and then binds to a property instead of a method. It is never
4869+
# parked: the parked entries are cross-repo CALL candidates (#3152),
4870+
# and a property read on an out-of-corpus type is not one.
4871+
is_access = bool(rc.get("is_member_access"))
48404872
src_file = rc.get("source_file", "")
48414873
caller_node = node_by_id.get(caller)
48424874
if receiver == "this":
@@ -4863,7 +4895,8 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -
48634895
if not type_nid:
48644896
type_nid = _resolve_type_name_nid(receiver, caller_node, src_file)
48654897
if not type_nid:
4866-
_park_if_absent(type_name or receiver, caller_node, rc)
4898+
if not is_access:
4899+
_park_if_absent(type_name or receiver, caller_node, rc)
48674900
continue
48684901
type_qualified = True
48694902
else:
@@ -4872,20 +4905,23 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -
48724905
continue
48734906
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
48744907
if not type_nid: # ambiguous or absent -> bail (god-node guard)
4875-
_park_if_absent(type_name, caller_node, rc)
4908+
if not is_access:
4909+
_park_if_absent(type_name, caller_node, rc)
48764910
continue
48774911
type_qualified = False
4878-
method_nid = _method_on_type_or_bases(type_nid, _key(callee))
4879-
if not method_nid:
4880-
continue # receiver typed, but the type has no such method — skip
4881-
if method_nid == caller or (caller, method_nid) in existing_pairs:
4912+
member_nid = _member_on_type_or_bases(
4913+
property_index if is_access else method_index, type_nid, _key(callee)
4914+
)
4915+
if not member_nid:
4916+
continue # receiver typed, but the type has no such member — skip
4917+
if member_nid == caller or (caller, member_nid) in existing_pairs:
48824918
continue
4883-
existing_pairs.add((caller, method_nid))
4919+
existing_pairs.add((caller, member_nid))
48844920
all_edges.append({
48854921
"source": caller,
4886-
"target": method_nid,
4887-
"relation": "calls",
4888-
"context": "call",
4922+
"target": member_nid,
4923+
"relation": "uses" if is_access else "calls",
4924+
"context": "member_access" if is_access else "call",
48894925
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
48904926
"confidence_score": 1.0 if type_qualified else 0.85,
48914927
"source_file": src_file,

‎graphify/extractors/engine.py‎

Lines changed: 101 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3685,6 +3685,50 @@ def _csharp_member_call_parts(fn_node):
36853685
return None
36863686

36873687

3688+
def _csharp_member_receiver(recv, source: bytes) -> str | None:
3689+
"""The receiver name of a C# member access, or None when it is not simple.
3690+
3691+
``recv`` is the `expression` field of a member_access_expression. A bare
3692+
identifier reads as-is; `this` and `base` read as those literals, resolved
3693+
against the caller's own type (or its single resolvable base) in the
3694+
cross-file pass; `this.field` reads as the bare field name, so it is typed
3695+
exactly like `field` via the method's scoped receiver table. A cast
3696+
receiver, `((IStore)x)` or `(x as IStore)`, names its type in source and
3697+
reads as that type, so it resolves as `IStore.M` (#3797). Any other chain
3698+
(`a.b`, a call result, `typeof(T)`) stays untyped — the resolver bails
3699+
rather than guessing.
3700+
"""
3701+
if recv is None:
3702+
return None
3703+
if recv.type == "identifier":
3704+
return _read_text(recv, source)
3705+
if recv.type in ("this", "this_expression"):
3706+
return "this"
3707+
if recv.type in ("base", "base_expression"):
3708+
return "base"
3709+
if recv.type == "member_access_expression":
3710+
inner = recv.child_by_field_name("expression")
3711+
fname = recv.child_by_field_name("name")
3712+
if (
3713+
inner is not None
3714+
and inner.type in ("this", "this_expression")
3715+
and fname is not None
3716+
and fname.type == "identifier"
3717+
):
3718+
return _read_text(fname, source)
3719+
if recv.type == "parenthesized_expression":
3720+
inner = recv.named_children[0] if recv.named_children else None
3721+
cast_type = None
3722+
if inner is not None and inner.type == "cast_expression":
3723+
cast_type = inner.child_by_field_name("type")
3724+
elif inner is not None and inner.type == "as_expression":
3725+
cast_type = inner.child_by_field_name("right")
3726+
type_info = _read_csharp_type_name(cast_type, source)
3727+
if type_info and type_info[0]:
3728+
return type_info[0]
3729+
return None
3730+
3731+
36883732
def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None:
36893733
"""Resolve a C# type name, whether it was qualified, and its qualifier prefix."""
36903734
if node is None:
@@ -7358,41 +7402,7 @@ def walk_calls(
73587402
# (#3406) — read the bare identifier instead.
73597403
callee_name = _csharp_bare_call_name(mname, source)
73607404
is_member_call = True
7361-
if recv is not None and recv.type == "identifier":
7362-
member_receiver = _read_text(recv, source)
7363-
elif recv is not None and recv.type in ("this", "this_expression"):
7364-
member_receiver = "this"
7365-
elif recv is not None and recv.type in ("base", "base_expression"):
7366-
# base.M(): resolved against the caller's single
7367-
# resolvable base class in the cross-file pass.
7368-
member_receiver = "base"
7369-
elif recv is not None and recv.type == "member_access_expression":
7370-
# this.field.M(): the explicit-`this` field access is
7371-
# typed exactly like a bare `field.M()` via the file
7372-
# table; any other chained receiver stays untyped
7373-
# (the resolver bails rather than guessing).
7374-
inner = recv.child_by_field_name("expression")
7375-
fname = recv.child_by_field_name("name")
7376-
if (
7377-
inner is not None
7378-
and inner.type in ("this", "this_expression")
7379-
and fname is not None
7380-
and fname.type == "identifier"
7381-
):
7382-
member_receiver = _read_text(fname, source)
7383-
elif recv is not None and recv.type == "parenthesized_expression":
7384-
# ((IStore)x).M() / (x as IStore).M(): the cast names
7385-
# the receiver's type in source, so resolve it as
7386-
# IStore.M() (#3797).
7387-
inner = recv.named_children[0] if recv.named_children else None
7388-
cast_type = None
7389-
if inner is not None and inner.type == "cast_expression":
7390-
cast_type = inner.child_by_field_name("type")
7391-
elif inner is not None and inner.type == "as_expression":
7392-
cast_type = inner.child_by_field_name("right")
7393-
type_info = _read_csharp_type_name(cast_type, source)
7394-
if type_info and type_info[0]:
7395-
member_receiver = type_info[0]
7405+
member_receiver = _csharp_member_receiver(recv, source)
73967406
elif fn_node is not None and fn_node.type == "identifier":
73977407
callee_name = _read_text(fn_node, source)
73987408
elif fn_node is not None and fn_node.type == "generic_name":
@@ -8026,6 +8036,62 @@ def walk_calls(
80268036
"weight": 1.0,
80278037
})
80288038

8039+
# C#: a member access that is not itself a call — `db.Users` inside
8040+
# `db.Users.Where(...)`, `order.Status`, `Config.Instance` (#3528).
8041+
# The invocation branch keeps only a simple receiver, so the chained
8042+
# `db.Users` was dropped on the floor and the DbSet property (a node
8043+
# since #3006) sat in the graph with nothing but its `defines` edge:
8044+
# "what code reads or writes this table" had no answer. Record the
8045+
# access as a raw entry typed from the same scoped receiver table the
8046+
# member calls use, for _resolve_csharp_member_calls to bind to the
8047+
# receiver type's property node. The callee of an invocation is its
8048+
# `function` field, and that is the only member_access_expression
8049+
# ever parented directly by one (arguments sit under argument_list),
8050+
# so the parent check is what separates `db.Users` from
8051+
# `db.Users.Add`. A generic_name member (`db.Set<T>`) is a call, not a
8052+
# property, and is left to the call-site type-argument pass (#2911).
8053+
if (
8054+
config.ts_module == "tree_sitter_c_sharp"
8055+
and node.type == "member_access_expression"
8056+
and not (node.parent is not None and node.parent.type == "invocation_expression")
8057+
):
8058+
member_name = node.child_by_field_name("name")
8059+
access_receiver = (
8060+
_csharp_member_receiver(node.child_by_field_name("expression"), source)
8061+
if member_name is not None and member_name.type == "identifier"
8062+
else None
8063+
)
8064+
if access_receiver:
8065+
receiver_type = _csharp_scoped_receiver_type(
8066+
receiver_types, access_receiver, node.start_byte
8067+
)
8068+
# Property reads are far more common than calls, and raw_calls
8069+
# ride the AST cache, so only record an entry the resolver can
8070+
# act on: `this`/`base`, a type name, or a typed receiver. An
8071+
# untyped lowercase receiver (`u.Email` on a lambda parameter)
8072+
# would be skipped there anyway.
8073+
if (
8074+
receiver_type
8075+
or access_receiver in ("this", "base")
8076+
or access_receiver[:1].isupper()
8077+
):
8078+
rc_entry = {
8079+
"caller_nid": caller_nid,
8080+
"callee": _read_text(member_name, source),
8081+
# is_member_call keeps every bare-name resolver off this
8082+
# entry, the way it does for a receiver call;
8083+
# is_member_access is what the C# resolver branches on.
8084+
"is_member_call": True,
8085+
"is_member_access": True,
8086+
"lang": "csharp",
8087+
"source_file": str_path,
8088+
"source_location": f"L{node.start_point[0] + 1}",
8089+
"receiver": access_receiver,
8090+
}
8091+
if receiver_type:
8092+
rc_entry["receiver_type"] = receiver_type
8093+
raw_calls.append(rc_entry)
8094+
80298095
# Static property access: Foo::$bar → uses_static_prop edge
80308096
if node.type in config.static_prop_types:
80318097
scope_node = node.child_by_field_name("scope")

0 commit comments

Comments
 (0)