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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-30 18:38:58 +01:00
co-authored by Claude Opus 4.8
parent 2ca565ac1a
commit f99f8d70c8
3 changed files with 302 additions and 100 deletions
+13 -16
View File
@@ -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)
+186 -84
View File
@@ -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:
+103
View File
@@ -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<Item> 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