From ab92f9810381e3fc3cc2a7e48f90cbf3420bdd7a Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Mon, 14 Sep 2026 00:03:12 +0530 Subject: [PATCH] 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 cb65991657..4b1f3dd504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.61 (unreleased) +- 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 90a6ab0267..8cd93778ac 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3905,6 +3905,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: @@ -3938,6 +3945,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 @@ -3962,13 +3986,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() @@ -3978,9 +4005,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 @@ -4035,6 +4062,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": @@ -4060,7 +4092,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: @@ -4069,20 +4102,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.8, "source_file": src_file, diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index aeec2574aa..bf8d952906 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2962,6 +2962,38 @@ def _csharp_bare_call_name(name_node, source: bytes) -> str: return _read_text(name_node, source) +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: @@ -5534,28 +5566,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": @@ -6057,6 +6068,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 0000000000..e6229ff4c1 --- /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.8 + + +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 == []