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:
co-authored by
Claude Opus 4.8
parent
4e7e6b1f7e
commit
e300587439
+120
-59
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user