diff --git a/graphify/extract.py b/graphify/extract.py index d55f456..0835a97 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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: diff --git a/graphify/extractors/csharp.py b/graphify/extractors/csharp.py index 2cda372..3aa34e0 100644 --- a/graphify/extractors/csharp.py +++ b/graphify/extractors/csharp.py @@ -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") diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 39c6987..8099c85 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -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: diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index 0dba3ed..5487939 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -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