Skip to content

Commit 1ea67ba

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 ab03cb5 commit 1ea67ba

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
@@ -273,7 +273,6 @@ 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).
277276
- 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`.
278277
- 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.
279278

‎graphify/extract.py‎

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

47364736
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
4737-
# C# owns its methods via `method` edges.
4737+
# C# owns its methods via `method` edges. property_index is the member-
4738+
# access twin (#3528): (type_node_id, property_key) -> property_node_id. A
4739+
# C# property is the target of a `defines` edge from its type (#3006); C++
4740+
# data members ride the same relation, so keep to targets declared in a
4741+
# .cs file — a receiver typed by bare-name fallback must not reach a
4742+
# same-named C++ member. One pass over the edges fills both.
47384743
method_index: dict[tuple[str, str], str] = {}
4744+
property_index: dict[tuple[str, str], str] = {}
47394745
enclosing_type: dict[str, str] = {}
47404746
for e in all_edges:
4741-
if e.get("relation") != "method":
4747+
rel = e.get("relation")
4748+
if rel != "method" and rel != "defines":
47424749
continue
47434750
src, tgt = e.get("source"), e.get("target")
47444751
tnode = node_by_id.get(tgt)
47454752
if tnode is None:
47464753
continue
4747-
enclosing_type.setdefault(tgt, src)
4748-
method_index[(src, _key(tnode.get("label", "")))] = tgt
4749-
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
4754+
if rel == "method":
4755+
enclosing_type.setdefault(tgt, src)
4756+
method_index[(src, _key(tnode.get("label", "")))] = tgt
4757+
elif (
4758+
isinstance(src, str) and isinstance(tgt, str)
4759+
and str(tnode.get("source_file", "")).endswith(".cs")
4760+
):
4761+
property_index[(src, _key(tnode.get("label", "")))] = tgt
47664762

47674763
# Base-class chain from `inherits` edges (C# files only). The type-reference
47684764
# pass has already re-pointed each resolvable base to its real definition and
@@ -4816,6 +4812,13 @@ def _member_on_type_or_bases(
48164812
frontier.extend(bases_of.get(nid, []))
48174813
return next(iter(hits)) if len(hits) == 1 else None
48184814

4815+
# (type_name, caller_nid, src_file) -> resolved type nid, or None. A
4816+
# method reads and calls the same receiver (`db`, `_context`) many times
4817+
# over, and every member access (#3528) is one more entry that types it,
4818+
# so resolve each name once per caller rather than re-walking the
4819+
# namespace/using scope chain for every site.
4820+
type_nid_memo: dict[tuple[str, str | None, str], str | None] = {}
4821+
48194822
def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
48204823
src_file: str) -> str | None:
48214824
"""Resolve a declared type name to exactly one definition node id.
@@ -4828,16 +4831,24 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
48284831
"""
48294832
if not type_name:
48304833
return None
4834+
memo_key = (
4835+
type_name,
4836+
caller_node.get("id") if caller_node is not None else None,
4837+
src_file,
4838+
)
4839+
if memo_key in type_nid_memo:
4840+
return type_nid_memo[memo_key]
4841+
type_nid: str | None = None
4842+
decisive = False
48314843
if caller_node is not None:
4832-
resolved, decisive = resolver.resolve_type_name(
4844+
type_nid, decisive = resolver.resolve_type_name(
48334845
type_name, caller_node, src_file
48344846
)
4835-
if resolved:
4836-
return resolved
4837-
if decisive:
4838-
return None
4839-
type_defs = type_def_nids.get(_key(type_name), [])
4840-
return type_defs[0] if len(type_defs) == 1 else None
4847+
if not type_nid and not decisive:
4848+
type_defs = type_def_nids.get(_key(type_name), [])
4849+
type_nid = type_defs[0] if len(type_defs) == 1 else None
4850+
type_nid_memo[memo_key] = type_nid
4851+
return type_nid
48414852

48424853
def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -> None:
48434854
"""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
@@ -6928,6 +6928,12 @@ def scala_base_name(type_node) -> str | None:
69286928
seen_static_ref_pairs: set[tuple[str, str, str]] = set()
69296929
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
69306930
seen_bind_pairs: set[tuple[str, str, str]] = set()
6931+
# C#: node ids of the member_access_expression that is an invocation's
6932+
# callee (`db.Users.Where` in `db.Users.Where(...)`), recorded when the
6933+
# invocation is visited so the member-access branch below can tell it
6934+
# apart from a property read (#3528) without asking tree-sitter for
6935+
# `node.parent`, which re-descends from the root on every call.
6936+
csharp_callee_ids: set[int] = set()
69316937
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
69326938
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
69336939
# populated before walk_calls runs. Lets member-call raw_calls carry a
@@ -7394,6 +7400,8 @@ def walk_calls(
73947400
fn_node = node.child_by_field_name("function")
73957401
member_parts = _csharp_member_call_parts(fn_node)
73967402
if member_parts is not None:
7403+
if fn_node.type == "member_access_expression":
7404+
csharp_callee_ids.add(fn_node.id)
73977405
mname, recv = member_parts
73987406
if mname is not None:
73997407
# `recv.Get<int>(...)`: the name field is a
@@ -8046,14 +8054,16 @@ def walk_calls(
80468054
# member calls use, for _resolve_csharp_member_calls to bind to the
80478055
# receiver type's property node. The callee of an invocation is its
80488056
# `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
8057+
# ever parented directly by one (arguments sit under argument_list);
8058+
# the walk is pre-order, so the invocation branch above has already
8059+
# put that node's id in csharp_callee_ids by the time the walk reaches
8060+
# it, and the id check is what separates `db.Users` from
80518061
# `db.Users.Add`. A generic_name member (`db.Set<T>`) is a call, not a
80528062
# property, and is left to the call-site type-argument pass (#2911).
80538063
if (
80548064
config.ts_module == "tree_sitter_c_sharp"
80558065
and node.type == "member_access_expression"
8056-
and not (node.parent is not None and node.parent.type == "invocation_expression")
8066+
and node.id not in csharp_callee_ids
80578067
):
80588068
member_name = node.child_by_field_name("name")
80598069
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)