Skip to content

Commit fd5b241

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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 0f8d29d7326acb10c89bbd87cc1cdcb842c32f5a)
1 parent ab92f98 commit fd5b241

4 files changed

Lines changed: 72 additions & 32 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ Full release notes with details on each version: [GitHub Releases](https://githu
44

55
## 0.9.61 (unreleased)
66

7-
- 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).
87
- 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`.
98
- 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.
109

‎graphify/extract.py‎

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

39343934
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
3935-
# C# owns its methods via `method` edges.
3935+
# C# owns its methods via `method` edges. property_index is the member-
3936+
# access twin (#3528): (type_node_id, property_key) -> property_node_id. A
3937+
# C# property is the target of a `defines` edge from its type (#3006); C++
3938+
# data members ride the same relation, so keep to targets declared in a
3939+
# .cs file — a receiver typed by bare-name fallback must not reach a
3940+
# same-named C++ member. One pass over the edges fills both.
39363941
method_index: dict[tuple[str, str], str] = {}
3942+
property_index: dict[tuple[str, str], str] = {}
39373943
enclosing_type: dict[str, str] = {}
39383944
for e in all_edges:
3939-
if e.get("relation") != "method":
3945+
rel = e.get("relation")
3946+
if rel != "method" and rel != "defines":
39403947
continue
39413948
src, tgt = e.get("source"), e.get("target")
39423949
tnode = node_by_id.get(tgt)
39433950
if tnode is None:
39443951
continue
3945-
enclosing_type.setdefault(tgt, src)
3946-
method_index[(src, _key(tnode.get("label", "")))] = tgt
3947-
3948-
# (type_node_id, property_key) -> property_node_id, the member-access twin
3949-
# of method_index (#3528). A C# property is the target of a `defines` edge
3950-
# from its type (#3006); C++ data members ride the same relation, so keep
3951-
# to targets declared in a .cs file — a receiver typed by bare-name
3952-
# fallback must not reach a same-named C++ member.
3953-
property_index: dict[tuple[str, str], str] = {}
3954-
for e in all_edges:
3955-
if e.get("relation") != "defines":
3956-
continue
3957-
src, tgt = e.get("source"), e.get("target")
3958-
if not (isinstance(src, str) and isinstance(tgt, str)):
3959-
continue
3960-
tnode = node_by_id.get(tgt)
3961-
if tnode is None or not str(tnode.get("source_file", "")).endswith(".cs"):
3962-
continue
3963-
property_index[(src, _key(tnode.get("label", "")))] = tgt
3952+
if rel == "method":
3953+
enclosing_type.setdefault(tgt, src)
3954+
method_index[(src, _key(tnode.get("label", "")))] = tgt
3955+
elif (
3956+
isinstance(src, str) and isinstance(tgt, str)
3957+
and str(tnode.get("source_file", "")).endswith(".cs")
3958+
):
3959+
property_index[(src, _key(tnode.get("label", "")))] = tgt
39643960

39653961
# Base-class chain from `inherits` edges (C# files only). The type-reference
39663962
# pass has already re-pointed each resolvable base to its real definition and
@@ -4014,6 +4010,13 @@ def _member_on_type_or_bases(
40144010
frontier.extend(bases_of.get(nid, []))
40154011
return next(iter(hits)) if len(hits) == 1 else None
40164012

4013+
# (type_name, caller_nid, src_file) -> resolved type nid, or None. A
4014+
# method reads and calls the same receiver (`db`, `_context`) many times
4015+
# over, and every member access (#3528) is one more entry that types it,
4016+
# so resolve each name once per caller rather than re-walking the
4017+
# namespace/using scope chain for every site.
4018+
type_nid_memo: dict[tuple[str, str | None, str], str | None] = {}
4019+
40174020
def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
40184021
src_file: str) -> str | None:
40194022
"""Resolve a declared type name to exactly one definition node id.
@@ -4026,16 +4029,24 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
40264029
"""
40274030
if not type_name:
40284031
return None
4032+
memo_key = (
4033+
type_name,
4034+
caller_node.get("id") if caller_node is not None else None,
4035+
src_file,
4036+
)
4037+
if memo_key in type_nid_memo:
4038+
return type_nid_memo[memo_key]
4039+
type_nid: str | None = None
4040+
decisive = False
40294041
if caller_node is not None:
4030-
resolved, decisive = resolver.resolve_type_name(
4042+
type_nid, decisive = resolver.resolve_type_name(
40314043
type_name, caller_node, src_file
40324044
)
4033-
if resolved:
4034-
return resolved
4035-
if decisive:
4036-
return None
4037-
type_defs = type_def_nids.get(_key(type_name), [])
4038-
return type_defs[0] if len(type_defs) == 1 else None
4045+
if not type_nid and not decisive:
4046+
type_defs = type_def_nids.get(_key(type_name), [])
4047+
type_nid = type_defs[0] if len(type_defs) == 1 else None
4048+
type_nid_memo[memo_key] = type_nid
4049+
return type_nid
40394050

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

‎graphify/extractors/engine.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5174,6 +5174,12 @@ def scala_base_name(type_node) -> str | None:
51745174
seen_static_ref_pairs: set[tuple[str, str, str]] = set()
51755175
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
51765176
seen_bind_pairs: set[tuple[str, str, str]] = set()
5177+
# C#: node ids of the member_access_expression that is an invocation's
5178+
# callee (`db.Users.Where` in `db.Users.Where(...)`), recorded when the
5179+
# invocation is visited so the member-access branch below can tell it
5180+
# apart from a property read (#3528) without asking tree-sitter for
5181+
# `node.parent`, which re-descends from the root on every call.
5182+
csharp_callee_ids: set[int] = set()
51775183
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
51785184
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
51795185
# populated before walk_calls runs. Lets member-call raw_calls carry a
@@ -5557,6 +5563,7 @@ def walk_calls(
55575563
# `_server.Save()` to an unrelated `Cache.Save()` (#1609).
55585564
fn_node = node.child_by_field_name("function")
55595565
if fn_node is not None and fn_node.type == "member_access_expression":
5566+
csharp_callee_ids.add(fn_node.id)
55605567
mname = fn_node.child_by_field_name("name")
55615568
recv = fn_node.child_by_field_name("expression")
55625569
if mname is not None:
@@ -6078,14 +6085,16 @@ def walk_calls(
60786085
# member calls use, for _resolve_csharp_member_calls to bind to the
60796086
# receiver type's property node. The callee of an invocation is its
60806087
# `function` field, and that is the only member_access_expression
6081-
# ever parented directly by one (arguments sit under argument_list),
6082-
# so the parent check is what separates `db.Users` from
6088+
# ever parented directly by one (arguments sit under argument_list);
6089+
# the walk is pre-order, so the invocation branch above has already
6090+
# put that node's id in csharp_callee_ids by the time the walk reaches
6091+
# it, and the id check is what separates `db.Users` from
60836092
# `db.Users.Add`. A generic_name member (`db.Set<T>`) is a call, not a
60846093
# property, and is left to the call-site type-argument pass (#2911).
60856094
if (
60866095
config.ts_module == "tree_sitter_c_sharp"
60876096
and node.type == "member_access_expression"
6088-
and not (node.parent is not None and node.parent.type == "invocation_expression")
6097+
and node.id not in csharp_callee_ids
60896098
):
60906099
member_name = node.child_by_field_name("name")
60916100
access_receiver = (

‎tests/test_csharp_member_access.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,24 @@ def test_untypable_lowercase_receiver_records_no_raw_entry(tmp_path):
380380
)
381381
entries = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("is_member_access")]
382382
assert entries == []
383+
384+
385+
def test_invocation_callee_is_a_call_entry_not_an_access_entry(tmp_path):
386+
# `db.Save()` reaches the walk twice: once as the invocation, once as the
387+
# member_access_expression that is its callee. The callee must surface
388+
# only as the call — never as a second, dead access entry for `Save`.
389+
# The walk is pre-order, so the invocation records the callee's node id
390+
# before the access branch reaches it; nested and chained shapes must
391+
# keep that straight too.
392+
p = tmp_path / "S.cs"
393+
p.write_text(
394+
"public class Svc {\n"
395+
" private Ctx db;\n"
396+
" public void Q() { db.Save(); db.Log(db.Users); db.Users.Add(db.Orders.First()); }\n"
397+
"}\n"
398+
)
399+
raw = [rc for rc in extract_csharp(p)["raw_calls"] if rc.get("lang") == "csharp"]
400+
accesses = sorted(rc["callee"] for rc in raw if rc.get("is_member_access"))
401+
calls = sorted(rc["callee"] for rc in raw if not rc.get("is_member_access"))
402+
assert accesses == ["Orders", "Users", "Users"]
403+
assert calls == ["Add", "First", "Log", "Save"]

0 commit comments

Comments
 (0)