Skip to content

fix(csharp): resolve calls with explicit type arguments and same-name extension overloads - #536

Merged
zzet merged 4 commits into
mainfrom
fix/csharp-generic-invocation-call-edges
Aug 11, 2026
Merged

fix(csharp): resolve calls with explicit type arguments and same-name extension overloads#536
zzet merged 4 commits into
mainfrom
fix/csharp-generic-invocation-call-edges

Conversation

@zzet

@zzet zzet commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Fixes #534.

What the report got right, and what it turned out to be

The reporter's observation is exact — every cross-file call to their extension method produced total_edges: 0 — but the cause is not cross-file resolution. Running their fixture through the real extractor shows the three failing call sites never reach the resolver at all:

=== EDGES BEFORE RESOLVE ===
ServiceCollectionExtensions.AddFooDecorator_L20 -> unresolved::*.AddFooDecoratorInfrastructure  line=26
ServiceCollectionExtensions.AddFooDecorator     -> unresolved::*.AddFooDecoratorInfrastructure  line=16
                                              (nothing from the three callers)

The variable is not the file. It is the type arguments. Their intra-file control calls services.AddFooDecoratorInfrastructure(); all three failing sites call services.AddFooDecorator<I, T>(). Two independent defects sit behind that, and both had to be fixed to close the issue.

1. Generic invocations emitted no call edge at all

The C# call-site query pinned every invoked name to (identifier). A call spelling explicit type arguments parses that name as a generic_name, so it matched no pattern — no edge, not even an unresolved stub. The member-access patterns carried the same constraint, so the call earned no reference edge either, which is why query usages also reported zero.

This is not a corner case. GetRequiredService<T>(), AddSingleton<TI, TImpl>(), Deserialize<T>(), Map<TDest>() — generic invocation is the dominant call shape in .NET, so a large fraction of every indexed C# repo was invisible. It is also a recurring class here: rust.go carries a note that the turbofish version cost 5,301 unrecorded call sites.

Each affected pattern now accepts either spelling and captures the inner identifier, so the callee name stays bare and every downstream tier is unchanged.

2. Same-name extension overloads could never be bound

With edges flowing, the fixture still resolved to nothing. Both AddFooDecorator overloads extend IServiceCollection and both live in Lib.Extensions, so neither the receiver-type tiers nor the namespace-visibility tie-break could separate them, and the binder's ambiguity refusal dropped all three sites. Overloaded extension methods over one interface is the standard .NET DI registration shape.

C# separates such a set by applicability, which is exact rather than heuristic: an overload is a candidate only if the call's argument count fits its parameter list and its type-parameter count matches any explicitly spelled type arguments. Neither fact was in the graph. Both are now recorded — argument and type-argument counts on the member-call edge, param_count / param_required / param_variadic on the method node — and the binder narrows on them before the visibility tie-break, matching §12.8.10.3, where each scope level considers only applicable candidates.

All three of the reporter's cases now bind to the one-parameter overload, and the intra-file control still binds.

Static-form calls, and why they mattered

An extension can also be invoked through its declaring class — BagExt.Add(bag) — where the receiver is passed as the first argument, so the argument list has to cover every parameter. Comparing such a call against the extension-form window shifts it by one, and with a sibling overload present that does not merely lose the bind: it leaves the wrong candidate as the sole survivor, and narrowing then binds it. I hit this while reviewing my own patch, on a probe rather than a test.

The resolver had no way to tell the forms apart, so the extractor now stamps a bare receiver's spelling — but only in the one case nothing else explains it, after the local, parameter and builtin lookups have all missed. That restriction is what makes the signal trustworthy: reaching it means no value in scope carries the name, so a receiver equal to a candidate's declaring class is a type reference rather than a variable shadowing it. Beyond removing the misbinding, this also resolves static-form calls correctly, which previously stayed unresolved on any overloaded set.

Narrowing only, and never past the visibility veto

Missing evidence, an unreadable stamp, or a filter that would empty the set all leave the candidates untouched, so this can turn a refusal into a bind but never a bind into a different one. Where it does narrow, the winner must still clear the visibility veto: the pool-unique rule waives visibility deliberately, but that waiver is scoped to sets applicability never touched, so an overload the call site cannot see is refused rather than promoted by arity. ArityNeverBeatsVisibility pins exactly this — I hit it as a real regression in an earlier draft of the patch.

3. The reporter's second note, same root cause

Their Note about an edge anchored at a declaration line rather than a call site is real, and it is the same < blind spot. The function-as-value capture treats a ( after an identifier as proof the identifier is a callee, and relies on that to skip a declaration's own name. AddFooDecorator<TInterface, TImpl>( puts the type parameters where the ( is looked for, so the method's own name was captured as a function reference at its declaration line, and the gate then bound it to the first same-name sibling — an edge between two overloads at a line where no call exists.

The byte rule cannot simply learn <: in JavaScript, TypeScript and every C-family language the capture serves, f < g is a comparison. The tree answers exactly and for all of them at once — a node filling its parent's name field where that parent also declares parameters is a declaration however it is spelled.

Also fixed along the way

  • Existing indexes: extractorVersions["csharp"] is bumped, so already-indexed .cs files re-extract instead of silently keeping the edge-less graph. Razor templates are salted with C# too, since they are extracted by the C# extractor.
  • Parameter positions: a discard (Foo(int _, string name)) emits no param node but still occupies its slot; skipping the slot along with the node renumbered every later parameter, so name reported position 0.

Two grammar traps the tests pin

  • The vendored grammar does not resolve a params object[] rest entry into a parameter node — it flattens it into loose siblings. A naive count claims one parameter for a two-parameter method, which would exclude that overload from argument counts it really accepts. A parameter list that cannot be read in full now yields no evidence at all, leaving the method universally applicable.
  • A defaulted parameter has no equals-value wrapper node, only a bare = token, so optional parameters were invisible to the obvious check.

Verification

go test ./internal/... passes in full; go test -race on the three touched packages passes; golangci-lint reports 0 issues.

New coverage, all failing before the change:

  • the reporter's four-file fixture, asserting all three cases bind and the declaration reports three incoming callers;
  • each call shape (typed local, null-conditional, this-, base-qualified, receiverless) paired with and without type arguments, asserting identical behaviour — the property that was actually violated;
  • argument count picking each overload in turn, an optional parameter correctly keeping the ambiguity, a params list widening it, type-argument count splitting Map<T> from Map<TIn, TOut>, and arity not overriding visibility;
  • extension form and both static forms of an overloaded extension each landing on the right overload;
  • a generic declaration minting no function-value reference at its own line.

Not in this PR

The same class of blind spot is present in scala.go, golang.go, cpp.go and swift.go — verified by running each real extractor and comparing the plain and generic spellings of the same call, not by reading queries. Go has two distinct mechanisms behind it (index_expression, and type_conversion_expression when a single type argument meets exactly one value argument), and C++ additionally drops every ns::-qualified call. Filed separately rather than bundled here, since each needs its own grammar handling, version bump and fixtures.

zzet added 4 commits August 11, 2026 09:33
…ents

The C# call-site query pinned every invoked name to an `identifier`.
A call that spells its type arguments — `services.AddFooDecorator<I, T>()`,
`GetRequiredService<T>()`, `AddSingleton<TI, TImpl>()` — parses that name as
a `generic_name` instead, so it matched no pattern at all and produced no
call edge: not an unresolved stub, nothing. The method-access patterns had
the same constraint, so the call earned no reference edge either.

The result was that a generic method could be called from everywhere and
still answer `query callers` with `total_edges: 0` and the `likely_unused`
caveat — a resolution failure indistinguishable from dead code. Generic
invocation is the dominant shape in .NET, so this covered a large fraction
of every indexed C# repo. Rust hit this same class of bug (its query carries
a note about 5,301 lost turbofish sites); the C# query is now equally
explicit about it.

Each affected pattern gains an alternation that accepts either spelling and
captures the inner identifier, so the callee name stays bare and every
downstream tier is unchanged. The extractor version is bumped so
already-indexed files re-extract instead of keeping the edge-less graph, and
Razor templates are salted with C# since they are extracted by it.

The test pairs each call shape with and without type arguments and asserts
they behave identically, which is the property that was actually violated.
Once generic invocations emit edges, the reporter's fixture still resolved
to nothing. Both `AddFooDecorator` overloads extend `IServiceCollection` and
both live in `Lib.Extensions`, so neither the receiver-type tiers nor the
namespace-visibility tie-break could separate them, and the binder's
ambiguity refusal dropped all three call sites. Overloaded extension methods
over one interface are the standard .NET DI registration shape, so this hit
far more than the reported fixture.

C# separates such a set by APPLICABILITY, which is exact rather than
heuristic: an overload is a candidate only if the call's argument count fits
its parameter list and its type-parameter count matches any explicitly
spelled type arguments. Neither fact was in the graph, so both are now
recorded — argument and type-argument counts on the member-call edge, and
`param_count` / `param_required` / `param_variadic` on the method node.
Node-level arity rather than the existing KindParam nodes because those
carry no default-value marker and cannot answer "how many MUST be supplied".

Applicability runs before the visibility tie-break, matching §12.8.10.3
(each scope level considers only applicable candidates). It narrows only:
missing evidence, an unreadable stamp, or a filter that would empty the set
all leave the candidates untouched, so this can turn a refusal into a bind
but never a bind into a different one. Where it does narrow, the winner must
still clear the visibility veto — the pool-unique rule's waiver is scoped to
sets applicability never touched, so an overload the call site cannot see is
refused rather than promoted by arity.

Two traps the fixtures pin. The vendored grammar does not resolve a `params`
entry into a parameter node, so a naive count claims one parameter for a
two-parameter method; a list that cannot be read in full now yields no
evidence at all, leaving the method universally applicable instead of
wrongly excluded. And a defaulted parameter has no equals-value wrapper —
only a bare `=` token — so optional parameters were invisible.

Also fixes parameter positions: a discard (`Foo(int _, string name)`) emits
no node but still occupies its slot, and skipping the slot with the node
renumbered every parameter after it.
…ion value

The function-as-value capture treats a `(` after an identifier as proof the
identifier is a callee, and leans on that to skip a declaration's own name.
A generic declaration puts the type parameters in between —
`AddFooDecorator<TInterface, TImpl>(` — so the `(` is not where the rule
looks and the method's own name was captured as a function reference. The
placeholder edge carried the DECLARATION line, and the gate then bound it to
whichever same-name sibling it found first, minting an edge between two
overloads anchored at a line where no call exists. Reported as the second
symptom of issue #534.

Same root cause as the dropped call edges — an identifier followed by `<` —
but the byte rule cannot simply learn `<`: in JavaScript, TypeScript and
every C-family language the capture serves, `f < g` is a comparison. The
tree answers exactly and for all of them at once: a node that fills its
parent's `name` field where that parent also declares `parameters` is a
declaration however it is spelled.
…statically

Applicability compared argument counts against the EXTENSION-form window,
where the receiver fills the `this` slot. An extension invoked through its
declaring class (`BagExt.Add(bag)`) passes that receiver as the first
argument instead, so every parameter has to be covered by the argument list.
Reading one form as the other shifts the window by one, which does not
merely lose a bind: with a sibling overload present it leaves the WRONG
candidate as the sole survivor, and narrowing then binds it.

The resolver had no way to tell the forms apart, so the extractor now stamps
a bare receiver's spelling — but only in the one case nothing else explains
it, after the local, parameter and builtin lookups have all missed. That
restriction is what makes the signal trustworthy: reaching it means no value
in scope carries the name, so a receiver equal to a candidate's declaring
class is a type reference rather than a variable shadowing it. Receivers the
extractor did type are values by construction and carry no such stamp.

Beyond removing the misbinding this also resolves static-form calls
correctly, which previously stayed unresolved on any overloaded set.
@zzet

zzet commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Follow-up for the sibling-language findings: #537

@zzet

zzet commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Sibling-language fixes are now up as #538. The two PRs touch the same extractorVersions map, so whichever merges second needs a one-line conflict resolution.

@zzet
zzet merged commit 6db9d8d into main Aug 11, 2026
10 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: extension-method calls resolve only within the declaring file — every cross-file call is dropped

1 participant