fix(csharp): scope receiver types per declaration so an untypeable rebind can't drop a true call (#2472)

Track C# receiver types per lexical declaration scope (byte ranges) and
resolve by the call's position, instead of a method-wide flat table that
poisoned a name on any None-typed binding. A typed static local-function
parameter now keeps resolving even when an out var reuses the name in the
enclosing body. Fixes a regression from #2346; #2299 cross-method
independence and field-conflict poisoning are unchanged.

Thanks @JensD-git for the bisect and repro.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-05 22:08:29 +01:00
co-authored by Claude Opus 4.8
parent 4e7e6b1f7e
commit e300587439
2 changed files with 198 additions and 59 deletions
+120 -59
View File
@@ -1438,59 +1438,71 @@ 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).
) -> tuple[dict[str, list[tuple[int, int, str | None]]], dict[str, str]]:
"""Build the SCOPED receiver bindings visible to one C# method (#2299, #2472).
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.
The C# twin of ``_java_method_receiver_types``, but positional: instead of
a flat name -> type map, the first element maps each name to a list of
``(scope_start_byte, scope_end_byte, type_name)`` bindings and the second
is the class field/property base scope; ``_csharp_scoped_receiver_type``
resolves a call site against them by byte offset. C# scoping is per-method,
so a name rebound in a DIFFERENT method never affects this one (#2299) —
and, since #2472, an untypable binding (``out var x``) in one lexical scope
no longer wipes a same-named typed binding in a sibling or nested scope
(a ``static`` local-function parameter, a declaration in the other branch
of an ``if``), the regression the #2346 declaration-expression harvest
exposed under the old method-wide poison rule.
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).
A receiver_type is stamped iff exactly one binding is lexically visible at
the call site (innermost scope wins) and it is typed; an untypable or tied
binding at the call site yields no edge (never a guess). Scope ranges are
deliberately conservative — a pattern binding (``is T x``, ``case T x:``)
spans its whole enclosing block, which is over-wide, but over-wide only
ever produces ties (drop), never a wrong bind. The class-field conflict
rule is unchanged: a local binding disagreeing with a same-named
field/property's type drops the name entirely. Residual limitation:
``out var x`` itself stays untyped — resolving it from the callee's
``out`` parameter signature is a separate, pre-existing gap.
"""
method_types: dict[str, str] = {}
ambiguous: set[str] = set()
bindings: dict[str, list[tuple[int, int, str | None]]] = {}
field_poisoned: set[str] = set()
def bind(name: str | None, type_name: str | None) -> None:
if not name or name in ambiguous:
def bind(name: str | None, type_name: str | None, scope_node) -> None:
if not name or scope_node is None:
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
if field_types.get(name) not in (None, type_name):
field_poisoned.add(name)
bindings.setdefault(name, []).append(
(scope_node.start_byte, scope_node.end_byte, type_name)
)
def bind_parameter(param) -> None:
def bind_parameter(param, scope_node) -> 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),
scope_node,
)
body = method_node.child_by_field_name("body")
# Parameters scope to the BODY range: a parameter and an (illegal)
# same-named top-level local share one C# declaration space, and equal
# ranges tie at the call site — drop, never a guess.
param_scope = body if body is not None else method_node
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)
bind_parameter(param, param_scope)
body = method_node.child_by_field_name("body")
stack = list(body.children) if body is not None else []
stack = (
[(child, param_scope) for child in body.children]
if body is not None
else []
)
while stack:
node = stack.pop()
node, scope = stack.pop()
if node.type in (
"class_declaration",
"struct_declaration",
@@ -1500,26 +1512,26 @@ def _csharp_method_receiver_types(
):
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.
# A lambda parameter is visible exactly inside the lambda: a typed
# one binds its type there, an untyped one (`x => ...`,
# `(z) => ...`) binds None so calls on it inside the lambda stay
# unstamped — without wiping a same-named outer binding (#2472).
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)
bind(_read_text(lam_params, source), None, node)
else:
for param in lam_params.children:
if param.type == "parameter":
bind_parameter(param)
bind_parameter(param, node)
elif param.type == "implicit_parameter":
bind(_read_text(param, source), None)
bind(_read_text(param, source), None, node)
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)
bind_parameter(param, node)
elif node.type == "local_declaration_statement":
vd = next(
(c for c in node.children if c.type == "variable_declaration"), None
@@ -1546,16 +1558,16 @@ def _csharp_method_receiver_types(
g.child_by_field_name("type"), source
)
break
bind(_read_text(name_node, source), type_name)
bind(_read_text(name_node, source), type_name, scope)
elif node.type in ("declaration_expression", "declaration_pattern"):
# #2346: inline-declared receivers. `out Sect s` is a
# declaration_expression; `is Leaf lf`, `is not Node nd`,
# `case Twig tw:` and a switch-arm `Stem st =>` are
# declaration_patterns — all carry `type` + `name` fields and
# bind the name for the rest of the method. `out var v`
# bind the name for the enclosing block. `out var v`
# (implicit_type) yields None from _csharp_receiver_type_name
# and poisons the name method-locally, matching the
# untypable-local rule above (no guess).
# and stays untypable inside that block only — no guess at its
# own call sites, no method-wide wipe of other scopes (#2472).
name_node = node.child_by_field_name("name")
if name_node is not None and name_node.type == "identifier":
bind(
@@ -1563,14 +1575,57 @@ def _csharp_method_receiver_types(
_csharp_receiver_type_name(
node.child_by_field_name("type"), source
),
scope,
)
stack.extend(node.children)
child_scope = (
node
if node.type in (
"block", "lambda_expression", "local_function_statement"
)
else scope
)
stack.extend((child, child_scope) for child in node.children)
table = dict(field_types)
table.update(method_types)
for name in ambiguous:
table.pop(name, None)
return table
base = {
name: type_name
for name, type_name in field_types.items()
if name not in field_poisoned
}
for name in field_poisoned:
bindings.pop(name, None)
return bindings, base
def _csharp_scoped_receiver_type(
table: tuple[dict[str, list[tuple[int, int, str | None]]], dict[str, str]] | None,
name: str | None,
call_byte: int,
) -> str | None:
"""Resolve a C# receiver name to its type at a specific call offset (#2472).
``table`` is the (scoped bindings, field base) pair built by
``_csharp_method_receiver_types``. Bindings whose scope contains the call
offset are candidates and the innermost (smallest-range) one wins; no
candidate at all falls back to the class field/property base scope. A tie
at the innermost range (an illegal same-declaration-space clash, e.g. a
parameter redeclared as a top-level local, or two sibling pattern bindings
of the same name) or an untypable winner yields None — no edge, never a
guess.
"""
if not table or not name:
return None
bindings, base = table
candidates = [
b for b in bindings.get(name, ())
if b[0] <= call_byte < b[1]
]
if not candidates:
return base.get(name)
innermost = min(end - start for start, end, _ in candidates)
inner = [b for b in candidates if b[1] - b[0] == innermost]
if len(inner) == 1:
return inner[0][2]
return None
def _ts_receiver_type_table(root, source: bytes, table: dict[str, str]) -> None:
"""Add TS/JS receiver bindings to ``table`` (name -> TypeName), for member-call
@@ -4146,7 +4201,9 @@ def _extract_generic(
def walk_calls(
node,
caller_nid: str,
receiver_types: dict[str, str] | None = None,
# Java: flat name -> type. C#: the (scoped bindings, field base) pair
# from _csharp_method_receiver_types, resolved positionally (#2472).
receiver_types: dict[str, str] | tuple | None = None,
extra_locals: frozenset[str] = frozenset(),
) -> None:
if node.type in config.function_boundary_types:
@@ -4494,13 +4551,16 @@ def _extract_generic(
if config.ts_module == "tree_sitter_cpp":
rc_entry["lang"] = "cpp"
# 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
# it, and stamp the receiver's type from the method's SCOPED
# bindings by the call's byte offset (#1609, per-method since
# #2299, position-aware since #2472). `this.field.M()` is
# covered too: member_receiver is the bare field name, and
# class fields/properties are in the table.
# class fields/properties are the base scope.
if config.ts_module == "tree_sitter_c_sharp":
rc_entry["lang"] = "csharp"
receiver_type = (receiver_types or {}).get(member_receiver or "")
receiver_type = _csharp_scoped_receiver_type(
receiver_types, member_receiver, node.start_byte
)
if receiver_type:
rc_entry["receiver_type"] = receiver_type
if config.ts_module == "tree_sitter_java":
@@ -4743,8 +4803,9 @@ 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.
# Body ids are unique (one language per file), so the Java (flat) and C#
# (scoped, #2472) per-method receiver tables merge without collision — the
# stamp site branches on language to read the matching shape.
receiver_types_by_body = {**java_receiver_types, **csharp_receiver_types}
for caller_nid, body_node in function_bodies:
walk_calls(
+78
View File
@@ -570,6 +570,84 @@ def test_switch_arm_pattern_receiver_resolves(tmp_path):
assert (r_a, twig_go) not in calls
# ── Lexically-scoped receiver typing (#2472) ──────────────────────────────────
# The #2346 harvest of `out var x` (untypable) rode the #2299 method-wide
# poison rule: ANY None-typed binding of a name wiped a correctly typed
# same-name binding in a DIFFERENT lexical scope of the same method, dropping
# true calls edges. Bindings are now scoped by byte range and resolved at the
# call site: exactly one visible typed binding stamps, an untypable or tied
# binding at the call site still yields no edge (never a guess).
def test_static_local_function_param_survives_out_var_reuse(tmp_path):
"""#2472 corpus: a typed `Target2 shared` local-function parameter must
keep its calls edges despite an `out var shared` (untypable) elsewhere in
the enclosing method — while `shared.Gamma()` on the out-var itself stays
unresolved (`out var` is still untyped: recovering it from the callee's
`out` parameter signature is a separate, pre-existing gap)."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Target2 {\n"
" public bool Alpha() => true;\n"
" public bool Beta() => true;\n"
" public bool Gamma() => true;\n"
"}\n"
"public class Maker { public bool Make(out int v) { v = 1; return true; } }\n"
"public class R {\n"
" public bool Outer(Maker m) {\n"
" m.Make(out var shared);\n"
" shared.Gamma();\n"
" return Inner(new Target2());\n"
" static bool Inner(Target2 shared) { return shared.Alpha() && shared.Beta(); }\n"
" }\n"
"}\n"
)
})
outer = _find(r, ".Outer()", "_r_outer")
alpha = _find(r, ".Alpha()", "target2")
beta = _find(r, ".Beta()", "target2")
gamma = _find(r, ".Gamma()", "target2")
assert (outer, alpha) in calls, \
"typed local-function param must survive a same-named out-var elsewhere"
assert (outer, beta) in calls
for tgt in (alpha, beta):
edge = next(e for e in r["edges"] if e["relation"] == "calls"
and e["source"] == outer and e["target"] == tgt)
assert edge["confidence"] == "INFERRED"
assert (outer, gamma) not in calls, \
"the out-var receiver itself stays untypable — no guessed edge, and no " \
"method-wide binding leak from the local-function param"
def test_untypeable_out_var_in_sibling_block_does_not_poison(tmp_path):
"""An `out var s` in the ELSE block must not wipe the typed `Server s`
declared in the sibling IF block — the two scopes never overlap."""
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 Maker { public bool Make(out int v) { v = 1; return true; } }\n"
"public class R {\n"
" public bool A(Maker m, bool flag) {\n"
" if (flag) {\n"
" Server s = new Server();\n"
" return s.Save();\n"
" } else {\n"
" m.Make(out var s);\n"
" return true;\n"
" }\n"
" }\n"
"}\n"
)
})
r_a = _find(r, ".A()", "_r_a")
server_save = _find(r, ".Save()", "server")
cache_save = _find(r, ".Save()", "cache")
assert (r_a, server_save) in calls, \
"a sibling-block out-var must not poison the typed local's scope"
assert (r_a, cache_save) not in calls
def test_sibling_pattern_rebind_conflict_poisons(tmp_path):
"""The same name pattern-bound to two DIFFERENT types in one method: raw
calls carry no lexical position, so neither candidate may win — no edge."""