feat(csharp): namespace-aware member-call resolution + shadow poisoning (#1609)

Adapted from #1620 by @TheFedaikin, reworked onto v8 as a focused change
(without the module split or the references-fallback behavior change).

Builds on the shipped #1609 resolver: instead of bailing when a receiver's
class name is ambiguous corpus-wide, the declared type is resolved with a
shared CsharpNameResolver (same-namespace, using-directive, and alias aware)
against the caller's namespace/scope, falling back to the unique bare match
only when scoping is non-decisive. Adds base./this.field receivers and
inherited-member lookup through the inherits chain (an out-of-corpus base
poisons the lookup, so no wrong edge). The per-file type table now poisons a
name on any conflicting rebinding, killing the wrong-edge class where a local
shadows a field of a different type. C#-gated; never emits a wrong edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-26 12:43:17 +01:00
co-authored by Claude Opus 4.8
parent 8adb261d16
commit 0858954db9
4 changed files with 518 additions and 162 deletions
+112 -14
View File
@@ -34,6 +34,7 @@ from graphify.extractors.apex import extract_apex # noqa: F401
from graphify.extractors.bash import extract_bash # noqa: F401
from graphify.extractors.blade import extract_blade # noqa: F401
from graphify.extractors.csharp import (
CsharpNameResolver,
_resolve_cross_file_csharp_imports,
_resolve_csharp_type_references,
)
@@ -2517,20 +2518,32 @@ def _resolve_csharp_member_calls(
all_edges: list[dict],
) -> None:
"""Resolve C# member calls (``recv.Method()``) to the receiver's declared type
(#1609).
(#1609), namespace-aware (#1620).
The shared cross-file pass drops every ``is_member_call`` because a bare method
name collides across the corpus — and for C# an in-file bare match silently
mis-bound ``_server.Save()`` to an unrelated ``Cache.Save()``. The C# extractor
now records each member call's receiver plus a per-file ``name -> Type`` table
(``csharp_type_table``) of fields/properties/params/locals. This pass types the
receiver, then emits an edge ONLY when that type resolves to exactly ONE
definition (the god-node guard); an untypable receiver is skipped (no guess).
(``csharp_type_table``) of fields/properties/params/locals (with conflicting
rebindings POISONED out, so a shadowing local of a different type produces no
edge rather than a wrong one). This pass types the receiver, then resolves the
declared type name with the same namespace/using/alias scoping machinery the
type-reference pass uses (``CsharpNameResolver``), so a class name duplicated
across namespaces still binds to the one in scope; only when scoping knows
nothing about the name does it fall back to the corpus-wide unique bare-name
match (the god-node guard). An untypable/ambiguous receiver is skipped — never
a guess.
Receiver typing, by precision tier:
* ``this.M()`` — receiver is the caller's own enclosing class -> EXTRACTED.
* ``base.M()`` — the caller's single resolvable base class -> EXTRACTED.
* ``Type.M()`` (capitalized) — the type is named explicitly in source -> EXTRACTED.
* ``recv.M()`` — ``recv`` typed via the file's field/param/local table -> INFERRED.
* ``recv.M()`` / ``this.recv.M()`` — ``recv`` typed via the file's
field/param/local table -> INFERRED.
A method not declared on the receiver's type is looked up through its
``inherits`` chain; a chain containing an unresolvable (out-of-corpus) base
poisons the lookup — the method may live there, so no edge is emitted.
Must run after id-disambiguation so node ids and caller_nids are final.
"""
@@ -2552,6 +2565,11 @@ def _resolve_csharp_member_calls(
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
# Namespace/using/alias-aware simple-name resolution, shared with the C#
# type-reference pass (which has already arbitrated inherits/implements/
# references targets by the time the resolver registry runs).
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.
method_index: dict[tuple[str, str], str] = {}
@@ -2566,6 +2584,78 @@ def _resolve_csharp_member_calls(
enclosing_type.setdefault(tgt, src)
method_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
# the derived type's base chain as UNRESOLVED (poison: an inherited-member
# lookup through it must bail, the member may be declared out of corpus).
bases_of: dict[str, list[str]] = {}
unresolved_base: set[str] = set()
for e in all_edges:
if e.get("relation") != "inherits":
continue
src_file = e.get("source_file")
if not (isinstance(src_file, str) and src_file.endswith(".cs")):
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 tnode.get("source_file"):
unresolved_base.add(src)
else:
bucket = bases_of.setdefault(src, [])
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.
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.
"""
hits: set[str] = set()
seen: set[str] = set()
frontier = [type_nid]
while frontier:
nid = frontier.pop()
if nid in seen:
continue
seen.add(nid)
method_nid = method_index.get((nid, callee_key))
if method_nid:
hits.add(method_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
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.
Namespace/using/alias scoping first (so `Svc` duplicated across
namespaces binds to the one in scope); when scoping is decisive but
ambiguous, bail. Only when scoping knows nothing about the name fall
back to the corpus-wide unique bare-name match (which also covers
nested types, absent from the scoped index).
"""
if not type_name:
return None
if caller_node is not None:
resolved, 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
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
@@ -2580,33 +2670,41 @@ def _resolve_csharp_member_calls(
if not receiver or not callee or not caller:
continue
src_file = rc.get("source_file", "")
caller_node = node_by_id.get(caller)
if receiver == "this":
type_nid = enclosing_type.get(caller)
if not type_nid:
continue
type_qualified = True
elif receiver == "base":
enclosing = enclosing_type.get(caller)
if not enclosing or enclosing in unresolved_base:
continue
bases = bases_of.get(enclosing, [])
if len(bases) != 1: # no base, or can't tell which — bail
continue
type_nid = bases[0]
type_qualified = True
elif receiver[:1].isupper():
# Type.M() — the type is named explicitly (also covers a Pascal-cased
# local whose name equals its type, resolved via the table below if the
# explicit-type lookup misses).
type_defs = type_def_nids.get(_key(receiver), [])
if len(type_defs) != 1:
type_nid = _resolve_type_name_nid(receiver, caller_node, src_file)
if not type_nid:
type_name = type_table_by_file.get(src_file, {}).get(receiver)
type_defs = type_def_nids.get(_key(type_name), []) if type_name else []
if len(type_defs) != 1:
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
if not type_nid:
continue
type_nid = type_defs[0]
type_qualified = True
else:
type_name = type_table_by_file.get(src_file, {}).get(receiver)
if not type_name:
continue
type_defs = type_def_nids.get(_key(type_name), [])
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
type_nid = _resolve_type_name_nid(type_name, caller_node, src_file)
if not type_nid: # ambiguous or absent -> bail (god-node guard)
continue
type_nid = type_defs[0]
type_qualified = False
method_nid = method_index.get((type_nid, _key(callee)))
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:
+175 -127
View File
@@ -149,6 +149,176 @@ def _resolve_cross_file_csharp_imports(
]
def _is_cs_file(value: object) -> bool:
return isinstance(value, str) and value.endswith(".cs")
def _metadata(value: object) -> dict:
return value if isinstance(value, dict) else {}
class CsharpNameResolver:
"""Namespace/using/alias-aware C# simple-name resolution.
Factored out of ``_resolve_csharp_type_references`` so the member-call pass
(``_resolve_csharp_member_calls`` in ``graphify/extract.py``) can type a
receiver with the same scoping rules the type-reference side already applies
(the core of #1620), instead of bailing on any corpus-wide bare-name clash.
Built from graph-stamped facts only: the ``(namespace, name)`` type-definition
index plus the per-file ``using``/alias import edges (each carrying its lexical
scope — ``("file", None)`` applies file-wide; ``("namespace", scope_id)``
applies only where ``scope_id`` is in the referencing node's ``scope_chain``).
"""
def __init__(self, all_nodes: list[dict], all_edges: list[dict]) -> None:
self.node_by_id: dict[str, dict] = {
node["id"]: node
for node in all_nodes
if isinstance(node.get("id"), str) and node.get("id")
}
self.type_def_index = _build_csharp_type_def_index(all_nodes)
self.known_namespaces = {
node.get("label")
for node in all_nodes
if node.get("type") == "namespace" and isinstance(node.get("label"), str)
}
self.namespace_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {}
self.aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {}
for edge in all_edges:
if edge.get("relation") != "imports":
continue
source_node = self.node_by_id.get(edge.get("source"))
if not (
source_node
and isinstance(source_node.get("label"), str)
and source_node.get("label", "").endswith(".cs")
):
continue
source_file = source_node.get("source_file")
if not _is_cs_file(source_file):
continue
metadata = _metadata(edge.get("metadata"))
target_fqn = metadata.get("target_fqn")
if not isinstance(target_fqn, str) or not target_fqn:
continue
scope_kind = metadata.get("scope_kind") or "file"
scope_id = metadata.get("scope_id")
using_kind = metadata.get("using_kind")
if using_kind == "namespace":
entry = (target_fqn, scope_kind, scope_id)
bucket = self.namespace_usings_by_file.setdefault(source_file, [])
if entry not in bucket:
bucket.append(entry)
elif using_kind == "alias":
alias = metadata.get("alias")
if isinstance(alias, str) and alias:
entry = (target_fqn, scope_kind, scope_id)
bucket = self.aliases_by_file.setdefault(source_file, {}).setdefault(alias, [])
if entry not in bucket:
bucket.append(entry)
@staticmethod
def _namespace(node: dict | None) -> str:
metadata = _metadata((node or {}).get("metadata"))
namespace = metadata.get("namespace", "")
return namespace if isinstance(namespace, str) else ""
@staticmethod
def _scope_chain(node: dict) -> list[str]:
chain = _metadata(node.get("metadata")).get("scope_chain")
return chain if isinstance(chain, list) else []
def _using_in_scope(self, scope_kind: str, scope_id: str | None, source_node: dict) -> bool:
if scope_kind == "file":
return True
return scope_id is not None and scope_id in self._scope_chain(source_node)
def _scopes_for(self, source_node: dict, source_file: str) -> list[str]:
def _append_unique(items: list[str], value: str) -> None:
if value not in items:
items.append(value)
scopes: list[str] = []
_append_unique(scopes, self._namespace(source_node))
_append_unique(scopes, "")
for namespace, scope_kind, scope_id in self.namespace_usings_by_file.get(source_file, []):
if self._using_in_scope(scope_kind, scope_id, source_node):
_append_unique(scopes, namespace)
return scopes
def _resolve_alias(self, label: str, source_node: dict, source_file: str) -> str | None:
hits = set()
for target_fqn, scope_kind, scope_id in self.aliases_by_file.get(source_file, {}).get(label, []):
if not self._using_in_scope(scope_kind, scope_id, source_node):
continue
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
namespace, sep, simple_name = base_fqn.rpartition(".")
if not sep:
simple_name = namespace
namespace = ""
if not simple_name:
continue
hit = self.type_def_index.get((namespace, simple_name))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
def resolve_type_name(
self, label: str, source_node: dict, source_file: str
) -> tuple[str | None, bool]:
"""Resolve a simple type name to a definition node id, with a verdict.
Returns ``(node_id, decisive)``:
* ``(nid, True)`` — exactly one in-scope definition (or alias target).
* ``(None, True)`` — the name is claimed by this file's scoping (an
alias, or >1 in-scope candidates): genuinely ambiguous/unresolvable,
the caller must NOT fall back to a looser corpus-wide match.
* ``(None, False)`` — scoping knows nothing about the name; a caller
may fall back (e.g. to the corpus-wide unique bare-name match).
"""
if label in self.aliases_by_file.get(source_file, {}):
return self._resolve_alias(label, source_node, source_file), True
candidates: list[str] = []
for namespace in self._scopes_for(source_node, source_file):
hit = self.type_def_index.get((namespace, label))
if hit and hit not in candidates:
candidates.append(hit)
if len(candidates) == 1:
return candidates[0], True
return None, bool(candidates)
def resolve_label(self, label: str, source_node: dict, source_file: str) -> str | None:
resolved, _decisive = self.resolve_type_name(label, source_node, source_file)
return resolved
def resolve_qualified(
self, label: str, qualifier: object, source_node: dict, source_file: str
) -> str | None:
# Sound qualified resolution: an in-scope alias for Q shadows the namespace Q. For a qualified
# ref Q.label, look up (alias_target_namespace, label). If no in-scope alias, fall through to an
# exact known namespace. Dangle on ambiguity / no hit / unknown qualifier.
if not isinstance(qualifier, str) or not qualifier:
return None
in_scope = [
entry for entry in self.aliases_by_file.get(source_file, {}).get(qualifier, [])
if self._using_in_scope(entry[1], entry[2], source_node)
]
if in_scope:
hits = set()
for target_fqn, _scope_kind, _scope_id in in_scope:
alias_ns = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
hit = self.type_def_index.get((alias_ns, label))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
if qualifier in self.known_namespaces:
return self.type_def_index.get((qualifier, label))
return None
def _resolve_csharp_type_references(
per_file: list[dict],
paths: list[Path],
@@ -164,137 +334,15 @@ def _resolve_csharp_type_references(
"""
_ = (per_file, paths)
def _is_cs_file(value: object) -> bool:
return isinstance(value, str) and value.endswith(".cs")
def _metadata(value: object) -> dict:
return value if isinstance(value, dict) else {}
def _namespace(node: dict | None) -> str:
metadata = _metadata((node or {}).get("metadata"))
namespace = metadata.get("namespace", "")
return namespace if isinstance(namespace, str) else ""
def _append_unique(items: list[str], value: str) -> None:
if value not in items:
items.append(value)
node_by_id = {
node["id"]: node
for node in all_nodes
if isinstance(node.get("id"), str) and node.get("id")
}
type_def_index = _build_csharp_type_def_index(all_nodes)
known_namespaces = {
node.get("label")
for node in all_nodes
if node.get("type") == "namespace" and isinstance(node.get("label"), str)
}
# Each using carries its lexical scope: ("file", None) applies file-wide;
# ("namespace", scope_id) applies only where scope_id is in the ref's scope_chain.
namespace_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {}
aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {}
for edge in all_edges:
if edge.get("relation") != "imports":
continue
source_node = node_by_id.get(edge.get("source"))
if not (
source_node
and isinstance(source_node.get("label"), str)
and source_node.get("label", "").endswith(".cs")
):
continue
source_file = source_node.get("source_file")
if not _is_cs_file(source_file):
continue
metadata = _metadata(edge.get("metadata"))
target_fqn = metadata.get("target_fqn")
if not isinstance(target_fqn, str) or not target_fqn:
continue
scope_kind = metadata.get("scope_kind") or "file"
scope_id = metadata.get("scope_id")
using_kind = metadata.get("using_kind")
if using_kind == "namespace":
entry = (target_fqn, scope_kind, scope_id)
bucket = namespace_usings_by_file.setdefault(source_file, [])
if entry not in bucket:
bucket.append(entry)
elif using_kind == "alias":
alias = metadata.get("alias")
if isinstance(alias, str) and alias:
entry = (target_fqn, scope_kind, scope_id)
bucket = aliases_by_file.setdefault(source_file, {}).setdefault(alias, [])
if entry not in bucket:
bucket.append(entry)
def _scope_chain(node: dict) -> list[str]:
chain = _metadata(node.get("metadata")).get("scope_chain")
return chain if isinstance(chain, list) else []
def _using_in_scope(scope_kind: str, scope_id: str | None, source_node: dict) -> bool:
if scope_kind == "file":
return True
return scope_id is not None and scope_id in _scope_chain(source_node)
def _scopes_for(source_node: dict, source_file: str) -> list[str]:
scopes: list[str] = []
_append_unique(scopes, _namespace(source_node))
_append_unique(scopes, "")
for namespace, scope_kind, scope_id in namespace_usings_by_file.get(source_file, []):
if _using_in_scope(scope_kind, scope_id, source_node):
_append_unique(scopes, namespace)
return scopes
def _resolve_alias(label: str, source_node: dict, source_file: str) -> str | None:
hits = set()
for target_fqn, scope_kind, scope_id in aliases_by_file.get(source_file, {}).get(label, []):
if not _using_in_scope(scope_kind, scope_id, source_node):
continue
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
namespace, sep, simple_name = base_fqn.rpartition(".")
if not sep:
simple_name = namespace
namespace = ""
if not simple_name:
continue
hit = type_def_index.get((namespace, simple_name))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
resolver = CsharpNameResolver(all_nodes, all_edges)
node_by_id = resolver.node_by_id
aliases_by_file = resolver.aliases_by_file
def _resolve_label(label: str, source_node: dict, source_file: str) -> str | None:
if label in aliases_by_file.get(source_file, {}):
return _resolve_alias(label, source_node, source_file)
candidates: list[str] = []
for namespace in _scopes_for(source_node, source_file):
hit = type_def_index.get((namespace, label))
if hit and hit not in candidates:
candidates.append(hit)
return candidates[0] if len(candidates) == 1 else None
return resolver.resolve_label(label, source_node, source_file)
def _resolve_qualified(label: str, qualifier: object, source_node: dict, source_file: str) -> str | None:
# Sound qualified resolution: an in-scope alias for Q shadows the namespace Q. For a qualified
# ref Q.label, look up (alias_target_namespace, label). If no in-scope alias, fall through to an
# exact known namespace. Dangle on ambiguity / no hit / unknown qualifier.
if not isinstance(qualifier, str) or not qualifier:
return None
in_scope = [
entry for entry in aliases_by_file.get(source_file, {}).get(qualifier, [])
if _using_in_scope(entry[1], entry[2], source_node)
]
if in_scope:
hits = set()
for target_fqn, _scope_kind, _scope_id in in_scope:
alias_ns = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
hit = type_def_index.get((alias_ns, label))
if hit:
hits.add(hit)
return next(iter(hits)) if len(hits) == 1 else None
if qualifier in known_namespaces:
return type_def_index.get((qualifier, label))
return None
return resolver.resolve_qualified(label, qualifier, source_node, source_file)
def _is_placeholder(node: dict | None) -> bool:
return bool(node) and not node.get("source_file")
+47 -21
View File
@@ -1424,15 +1424,29 @@ def _csharp_member_type_table(root, source: bytes) -> dict[str, str]:
"""Collect ``name -> TypeName`` for C# receiver typing (#1609): class fields,
properties, method parameters, and local variable declarations.
File-scoped, first-binding-wins (like the C++ table): a field declared once at
class scope is visible to every method's `field.Method()`, and a param/local
shadowing the same name is a conservative approximation graphify already accepts
for receiver typing. Only a resolvable, non-`var` type name is recorded; `var`
without a `new T()` initializer, and predefined/lower-cased primitives, are
skipped (precision over recall — an untypable receiver is left for the resolver
to drop rather than guess). `var v = new T()` is typed from the object-creation.
File-scoped with conflict POISONING (#1620): a name bound to two different
resolvable types anywhere in the file — or bound once to a resolvable type and
redeclared with an unresolvable one (``var x = Compute();``, a primitive, a
``dynamic``) — is dropped from the table entirely, so a local shadowing a field
of a DIFFERENT type can never produce a wrong edge (the resolver simply emits
none). Consistent rebindings (the same resolved type) keep the single entry.
Only a resolvable, non-`var` type name is recorded; `var` without a `new T()`
initializer, and predefined/lower-cased primitives, are unresolvable (precision
over recall — an untypable receiver is left for the resolver to drop rather
than guess). `var v = new T()` is typed from the object-creation.
"""
table: dict[str, str] = {}
poisoned: set[str] = set()
def _bind(name: str | None, resolved: str | None) -> None:
if not name:
return
if resolved is None or table.get(name, resolved) != resolved:
# An unresolvable redeclaration, or a second binding with a different
# type: the name is scope-ambiguous at file granularity — poison it.
poisoned.add(name)
else:
table[name] = resolved
def _typed(type_node) -> str | None:
info = _read_csharp_type_name(type_node, source)
@@ -1468,25 +1482,19 @@ def _csharp_member_type_table(root, source: bytes) -> dict[str, str]:
type_node = vd.child_by_field_name("type")
declared = _typed(type_node)
for name, decl in _decl_names(vd):
resolved = declared or _new_type(decl)
if name and resolved and name not in table:
table[name] = resolved
_bind(name, declared or _new_type(decl))
elif t == "property_declaration":
nm = n.child_by_field_name("name")
resolved = _typed(n.child_by_field_name("type"))
if nm is not None and resolved:
pname = _read_text(nm, source)
if pname not in table:
table[pname] = resolved
if nm is not None:
_bind(_read_text(nm, source), _typed(n.child_by_field_name("type")))
elif t == "parameter":
nm = n.child_by_field_name("name")
resolved = _typed(n.child_by_field_name("type"))
if nm is not None and resolved:
pname = _read_text(nm, source)
if pname not in table:
table[pname] = resolved
if nm is not None:
_bind(_read_text(nm, source), _typed(n.child_by_field_name("type")))
for c in n.children:
stack.append(c)
for name in poisoned:
table.pop(name, None)
return table
def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None:
@@ -3928,8 +3936,26 @@ def _extract_generic(
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 == "this_expression":
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)
elif fn_node is not None and fn_node.type == "identifier":
callee_name = _read_text(fn_node, source)
else:
+184
View File
@@ -143,6 +143,190 @@ def test_unqualified_call_still_resolves(tmp_path):
assert any("_r_a" in s and "helper" in t for s, t in calls), "no regression on unqualified calls"
# ── Namespace-aware receiver typing + shadow poisoning (#1620) ────────────────
_NS_AB = {
"A.cs": "namespace A { public class Svc { public bool Do() => true; } }\n",
"B.cs": "namespace B { public class Svc { public bool Do() => false; } }\n",
}
def test_namespace_using_directive_disambiguates_receiver_type(tmp_path):
"""`Svc` exists in namespaces A and B; a caller file `using A;` must bind an
`A.Svc`-typed receiver to A.Svc.Do — before #1620 the corpus-wide bare-name
ambiguity made the resolver bail (missing edge)."""
calls, r = _calls(tmp_path, {
**_NS_AB,
"Caller.cs": (
"using A;\n"
"namespace App {\n"
" public class Runner { public bool Go(Svc s) { return s.Do(); } }\n"
"}\n"
),
})
a_do = _find(r, ".Do()", "a_a_svc")
b_do = _find(r, ".Do()", "b_b_svc")
runner_go = _find(r, ".Go()", "runner")
assert (runner_go, a_do) in calls, "using A; must resolve Svc to A.Svc"
assert (runner_go, b_do) not in calls, "must NOT bind to the same-named B.Svc"
def test_namespace_using_directive_resolves_to_other_namespace(tmp_path):
calls, r = _calls(tmp_path, {
**_NS_AB,
"Caller.cs": (
"using B;\n"
"namespace App {\n"
" public class Runner { public bool Go(Svc s) { return s.Do(); } }\n"
"}\n"
),
})
a_do = _find(r, ".Do()", "a_a_svc")
b_do = _find(r, ".Do()", "b_b_svc")
runner_go = _find(r, ".Go()", "runner")
assert (runner_go, b_do) in calls, "using B; must resolve Svc to B.Svc"
assert (runner_go, a_do) not in calls
def test_namespace_ambiguous_without_using_bails(tmp_path):
"""No using directive and `Svc` in two foreign namespaces: genuinely
ambiguous — no edge to either candidate (never a guess)."""
calls, r = _calls(tmp_path, {
**_NS_AB,
"Caller.cs": (
"namespace App {\n"
" public class Runner { public bool Go(Svc s) { return s.Do(); } }\n"
"}\n"
),
})
assert not any("runner" in s and "svc_do" in t for s, t in calls), \
"ambiguous cross-namespace type must produce no edge"
def test_same_namespace_receiver_resolves_without_using(tmp_path):
"""A caller in namespace A resolves `Svc` to A.Svc even though B.Svc also
exists — same-namespace visibility needs no using directive."""
calls, r = _calls(tmp_path, {
**_NS_AB,
"A2.cs": (
"namespace A {\n"
" public class Client { public bool Go(Svc s) { return s.Do(); } }\n"
"}\n"
),
})
a_do = _find(r, ".Do()", "a_a_svc")
b_do = _find(r, ".Do()", "b_b_svc")
client_go = _find(r, ".Go()", "client")
assert (client_go, a_do) in calls
assert (client_go, b_do) not in calls
def test_local_shadowing_field_of_different_type_poisons_name(tmp_path):
"""A local `Other x` shadowing a field `Server x` makes the name's type
conflicting — the binding is poisoned and `x.Run()` emits NO edge, instead
of first-binding-wins mis-binding to Server.Run (a wrong edge)."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Run() => true; }\n"
"public class Other { public bool Run() => false; }\n"
"public class Holder {\n"
" private Server x = new Server();\n"
" public bool A() { Other x = new Other(); return x.Run(); }\n"
"}\n"
)
})
holder_a = _find(r, ".A()", "holder")
server_run = _find(r, ".Run()", "server")
other_run = _find(r, ".Run()", "other")
assert (holder_a, server_run) not in calls, \
"shadowed field's type must not win (wrong edge)"
assert (holder_a, other_run) not in calls, \
"conflicting bindings poison the name entirely (conservative: no edge)"
def test_untyped_redeclaration_poisons_typed_field(tmp_path):
"""`var x = Compute();` (untypable) redeclaring a typed field poisons the
name: `x.Run()` must not bind to the field's type."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Run() => true; }\n"
"public class Holder {\n"
" private Server x = new Server();\n"
" public object Compute() => new object();\n"
" public bool A() { var x = Compute(); return x.Run(); }\n"
"}\n"
)
})
assert not any("holder_a" in s and "run" in t.lower() for s, t in calls)
def test_this_field_receiver_resolves(tmp_path):
"""`this._s.Save()` types the field exactly like a bare `_s.Save()`."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
"public class Repo {\n"
" private Server _s = new Server();\n"
" public bool Commit() { return this._s.Save(); }\n"
"}\n"
)
})
commit = _find(r, ".Commit()", "commit")
server_save = _find(r, ".Save()", "server")
cache_save = _find(r, ".Save()", "cache")
assert (commit, server_save) in calls
assert (commit, cache_save) not in calls
def test_base_receiver_resolves_to_base_class_method(tmp_path):
calls, r = _calls(tmp_path, {
"Base.cs": "public class BaseSvc { public bool Ping() => true; }\n",
"Sub.cs": (
"public class Sub : BaseSvc {\n"
" public bool Go() { return base.Ping(); }\n"
"}\n"
),
})
sub_go = _find(r, ".Go()", "sub")
ping = _find(r, ".Ping()", "basesvc")
assert (sub_go, ping) in calls, "base.Ping() must resolve to the base class method"
def test_inherited_method_resolves_through_base_chain(tmp_path):
"""A method not declared on the receiver's type but inherited from a
resolvable in-corpus base resolves to the base's declaration."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class BaseSvc { public bool Ping() => true; }\n"
"public class Derived : BaseSvc { }\n"
"public class User {\n"
" public bool Use(Derived d) { return d.Ping(); }\n"
"}\n"
)
})
use = _find(r, ".Use()", "user")
ping = _find(r, ".Ping()", "basesvc")
assert (use, ping) in calls
def test_unresolved_base_poisons_inherited_member_lookup(tmp_path):
"""The receiver's type inherits from an out-of-corpus base: a method missing
on the type may live on that base, so the lookup is poisoned — and it must
NOT fall back to an unrelated same-named in-corpus method."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Ext : NotInCorpus { }\n"
"public class User {\n"
" public bool U(Ext e) { return e.Save(); }\n"
"}\n"
)
})
assert not any("user_u" in s and "save" in t.lower() for s, t in calls), \
"unresolved base chain must bail, not mis-bind to Server.Save"
def test_method_chained_off_new_expression_resolves(tmp_path):
"""#1770: a method invoked directly on a `new X(...)` object-creation
expression (no intermediate variable) must still emit a calls edge to the