Skip to content

Commit 1a92f5e

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 2cb81c6 commit 1a92f5e

4 files changed

Lines changed: 588 additions & 81 deletions

File tree

‎CHANGELOG.md‎

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

327327
## 0.9.61 (2026-09-12)
328328

329+
- 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).
329330
- 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`.
330331
- 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.
331332

‎graphify/extract.py‎

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4832,6 +4832,13 @@ def _resolve_csharp_member_calls(
48324832
``inherits`` chain; a chain containing an unresolvable (out-of-corpus) base
48334833
poisons the lookup — the method may live there, so no edge is emitted.
48344834
4835+
A member ACCESS (``db.Users``, ``this.Count``, ``Config.Instance`` — a raw
4836+
call stamped ``is_member_access``, #3528) is typed by the same tiers and
4837+
bound to the receiver type's property node (#3006) instead of a method,
4838+
as a ``uses`` edge. That is what makes the ORM query sites behind a
4839+
``DbSet<T>`` property reachable: the table name never appears in source,
4840+
only the property does.
4841+
48354842
Must run after id-disambiguation so node ids and caller_nids are final.
48364843
"""
48374844
def _key(label: str) -> str:
@@ -4865,6 +4872,23 @@ def _key(label: str) -> str:
48654872
enclosing_type.setdefault(tgt, src)
48664873
method_index[(src, _key(tnode.get("label", "")))] = tgt
48674874

4875+
# (type_node_id, property_key) -> property_node_id, the member-access twin
4876+
# of method_index (#3528). A C# property is the target of a `defines` edge
4877+
# from its type (#3006); C++ data members ride the same relation, so keep
4878+
# to targets declared in a .cs file — a receiver typed by bare-name
4879+
# fallback must not reach a same-named C++ member.
4880+
property_index: dict[tuple[str, str], str] = {}
4881+
for e in all_edges:
4882+
if e.get("relation") != "defines":
4883+
continue
4884+
src, tgt = e.get("source"), e.get("target")
4885+
if not (isinstance(src, str) and isinstance(tgt, str)):
4886+
continue
4887+
tnode = node_by_id.get(tgt)
4888+
if tnode is None or not str(tnode.get("source_file", "")).endswith(".cs"):
4889+
continue
4890+
property_index[(src, _key(tnode.get("label", "")))] = tgt
4891+
48684892
# Base-class chain from `inherits` edges (C# files only). The type-reference
48694893
# pass has already re-pointed each resolvable base to its real definition and
48704894
# left unresolvable ones on dangling sourceless stubs — a stub target marks
@@ -4889,13 +4913,16 @@ def _key(label: str) -> str:
48894913
if tgt not in bucket:
48904914
bucket.append(tgt)
48914915

4892-
def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
4893-
"""The method's definition on the type or its resolvable base chain.
4916+
def _member_on_type_or_bases(
4917+
index: dict[tuple[str, str], str], type_nid: str, callee_key: str
4918+
) -> str | None:
4919+
"""The member's definition on the type or its resolvable base chain.
48944920
4895-
A type that declares the method directly wins (overrides shadow the
4896-
base). Otherwise walk `inherits` upward; an unresolved base anywhere the
4897-
walk actually reaches poisons the lookup (no edge), as does anything
4898-
other than exactly one declaration found.
4921+
``index`` is method_index for a call and property_index for a member
4922+
access. A type that declares the member directly wins (overrides
4923+
shadow the base). Otherwise walk `inherits` upward; an unresolved base
4924+
anywhere the walk actually reaches poisons the lookup (no edge), as
4925+
does anything other than exactly one declaration found.
48994926
"""
49004927
hits: set[str] = set()
49014928
seen: set[str] = set()
@@ -4905,9 +4932,9 @@ def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None:
49054932
if nid in seen:
49064933
continue
49074934
seen.add(nid)
4908-
method_nid = method_index.get((nid, callee_key))
4909-
if method_nid:
4910-
hits.add(method_nid)
4935+
member_nid = index.get((nid, callee_key))
4936+
if member_nid:
4937+
hits.add(member_nid)
49114938
continue # an override shadows anything above it
49124939
if nid in unresolved_base:
49134940
return None # the method may live on the out-of-corpus base
@@ -5000,6 +5027,11 @@ def _field_type_nid(type_nid: str, field: str) -> str | None:
50005027
chain = rc.get("receiver_chain") or []
50015028
if not (receiver or chain) or not callee or not caller:
50025029
continue
5030+
# A member access (`db.Users`, #3528) types its receiver exactly like a
5031+
# call and then binds to a property instead of a method. It is never
5032+
# parked: the parked entries are cross-repo CALL candidates (#3152),
5033+
# and a property read on an out-of-corpus type is not one.
5034+
is_access = bool(rc.get("is_member_access"))
50035035
src_file = rc.get("source_file", "")
50045036
caller_node = node_by_id.get(caller)
50055037
if not receiver:
@@ -5036,7 +5068,8 @@ def _field_type_nid(type_nid: str, field: str) -> str | None:
50365068
if not type_nid:
50375069
type_nid = _resolve_type_name_nid(receiver, caller_node, src_file)
50385070
if not type_nid:
5039-
_park_if_absent(type_name or receiver, caller_node, rc)
5071+
if not is_access:
5072+
_park_if_absent(type_name or receiver, caller_node, rc)
50405073
continue
50415074
type_qualified = True
50425075
else:
@@ -5045,20 +5078,23 @@ def _field_type_nid(type_nid: str, field: str) -> str | None:
50455078
continue
50465079
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
50475080
if not type_nid: # ambiguous or absent -> bail (god-node guard)
5048-
_park_if_absent(type_name, caller_node, rc)
5081+
if not is_access:
5082+
_park_if_absent(type_name, caller_node, rc)
50495083
continue
50505084
type_qualified = False
5051-
method_nid = _method_on_type_or_bases(type_nid, _key(callee))
5052-
if not method_nid:
5053-
continue # receiver typed, but the type has no such method — skip
5054-
if method_nid == caller or (caller, method_nid) in existing_pairs:
5085+
member_nid = _member_on_type_or_bases(
5086+
property_index if is_access else method_index, type_nid, _key(callee)
5087+
)
5088+
if not member_nid:
5089+
continue # receiver typed, but the type has no such member — skip
5090+
if member_nid == caller or (caller, member_nid) in existing_pairs:
50555091
continue
5056-
existing_pairs.add((caller, method_nid))
5092+
existing_pairs.add((caller, member_nid))
50575093
all_edges.append({
50585094
"source": caller,
5059-
"target": method_nid,
5060-
"relation": "calls",
5061-
"context": "call",
5095+
"target": member_nid,
5096+
"relation": "uses" if is_access else "calls",
5097+
"context": "member_access" if is_access else "call",
50625098
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
50635099
"confidence_score": 1.0 if type_qualified else 0.85,
50645100
"source_file": src_file,

0 commit comments

Comments
 (0)