From 86bf0e2e8498894dff50c2e6a5229d8d9fbe4ddd Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:52:54 +0200 Subject: [PATCH 1/3] csharp: consult arity for ordinary overload sets (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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. --- internal/indexer/extractor_version.go | 2 +- internal/indexer/extractor_version_test.go | 4 +- internal/parser/languages/csharp.go | 27 +++- .../csharp_overload_evidence_test.go | 11 +- internal/resolver/csharp_applicability.go | 49 ++++++ .../resolver/csharp_overload_arity_test.go | 150 ++++++++++++++++++ internal/resolver/resolver.go | 16 ++ 7 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 internal/resolver/csharp_overload_arity_test.go diff --git a/internal/indexer/extractor_version.go b/internal/indexer/extractor_version.go index f2ea786b..384c0852 100644 --- a/internal/indexer/extractor_version.go +++ b/internal/indexer/extractor_version.go @@ -32,7 +32,7 @@ var extractorVersions = map[string]int{ // "go": 2, "c": generatedParserProjectionPolicyVersion, // generated parser projection covers all strictly detected table sizes "php": 2, // class/interface inheritance now emits typed structural edges - "csharp": 11, // params parameters emit complete shape and arity evidence (was: qualified static-form extension calls pick the right overload) + "csharp": 12, // receiverless calls carry arg_count / type_arg_count for #559 (was: params parameters emit complete shape and arity evidence) "scala": 2, // explicitly instantiated generic calls emit call edges "go": 3, // generic instantiations are marked so indexing a func value cannot bind (was: generic calls emit call edges) "cpp": 2, // templated and namespace-qualified calls emit call edges diff --git a/internal/indexer/extractor_version_test.go b/internal/indexer/extractor_version_test.go index 8d6a29ee..4300cbb8 100644 --- a/internal/indexer/extractor_version_test.go +++ b/internal/indexer/extractor_version_test.go @@ -63,8 +63,8 @@ func TestStaleLangsDetection(t *testing.T) { t.Errorf("stored pre-params C# version = %v, want [csharp]", got) } for _, path := range []string{"src/Handler.cs", "Views/Page.razor", "Views/Page.cshtml"} { - if got := merkleSaltFor(path); got != "csharp@11" { - t.Errorf("C# extractor salt for %s = %q, want csharp@11", path, got) + if got := merkleSaltFor(path); got != "csharp@12" { + t.Errorf("C# extractor salt for %s = %q, want csharp@12", path, got) } } if got := merkleSaltFor("src/Handler.php"); got != "php@2" { diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index bca213b6..05aef809 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -389,16 +389,17 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex returnUsage: classifyReturnUsage(expr.Node, src, csharpReturnUsageSpec), }, expr.Node)) - // A receiverless call carries no applicability stamps: nothing - // resolves it through the extension binder, and the scope rules - // that do bind it never consult arity. + // Receiverless calls carry applicability stamps too: the + // same-file and locality tiers pick among ordinary overload + // sets, and without arg_count that pick is declaration order + // (#559). case m.Captures["call.expr"] != nil: expr := m.Captures["call.expr"] - calls = append(calls, csharpDeferredCall{ + calls = append(calls, withCSharpCallArity(csharpDeferredCall{ name: m.Captures["call.name"].Text, line: expr.StartLine + 1, returnUsage: classifyReturnUsage(expr.Node, src, csharpReturnUsageSpec), - }) + }, expr.Node)) case m.Captures["maccess.expr"] != nil: accesses = append(accesses, csharpDeferredAccess{ @@ -715,6 +716,22 @@ func (e *CSharpExtractor) extractCSharp(filePath string, src []byte) (*parser.Ex From: callerID, To: "unresolved::" + c.name, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } + // Applicability evidence rides receiverless calls too: the + // resolver's same-file and locality tiers pick among ordinary + // overload sets, and without arg_count that pick is declaration + // order (#559). + if c.argKnown { + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["arg_count"] = c.argCount + } + if c.typeArgKnown { + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["type_arg_count"] = c.typeArgCount + } stampReturnUsage(edge, c.returnUsage) result.Edges = append(result.Edges, edge) } diff --git a/internal/parser/languages/csharp_overload_evidence_test.go b/internal/parser/languages/csharp_overload_evidence_test.go index 0bec24b1..204024a6 100644 --- a/internal/parser/languages/csharp_overload_evidence_test.go +++ b/internal/parser/languages/csharp_overload_evidence_test.go @@ -91,9 +91,14 @@ func TestCSharpExtractor_TypeArgCountAcrossCallShapes(t *testing.T) { bare := callEdgesFrom(result.Edges, "App.cs::Runner.Bare", "Helper") require.Len(t, bare, 1, "the receiverless call still emits its edge") - assert.NotContains(t, bare[0].Meta, "type_arg_count", - "a receiverless call has no extension binder to narrow, so it carries no stamp") - assert.NotContains(t, bare[0].Meta, "arg_count") + // #559: receiverless calls carry the applicability stamps too — the + // resolver's same-file and locality tiers pick among ordinary + // overload sets, and without arg_count that pick is declaration + // order. + assert.Equal(t, 1, bare[0].Meta["type_arg_count"], + "a receiverless generic call spells its type argument") + assert.Equal(t, 0, bare[0].Meta["arg_count"], + "zero arguments is a measured count, not missing evidence") } // The declaration side of the same evidence. `param_required` is stamped diff --git a/internal/resolver/csharp_applicability.go b/internal/resolver/csharp_applicability.go index b31d7519..b88e4887 100644 --- a/internal/resolver/csharp_applicability.go +++ b/internal/resolver/csharp_applicability.go @@ -98,6 +98,55 @@ func csharpMethodTypeParamCount(c *graph.Node) int { return n } +// csharpNarrowMethodByApplicability filters a same-name ORDINARY C# +// candidate set (instance/static methods, not extensions) to the members +// the call site could invoke. Same shape as csharpNarrowByApplicability, +// with ordinary-arity semantics in the arg-count half — see +// csharpMethodAcceptsArgCount (#559). +func csharpNarrowMethodByApplicability(e *graph.Edge, cands []*graph.Node) []*graph.Node { + if len(cands) < 2 || e == nil || e.Meta == nil { + return cands + } + out := csharpKeepIf(cands, func(c *graph.Node) bool { + return csharpAcceptsTypeArgCount(e, c) + }) + return csharpKeepIf(out, func(c *graph.Node) bool { + return csharpMethodAcceptsArgCount(e, c) + }) +} + +// csharpMethodAcceptsArgCount reports whether an ordinary C# method can +// accept the call's argument count — the [required, declared] window, +// widened by a trailing `params` array. +// +// Two deliberate differences from csharpExtensionAcceptsArgCount: there +// is no `this` slot to discount, and param_count == 0 is a REAL arity — +// an extension method always declares its `this` parameter, so only +// there does zero mean "no evidence". +func csharpMethodAcceptsArgCount(e *graph.Edge, c *graph.Node) bool { + argc, ok := metaIntValue(e.Meta["arg_count"]) + if !ok || c == nil || c.Meta == nil { + return true + } + if isCSharpExtension(c) { + // Extensions are adjudicated by their own binder with the + // `this`-slot discount; the ordinary window would misread them. + return true + } + count, ok := metaIntValue(c.Meta["param_count"]) + if !ok { + return true // no stamp — an older graph or an unexposed parameter list + } + required := count + if r, rok := metaIntValue(c.Meta["param_required"]); rok { + required = r + } + if variadic, _ := c.Meta["param_variadic"].(bool); variadic { + return argc >= required + } + return argc >= required && argc <= count +} + // csharpExtensionAcceptsArgCount reports whether a candidate extension // method can accept the call's argument count. // diff --git a/internal/resolver/csharp_overload_arity_test.go b/internal/resolver/csharp_overload_arity_test.go new file mode 100644 index 00000000..0fafef9d --- /dev/null +++ b/internal/resolver/csharp_overload_arity_test.go @@ -0,0 +1,150 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// #559: outside extension methods, C# overload sets bound to whichever +// declaration came first — arity evidence (edge arg_count vs candidate +// param_count) was consulted nowhere else. These pins drive the calls +// through the real extractor + resolver, so they cover both halves: the +// receiverless arg_count stamp and the resolver-side narrowing. + +// csharpBoundCallTo finds the single resolved call edge from `from` to a +// node named `name`. Overload node IDs carry a line disambiguator +// (Write_L4), so matching goes through the resolved node's Name; an edge +// still parked at unresolved:: has no node and correctly fails the pin. +func csharpBoundCallTo(t *testing.T, g graph.Store, from, name string) *graph.Node { + t.Helper() + var found *graph.Node + for _, e := range g.GetOutEdges(from) { + if e.Kind != graph.EdgeCalls { + continue + } + if n := g.GetNode(e.To); n != nil && n.Name == name { + require.Nil(t, found, "expected exactly one resolved call from %s to %s", from, name) + found = n + } + } + require.NotNil(t, found, "no resolved call edge from %s to %s", from, name) + return found +} + +func TestCSharpOverloadArity_TypedReceiverPicksMatchingOverload(t *testing.T) { + // zzet's measured repro: the 1-param overload declared FIRST, the + // call supplies three arguments. Declaration order used to win. + g := buildCSharpResolverGraph(t, map[string]string{ + "Lib/Writer.cs": `namespace App.Lib { + public class Writer { + public void Write(string s) { } + public void Write(string s, int n, bool flush) { } + } +}`, + "Svc/Report.cs": `namespace App.Svc { + public class Report { + public void Run() { + Writer w = new Writer(); + w.Write("a", 1, true); + } + } +}`, + }) + New(g).ResolveAll() + + to := csharpBoundCallTo(t, g, "Svc/Report.cs::Report.Run", "Write") + pc, _ := to.Meta["param_count"].(int) + require.Equal(t, 3, pc, "arg_count=3 must bind the 3-param overload, got %s", to.ID) +} + +func TestCSharpOverloadArity_TypedReceiverOtherDeclarationOrder(t *testing.T) { + // The mirror: 3-param declared first, call supplies ONE argument. + // Together with the test above this proves the pick is arity, not + // declaration order in either direction. + g := buildCSharpResolverGraph(t, map[string]string{ + "Lib/Writer.cs": `namespace App.Lib { + public class Writer { + public void Write(string s, int n, bool flush) { } + public void Write(string s) { } + } +}`, + "Svc/Report.cs": `namespace App.Svc { + public class Report { + public void Run() { + Writer w = new Writer(); + w.Write("a"); + } + } +}`, + }) + New(g).ResolveAll() + + to := csharpBoundCallTo(t, g, "Svc/Report.cs::Report.Run", "Write") + pc, _ := to.Meta["param_count"].(int) + require.Equal(t, 1, pc, "arg_count=1 must bind the 1-param overload, got %s", to.ID) +} + +func TestCSharpOverloadArity_ReceiverlessSameFilePick(t *testing.T) { + // Receiverless calls carried no arg_count at all (the stamp was + // scoped to member calls), so the same-file tier took the first + // same-file overload. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Emitter.cs": `namespace App.Svc { + public class Emitter { + public void Emit(string s) { } + public void Emit(string s, int n) { } + public void Run() { + Emit("x", 1); + } + } +}`, + }) + New(g).ResolveAll() + + to := csharpBoundCallTo(t, g, "Svc/Emitter.cs::Emitter.Run", "Emit") + pc, _ := to.Meta["param_count"].(int) + require.Equal(t, 2, pc, "receiverless arg_count=2 must bind the 2-param overload, got %s", to.ID) +} + +func TestCSharpMethodAcceptsArgCount_ZeroParamIsRealArity(t *testing.T) { + // For an ordinary method, param_count == 0 is a genuine arity — + // unlike an extension method, which always declares its `this` + // parameter (the case csharpExtensionAcceptsArgCount special-cases). + zero := &graph.Node{ID: "x.cs::T.Ping", Meta: map[string]any{"param_count": 0}} + unstamped := &graph.Node{ID: "x.cs::T.Old", Meta: map[string]any{}} + + call0 := &graph.Edge{Meta: map[string]any{"arg_count": 0}} + require.True(t, csharpMethodAcceptsArgCount(call0, zero), "0 args fit a 0-param method") + + call1 := &graph.Edge{Meta: map[string]any{"arg_count": 1}} + require.False(t, csharpMethodAcceptsArgCount(call1, zero), "1 arg cannot fit a 0-param method") + require.True(t, csharpMethodAcceptsArgCount(call1, unstamped), "no stamp = no evidence, accept") +} + +func TestCSharpNarrowMethodByApplicability_OptionalAndVariadicWindow(t *testing.T) { + // Ordinary methods use the same [required, declared] window as + // extensions, minus the `this`-slot discount: optional parameters + // lower the floor, a params array removes the ceiling. + optional := &graph.Node{ID: "x.cs::T.Opt", Meta: map[string]any{ + "param_count": 3, "param_required": 1, + }} + variadic := &graph.Node{ID: "x.cs::T.Var", Meta: map[string]any{ + "param_count": 2, "param_required": 1, "param_variadic": true, + }} + + call2 := &graph.Edge{Meta: map[string]any{"arg_count": 2}} + require.True(t, csharpMethodAcceptsArgCount(call2, optional), "2 args inside [1,3]") + + call4 := &graph.Edge{Meta: map[string]any{"arg_count": 4}} + require.False(t, csharpMethodAcceptsArgCount(call4, optional), "4 args above the declared count") + require.True(t, csharpMethodAcceptsArgCount(call4, variadic), "params tail accepts any count above required") + + // A filter that would empty the set keeps it — narrowing must never + // turn a bind into a refusal. + call9 := &graph.Edge{Meta: map[string]any{"arg_count": 9}} + got := csharpNarrowMethodByApplicability(call9, []*graph.Node{optional}) + require.Equal(t, []*graph.Node{optional}, got) +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 11876e50..2c01eaef 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3653,6 +3653,13 @@ func (r *Resolver) resolveFunctionCall(e *graph.Edge, funcName string, stats *Re } } + // #559: receiverless C# calls carry arg_count too — narrow the + // ordinary-overload set before the same-file pick and the locality + // cascade below. Runs after the extension routing above on purpose: + // extension candidates are adjudicated by their own binder and are + // exempt from the ordinary window. + candidates = csharpNarrowMethodByApplicability(e, candidates) + // File-local candidates outrank everything below: a symbol defined in // the caller's own file is strictly more local than a same-directory // neighbour in every language (in Go both are package scope, so the @@ -4059,6 +4066,15 @@ func (r *Resolver) resolveMethodCall(e *graph.Edge, methodName string, stats *Re // would empty the list, the original candidates pass through. candidates := r.filterByReachability(e.FilePath, rawCandidates) + // #559: consult applicability before any pick. C# call edges carry + // arg_count / type_arg_count and candidates carry parameter stamps, + // so narrowing here lets every tier below — the exact-type passes + // and the fallbacks alike — see only overloads the call site could + // invoke. Edges without the stamps (every other language) pass + // through untouched, and a filter that would empty a set keeps it. + rawCandidates = csharpNarrowMethodByApplicability(e, rawCandidates) + candidates = csharpNarrowMethodByApplicability(e, candidates) + // Per-language scope rule lands binding when its evidence is // strong (C static / C++ namespace + ADL / Java enclosing class / // PHP parent::/self::/namespace). Empty return falls through to From 6fc3453db3a771f1039128ff4906fc555b9d78ad Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:02:18 +0200 Subject: [PATCH 2/3] csharp: stamp method_type_params on ordinary methods too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(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. --- internal/parser/languages/csharp.go | 22 +++--- .../resolver/csharp_overload_arity_test.go | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/internal/parser/languages/csharp.go b/internal/parser/languages/csharp.go index 05aef809..1f5d6c11 100644 --- a/internal/parser/languages/csharp.go +++ b/internal/parser/languages/csharp.go @@ -1268,10 +1268,22 @@ func (e *CSharpExtractor) emitMethod(m parser.QueryResult, filePath, fileID stri // Extension method: a static method whose first parameter carries the // `this` modifier. Record the receiver type it extends so member-call // resolution can bind `x.Foo()` to it (the id stays .). + // The method's own type parameters are applicability evidence for + // EVERY method, not just extensions: an explicit `Foo(x)` call + // splits a generic/non-generic ordinary overload pair only when the + // generic one is stamped (#559). + tparams := csharpMethodTypeParamNames(def.Node, src) + if len(tparams) > 0 { + names := make([]string, 0, len(tparams)) + for n := range tparams { + names = append(names, n) + } + sort.Strings(names) + meta["method_type_params"] = strings.Join(names, ",") + } if extType := csharpExtensionReceiverType(def.Node, src); extType != "" { meta["extension"] = true meta["this_param_type"] = extType - tparams := csharpMethodTypeParamNames(def.Node, src) // `Foo(this T v)` — the this-param names the method's own // type parameter, i.e. it matches any receiver; the binder must // not treat it as a concrete type named "T". A `where T : X` @@ -1290,14 +1302,6 @@ func (e *CSharpExtractor) emitMethod(m parser.QueryResult, filePath, fileID stri meta["this_param_shape"] = shape } } - if len(tparams) > 0 { - names := make([]string, 0, len(tparams)) - for n := range tparams { - names = append(names, n) - } - sort.Strings(names) - meta["method_type_params"] = strings.Join(names, ",") - } } // Parameter arity — the evidence that splits a same-name overload set // the receiver type alone cannot. Stamped on the node rather than read diff --git a/internal/resolver/csharp_overload_arity_test.go b/internal/resolver/csharp_overload_arity_test.go index 0fafef9d..2c45047b 100644 --- a/internal/resolver/csharp_overload_arity_test.go +++ b/internal/resolver/csharp_overload_arity_test.go @@ -109,6 +109,77 @@ func TestCSharpOverloadArity_ReceiverlessSameFilePick(t *testing.T) { require.Equal(t, 2, pc, "receiverless arg_count=2 must bind the 2-param overload, got %s", to.ID) } +func TestCSharpOverloadArity_NamedArgumentsCount(t *testing.T) { + // Named arguments are `argument` nodes like positional ones — three + // named args must pick the 3-param overload declared second. Also a + // named arg that skips an optional (window [1,3]) must keep binding. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Gauge.cs": `namespace App.Svc { + public class Gauge { + public void Tune(string tag) { } + public void Tune(string tag, int level, bool loud) { } + public void Adjust(string tag, int scale = 1, bool loud = false) { } + public void Run() { + Gauge g = new Gauge(); + g.Tune(tag: "a", level: 2, loud: true); + g.Adjust("a", loud: true); + } + } +}`, + }) + New(g).ResolveAll() + + tune := csharpBoundCallTo(t, g, "Svc/Gauge.cs::Gauge.Run", "Tune") + pc, _ := tune.Meta["param_count"].(int) + require.Equal(t, 3, pc, "three named args must bind the 3-param overload, got %s", tune.ID) + + adjust := csharpBoundCallTo(t, g, "Svc/Gauge.cs::Gauge.Run", "Adjust") + require.Equal(t, "Adjust", adjust.Name, "optional-skipping named call must stay bound") +} + +func TestCSharpOverloadArity_GenericOverloadByTypeArgs(t *testing.T) { + // Explicit type arguments split a generic/non-generic ordinary pair: + // Pack(5) must bind the generic overload declared second. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Boxer.cs": `namespace App.Svc { + public class Boxer { + public void Pack(string s) { } + public void Pack(T item) { } + public void Run() { + Boxer b = new Boxer(); + b.Pack(5); + } + } +}`, + }) + New(g).ResolveAll() + + to := csharpBoundCallTo(t, g, "Svc/Boxer.cs::Boxer.Run", "Pack") + tp, _ := to.Meta["method_type_params"].(string) + require.NotEmpty(t, tp, "explicit must bind the generic overload, got %s", to.ID) +} + +func TestCSharpOverloadArity_OutVarArgumentCounts(t *testing.T) { + // `out var n` is one argument — the 2-arg receiverless call must + // bind the out-parameter overload declared second. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Splitter.cs": `namespace App.Svc { + public class Splitter { + public bool TryCut(string s) { return false; } + public bool TryCut(string s, out int n) { n = 0; return true; } + public void Run() { + TryCut("a", out var n); + } + } +}`, + }) + New(g).ResolveAll() + + to := csharpBoundCallTo(t, g, "Svc/Splitter.cs::Splitter.Run", "TryCut") + pc, _ := to.Meta["param_count"].(int) + require.Equal(t, 2, pc, "out var counts as an argument, got %s", to.ID) +} + func TestCSharpMethodAcceptsArgCount_ZeroParamIsRealArity(t *testing.T) { // For an ordinary method, param_count == 0 is a genuine arity — // unlike an extension method, which always declares its `this` From 9c3ffa919ea2c2f9b4919b53e1be4d6d3f02090f Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:05:57 +0200 Subject: [PATCH 3/3] csharp: pin argument-shape coverage for arity narrowing 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 spelling splits generic overloads by type-parameter arity. Every pin targets the second-declared overload so declaration order would fail it. --- .../resolver/csharp_overload_arity_test.go | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/internal/resolver/csharp_overload_arity_test.go b/internal/resolver/csharp_overload_arity_test.go index 2c45047b..d1a8cbae 100644 --- a/internal/resolver/csharp_overload_arity_test.go +++ b/internal/resolver/csharp_overload_arity_test.go @@ -180,6 +180,79 @@ func TestCSharpOverloadArity_OutVarArgumentCounts(t *testing.T) { require.Equal(t, 2, pc, "out var counts as an argument, got %s", to.ID) } +func TestCSharpOverloadArity_ArgumentExpressionShapes(t *testing.T) { + // A nested invocation, a lambda, and out-of-order named arguments + // each count as ordinary arguments. Every call targets the + // SECOND-declared overload, so declaration order would fail each pin. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Relay.cs": `namespace App.Svc { + public class Relay { + public string Make(int a, int b) { return ""; } + + public void Send(string s, int n) { } + public void Send(object o) { } + + public void Apply(string s, int n) { } + public void Apply(object o) { } + + public void Order(string tag) { } + public void Order(string tag, int level, bool loud) { } + + public void Run() { + Send(Make(1, 2)); + Apply(x => x); + Order(loud: true, tag: "a", level: 2); + } + } +}`, + }) + New(g).ResolveAll() + + send := csharpBoundCallTo(t, g, "Svc/Relay.cs::Relay.Run", "Send") + pc, _ := send.Meta["param_count"].(int) + require.Equal(t, 1, pc, "a nested invocation is one argument, got %s", send.ID) + + apply := csharpBoundCallTo(t, g, "Svc/Relay.cs::Relay.Run", "Apply") + pc, _ = apply.Meta["param_count"].(int) + require.Equal(t, 1, pc, "a lambda is one argument, got %s", apply.ID) + + order := csharpBoundCallTo(t, g, "Svc/Relay.cs::Relay.Run", "Order") + pc, _ = order.Meta["param_count"].(int) + require.Equal(t, 3, pc, "out-of-order named args still count 3, got %s", order.ID) +} + +func TestCSharpOverloadArity_EmptyParamsCallAndGenericArity(t *testing.T) { + // `Pour()` fits only the params overload (required 0); an explicit + // spelling fits only the two-type-parameter overload. Both + // targets are declared second. + g := buildCSharpResolverGraph(t, map[string]string{ + "Svc/Cellar.cs": `namespace App.Svc { + public class Cellar { + public void Pour(string tag) { } + public void Pour(params int[] xs) { } + + public void Wrap(T item) { } + public void Wrap(T item) { } + + public void Run() { + Pour(); + Cellar c = new Cellar(); + c.Wrap(5); + } + } +}`, + }) + New(g).ResolveAll() + + pour := csharpBoundCallTo(t, g, "Svc/Cellar.cs::Cellar.Run", "Pour") + variadic, _ := pour.Meta["param_variadic"].(bool) + require.True(t, variadic, "an empty call fits only the params overload, got %s", pour.ID) + + wrap := csharpBoundCallTo(t, g, "Svc/Cellar.cs::Cellar.Run", "Wrap") + tp, _ := wrap.Meta["method_type_params"].(string) + require.Equal(t, "T,U", tp, "explicit must bind the two-type-param overload, got %s", wrap.ID) +} + func TestCSharpMethodAcceptsArgCount_ZeroParamIsRealArity(t *testing.T) { // For an ordinary method, param_count == 0 is a genuine arity — // unlike an extension method, which always declares its `this`