From f99f8d70c8887a6b990d86b9236e38be005aafa5 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Thu, 30 Jul 2026 18:38:58 +0100 Subject: [PATCH] fix(csharp): per-method receiver typing so cross-method name reuse stops dropping true calls edges (#2299) The C# receiver-type table was per-FILE and poisoned a name on any conflicting/untypable rebind anywhere in the file, so `var item = items[i]` in one method silently deleted the true calls edge in another method where the same name was a typed parameter (~2.3% of calls lost, per the reporter). Ported C# to a per-method table mirroring the Java resolver (per-class field scope + method params/locals, method-local poisoning only) and retired the file-wide table. The namespace resolver, ambiguity bail, and inherits-chain guards are unchanged, so the no-wrong-edge bar holds. Co-Authored-By: Claude Opus 4.8 (1M context) --- graphify/extract.py | 29 ++-- graphify/extractors/engine.py | 270 ++++++++++++++++++++---------- tests/test_csharp_member_calls.py | 103 ++++++++++++ 3 files changed, 302 insertions(+), 100 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 78534bf..300a596 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -138,7 +138,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401 from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402 -from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_member_type_table, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 +from graphify.extractors.engine import REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401 @@ -2571,11 +2571,13 @@ def _resolve_csharp_member_calls( 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 (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 + records each member call's receiver and stamps ``receiver_type`` on the raw + call from a METHOD-scoped ``name -> Type`` table of class fields/properties + plus the declaring method's params/locals (#2299 — per-method like Java, so a + name rebound in a different method never poisons this one; same-method + conflicts and untypable rebindings are still POISONED, so a shadowing local of + a different type produces no edge rather than a wrong one). This pass resolves + the stamped 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 @@ -2586,8 +2588,9 @@ def _resolve_csharp_member_calls( * ``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()`` / ``this.recv.M()`` — ``recv`` typed via the file's - field/param/local table -> INFERRED. + * ``recv.M()`` / ``this.recv.M()`` — ``recv`` typed via the extractor's + method-scoped field/property/param/local table (``receiver_type`` on the + raw call) -> 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 @@ -2595,12 +2598,6 @@ def _resolve_csharp_member_calls( Must run after id-disambiguation so node ids and caller_nids are final. """ - type_table_by_file: dict[str, dict[str, str]] = {} - for result in per_file: - tt = result.get("csharp_type_table") - if tt and tt.get("path"): - type_table_by_file[tt["path"]] = tt.get("table", {}) - def _key(label: str) -> str: return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower() @@ -2739,13 +2736,13 @@ def _resolve_csharp_member_calls( # explicit-type lookup misses). 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_name = rc.get("receiver_type") type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: continue type_qualified = True else: - type_name = type_table_by_file.get(src_file, {}).get(receiver) + type_name = rc.get("receiver_type") if not type_name: continue type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 5b7f522..7f25d02 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -1420,80 +1420,138 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N for c in n.children: stack.append(c) -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. +def _csharp_receiver_type_name(type_node, source: bytes) -> str | None: + """Resolve a C# declared type to a receiver-typable class name, or None. - 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. + A genuine C# class name is Pascal-cased; predefined primitives + (int/bool/string) and ``dynamic`` never own a resolvable method definition + here, and ``var`` (``implicit_type``) carries no name at all. """ - 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) - if not info: - return None - name = info[0] - # A genuine C# class name is Pascal-cased; skip predefined primitives - # (int/bool/string) which never own a resolvable method definition here. - return name if name and name[:1].isupper() else None - - def _decl_names(var_decl): - for c in var_decl.children: - if c.type == "variable_declarator": - nm = c.child_by_field_name("name") or next( - (g for g in c.children if g.type == "identifier"), None) - if nm is not None: - yield _read_text(nm, source), c - - def _new_type(declarator) -> str | None: - # `var v = new Server()` — recover the type from the object_creation_expression. - for g in declarator.children: - if g.type == "object_creation_expression": - return _typed(g.child_by_field_name("type")) + info = _read_csharp_type_name(type_node, source) + if not info: return None + name = info[0] + return name if name and name[:1].isupper() else None - stack = [root] + +def _csharp_method_receiver_types( + method_node, + source: bytes, + field_types: dict[str, str], +) -> dict[str, str]: + """Build the receiver type table visible to one C# method (#2299). + + The C# twin of ``_java_method_receiver_types``: current-class fields and + properties are the base scope, and parameters plus local declarations bind + on top of them for the full method. C# scoping is per-method, so a name + rebound in a DIFFERENT method never poisons this one — the #2299 regression + under the old file-wide table, where ``var item = items[i]`` in one method + (untypable) silently deleted the true edge for a same-named, explicitly + typed parameter elsewhere in the file. + + Poisoning stays method-local and conservative, because raw call facts do + not retain lexical position inside the method: a name is dropped entirely on + an unresolvable binding (``var x = Compute();``, a primitive, ``dynamic``, + an untyped lambda parameter), a same-method conflict, or a conflict with + the class field's type for that name. ``var v = new T()`` is typed from the + object-creation (precision over recall — an untypable receiver is left for + the resolver to drop rather than guess). + """ + method_types: dict[str, str] = {} + ambiguous: set[str] = set() + + def bind(name: str | None, type_name: str | None) -> None: + if not name or name in ambiguous: + return + if ( + type_name is None + or method_types.get(name, type_name) != type_name + or field_types.get(name) not in (None, type_name) + ): + method_types.pop(name, None) + ambiguous.add(name) + else: + method_types[name] = type_name + + def bind_parameter(param) -> None: + name_node = param.child_by_field_name("name") + if name_node is not None: + bind( + _read_text(name_node, source), + _csharp_receiver_type_name(param.child_by_field_name("type"), source), + ) + + params = method_node.child_by_field_name("parameters") + if params is not None: + for param in params.children: + if param.type == "parameter": + bind_parameter(param) + + body = method_node.child_by_field_name("body") + stack = list(body.children) if body is not None else [] while stack: - n = stack.pop() - t = n.type - if t in ("field_declaration", "local_declaration_statement"): - vd = next((c for c in n.children if c.type == "variable_declaration"), None) + node = stack.pop() + if node.type in ( + "class_declaration", + "struct_declaration", + "interface_declaration", + "record_declaration", + "enum_declaration", + ): + continue + if node.type == "lambda_expression": + # Raw calls are method-scoped, so a lambda-local binding cannot be + # distinguished from an enclosing binding with the same name: a + # typed lambda parameter binds, an untyped one (`x => ...`, + # `(z) => ...`) binds None and poisons the name method-locally. + lam_params = node.child_by_field_name("parameters") + if lam_params is not None: + if lam_params.type == "implicit_parameter": + bind(_read_text(lam_params, source), None) + else: + for param in lam_params.children: + if param.type == "parameter": + bind_parameter(param) + elif param.type == "implicit_parameter": + bind(_read_text(param, source), None) + elif node.type == "local_function_statement": + lf_params = node.child_by_field_name("parameters") + if lf_params is not None: + for param in lf_params.children: + if param.type == "parameter": + bind_parameter(param) + elif node.type == "local_declaration_statement": + vd = next( + (c for c in node.children if c.type == "variable_declaration"), None + ) if vd is not None: - type_node = vd.child_by_field_name("type") - declared = _typed(type_node) - for name, decl in _decl_names(vd): - _bind(name, declared or _new_type(decl)) - elif t == "property_declaration": - nm = n.child_by_field_name("name") - 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") - 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: + declared = _csharp_receiver_type_name( + vd.child_by_field_name("type"), source + ) + for declarator in vd.children: + if declarator.type != "variable_declarator": + continue + name_node = declarator.child_by_field_name("name") or next( + (g for g in declarator.children if g.type == "identifier"), + None, + ) + if name_node is None: + continue + type_name = declared + if type_name is None: + # `var v = new T()` — recover T from the object-creation. + for g in declarator.children: + if g.type == "object_creation_expression": + type_name = _csharp_receiver_type_name( + g.child_by_field_name("type"), source + ) + break + bind(_read_text(name_node, source), type_name) + stack.extend(node.children) + + table = dict(field_types) + table.update(method_types) + for name in ambiguous: table.pop(name, None) return table @@ -2274,6 +2332,12 @@ def _extract_generic( # while parameters and locals belong only to their declaring method. java_field_types: dict[str, dict[str, str]] = {} java_method_scopes: dict[int, tuple[object, str]] = {} + # C# receiver typing is method-scoped too (#2299): class fields/properties + # are shared, parameters and locals belong only to their declaring method — + # the old file-wide table let one method's untypable rebinding poison a + # same-named, explicitly typed receiver in a different method. + csharp_field_types: dict[str, dict[str, str]] = {} + csharp_method_scopes: dict[int, tuple[object, str]] = {} csharp_interface_names: set[str] = set() if config.ts_module == "tree_sitter_c_sharp": @@ -2977,6 +3041,24 @@ def _extract_generic( ) if not type_name or type_name in csharp_type_params: return + # Record the field's declared type for the method-scoped + # receiver tables (#2299) — the C# twin of java_field_types. + # Pascal-case only: primitives never own a resolvable method. + if type_name[:1].isupper(): + fields = csharp_field_types.setdefault(parent_class_nid, {}) + for child in node.children: + if child.type != "variable_declaration": + continue + for declarator in child.children: + if declarator.type != "variable_declarator": + continue + name_node = declarator.child_by_field_name("name") or next( + (g for g in declarator.children + if g.type == "identifier"), + None, + ) + if name_node is not None: + fields[_read_text(name_node, source)] = type_name line = node.start_point[0] + 1 metadata = {"ref_token": type_name} if qualified: @@ -3000,6 +3082,15 @@ def _extract_generic( # Widget generic_arg ref. type_node = node.child_by_field_name("type") if type_node is not None: + # Record the property's declared type for the method-scoped + # receiver tables (#2299), like a field: `Main.Render()` on a + # `public Widget Main { get; set; }` types Main as Widget. + prop_name_node = node.child_by_field_name("name") + prop_type = _csharp_receiver_type_name(type_node, source) + if prop_name_node is not None and prop_type: + csharp_field_types.setdefault(parent_class_nid, {})[ + _read_text(prop_name_node, source) + ] = prop_type line = node.start_point[0] + 1 refs: list[tuple[str, str, bool, str]] = [] _csharp_collect_type_refs(type_node, source, False, refs) @@ -3594,6 +3685,8 @@ def _extract_generic( if body: if config.ts_module == "tree_sitter_java" and parent_class_nid: java_method_scopes[id(body)] = (node, parent_class_nid) + if config.ts_module == "tree_sitter_c_sharp" and parent_class_nid: + csharp_method_scopes[id(body)] = (node, parent_class_nid) function_bodies.append((func_nid, body)) return @@ -3738,6 +3831,14 @@ def _extract_generic( ) for body_id, (method_node, class_nid) in java_method_scopes.items() } + csharp_receiver_types = { + body_id: _csharp_method_receiver_types( + method_node, + source, + csharp_field_types.get(class_nid, {}), + ) + for body_id, (method_node, class_nid) in csharp_method_scopes.items() + } def _emit_indirect_by_name(ident_name: str, loc_node, scope_nid: str, context: str) -> None: @@ -3887,7 +3988,7 @@ def _extract_generic( def walk_calls( node, caller_nid: str, - java_types: dict[str, str] | None = None, + receiver_types: dict[str, str] | None = None, extra_locals: frozenset[str] = frozenset(), ) -> None: if node.type in config.function_boundary_types: @@ -3912,7 +4013,7 @@ def _extract_generic( # closures compound the same way on their own recursion. closure_locals = extra_locals | _js_local_bound_names(node, source) for child in node.children: - walk_calls(child, caller_nid, java_types, closure_locals) + walk_calls(child, caller_nid, receiver_types, closure_locals) return if node.type in config.call_types: @@ -3922,7 +4023,7 @@ def _extract_generic( edges, seen_dyn_import_pairs): # Still recurse into children (import().then(...) may have calls) for child in node.children: - walk_calls(child, caller_nid, java_types, extra_locals) + walk_calls(child, caller_nid, receiver_types, extra_locals) return callee_name: str | None = None @@ -4234,14 +4335,19 @@ def _extract_generic( # suffix sets, so a source_file suffix alone can't separate them. if config.ts_module == "tree_sitter_cpp": rc_entry["lang"] = "cpp" - # C#: tag the raw_call so _resolve_csharp_member_calls claims it - # and types the receiver against the file's field/param/local - # type table (#1609). + # C#: tag the raw_call so _resolve_csharp_member_calls claims + # it, and stamp the receiver's type from the METHOD-scoped + # table (#1609, per-method since #2299). `this.field.M()` is + # covered too: member_receiver is the bare field name, and + # class fields/properties are in the table. if config.ts_module == "tree_sitter_c_sharp": rc_entry["lang"] = "csharp" + receiver_type = (receiver_types or {}).get(member_receiver or "") + if receiver_type: + rc_entry["receiver_type"] = receiver_type if config.ts_module == "tree_sitter_java": rc_entry["lang"] = "java" - receiver_type = (java_types or {}).get(member_receiver or "") + receiver_type = (receiver_types or {}).get(member_receiver or "") if receiver_type: rc_entry["receiver_type"] = receiver_type raw_calls.append(rc_entry) @@ -4450,7 +4556,7 @@ def _extract_generic( _emit_indirect_ref(ident, caller_nid, enclosing_locals, "return") for child in node.children: - walk_calls(child, caller_nid, java_types, extra_locals) + walk_calls(child, caller_nid, receiver_types, extra_locals) if config.ts_module == "tree_sitter_ruby": for caller_nid, body_node in function_bodies: @@ -4479,11 +4585,14 @@ def _extract_generic( # (#1630 Pattern B). Guarding on the tracked set prevents double-walking. _tracked_body_ids.update(id(b) for _, b in function_bodies) + # Body ids are unique (one language per file), so the Java and C# per-method + # receiver tables merge without collision. + receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types} for caller_nid, body_node in function_bodies: walk_calls( body_node, caller_nid, - java_receiver_types.get(id(body_node)), + receiver_types_by_body.get(id(body_node)), ) # #1356: walk property/field initializers (collected above). walk_calls @@ -4611,13 +4720,6 @@ def _extract_generic( result["ts_type_table"] = {"path": str_path, "table": type_table} elif config.ts_module == "tree_sitter_cpp": result["cpp_type_table"] = {"path": str_path, "table": type_table} - # C#: a file-wide receiver type table (field/property/param/local -> Type) for - # _resolve_csharp_member_calls (#1609). Built from the whole tree, not just - # function bodies, so class-level fields/properties are in scope for every method. - if config.ts_module == "tree_sitter_c_sharp": - cs_table = _csharp_member_type_table(root, source) - if cs_table: - result["csharp_type_table"] = {"path": str_path, "table": cs_table} return result def _python_decorator_name(deco_node, source: bytes) -> str | None: diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index 5487939..b29dea0 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -327,6 +327,109 @@ def test_unresolved_base_poisons_inherited_member_lookup(tmp_path): "unresolved base chain must bail, not mis-bind to Server.Save" +# ── Method-scoped receiver typing (#2299) ──────────────────────────────────── +# C# scoping is per-method: a name rebound (even untypably) in ONE method must +# not poison a same-named, explicitly typed receiver in a DIFFERENT method. The +# old file-wide table did exactly that, silently deleting true calls edges. + + +def test_cross_method_name_reuse_does_not_poison(tmp_path): + """#2299 corpus: `var item = items[i]` (untypable) in RunIndexed must not + poison the explicitly typed `Item item` parameter in RunOne.""" + calls, r = _calls(tmp_path, { + "Item.cs": ( + "namespace Demo {\n" + " public class Item { public void Handle() {} }\n" + "}\n" + ), + "Runner.cs": ( + "using System.Collections.Generic;\n" + "namespace Demo {\n" + " public class Runner {\n" + " public void RunOne(Item item) { item.Handle(); }\n" + " public void RunIndexed(List items, int i) {\n" + " var item = items[i];\n" + " item.Handle();\n" + " }\n" + " }\n" + "}\n" + ), + }) + run_one = _find(r, ".RunOne()", "runner") + run_indexed = _find(r, ".RunIndexed()", "runner") + handle = _find(r, ".Handle()", "item") + assert (run_one, handle) in calls, \ + "typed param receiver must resolve despite a same-named untypable local elsewhere" + edge = next(e for e in r["edges"] if e["relation"] == "calls" + and e["source"] == run_one and e["target"] == handle) + assert edge["confidence"] == "INFERRED" + assert (run_indexed, handle) not in calls, \ + "the untypable local (`var item = items[i]`) stays unresolved — no guessed edge" + + +def test_per_method_locals_resolve_independently(tmp_path): + """Same local name bound to DIFFERENT types in different methods: each + method resolves to its own binding (the file-wide table poisoned both).""" + calls, r = _calls(tmp_path, { + "S.cs": ( + "public class HtmlWriter { public void Render() {} }\n" + "public class TextWriter { public void Render() {} }\n" + "public class Doc {\n" + " public void AsHtml() { var w = new HtmlWriter(); w.Render(); }\n" + " public void AsText() { var w = new TextWriter(); w.Render(); }\n" + "}\n" + ) + }) + as_html = _find(r, ".AsHtml()", "doc") + as_text = _find(r, ".AsText()", "doc") + html_render = _find(r, ".Render()", "htmlwriter") + text_render = _find(r, ".Render()", "textwriter") + assert (as_html, html_render) in calls + assert (as_text, text_render) in calls + assert (as_html, text_render) not in calls, "no cross-method binding leak" + assert (as_text, html_render) not in calls, "no cross-method binding leak" + + +def test_same_method_shadow_still_poisons(tmp_path): + """Keep-the-bar: a SAME-method conflict (param `Server x` + local `Other x`) + still poisons the name — raw calls carry no lexical position, so neither + candidate may win.""" + 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" + " public bool A(Server x) { 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 + assert (holder_a, other_run) not in calls + + +def test_file_scoped_namespace_receiver_resolves(tmp_path): + """The C# 10 file-scoped namespace form (`namespace Demo;`) types receivers + the same as the braced form.""" + calls, r = _calls(tmp_path, { + "Item.cs": ( + "namespace Demo;\n" + "public class Item { public void Handle() {} }\n" + ), + "Runner.cs": ( + "namespace Demo;\n" + "public class Runner {\n" + " public void RunOne(Item item) { item.Handle(); }\n" + "}\n" + ), + }) + run_one = _find(r, ".RunOne()", "runner") + handle = _find(r, ".Handle()", "item") + assert (run_one, handle) in calls + + 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