Skip to content

Commit 3f305bf

Browse files
perf(csharp): cut the member-access pass's overhead; drop the stale changelog hunk (#3528)
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.
1 parent 1a92f5e commit 3f305bf

4 files changed

Lines changed: 73 additions & 32 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,6 @@ 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).
330329
- 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`.
331330
- 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.
332331

‎graphify/extract.py‎

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4859,35 +4859,31 @@ def _key(label: str) -> str:
48594859
resolver = CsharpNameResolver(all_nodes, all_edges)
48604860

48614861
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
4862-
# C# owns its methods via `method` edges.
4862+
# C# owns its methods via `method` edges. property_index is the member-
4863+
# access twin (#3528): (type_node_id, property_key) -> property_node_id. A
4864+
# C# property is the target of a `defines` edge from its type (#3006); C++
4865+
# data members ride the same relation, so keep to targets declared in a
4866+
# .cs file — a receiver typed by bare-name fallback must not reach a
4867+
# same-named C++ member. One pass over the edges fills both.
48634868
method_index: dict[tuple[str, str], str] = {}
4869+
property_index: dict[tuple[str, str], str] = {}
48644870
enclosing_type: dict[str, str] = {}
48654871
for e in all_edges:
4866-
if e.get("relation") != "method":
4872+
rel = e.get("relation")
4873+
if rel != "method" and rel != "defines":
48674874
continue
48684875
src, tgt = e.get("source"), e.get("target")
48694876
tnode = node_by_id.get(tgt)
48704877
if tnode is None:
48714878
continue
4872-
enclosing_type.setdefault(tgt, src)
4873-
method_index[(src, _key(tnode.get("label", "")))] = tgt
4874-
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
4879+
if rel == "method":
4880+
enclosing_type.setdefault(tgt, src)
4881+
method_index[(src, _key(tnode.get("label", "")))] = tgt
4882+
elif (
4883+
isinstance(src, str) and isinstance(tgt, str)
4884+
and str(tnode.get("source_file", "")).endswith(".cs")
4885+
):
4886+
property_index[(src, _key(tnode.get("label", "")))] = tgt
48914887

48924888
# Base-class chain from `inherits` edges (C# files only). The type-reference
48934889
# pass has already re-pointed each resolvable base to its real definition and
@@ -4941,6 +4937,13 @@ def _member_on_type_or_bases(
49414937
frontier.extend(bases_of.get(nid, []))
49424938
return next(iter(hits)) if len(hits) == 1 else None
49434939

4940+
# (type_name, caller_nid, src_file) -> resolved type nid, or None. A
4941+
# method reads and calls the same receiver (`db`, `_context`) many times
4942+
# over, and every member access (#3528) is one more entry that types it,
4943+
# so resolve each name once per caller rather than re-walking the
4944+
# namespace/using scope chain for every site.
4945+
type_nid_memo: dict[tuple[str, str | None, str], str | None] = {}
4946+
49444947
def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
49454948
src_file: str) -> str | None:
49464949
"""Resolve a declared type name to exactly one definition node id.
@@ -4953,16 +4956,24 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
49534956
"""
49544957
if not type_name:
49554958
return None
4959+
memo_key = (
4960+
type_name,
4961+
caller_node.get("id") if caller_node is not None else None,
4962+
src_file,
4963+
)
4964+
if memo_key in type_nid_memo:
4965+
return type_nid_memo[memo_key]
4966+
type_nid: str | None = None
4967+
decisive = False
49564968
if caller_node is not None:
4957-
resolved, decisive = resolver.resolve_type_name(
4969+
type_nid, decisive = resolver.resolve_type_name(
49584970
type_name, caller_node, src_file
49594971
)
4960-
if resolved:
4961-
return resolved
4962-
if decisive:
4963-
return None
4964-
type_defs = type_def_nids.get(_key(type_name), [])
4965-
return type_defs[0] if len(type_defs) == 1 else None
4972+
if not type_nid and not decisive:
4973+
type_defs = type_def_nids.get(_key(type_name), [])
4974+
type_nid = type_defs[0] if len(type_defs) == 1 else None
4975+
type_nid_memo[memo_key] = type_nid
4976+
return type_nid
49664977

49674978
def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -> None:
49684979
"""Park a call whose receiver type is declared nowhere in this corpus (#3152).

‎graphify/extractors/engine.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7183,6 +7183,12 @@ def scala_base_name(type_node) -> str | None:
71837183
seen_static_ref_pairs: set[tuple[str, str, str]] = set()
71847184
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
71857185
seen_bind_pairs: set[tuple[str, str, str]] = set()
7186+
# C#: node ids of the member_access_expression that is an invocation's
7187+
# callee (`db.Users.Where` in `db.Users.Where(...)`), recorded when the
7188+
# invocation is visited so the member-access branch below can tell it
7189+
# apart from a property read (#3528) without asking tree-sitter for
7190+
# `node.parent`, which re-descends from the root on every call.
7191+
csharp_callee_ids: set[int] = set()
71867192
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
71877193
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
71887194
# populated before walk_calls runs. Lets member-call raw_calls carry a
@@ -7664,6 +7670,8 @@ def walk_calls(
76647670
member_parts = _csharp_member_call_parts(fn_node)
76657671
csharp_table = receiver_types if isinstance(receiver_types, tuple) else None
76667672
if member_parts is not None:
7673+
if fn_node.type == "member_access_expression":
7674+
csharp_callee_ids.add(fn_node.id)
76677675
mname, recv = member_parts
76687676
if mname is not None:
76697677
# `recv.Get<int>(...)`: the name field is a
@@ -8363,14 +8371,16 @@ def walk_calls(
83638371
# member calls use, for _resolve_csharp_member_calls to bind to the
83648372
# receiver type's property node. The callee of an invocation is its
83658373
# `function` field, and that is the only member_access_expression
8366-
# ever parented directly by one (arguments sit under argument_list),
8367-
# so the parent check is what separates `db.Users` from
8374+
# ever parented directly by one (arguments sit under argument_list);
8375+
# the walk is pre-order, so the invocation branch above has already
8376+
# put that node's id in csharp_callee_ids by the time the walk reaches
8377+
# it, and the id check is what separates `db.Users` from
83688378
# `db.Users.Add`. A generic_name member (`db.Set<T>`) is a call, not a
83698379
# property, and is left to the call-site type-argument pass (#2911).
83708380
if (
83718381
config.ts_module == "tree_sitter_c_sharp"
83728382
and node.type == "member_access_expression"
8373-
and not (node.parent is not None and node.parent.type == "invocation_expression")
8383+
and node.id not in csharp_callee_ids
83748384
):
83758385
member_name = node.child_by_field_name("name")
83768386
access_receiver = (

‎tests/test_csharp_member_access.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,24 @@ def test_untypable_lowercase_receiver_records_no_raw_entry(tmp_path):
398398
)
399399
entries = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("is_member_access")]
400400
assert entries == []
401+
402+
403+
def test_invocation_callee_is_a_call_entry_not_an_access_entry(tmp_path):
404+
# `db.Save()` reaches the walk twice: once as the invocation, once as the
405+
# member_access_expression that is its callee. The callee must surface
406+
# only as the call — never as a second, dead access entry for `Save`.
407+
# The walk is pre-order, so the invocation records the callee's node id
408+
# before the access branch reaches it; nested and chained shapes must
409+
# keep that straight too.
410+
p = tmp_path / "S.cs"
411+
p.write_text(
412+
"public class Svc {\n"
413+
" private Ctx db;\n"
414+
" public void Q() { db.Save(); db.Log(db.Users); db.Users.Add(db.Orders.First()); }\n"
415+
"}\n"
416+
)
417+
raw = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("lang") == "csharp"]
418+
accesses = sorted(rc["callee"] for rc in raw if rc.get("is_member_access"))
419+
calls = sorted(rc["callee"] for rc in raw if not rc.get("is_member_access"))
420+
assert accesses == ["Orders", "Users", "Users"]
421+
assert calls == ["Add", "First", "Log", "Save"]

0 commit comments

Comments
 (0)