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:
co-authored by
Claude Opus 4.8
parent
8adb261d16
commit
0858954db9
+112
-14
@@ -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
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user