Skip to content

csharp: consult arity for ordinary overload sets - #610

Merged
zzet merged 3 commits into
zzet:mainfrom
pbednarcik:fix/csharp-overload-arity
Aug 19, 2026
Merged

csharp: consult arity for ordinary overload sets#610
zzet merged 3 commits into
zzet:mainfrom
pbednarcik:fix/csharp-overload-arity

Conversation

@pbednarcik

Copy link
Copy Markdown
Contributor

Fixes #559.

What changed

The issue's framing held up exactly: both halves of the evidence were already in the
graph, nothing outside the extension binder consulted them. Three pieces:

  1. csharpMethodAcceptsArgCount + csharpNarrowMethodByApplicability — the same
    [required, declared] window the extension binder uses (optional parameters lower the
    floor, a params tail removes the ceiling), with the two ordinary-method differences
    your note called out: no this-slot discount, and param_count == 0 is a real arity
    rather than "no evidence". Extension candidates are exempt from the ordinary window —
    their own binder adjudicates them with the static-form logic. Same never-empties rule
    as the extension path: narrowing can turn a refusal into a bind, never a bind into a
    different refusal.
  2. resolveMethodCall narrows rawCandidates and the reachability-filtered set once,
    up front — so Pass 1, Pass 2, and every fallback below (including the 0.00 text tier
    your table showed for untyped receivers) see only invocable overloads. A side benefit:
    Pass 2's uniqueness guard now converges when arity makes a cross-directory overload set
    unique, where it previously refused. resolveFunctionCall narrows after the extension
    routing, covering the same-file pick and the locality cascade.
  3. The extractor stamps arg_count / type_arg_count on receiverless calls — the old
    comment's premise ("the scope rules that bind them never consult arity") is exactly
    what this issue retired. csharp salt 11 → 12 so persisted graphs re-extract.
  4. method_type_params now stamps on ordinary methods too (second commit). A
    call-shape coverage sweep found it was extension-only, so an explicit Pack<int>(5)
    could not split an ordinary generic/non-generic pair — the type-arg filter saw two
    non-generic candidates and correctly declined, leaving declaration order to win.
    Rides the same salt bump.

Scope and known limits

Arity and type-parameter count only, per the issue. Overloads this evidence cannot split
keep the existing declaration-order behavior via the never-empties rule: same-arity
different-type sets (Foo(int) vs Foo(string)), and ref-kind-only distinctions
(Foo(int) vs Foo(ref int) — the call site's ref keyword is recordable evidence if
that ever matters). Constructor overload sets (instantiates edges) don't go through
these tiers and are untouched — possible follow-up.

Tests

Your measured repros, driven through the real extractor + resolver: the typed-receiver
Write(string) / Write(string, int, bool) pair in both declaration orders (proving
the pick is arity, not a new fixed order), and the receiverless same-file Emit set.
Plus call-shape pins that each target the second-declared overload (so they fail under
declaration order): named arguments, a named argument skipping an optional, out var
arguments, nested invocations and lambdas as arguments, out-of-order named arguments,
an empty call against a params overload (required 0), and explicit type arguments
splitting both a generic/non-generic pair and a <T>/<T, U> arity pair. And unit pins for the zero-arity
case, the optional-parameter window, the params tail, and the never-empties guard. Also
flipped the one existing pin that asserted the old receiverless no-stamp policy. Full
go test ./... at my Windows baseline.

Deployed and measured. On my counted fixture repo, all seven pinned pre-fix cells
(typed receiver in both declaration orders, receiverless same-file, params tail, named
arguments, generic pair, out var) flipped to their arity-correct targets on the salt-12
re-extract, byte-identical across runs.

On my production monorepo (~117k nodes): before the fix, 0 of 12,800 ast_resolved
method-call binds pointed at a later-declared overload — the trusted tier literally never
looked past the first declaration. After the salt-12 re-extract and re-resolve, 408 of
14,267
do; every one of those was previously attributed to the wrong overload at the
tier downstream consumers trust most. (Measured at the resolver tier, before enrichment;
the enrichment passes can only add to it.)

Arity narrowing existed only inside the extension binder; every other C#
overload set bound to whichever declaration came first — at 0.95
ast_resolved when the receiver was typed. Both halves of the evidence
were already in the graph (edge arg_count, candidate param_count /
param_required / param_variadic); nothing consulted them.

- csharpNarrowMethodByApplicability / csharpMethodAcceptsArgCount: the
  extension window's [required, declared] range with ordinary-method
  semantics — no this-slot discount, and param_count == 0 is a real
  arity, not missing evidence. Extension candidates are exempt (their
  own binder adjudicates them). A filter that would empty a set keeps
  it: narrowing can turn a refusal into a bind, never a bind into a
  refusal.
- resolveMethodCall narrows rawCandidates + candidates up front, so the
  exact-type passes and every fallback below see only invocable
  overloads. Pass 2's uniqueness guard now also converges when arity
  makes an overload set unique across directories.
- resolveFunctionCall narrows after the extension routing, covering the
  same-file pick and the locality cascade.
- The extractor stamps arg_count / type_arg_count on receiverless calls
  too (the scope rules that bound them never consulted arity — the
  exact premise zzet#559 retires). csharp salt 11 -> 12.

Tests drive zzet's measured repros through the real extractor +
resolver: both declaration orders for the typed receiver, the
receiverless same-file set, zero-arity, optional-parameter and
params-tail windows.
Review coverage sweep over call shapes found a real gap: the
method_type_params stamp lived inside the extension-only branch, so an
explicit Pack<int>(5) could not split an ordinary generic/non-generic
overload pair — the type-arg filter saw two non-generic candidates and
declined, leaving declaration order to win. Hoist the stamp out of the
extension block; rides the same csharp@12 salt.

Also pins named arguments and out-var arguments as counted call shapes
(both already handled by csharpCallArgCount — argument nodes either
way), each targeting the second-declared overload so the pins fail
under declaration order.
Nested invocations, lambdas, and out-of-order named arguments each
count as one ordinary argument; an empty call fits only a params
overload (required 0); an explicit <A, B> spelling splits generic
overloads by type-parameter arity. Every pin targets the
second-declared overload so declaration order would fail it.
@zzet
zzet merged commit 1145b9f into zzet:main Aug 19, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

csharp: overload resolution ignores arity outside extension methods — first declaration wins

2 participants