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
107 changes: 77 additions & 30 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4530,6 +4530,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 All @@ -4550,18 +4557,31 @@ def _key(label: str) -> str:
resolver = CsharpNameResolver(all_nodes, all_edges)

# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
# C# owns its methods via `method` edges.
# C# owns its methods via `method` edges. property_index is the member-
# access twin (#3528): (type_node_id, property_key) -> property_node_id. 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. One pass over the edges fills both.
method_index: dict[tuple[str, str], str] = {}
property_index: dict[tuple[str, str], str] = {}
enclosing_type: dict[str, str] = {}
for e in all_edges:
if e.get("relation") != "method":
rel = e.get("relation")
if rel != "method" and rel != "defines":
continue
src, tgt = e.get("source"), e.get("target")
tnode = node_by_id.get(tgt)
if tnode is None:
continue
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt
if rel == "method":
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt
elif (
isinstance(src, str) and isinstance(tgt, str)
and str(tnode.get("source_file", "")).endswith(".cs")
):
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
Expand All @@ -4587,13 +4607,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 @@ -4603,15 +4626,22 @@ 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
frontier.extend(bases_of.get(nid, []))
return next(iter(hits)) if len(hits) == 1 else None

# (type_name, caller_nid, src_file) -> resolved type nid, or None. A
# method reads and calls the same receiver (`db`, `_context`) many times
# over, and every member access (#3528) is one more entry that types it,
# so resolve each name once per caller rather than re-walking the
# namespace/using scope chain for every site.
type_nid_memo: dict[tuple[str, str | None, str], str | None] = {}

def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
src_file: str) -> str | None:
"""Resolve a declared type name to exactly one definition node id.
Expand All @@ -4624,16 +4654,24 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None,
"""
if not type_name:
return None
memo_key = (
type_name,
caller_node.get("id") if caller_node is not None else None,
src_file,
)
if memo_key in type_nid_memo:
return type_nid_memo[memo_key]
type_nid: str | None = None
decisive = False
if caller_node is not None:
resolved, decisive = resolver.resolve_type_name(
type_nid, decisive = resolver.resolve_type_name(
type_name, caller_node, src_file
)
if resolved:
return resolved
if decisive:
return None
type_defs = type_def_nids.get(_key(type_name), [])
return type_defs[0] if len(type_defs) == 1 else None
if not type_nid and not decisive:
type_defs = type_def_nids.get(_key(type_name), [])
type_nid = type_defs[0] if len(type_defs) == 1 else None
type_nid_memo[memo_key] = type_nid
return type_nid

def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -> None:
"""Park a call whose receiver type is declared nowhere in this corpus (#3152).
Expand All @@ -4660,6 +4698,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 @@ -4685,7 +4728,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 @@ -4694,20 +4738,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.85,
"source_file": src_file,
Expand Down
121 changes: 99 additions & 22 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3542,6 +3542,38 @@ def _csharp_member_call_parts(fn_node):
return None


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 @@ -6505,6 +6537,12 @@ def scala_base_name(type_node) -> str | None:
seen_static_ref_pairs: set[tuple[str, str, str]] = set()
seen_helper_ref_pairs: set[tuple[str, str, str]] = set()
seen_bind_pairs: set[tuple[str, str, str]] = set()
# C#: node ids of the member_access_expression that is an invocation's
# callee (`db.Users.Where` in `db.Users.Where(...)`), recorded when the
# invocation is visited so the member-access branch below can tell it
# apart from a property read (#3528) without asking tree-sitter for
# `node.parent`, which re-descends from the root on every call.
csharp_callee_ids: set[int] = set()
raw_calls: list[dict] = [] # unresolved calls for cross-file resolution in extract()
# Ruby: per-method `var -> ClassName` table from `var = Const.new` bindings,
# populated before walk_calls runs. Lets member-call raw_calls carry a
Expand Down Expand Up @@ -6966,6 +7004,8 @@ def walk_calls(
fn_node = node.child_by_field_name("function")
member_parts = _csharp_member_call_parts(fn_node)
if member_parts is not None:
if fn_node.type == "member_access_expression":
csharp_callee_ids.add(fn_node.id)
mname, recv = member_parts
if mname is not None:
# `recv.Get<int>(...)`: the name field is a
Expand All @@ -6974,28 +7014,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 @@ -7603,6 +7622,64 @@ 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);
# the walk is pre-order, so the invocation branch above has already
# put that node's id in csharp_callee_ids by the time the walk reaches
# it, and the id 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 node.id not in csharp_callee_ids
):
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
Loading