fix(csharp): resolve calls with explicit type arguments and same-name extension overloads - #536
Merged
Merged
Conversation
…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.
Owner
Author
|
Follow-up for the sibling-language findings: #537 |
Owner
Author
|
Sibling-language fixes are now up as #538. The two PRs touch the same |
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 #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:The variable is not the file. It is the type arguments. Their intra-file control calls
services.AddFooDecoratorInfrastructure(); all three failing sites callservices.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 ageneric_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 whyquery usagesalso 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.gocarries 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
AddFooDecoratoroverloads extendIServiceCollectionand both live inLib.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_variadicon 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.
ArityNeverBeatsVisibilitypins 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 < gis a comparison. The tree answers exactly and for all of them at once — a node filling its parent'snamefield where that parent also declaresparametersis a declaration however it is spelled.Also fixed along the way
extractorVersions["csharp"]is bumped, so already-indexed.csfiles 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.Foo(int _, string name)) emits no param node but still occupies its slot; skipping the slot along with the node renumbered every later parameter, sonamereported position 0.Two grammar traps the tests pin
params object[] restentry into aparameternode — 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.=token, so optional parameters were invisible to the obvious check.Verification
go test ./internal/...passes in full;go test -raceon the three touched packages passes;golangci-lintreports 0 issues.New coverage, all failing before the change:
this-,base-qualified, receiverless) paired with and without type arguments, asserting identical behaviour — the property that was actually violated;paramslist widening it, type-argument count splittingMap<T>fromMap<TIn, TOut>, and arity not overriding visibility;Not in this PR
The same class of blind spot is present in
scala.go,golang.go,cpp.goandswift.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, andtype_conversion_expressionwhen a single type argument meets exactly one value argument), and C++ additionally drops everyns::-qualified call. Filed separately rather than bundled here, since each needs its own grammar handling, version bump and fixtures.