From b8da6f2beedb0f84e42a979098b6bdb97c40b23f Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Mon, 14 Sep 2026 00:03:12 +0530 Subject: [PATCH 1/2] fix(csharp): resolve a member access to the receiver type's property (#3528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `db.Users` inside `db.Users.Where(...)` was dropped: the invocation branch keeps only a simple receiver, so every ORM query site behind a DbSet 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 four receiver-capture arms of the invocation branch become _csharp_member_receiver, shared by both sites. --- CHANGELOG.md | 1 + graphify/extract.py | 74 ++++-- graphify/extractors/engine.py | 111 +++++++-- tests/test_csharp_member_access.py | 382 +++++++++++++++++++++++++++++ 4 files changed, 527 insertions(+), 41 deletions(-) create mode 100644 tests/test_csharp_member_access.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9780761..270eb7887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,7 @@ Intra-class call binding across five languages, new language-structure coverage, ## 0.9.61 (2026-09-12) +- 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` property, whose table name never appears in source, are reachable from the property (#3528). - 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`. - 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. diff --git a/graphify/extract.py b/graphify/extract.py index b463ae388..5d2fc5201 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4530,6 +4530,13 @@ def _resolve_csharp_member_calls( ``inherits`` chain; a chain containing an unresolvable (out-of-corpus) base poisons the lookup — the method may live there, so no edge is emitted. + A member ACCESS (``db.Users``, ``this.Count``, ``Config.Instance`` — a raw + call stamped ``is_member_access``, #3528) is typed by the same tiers and + bound to the receiver type's property node (#3006) instead of a method, + as a ``uses`` edge. That is what makes the ORM query sites behind a + ``DbSet`` property reachable: the table name never appears in source, + only the property does. + Must run after id-disambiguation so node ids and caller_nids are final. """ def _key(label: str) -> str: @@ -4563,6 +4570,23 @@ def _key(label: str) -> str: enclosing_type.setdefault(tgt, src) method_index[(src, _key(tnode.get("label", "")))] = tgt + # (type_node_id, property_key) -> property_node_id, the member-access twin + # of method_index (#3528). A C# property is the target of a `defines` edge + # from its type (#3006); C++ data members ride the same relation, so keep + # to targets declared in a .cs file — a receiver typed by bare-name + # fallback must not reach a same-named C++ member. + property_index: dict[tuple[str, str], str] = {} + for e in all_edges: + if e.get("relation") != "defines": + continue + src, tgt = e.get("source"), e.get("target") + if not (isinstance(src, str) and isinstance(tgt, str)): + continue + tnode = node_by_id.get(tgt) + if tnode is None or not str(tnode.get("source_file", "")).endswith(".cs"): + continue + property_index[(src, _key(tnode.get("label", "")))] = tgt + # Base-class chain from `inherits` edges (C# files only). The type-reference # pass has already re-pointed each resolvable base to its real definition and # left unresolvable ones on dangling sourceless stubs — a stub target marks @@ -4587,13 +4611,16 @@ def _key(label: str) -> str: if tgt not in bucket: bucket.append(tgt) - def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None: - """The method's definition on the type or its resolvable base chain. + def _member_on_type_or_bases( + index: dict[tuple[str, str], str], type_nid: str, callee_key: str + ) -> str | None: + """The member's definition on the type or its resolvable base chain. - A type that declares the method directly wins (overrides shadow the - base). Otherwise walk `inherits` upward; an unresolved base anywhere the - walk actually reaches poisons the lookup (no edge), as does anything - other than exactly one declaration found. + ``index`` is method_index for a call and property_index for a member + access. A type that declares the member directly wins (overrides + shadow the base). Otherwise walk `inherits` upward; an unresolved base + anywhere the walk actually reaches poisons the lookup (no edge), as + does anything other than exactly one declaration found. """ hits: set[str] = set() seen: set[str] = set() @@ -4603,9 +4630,9 @@ def _method_on_type_or_bases(type_nid: str, callee_key: str) -> str | None: if nid in seen: continue seen.add(nid) - method_nid = method_index.get((nid, callee_key)) - if method_nid: - hits.add(method_nid) + member_nid = index.get((nid, callee_key)) + if member_nid: + hits.add(member_nid) continue # an override shadows anything above it if nid in unresolved_base: return None # the method may live on the out-of-corpus base @@ -4660,6 +4687,11 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) - caller = rc.get("caller_nid") if not receiver or not callee or not caller: continue + # A member access (`db.Users`, #3528) types its receiver exactly like a + # call and then binds to a property instead of a method. It is never + # parked: the parked entries are cross-repo CALL candidates (#3152), + # and a property read on an out-of-corpus type is not one. + is_access = bool(rc.get("is_member_access")) src_file = rc.get("source_file", "") caller_node = node_by_id.get(caller) if receiver == "this": @@ -4685,7 +4717,8 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) - type_name = rc.get("receiver_type") type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: - _park_if_absent(type_name or receiver, caller_node, rc) + if not is_access: + _park_if_absent(type_name or receiver, caller_node, rc) continue type_qualified = True else: @@ -4694,20 +4727,23 @@ def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) - continue type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: # ambiguous or absent -> bail (god-node guard) - _park_if_absent(type_name, caller_node, rc) + if not is_access: + _park_if_absent(type_name, caller_node, rc) continue type_qualified = False - method_nid = _method_on_type_or_bases(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: + member_nid = _member_on_type_or_bases( + property_index if is_access else method_index, type_nid, _key(callee) + ) + if not member_nid: + continue # receiver typed, but the type has no such member — skip + if member_nid == caller or (caller, member_nid) in existing_pairs: continue - existing_pairs.add((caller, method_nid)) + existing_pairs.add((caller, member_nid)) all_edges.append({ "source": caller, - "target": method_nid, - "relation": "calls", - "context": "call", + "target": member_nid, + "relation": "uses" if is_access else "calls", + "context": "member_access" if is_access else "call", "confidence": "EXTRACTED" if type_qualified else "INFERRED", "confidence_score": 1.0 if type_qualified else 0.85, "source_file": src_file, diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3db8e7b09..2fbecc441 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3542,6 +3542,38 @@ def _csharp_member_call_parts(fn_node): return None +def _csharp_member_receiver(recv, source: bytes) -> str | None: + """The receiver name of a C# member access, or None when it is not simple. + + ``recv`` is the `expression` field of a member_access_expression. A bare + identifier reads as-is; `this` and `base` read as those literals, resolved + against the caller's own type (or its single resolvable base) in the + cross-file pass; `this.field` reads as the bare field name, so it is typed + exactly like `field` via the method's scoped receiver table. Any other + chain (`a.b`, a call result, `typeof(T)`) stays untyped — the resolver + bails rather than guessing. + """ + if recv is None: + return None + if recv.type == "identifier": + return _read_text(recv, source) + if recv.type in ("this", "this_expression"): + return "this" + if recv.type in ("base", "base_expression"): + return "base" + if recv.type == "member_access_expression": + inner = recv.child_by_field_name("expression") + fname = recv.child_by_field_name("name") + if ( + inner is not None + and inner.type in ("this", "this_expression") + and fname is not None + and fname.type == "identifier" + ): + return _read_text(fname, source) + return None + + def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: """Resolve a C# type name, whether it was qualified, and its qualifier prefix.""" if node is None: @@ -6974,28 +7006,7 @@ def walk_calls( # (#3406) — read the bare identifier instead. callee_name = _csharp_bare_call_name(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 in ("this", "this_expression"): - member_receiver = "this" - elif recv is not None and recv.type in ("base", "base_expression"): - # base.M(): resolved against the caller's single - # resolvable base class in the cross-file pass. - member_receiver = "base" - elif recv is not None and recv.type == "member_access_expression": - # this.field.M(): the explicit-`this` field access is - # typed exactly like a bare `field.M()` via the file - # table; any other chained receiver stays untyped - # (the resolver bails rather than guessing). - inner = recv.child_by_field_name("expression") - fname = recv.child_by_field_name("name") - if ( - inner is not None - and inner.type in ("this", "this_expression") - and fname is not None - and fname.type == "identifier" - ): - member_receiver = _read_text(fname, source) + member_receiver = _csharp_member_receiver(recv, source) elif fn_node is not None and fn_node.type == "identifier": callee_name = _read_text(fn_node, source) elif fn_node is not None and fn_node.type == "generic_name": @@ -7603,6 +7614,62 @@ def walk_calls( "weight": 1.0, }) + # C#: a member access that is not itself a call — `db.Users` inside + # `db.Users.Where(...)`, `order.Status`, `Config.Instance` (#3528). + # The invocation branch keeps only a simple receiver, so the chained + # `db.Users` was dropped on the floor and the DbSet property (a node + # since #3006) sat in the graph with nothing but its `defines` edge: + # "what code reads or writes this table" had no answer. Record the + # access as a raw entry typed from the same scoped receiver table the + # member calls use, for _resolve_csharp_member_calls to bind to the + # receiver type's property node. The callee of an invocation is its + # `function` field, and that is the only member_access_expression + # ever parented directly by one (arguments sit under argument_list), + # so the parent check is what separates `db.Users` from + # `db.Users.Add`. A generic_name member (`db.Set`) is a call, not a + # property, and is left to the call-site type-argument pass (#2911). + if ( + config.ts_module == "tree_sitter_c_sharp" + and node.type == "member_access_expression" + and not (node.parent is not None and node.parent.type == "invocation_expression") + ): + member_name = node.child_by_field_name("name") + access_receiver = ( + _csharp_member_receiver(node.child_by_field_name("expression"), source) + if member_name is not None and member_name.type == "identifier" + else None + ) + if access_receiver: + receiver_type = _csharp_scoped_receiver_type( + receiver_types, access_receiver, node.start_byte + ) + # Property reads are far more common than calls, and raw_calls + # ride the AST cache, so only record an entry the resolver can + # act on: `this`/`base`, a type name, or a typed receiver. An + # untyped lowercase receiver (`u.Email` on a lambda parameter) + # would be skipped there anyway. + if ( + receiver_type + or access_receiver in ("this", "base") + or access_receiver[:1].isupper() + ): + rc_entry = { + "caller_nid": caller_nid, + "callee": _read_text(member_name, source), + # is_member_call keeps every bare-name resolver off this + # entry, the way it does for a receiver call; + # is_member_access is what the C# resolver branches on. + "is_member_call": True, + "is_member_access": True, + "lang": "csharp", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "receiver": access_receiver, + } + if receiver_type: + rc_entry["receiver_type"] = receiver_type + raw_calls.append(rc_entry) + # Static property access: Foo::$bar → uses_static_prop edge if node.type in config.static_prop_types: scope_node = node.child_by_field_name("scope") diff --git a/tests/test_csharp_member_access.py b/tests/test_csharp_member_access.py new file mode 100644 index 000000000..e42d6f6ed --- /dev/null +++ b/tests/test_csharp_member_access.py @@ -0,0 +1,382 @@ +"""C# member access resolves to the receiver type's property node (#3528). + +An ORM query site never names its table: `db.Users.Where(...)` reaches the +`AspNetUsers` table through the `DbSet Users` property, and +only the property appears in source. The property has been a node since +#3006, but nothing pointed at it except its own type's `defines` edge — the +invocation branch keeps only a simple receiver, so the chained `db.Users` was +dropped and every query site in the corpus was invisible from the data layer. + +`recv.Prop` is now recorded like `recv.Method()` and resolved by the same +receiver-typed pass (#1609): the receiver is typed from the method-scoped +field/param/local table, the property is looked up on that type and its +`inherits` chain, and a `uses` edge lands on the property node. Same tiers as +the calls — `this.` / `Type.` are EXTRACTED, a typed receiver is INFERRED — +and the same guards: an untypable or ambiguous receiver yields no edge. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract, extract_csharp + + +def _extract(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=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + uses = [e for e in r["edges"] if e["relation"] == "uses"] + return uses, r + + +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 _pairs(uses): + return {(e["source"], e["target"]) for e in uses} + + +_EF = { + "Data/ApplicationUser.cs": ( + "namespace App.Data;\n" + "public class ApplicationUser {\n" + " public int Id { get; set; }\n" + " public string Email { get; set; }\n" + "}\n" + ), + "Data/Order.cs": ( + "namespace App.Data;\n" + "public class Order { public int Id { get; set; } }\n" + ), + "Data/AppDbContext.cs": ( + "using Microsoft.EntityFrameworkCore;\n" + "namespace App.Data;\n" + "public class AppDbContext : DbContext {\n" + " public DbSet Users { get; set; }\n" + " public DbSet Orders { get; set; }\n" + "}\n" + ), + "Services/UserService.cs": ( + "using System.Linq;\n" + "using App.Data;\n" + "namespace App.Services;\n" + "public class UserService {\n" + " private readonly AppDbContext db;\n" + " public UserService(AppDbContext db) { this.db = db; }\n" + " public ApplicationUser FindByEmail(string email) {\n" + " return db.Users.AsNoTracking().Where(u => u.Email == email).FirstOrDefault();\n" + " }\n" + " public int CountOrders() { return db.Orders.Count(); }\n" + " public void AddUser(ApplicationUser u) { db.Users.Add(u); }\n" + "}\n" + ), +} + + +def test_dbset_query_site_links_the_method_to_the_dbset_property(tmp_path): + # The issue's shape: the table name is nowhere in source, only the + # property is. This is the test that fails without the fix. + uses, r = _extract(tmp_path, _EF) + users = _find(r, "Users", "appdbcontext") + orders = _find(r, "Orders", "appdbcontext") + assert (_find(r, ".FindByEmail()"), users) in _pairs(uses) + assert (_find(r, ".CountOrders()"), orders) in _pairs(uses) + assert (_find(r, ".AddUser()"), users) in _pairs(uses) + + +def test_edge_carries_the_query_line_not_the_declaration_line(tmp_path): + uses, r = _extract(tmp_path, _EF) + edge = next(e for e in uses + if e["source"] == _find(r, ".CountOrders()")) + assert edge["source_location"] == "L10" + assert edge["source_file"].endswith("UserService.cs") + assert edge["context"] == "member_access" + # Typed through the field table, not named in source — INFERRED, like a + # `recv.Method()` call resolved the same way. + assert edge["confidence"] == "INFERRED" + assert edge["confidence_score"] == 0.85 + + +def test_untyped_lambda_parameter_yields_no_edge(tmp_path): + # `u => u.Email == email`: `u` is an untyped lambda parameter, so + # `u.Email` must not bind to ApplicationUser.Email — never a guess. + uses, r = _extract(tmp_path, _EF) + email = _find(r, "Email") + assert not any(t == email for _, t in _pairs(uses)) + + +def test_bare_access_without_a_call_is_an_edge_too(tmp_path): + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Ctx { public System.Collections.Generic.List Rows { get; set; } }\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public object Snapshot() { var rows = ctx.Rows; return rows; }\n" + " public void Walk() { foreach (var r in ctx.Rows) { } }\n" + "}\n" + ) + }) + rows = _find(r, "Rows") + assert (_find(r, ".Snapshot()"), rows) in _pairs(uses) + assert (_find(r, ".Walk()"), rows) in _pairs(uses) + + +def test_explicit_this_field_receiver_is_typed_like_the_bare_field(tmp_path): + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Ctx { public int Rows { get; set; } }\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Count() { return this.ctx.Rows; }\n" + "}\n" + ) + }) + assert (_find(r, ".Count()"), _find(r, "Rows")) in _pairs(uses) + + +def test_this_property_and_static_type_property_are_extracted(tmp_path): + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Config { public static Config Instance { get; set; } }\n" + "public class Svc {\n" + " public int Size { get; set; }\n" + " public int Own() { return this.Size; }\n" + " public Config Shared() { return Config.Instance; }\n" + "}\n" + ) + }) + by_pair = {(e["source"], e["target"]): e for e in uses} + own = by_pair[(_find(r, ".Own()"), _find(r, "Size"))] + shared = by_pair[(_find(r, ".Shared()"), _find(r, "Instance"))] + for edge in (own, shared): + assert edge["confidence"] == "EXTRACTED" + assert edge["confidence_score"] == 1.0 + + +def test_base_property_resolves_against_the_single_base_class(tmp_path): + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class BaseCtx { public int Rows { get; set; } }\n" + "public class Ctx : BaseCtx {\n" + " public int Count() { return base.Rows; }\n" + "}\n" + ) + }) + edge = next(e for e in uses if e["target"] == _find(r, "Rows")) + assert edge["source"] == _find(r, ".Count()") + assert edge["confidence"] == "EXTRACTED" + + +def test_interface_typed_receiver_binds_to_the_interface_property(tmp_path): + # The Clean Architecture shape: handlers take `IApplicationDbContext`, + # whose DbSet properties are declared on the interface. + uses, r = _extract(tmp_path, { + "S.cs": ( + "using Microsoft.EntityFrameworkCore;\n" + "public class TodoItem { public int Id { get; set; } }\n" + "public interface IAppDb { DbSet TodoItems { get; } }\n" + "public class Handler {\n" + " private readonly IAppDb _context;\n" + " public Handler(IAppDb context) { _context = context; }\n" + " public int Total() => _context.TodoItems.Count();\n" + "}\n" + ) + }) + assert (_find(r, ".Total()"), _find(r, "TodoItems", "iappdb")) in _pairs(uses) + + +def test_inherited_property_resolves_through_the_base_chain(tmp_path): + uses, r = _extract(tmp_path, { + "Base.cs": "public class BaseCtx { public int Rows { get; set; } }\n", + "S.cs": ( + "public class Ctx : BaseCtx { }\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Count() { return ctx.Rows; }\n" + "}\n" + ), + }) + assert (_find(r, ".Count()"), _find(r, "Rows", "basectx")) in _pairs(uses) + + +def test_partial_dbcontext_property_in_the_other_half(tmp_path): + # The DbSet lives in one half of a partial class and the receiver is + # typed by the class name; the merge must land before this pass. + uses, r = _extract(tmp_path, { + "Ctx.cs": "public partial class Ctx { }\n", + "Ctx.Sets.cs": "public partial class Ctx { public int Rows { get; set; } }\n", + "S.cs": ( + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Count() { return ctx.Rows; }\n" + "}\n" + ), + }) + assert (_find(r, ".Count()"), _find(r, "Rows")) in _pairs(uses) + + +def test_receiver_type_outside_the_corpus_yields_no_edge_and_is_not_parked(tmp_path): + # Both receiver tiers that can park a call: a typed lowercase receiver + # (`http` is an HttpContext) and an explicit type name (`Environment`). + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Svc {\n" + " public string Path(Microsoft.AspNetCore.Http.HttpContext http) {\n" + " return http.Request.Path + System.Environment.MachineName + Environment.NewLine;\n" + " }\n" + "}\n" + ) + }) + assert not uses + # Parked entries are cross-repo CALL candidates (#3152); a property read + # is not one and must not be parked as if it were. + path = next(n for n in r["nodes"] if n["label"] == ".Path()") + parked = (path.get("metadata") or {}).get("unresolved_calls", []) + assert not any(p.get("callee") in ("Request", "NewLine") for p in parked) + + +def test_ambiguous_receiver_type_yields_no_edge(tmp_path): + # Two `Ctx` types, neither in scope of the caller: the god-node guard + # that already applies to `ctx.Method()` applies to `ctx.Rows` too. + uses, r = _extract(tmp_path, { + "A.cs": "namespace A; public class Ctx { public int Rows { get; set; } }\n", + "B.cs": "namespace B; public class Ctx { public int Rows { get; set; } }\n", + "S.cs": ( + "namespace S;\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Count() { return ctx.Rows; }\n" + "}\n" + ), + }) + assert not uses + + +def test_field_or_method_member_is_not_a_property_edge(tmp_path): + # `_conn` is a field (no node, #3006) and `Save` a method group: neither + # is a property, so neither becomes a `uses` edge — and the method-group + # read must not become a `calls` edge either. + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Ctx {\n" + " public string _conn;\n" + " public bool Save() => true;\n" + "}\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public object Grab() { var f = ctx.Save; return ctx._conn; }\n" + "}\n" + ) + }) + assert not uses + grab = _find(r, ".Grab()") + assert not any(e["source"] == grab and e["relation"] == "calls" for e in r["edges"]) + + +def test_repeated_access_in_one_method_is_one_edge(tmp_path): + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Ctx { public int Rows { get; set; } }\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Twice() { return ctx.Rows + ctx.Rows; }\n" + "}\n" + ) + }) + assert len(uses) == 1 + + +def test_shadowing_local_of_another_type_poisons_the_receiver(tmp_path): + # The scoped-table rule from #2299: a local disagreeing with the field's + # type drops the name entirely rather than guessing. + uses, r = _extract(tmp_path, { + "S.cs": ( + "public class Ctx { public int Rows { get; set; } }\n" + "public class Other { public int Rows { get; set; } }\n" + "public class Svc {\n" + " private Ctx ctx;\n" + " public int Count() { Other ctx = new Other(); return ctx.Rows; }\n" + "}\n" + ) + }) + assert not uses + + +def test_generic_set_call_and_outer_chain_are_not_property_accesses(tmp_path): + # `db.Set()` names a generic method (the type argument is already + # linked by #2911); `db.Users.Local` only yields the inner `db.Users`. + uses, r = _extract(tmp_path, { + "S.cs": ( + "using Microsoft.EntityFrameworkCore;\n" + "public class Order { }\n" + "public class Ctx : DbContext { public DbSet Users { get; set; } }\n" + "public class Svc {\n" + " private Ctx db;\n" + " public object A() { return db.Set(); }\n" + " public object B() { return db.Users.Local; }\n" + "}\n" + ) + }) + assert _pairs(uses) == {(_find(r, ".B()"), _find(r, "Users"))} + + +def test_same_named_cpp_member_is_not_a_target(tmp_path): + # A C# receiver must never reach a C++ data member through the shared + # `defines` relation, even when the bare type name would resolve there. + uses, r = _extract(tmp_path, { + "w.hpp": "class Widget { public: int Size; };\n", + "S.cs": ( + "public class Svc {\n" + " public int Measure(Widget w) { return w.Size; }\n" + "}\n" + ), + }) + assert not uses + + +def test_raw_entry_is_flagged_as_both_member_call_and_member_access(tmp_path): + # `is_member_call` keeps every bare-name resolver off the entry, the way + # it does for `recv.Method()`; `is_member_access` is what the C# resolver + # branches on. Both must be present or another pass could claim it. + p = tmp_path / "S.cs" + p.write_text( + "public class Svc {\n" + " private Ctx db;\n" + " public object Q() { return db.Users; }\n" + "}\n" + ) + entries = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("callee") == "Users"] + assert len(entries) == 1 + rc = entries[0] + assert rc["is_member_call"] is True + assert rc["is_member_access"] is True + assert rc["lang"] == "csharp" + assert rc["receiver"] == "db" + assert rc["receiver_type"] == "Ctx" + assert rc["source_location"] == "L3" + + +def test_untypable_lowercase_receiver_records_no_raw_entry(tmp_path): + # raw_calls ride the AST cache; an access the resolver could never bind + # (no receiver type, not `this`/`base`, not a type name) is not recorded. + p = tmp_path / "S.cs" + p.write_text( + "public class Svc {\n" + " public object Q(System.Collections.Generic.List rows) {\n" + " return rows.Select(u => u.Email);\n" + " }\n" + "}\n" + ) + entries = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("is_member_access")] + assert entries == [] From 35d947a668f123378dc5fec740fdae37c115d081 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Sat, 19 Sep 2026 22:30:11 +0530 Subject: [PATCH 2/2] perf(csharp): cut the member-access pass's overhead; drop the stale changelog hunk (#3528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on jellyfin/jellyfin (2,208 .cs files, +26.6k access entries on top of 85.6k member calls, +6,347 `uses` edges), graph output byte-identical before and after: - engine.py: the member-access branch asked tree-sitter for `node.parent` twice per member_access_expression to tell a callee (`db.Users.Where`) from a property read (`db.Users`); `Node.parent` re-descends from the root on every call (~700ms over the corpus's 137k such nodes vs ~13ms for a set lookup). The walk is pre-order, so the invocation branch now records the callee node's id in a per-file `csharp_callee_ids` set and the access branch tests membership instead. Per-file extraction goes from +1.2-1.6s over base to +0.7-0.8s. - extract.py: `_resolve_type_name_nid` memoizes per (type_name, caller, file) — a method reads and calls the same receiver many times over, and every access is one more entry that types it (80k -> 40k scope-chain walks on the corpus). The `defines` scan that builds property_index is folded into the existing `method` scan: one pass over all_edges fills both twin indexes. `_resolve_csharp_member_calls` goes from 430ms to 288ms (base: 250ms). - CHANGELOG.md: the entry was written under `## 0.9.61 (unreleased)`; after rebasing onto v8 it lands inside the released 0.9.61 section. Release notes here are written at the version bump (see 9335cee), so the hunk is dropped rather than moved. --- CHANGELOG.md | 1 - graphify/extract.py | 67 +++++++++++++++++------------- graphify/extractors/engine.py | 16 +++++-- tests/test_csharp_member_access.py | 21 ++++++++++ 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 270eb7887..ee9780761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,7 +213,6 @@ Intra-class call binding across five languages, new language-structure coverage, ## 0.9.61 (2026-09-12) -- 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` property, whose table name never appears in source, are reachable from the property (#3528). - 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`. - 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. diff --git a/graphify/extract.py b/graphify/extract.py index 5d2fc5201..2ba63a184 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4557,35 +4557,31 @@ def _key(label: str) -> str: resolver = CsharpNameResolver(all_nodes, all_edges) # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type. - # C# owns its methods via `method` edges. + # C# owns its methods via `method` edges. property_index is the member- + # access twin (#3528): (type_node_id, property_key) -> property_node_id. A + # C# property is the target of a `defines` edge from its type (#3006); C++ + # data members ride the same relation, so keep to targets declared in a + # .cs file — a receiver typed by bare-name fallback must not reach a + # same-named C++ member. One pass over the edges fills both. method_index: dict[tuple[str, str], str] = {} + property_index: dict[tuple[str, str], str] = {} enclosing_type: dict[str, str] = {} for e in all_edges: - if e.get("relation") != "method": + rel = e.get("relation") + if rel != "method" and rel != "defines": 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 - - # (type_node_id, property_key) -> property_node_id, the member-access twin - # of method_index (#3528). A C# property is the target of a `defines` edge - # from its type (#3006); C++ data members ride the same relation, so keep - # to targets declared in a .cs file — a receiver typed by bare-name - # fallback must not reach a same-named C++ member. - property_index: dict[tuple[str, str], str] = {} - for e in all_edges: - if e.get("relation") != "defines": - continue - src, tgt = e.get("source"), e.get("target") - if not (isinstance(src, str) and isinstance(tgt, str)): - continue - tnode = node_by_id.get(tgt) - if tnode is None or not str(tnode.get("source_file", "")).endswith(".cs"): - continue - property_index[(src, _key(tnode.get("label", "")))] = tgt + if rel == "method": + enclosing_type.setdefault(tgt, src) + method_index[(src, _key(tnode.get("label", "")))] = tgt + elif ( + isinstance(src, str) and isinstance(tgt, str) + and str(tnode.get("source_file", "")).endswith(".cs") + ): + property_index[(src, _key(tnode.get("label", "")))] = tgt # Base-class chain from `inherits` edges (C# files only). The type-reference # pass has already re-pointed each resolvable base to its real definition and @@ -4639,6 +4635,13 @@ def _member_on_type_or_bases( frontier.extend(bases_of.get(nid, [])) return next(iter(hits)) if len(hits) == 1 else None + # (type_name, caller_nid, src_file) -> resolved type nid, or None. A + # method reads and calls the same receiver (`db`, `_context`) many times + # over, and every member access (#3528) is one more entry that types it, + # so resolve each name once per caller rather than re-walking the + # namespace/using scope chain for every site. + type_nid_memo: dict[tuple[str, str | None, str], str | None] = {} + def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None, src_file: str) -> str | None: """Resolve a declared type name to exactly one definition node id. @@ -4651,16 +4654,24 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None, """ if not type_name: return None + memo_key = ( + type_name, + caller_node.get("id") if caller_node is not None else None, + src_file, + ) + if memo_key in type_nid_memo: + return type_nid_memo[memo_key] + type_nid: str | None = None + decisive = False if caller_node is not None: - resolved, decisive = resolver.resolve_type_name( + type_nid, decisive = resolver.resolve_type_name( type_name, caller_node, src_file ) - if resolved: - return resolved - if decisive: - return None - type_defs = type_def_nids.get(_key(type_name), []) - return type_defs[0] if len(type_defs) == 1 else None + if not type_nid and not decisive: + type_defs = type_def_nids.get(_key(type_name), []) + type_nid = type_defs[0] if len(type_defs) == 1 else None + type_nid_memo[memo_key] = type_nid + return type_nid def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -> None: """Park a call whose receiver type is declared nowhere in this corpus (#3152). diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 2fbecc441..b1b97abf5 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -6537,6 +6537,12 @@ def scala_base_name(type_node) -> str | None: seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() seen_bind_pairs: set[tuple[str, str, str]] = set() + # C#: node ids of the member_access_expression that is an invocation's + # callee (`db.Users.Where` in `db.Users.Where(...)`), recorded when the + # invocation is visited so the member-access branch below can tell it + # apart from a property read (#3528) without asking tree-sitter for + # `node.parent`, which re-descends from the root on every call. + csharp_callee_ids: set[int] = set() raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract() # Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings, # populated before walk_calls runs. Lets member-call raw_calls carry a @@ -6998,6 +7004,8 @@ def walk_calls( fn_node = node.child_by_field_name("function") member_parts = _csharp_member_call_parts(fn_node) if member_parts is not None: + if fn_node.type == "member_access_expression": + csharp_callee_ids.add(fn_node.id) mname, recv = member_parts if mname is not None: # `recv.Get(...)`: the name field is a @@ -7624,14 +7632,16 @@ def walk_calls( # member calls use, for _resolve_csharp_member_calls to bind to the # receiver type's property node. The callee of an invocation is its # `function` field, and that is the only member_access_expression - # ever parented directly by one (arguments sit under argument_list), - # so the parent check is what separates `db.Users` from + # ever parented directly by one (arguments sit under argument_list); + # the walk is pre-order, so the invocation branch above has already + # put that node's id in csharp_callee_ids by the time the walk reaches + # it, and the id check is what separates `db.Users` from # `db.Users.Add`. A generic_name member (`db.Set`) is a call, not a # property, and is left to the call-site type-argument pass (#2911). if ( config.ts_module == "tree_sitter_c_sharp" and node.type == "member_access_expression" - and not (node.parent is not None and node.parent.type == "invocation_expression") + and node.id not in csharp_callee_ids ): member_name = node.child_by_field_name("name") access_receiver = ( diff --git a/tests/test_csharp_member_access.py b/tests/test_csharp_member_access.py index e42d6f6ed..4bd898161 100644 --- a/tests/test_csharp_member_access.py +++ b/tests/test_csharp_member_access.py @@ -380,3 +380,24 @@ def test_untypable_lowercase_receiver_records_no_raw_entry(tmp_path): ) entries = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("is_member_access")] assert entries == [] + + +def test_invocation_callee_is_a_call_entry_not_an_access_entry(tmp_path): + # `db.Save()` reaches the walk twice: once as the invocation, once as the + # member_access_expression that is its callee. The callee must surface + # only as the call — never as a second, dead access entry for `Save`. + # The walk is pre-order, so the invocation records the callee's node id + # before the access branch reaches it; nested and chained shapes must + # keep that straight too. + p = tmp_path / "S.cs" + p.write_text( + "public class Svc {\n" + " private Ctx db;\n" + " public void Q() { db.Save(); db.Log(db.Users); db.Users.Add(db.Orders.First()); }\n" + "}\n" + ) + raw = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("lang") == "csharp"] + accesses = sorted(rc["callee"] for rc in raw if rc.get("is_member_access")) + calls = sorted(rc["callee"] for rc in raw if not rc.get("is_member_access")) + assert accesses == ["Orders", "Users", "Users"] + assert calls == ["Add", "First", "Log", "Save"]