csharp: consult arity for ordinary overload sets - #610
Merged
Conversation
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
approved these changes
Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
csharpMethodAcceptsArgCount+csharpNarrowMethodByApplicability— the same[required, declared]window the extension binder uses (optional parameters lower thefloor, a
paramstail removes the ceiling), with the two ordinary-method differencesyour note called out: no
this-slot discount, andparam_count == 0is a real arityrather 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.
resolveMethodCallnarrowsrawCandidatesand 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.
resolveFunctionCallnarrows after the extensionrouting, covering the same-file pick and the locality cascade.
arg_count/type_arg_counton receiverless calls — the oldcomment'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.
method_type_paramsnow stamps on ordinary methods too (second commit). Acall-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)vsFoo(string)), andref-kind-only distinctions(
Foo(int)vsFoo(ref int)— the call site'srefkeyword is recordable evidence ifthat ever matters). Constructor overload sets (
instantiatesedges) don't go throughthese 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 (provingthe pick is arity, not a new fixed order), and the receiverless same-file
Emitset.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 vararguments, nested invocations and lambdas as arguments, out-of-order named arguments,
an empty call against a
paramsoverload (required 0), and explicit type argumentssplitting both a generic/non-generic pair and a
<T>/<T, U>arity pair. And unit pins for the zero-aritycase, the optional-parameter window, the
paramstail, and the never-empties guard. Alsoflipped 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,
paramstail, namedarguments, 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_resolvedmethod-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.)