Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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.

Expand Down
74 changes: 55 additions & 19 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`` 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:
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand All @@ -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,
Expand Down
111 changes: 89 additions & 22 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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<T>`) 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")
Expand Down
Loading