From 5fbad7c1184abb00fe5ef408c922fd9ac9d613e1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:10:29 +0100 Subject: [PATCH 1/3] feat(analyzers): report MethodDataSource Arguments that fit two overloads equally (TUnit0076) When the Arguments of a [MethodDataSource] fit two overloads equally well, reflection mode's binder throws AmbiguousMatchException while source-gen mode calls one of them. The new TUnit0076 error mirrors the runtime binder's most-specific-overload rules and reports the tie at compile time, so neither mode reaches the runtime difference. Fixes #7175 --- docs/docs/reference/tunit0076.md | 23 +++ docs/sidebars.ts | 1 + .../AnalyzerReleases.Unshipped.md | 1 + src/TUnit.Analyzers/DiagnosticIds.cs | 1 + src/TUnit.Analyzers/Resources.resx | 9 + src/TUnit.Analyzers/Rules.cs | 3 + src/TUnit.Analyzers/TestDataAnalyzer.cs | 156 +++++++++++++++++- .../MethodDataSourceAnalyzerTests.cs | 142 ++++++++++++++++ 8 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 docs/docs/reference/tunit0076.md diff --git a/docs/docs/reference/tunit0076.md b/docs/docs/reference/tunit0076.md new file mode 100644 index 00000000000..44f75d32fb7 --- /dev/null +++ b/docs/docs/reference/tunit0076.md @@ -0,0 +1,23 @@ +# TUnit0076: Data source Arguments fit more than one overload equally well + +**Severity:** Error + +The `Arguments` of a `[MethodDataSource]` select which overload of the data source method is called. TUnit0076 reports `Arguments` that fit two or more overloads equally well, where no overload is more specific than the others. A C# call with the same arguments would fail with CS0121 for the same reason. + +For example, with the overloads `Tie(string expected, IComparable value)` and `Tie(string expected, IConvertible value)`, the attribute `[MethodDataSource(nameof(Tie), Arguments = new object[] { "a", 1 })]` is ambiguous: `int` implements both interfaces, and neither interface converts to the other. + +## Why it is an error + +Without the diagnostic, the two execution modes disagree. Reflection mode asks the runtime binder for the overload that matches the argument types, and the binder throws `AmbiguousMatchException`. Source-generated mode would call one of the overloads instead. + +## How to fix it + +Make one overload the best match for the `Arguments`: + +- Rename one of the overloads, so the attribute names the method it means. +- Change a parameter type so that one overload is more specific, for example `int` instead of `IConvertible`. +- Remove the overload that the data source does not need. + +## Scope + +The rule follows the runtime binder's rules. It compares only overloads with one parameter per argument, and it accepts identity, reference, boxing and numeric widening conversions. It does not check `Arguments` that contain `null` or overloads that are generic methods. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 48d0df6d244..d3038d4555a 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -249,6 +249,7 @@ const sidebars: SidebarsConfig = { 'reference/environment-variables', 'reference/test-configuration', 'reference/tunit0075', + 'reference/tunit0076', { type: 'doc', id: 'guides/best-practices', diff --git a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md index f29448028ab..da866f05438 100644 --- a/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/TUnit.Analyzers/AnalyzerReleases.Unshipped.md @@ -7,6 +7,7 @@ TUnit0062 | Usage | Warning | CancellationToken must be the last parameter TUnit0073 | Usage | Error | Missing polyfill types required by TUnit TUnit0074 | Usage | Error | Hook attribute is redundant on an override TUnit0075 | Usage | Warning | Use the hook cancellation token for setup +TUnit0076 | Usage | Error | Data source Arguments fit more than one overload equally well ### Removed Rules diff --git a/src/TUnit.Analyzers/DiagnosticIds.cs b/src/TUnit.Analyzers/DiagnosticIds.cs index af4526e4ce0..d41003fe177 100644 --- a/src/TUnit.Analyzers/DiagnosticIds.cs +++ b/src/TUnit.Analyzers/DiagnosticIds.cs @@ -66,4 +66,5 @@ public static class DiagnosticIds public const string MissingPolyfillPackage = "TUnit0073"; public const string RedundantHookAttributeOnOverride = "TUnit0074"; public const string UseHookCancellationToken = "TUnit0075"; + public const string AmbiguousMethodDataSourceOverload = "TUnit0076"; } diff --git a/src/TUnit.Analyzers/Resources.resx b/src/TUnit.Analyzers/Resources.resx index 48070608a30..965d4a5c086 100644 --- a/src/TUnit.Analyzers/Resources.resx +++ b/src/TUnit.Analyzers/Resources.resx @@ -534,4 +534,13 @@ Use the hook cancellation token for setup + + The `Arguments` of a data source must select a single overload of the data source method. When they fit two or more overloads equally well, the overload to call is ambiguous, as a C# call with the same arguments would be (CS0121). + + + The `Arguments` fit more than one overload of '{0}' equally well: {1} + + + Data source Arguments fit more than one overload equally well + diff --git a/src/TUnit.Analyzers/Rules.cs b/src/TUnit.Analyzers/Rules.cs index 58d81fb7b5d..710151ffcaa 100644 --- a/src/TUnit.Analyzers/Rules.cs +++ b/src/TUnit.Analyzers/Rules.cs @@ -176,6 +176,9 @@ public static class Rules public static readonly DiagnosticDescriptor UseHookCancellationToken = CreateDescriptor(DiagnosticIds.UseHookCancellationToken, UsageCategory, DiagnosticSeverity.Warning); + public static readonly DiagnosticDescriptor AmbiguousMethodDataSourceOverload = + CreateDescriptor(DiagnosticIds.AmbiguousMethodDataSourceOverload, UsageCategory, DiagnosticSeverity.Error); + private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity, string[]? customTags = null, string? helpLinkUri = null) { diff --git a/src/TUnit.Analyzers/TestDataAnalyzer.cs b/src/TUnit.Analyzers/TestDataAnalyzer.cs index 4f7e1528756..0e5bb3128d7 100644 --- a/src/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/src/TUnit.Analyzers/TestDataAnalyzer.cs @@ -29,7 +29,8 @@ public class TestDataAnalyzer : ConcurrentDiagnosticAnalyzer Rules.MatrixDataSourceAttributeRequired, Rules.TooManyArguments, Rules.InstanceMethodSource, - Rules.PotentialEmptyDataSource + Rules.PotentialEmptyDataSource, + Rules.AmbiguousMethodDataSourceOverload ); protected override void InitializeInternal(AnalysisContext context) @@ -462,6 +463,19 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, return; } + var ambiguousOverloads = FindAmbiguousOverloads(context.Compilation, methodSymbols, methodName!, argumentsNamedArgument); + if (ambiguousOverloads is not null) + { + context.ReportDiagnostic( + Diagnostic.Create( + Rules.AmbiguousMethodDataSourceOverload, + attribute.GetLocation(), + methodName, + string.Join(", ", ambiguousOverloads.Select(m => m.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat)))) + ); + return; + } + if (dataSourceMethod.ReturnsVoid) { context.ReportDiagnostic( @@ -1077,4 +1091,144 @@ private static bool IsIAsyncEnumerable(ITypeSymbol type, Compilation compilation return false; } + + // Reflection mode picks the data source overload with Type.GetMethod(name, flags, binder: null, argumentTypes, modifiers: null), + // so this mirrors the default binder: only overloads with exactly one parameter per argument are candidates, and + // when none of them is at least as specific as all the others, the binder throws AmbiguousMatchException. + // Source-gen mode would silently pick one of them instead, so report the ambiguity at compile time. + // Returns the tied overloads, or null when the Arguments select a single overload (or cannot be checked). + private static List? FindAmbiguousOverloads(Compilation compilation, + IMethodSymbol[] methodSymbols, + string methodName, + TypedConstant argumentsNamedArgument) + { + ITypeSymbol[] argumentTypes; + if (argumentsNamedArgument.Kind == TypedConstantKind.Array) + { + // A null argument has no runtime type, so reflection mode falls back to a name-only lookup. + if (argumentsNamedArgument.Values.Any(x => x.IsNull || x.Type is null)) + { + return null; + } + + argumentTypes = argumentsNamedArgument.Values.Select(x => x.Type!).ToArray(); + } + else + { + argumentTypes = Array.Empty(); + } + + var candidates = new List(); + foreach (var method in methodSymbols) + { + // An override is the same runtime method as the member it overrides, which is also in the list. + if (method.Name != methodName + || method.Parameters.Length != argumentTypes.Length + || method.IsOverride) + { + continue; + } + + // Inferring type arguments is outside what the binder does, so leave generic overloads alone. + if (method.IsGenericMethod) + { + return null; + } + + var applicable = true; + for (var i = 0; i < argumentTypes.Length; i++) + { + if (!IsBinderConversion(compilation, argumentTypes[i], method.Parameters[i].Type)) + { + applicable = false; + break; + } + } + + if (applicable) + { + candidates.Add(method); + } + } + + if (candidates.Count < 2) + { + return null; + } + + foreach (var candidate in candidates) + { + if (candidates.All(other => ReferenceEquals(candidate, other) || IsAtLeastAsSpecific(compilation, candidate, other, argumentTypes))) + { + return null; + } + } + + return candidates; + } + + // Mirrors DefaultBinder.FindMostSpecificMethod: an overload wins when it is more specific for at least one + // parameter and less specific for none. Overloads with the same parameter types (a member hidden with `new`) + // are resolved by the binder in favour of the most derived declaration, so they never tie. + private static bool IsAtLeastAsSpecific(Compilation compilation, IMethodSymbol candidate, IMethodSymbol other, ITypeSymbol[] argumentTypes) + { + var otherWins = false; + + for (var i = 0; i < argumentTypes.Length; i++) + { + var candidateType = candidate.Parameters[i].Type; + var otherType = other.Parameters[i].Type; + + if (SymbolEqualityComparer.Default.Equals(candidateType, otherType)) + { + continue; + } + + switch (FindMostSpecificType(compilation, candidateType, otherType, argumentTypes[i])) + { + case 1: + break; + case 2: + otherWins = true; + break; + default: + return false; + } + } + + return !otherWins; + } + + // Mirrors DefaultBinder.FindMostSpecificType: 1 when the first type is more specific, 2 when the second is, 0 for a tie. + private static int FindMostSpecificType(Compilation compilation, ITypeSymbol first, ITypeSymbol second, ITypeSymbol argumentType) + { + if (SymbolEqualityComparer.Default.Equals(first, argumentType)) + { + return 1; + } + + if (SymbolEqualityComparer.Default.Equals(second, argumentType)) + { + return 2; + } + + var firstFromSecond = IsBinderConversion(compilation, second, first); + var secondFromFirst = IsBinderConversion(compilation, first, second); + + if (firstFromSecond == secondFromFirst) + { + return 0; + } + + return firstFromSecond ? 2 : 1; + } + + // The conversions the default binder accepts: identity, reference and boxing (Type.IsAssignableFrom), and + // primitive widening. User-defined and nullable conversions exist only in C#. + private static bool IsBinderConversion(Compilation compilation, ITypeSymbol from, ITypeSymbol to) + { + var conversion = compilation.ClassifyConversion(from, to); + return conversion.IsImplicit + && (conversion.IsIdentity || conversion.IsReference || conversion.IsBoxing || conversion.IsNumeric); + } } diff --git a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs index 9e8bd0bbc0e..f690ba30add 100644 --- a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs +++ b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs @@ -1970,4 +1970,146 @@ public class MyClass(T seed) where T : struct """ ); } + + [Test] + public async Task Arguments_That_Fit_Two_Overloads_Equally_Are_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + [{|#0:MethodDataSource(nameof(Tie), Arguments = new object[] { "IComparable", 1 })|}] + [Test] + public Task MyTest(string expected, string chosen) => Task.CompletedTask; + + public static IEnumerable<(string, string)> Tie(string expected, IComparable value) { yield return (expected, "IComparable"); } + public static IEnumerable<(string, string)> Tie(string expected, IConvertible value) { yield return (expected, "IConvertible"); } + } + """, + + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(0) + .WithArguments("Tie", "MyClass.Tie(string, IComparable), MyClass.Tie(string, IConvertible)") + ); + } + + [Test] + public async Task Arguments_That_Fit_Overloads_In_Opposite_Directions_Are_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [{|#0:MethodDataSource(nameof(Data), Arguments = new object[] { "a", "b" })|}] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(string first, object second) => [1]; + public static IEnumerable Data(object first, string second) => [2]; + } + """, + + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(0) + .WithArguments("Data", "MyClass.Data(string, object), MyClass.Data(object, string)") + ); + } + + [Test] + public async Task Arguments_With_A_More_Specific_Overload_Are_Not_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { "a" })] + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [MethodDataSource(nameof(Numbers), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(object value) => [1]; + public static IEnumerable Data(string value) => [2]; + public static IEnumerable Data(IComparable value) => [3]; + + public static IEnumerable Numbers(long value) => [1]; + public static IEnumerable Numbers(double value) => [2]; + } + """ + ); + } + + [Test] + public async Task Overloads_With_Other_Parameter_Counts_Are_Not_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(IComparable value) => [1]; + public static IEnumerable Data(IConvertible value, int other) => [2]; + } + """ + ); + } + + [Test] + public async Task Overridden_Data_Source_Methods_Are_Not_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class BaseClass + { + public virtual IEnumerable Data(int value) => [1]; + } + + public class MyClass : BaseClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public override IEnumerable Data(int value) => [2]; + } + """ + ); + } } From dede1e1399bc5064d37c27c799d43da8fc347dca Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:25:39 +0100 Subject: [PATCH 2/3] fix(analyzers): match the runtime binder when TUnit0076 picks candidate overloads Skip private methods declared on a base type and overloads with by-ref parameters, which Type.GetMethod never selects. Replace C#'s implicit numeric conversions with the runtime's primitive widening table (no int to decimal or nint, but byte to char), and accept T to Nullable the way Type.IsAssignableFrom does. --- docs/docs/reference/tunit0076.md | 8 +- src/TUnit.Analyzers/TestDataAnalyzer.cs | 86 +++++++++- .../MethodDataSourceAnalyzerTests.cs | 155 ++++++++++++++++++ 3 files changed, 242 insertions(+), 7 deletions(-) diff --git a/docs/docs/reference/tunit0076.md b/docs/docs/reference/tunit0076.md index 44f75d32fb7..854a3db817d 100644 --- a/docs/docs/reference/tunit0076.md +++ b/docs/docs/reference/tunit0076.md @@ -2,7 +2,7 @@ **Severity:** Error -The `Arguments` of a `[MethodDataSource]` select which overload of the data source method is called. TUnit0076 reports `Arguments` that fit two or more overloads equally well, where no overload is more specific than the others. A C# call with the same arguments would fail with CS0121 for the same reason. +The `Arguments` of a `[MethodDataSource]` select which overload of the data source method is called. TUnit0076 reports `Arguments` that fit two or more overloads equally well, where no overload is more specific than the others. A C# call with the same arguments usually fails with CS0121 for the same reason. For example, with the overloads `Tie(string expected, IComparable value)` and `Tie(string expected, IConvertible value)`, the attribute `[MethodDataSource(nameof(Tie), Arguments = new object[] { "a", 1 })]` is ambiguous: `int` implements both interfaces, and neither interface converts to the other. @@ -20,4 +20,8 @@ Make one overload the best match for the `Arguments`: ## Scope -The rule follows the runtime binder's rules. It compares only overloads with one parameter per argument, and it accepts identity, reference, boxing and numeric widening conversions. It does not check `Arguments` that contain `null` or overloads that are generic methods. +The rule follows the runtime binder's rules, which differ from C# overload resolution in a few places: + +- It compares only overloads with one parameter per argument, and skips overloads with `ref`, `in` or `out` parameters and private methods declared on a base class, because the runtime lookup never selects them. +- It accepts identity, reference and boxing conversions, `T` to `Nullable`, and the runtime's primitive widening conversions. These are narrower than C#'s implicit numeric conversions: `int` does not widen to `decimal`, `nint` or `nuint`. They also allow `byte` to `char`, which C# does not. +- It does not check `Arguments` that contain `null`, overloads that are generic methods, or enum arguments passed to primitive parameters. diff --git a/src/TUnit.Analyzers/TestDataAnalyzer.cs b/src/TUnit.Analyzers/TestDataAnalyzer.cs index 0e5bb3128d7..f6e6cc8b11d 100644 --- a/src/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/src/TUnit.Analyzers/TestDataAnalyzer.cs @@ -463,7 +463,7 @@ private void CheckMethodDataSource(SymbolAnalysisContext context, return; } - var ambiguousOverloads = FindAmbiguousOverloads(context.Compilation, methodSymbols, methodName!, argumentsNamedArgument); + var ambiguousOverloads = FindAmbiguousOverloads(context.Compilation, type, methodSymbols, methodName!, argumentsNamedArgument); if (ambiguousOverloads is not null) { context.ReportDiagnostic( @@ -1098,6 +1098,7 @@ private static bool IsIAsyncEnumerable(ITypeSymbol type, Compilation compilation // Source-gen mode would silently pick one of them instead, so report the ambiguity at compile time. // Returns the tied overloads, or null when the Arguments select a single overload (or cannot be checked). private static List? FindAmbiguousOverloads(Compilation compilation, + ITypeSymbol targetType, IMethodSymbol[] methodSymbols, string methodName, TypedConstant argumentsNamedArgument) @@ -1129,6 +1130,19 @@ private static bool IsIAsyncEnumerable(ITypeSymbol type, Compilation compilation continue; } + // Reflection never returns private members inherited from a base type, even with FlattenHierarchy. + if (method.DeclaredAccessibility == Accessibility.Private + && !SymbolEqualityComparer.Default.Equals(method.ContainingType.OriginalDefinition, targetType.OriginalDefinition)) + { + continue; + } + + // A ref, in or out parameter has a by-ref runtime type (T&), which never matches the argument's type. + if (method.Parameters.Any(p => p.RefKind != RefKind.None)) + { + continue; + } + // Inferring type arguments is outside what the binder does, so leave generic overloads alone. if (method.IsGenericMethod) { @@ -1223,12 +1237,74 @@ private static int FindMostSpecificType(Compilation compilation, ITypeSymbol fir return firstFromSecond ? 2 : 1; } - // The conversions the default binder accepts: identity, reference and boxing (Type.IsAssignableFrom), and - // primitive widening. User-defined and nullable conversions exist only in C#. + // Mirrors the default binder's applicability check (and Type.IsAssignableFrom in its specificity check): + // - a primitive parameter accepts only the CLR's primitive widening table, which is narrower than C#'s + // implicit numeric conversions (no int -> decimal, no conversions to nint/nuint) and also allows byte -> char; + // - Nullable is assignable from T, and Nullable itself is assignable only to object and ValueType; + // - everything else uses reference and boxing conversions. User-defined conversions exist only in C#. + // An enum argument for a primitive parameter is accepted by the runtime (through its underlying type) but not + // here, so those overloads are skipped: that can miss an ambiguity but never reports a false one for them. private static bool IsBinderConversion(Compilation compilation, ITypeSymbol from, ITypeSymbol to) { + if (SymbolEqualityComparer.Default.Equals(from, to)) + { + return true; + } + + if (IsClrPrimitive(to.SpecialType)) + { + return CanWidenPrimitive(from.SpecialType, to.SpecialType); + } + + if (to.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return to is INamedTypeSymbol { TypeArguments.Length: 1 } nullable + && SymbolEqualityComparer.Default.Equals(nullable.TypeArguments[0], from); + } + + if (from.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return to.SpecialType is SpecialType.System_Object or SpecialType.System_ValueType; + } + var conversion = compilation.ClassifyConversion(from, to); - return conversion.IsImplicit - && (conversion.IsIdentity || conversion.IsReference || conversion.IsBoxing || conversion.IsNumeric); + return conversion.IsImplicit && (conversion.IsReference || conversion.IsBoxing); } + + private static bool IsClrPrimitive(SpecialType type) => type is SpecialType.System_Boolean + or SpecialType.System_Char + or SpecialType.System_SByte + or SpecialType.System_Byte + or SpecialType.System_Int16 + or SpecialType.System_UInt16 + or SpecialType.System_Int32 + or SpecialType.System_UInt32 + or SpecialType.System_Int64 + or SpecialType.System_UInt64 + or SpecialType.System_Single + or SpecialType.System_Double + or SpecialType.System_IntPtr + or SpecialType.System_UIntPtr; + + // The runtime's primitive widening table (DefaultBinder.CanChangePrimitive), excluding identity. + private static bool CanWidenPrimitive(SpecialType from, SpecialType to) => from switch + { + SpecialType.System_Char => to is SpecialType.System_UInt16 or SpecialType.System_UInt32 or SpecialType.System_Int32 + or SpecialType.System_UInt64 or SpecialType.System_Int64 or SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_SByte => to is SpecialType.System_Int16 or SpecialType.System_Int32 or SpecialType.System_Int64 + or SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_Byte => to is SpecialType.System_Char or SpecialType.System_UInt16 or SpecialType.System_Int16 + or SpecialType.System_UInt32 or SpecialType.System_Int32 or SpecialType.System_UInt64 or SpecialType.System_Int64 + or SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_Int16 => to is SpecialType.System_Int32 or SpecialType.System_Int64 or SpecialType.System_Single + or SpecialType.System_Double, + SpecialType.System_UInt16 => to is SpecialType.System_UInt32 or SpecialType.System_Int32 or SpecialType.System_UInt64 + or SpecialType.System_Int64 or SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_Int32 => to is SpecialType.System_Int64 or SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_UInt32 => to is SpecialType.System_UInt64 or SpecialType.System_Int64 or SpecialType.System_Single + or SpecialType.System_Double, + SpecialType.System_Int64 or SpecialType.System_UInt64 => to is SpecialType.System_Single or SpecialType.System_Double, + SpecialType.System_Single => to is SpecialType.System_Double, + _ => false, + }; } diff --git a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs index f690ba30add..11ea955237a 100644 --- a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs +++ b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs @@ -2112,4 +2112,159 @@ public void MyTest(int value) """ ); } + + [Test] + public async Task Private_Base_Overloads_Are_Not_Flagged() + { + // Reflection never returns private members inherited from a base type. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class BaseClass + { + private static IEnumerable Data(IConvertible value) => [1]; + } + + public class MyClass : BaseClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(IComparable value) => [2]; + } + """ + ); + } + + [Test] + public async Task Protected_Base_Overloads_Are_Flagged() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class BaseClass + { + protected static IEnumerable Data(IConvertible value) => [1]; + } + + public class MyClass : BaseClass + { + [{|#0:MethodDataSource(nameof(Data), Arguments = new object[] { 1 })|}] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(IComparable value) => [2]; + } + """, + + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(0) + .WithArguments("Data", "MyClass.Data(IComparable), BaseClass.Data(IConvertible)") + ); + } + + [Test] + public async Task By_Ref_Overloads_Are_Not_Flagged() + { + // A by-ref parameter's runtime type (T&) never matches the argument's type. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(IComparable value) => [1]; + public static IEnumerable Data(ref IConvertible value) => [2]; + public static IEnumerable Data(in IFormattable value) => [3]; + } + """ + ); + } + + [Test] + public async Task Numeric_Conversions_The_Runtime_Does_Not_Widen_Are_Not_Flagged() + { + // The runtime binder does not widen int to decimal, so Data(double) is the only candidate. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [MethodDataSource(nameof(Data), Arguments = new object[] { 1L })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(decimal value) => [1]; + public static IEnumerable Data(double value) => [2]; + public static IEnumerable Data(nint value) => [3]; + } + """ + ); + } + + [Test] + public async Task Runtime_Only_Conversions_That_Tie_Are_Flagged() + { + // The runtime widens byte to char, and accepts an int for an int? parameter. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [{|#0:MethodDataSource(nameof(Chars), Arguments = new object[] { (byte)1 })|}] + [{|#1:MethodDataSource(nameof(Nullables), Arguments = new object[] { 1 })|}] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Chars(char value) => [1]; + public static IEnumerable Chars(short value) => [2]; + + public static IEnumerable Nullables(int? value) => [1]; + public static IEnumerable Nullables(long value) => [2]; + } + """, + + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(0) + .WithArguments("Chars", "MyClass.Chars(char), MyClass.Chars(short)"), + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(1) + .WithArguments("Nullables", "MyClass.Nullables(int?), MyClass.Nullables(long)") + ); + } } From cfe1bb79f9b43bd3114584018fc897966000c6c1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 20 Oct 2026 06:33:17 +0100 Subject: [PATCH 3/3] fix(analyzers): skip generic data source overloads instead of ending the TUnit0076 check A same-name generic method made the check return early, hiding a tie between two non-generic overloads. The runtime binder never matches a parameter typed by a method type parameter, so skip only those overloads and let generic methods with concrete parameter types compete normally. --- docs/docs/reference/tunit0076.md | 4 +- src/TUnit.Analyzers/TestDataAnalyzer.cs | 16 ++++- .../MethodDataSourceAnalyzerTests.cs | 58 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docs/docs/reference/tunit0076.md b/docs/docs/reference/tunit0076.md index 854a3db817d..63dc1fd32ba 100644 --- a/docs/docs/reference/tunit0076.md +++ b/docs/docs/reference/tunit0076.md @@ -22,6 +22,6 @@ Make one overload the best match for the `Arguments`: The rule follows the runtime binder's rules, which differ from C# overload resolution in a few places: -- It compares only overloads with one parameter per argument, and skips overloads with `ref`, `in` or `out` parameters and private methods declared on a base class, because the runtime lookup never selects them. +- It compares only overloads with one parameter per argument, and skips overloads with `ref`, `in` or `out` parameters, parameters typed by a generic method's type parameter, and private methods declared on a base class, because the runtime lookup never selects them. - It accepts identity, reference and boxing conversions, `T` to `Nullable`, and the runtime's primitive widening conversions. These are narrower than C#'s implicit numeric conversions: `int` does not widen to `decimal`, `nint` or `nuint`. They also allow `byte` to `char`, which C# does not. -- It does not check `Arguments` that contain `null`, overloads that are generic methods, or enum arguments passed to primitive parameters. +- It does not check `Arguments` that contain `null`, or enum arguments passed to primitive parameters. diff --git a/src/TUnit.Analyzers/TestDataAnalyzer.cs b/src/TUnit.Analyzers/TestDataAnalyzer.cs index f6e6cc8b11d..373431e96f3 100644 --- a/src/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/src/TUnit.Analyzers/TestDataAnalyzer.cs @@ -1143,10 +1143,11 @@ private static bool IsIAsyncEnumerable(ITypeSymbol type, Compilation compilation continue; } - // Inferring type arguments is outside what the binder does, so leave generic overloads alone. - if (method.IsGenericMethod) + // The binder does not infer type arguments: a parameter typed by a method type parameter never matches, + // while a generic method whose parameters do not use its type parameters competes like any other overload. + if (method.IsGenericMethod && method.Parameters.Any(p => ReferencesMethodTypeParameter(p.Type))) { - return null; + continue; } var applicable = true; @@ -1237,6 +1238,15 @@ private static int FindMostSpecificType(Compilation compilation, ITypeSymbol fir return firstFromSecond ? 2 : 1; } + private static bool ReferencesMethodTypeParameter(ITypeSymbol type) => type switch + { + ITypeParameterSymbol typeParameter => typeParameter.TypeParameterKind == TypeParameterKind.Method, + IArrayTypeSymbol array => ReferencesMethodTypeParameter(array.ElementType), + IPointerTypeSymbol pointer => ReferencesMethodTypeParameter(pointer.PointedAtType), + INamedTypeSymbol named => named.TypeArguments.Any(ReferencesMethodTypeParameter), + _ => false, + }; + // Mirrors the default binder's applicability check (and Type.IsAssignableFrom in its specificity check): // - a primitive parameter accepts only the CLR's primitive widening table, which is narrower than C#'s // implicit numeric conversions (no int -> decimal, no conversions to nint/nuint) and also allows byte -> char; diff --git a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs index 11ea955237a..da24213d148 100644 --- a/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs +++ b/tests/TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs @@ -2267,4 +2267,62 @@ public void MyTest(int value) .WithArguments("Nullables", "MyClass.Nullables(int?), MyClass.Nullables(long)") ); } + + [Test] + public async Task Generic_Overloads_Do_Not_Hide_A_Tie() + { + // A parameter typed by a method type parameter never matches at runtime, so the generic overload is ignored. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [{|#0:MethodDataSource(nameof(Data), Arguments = new object[] { 1 })|}] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(T value) => [1]; + public static IEnumerable Data(IComparable value) => [2]; + public static IEnumerable Data(IConvertible value) => [3]; + } + """, + + Verifier.Diagnostic(Rules.AmbiguousMethodDataSourceOverload) + .WithLocation(0) + .WithArguments("Data", "MyClass.Data(IComparable), MyClass.Data(IConvertible)") + ); + } + + [Test] + public async Task Generic_Overload_With_Concrete_Parameters_Can_Break_A_Tie() + { + // The runtime selects Data(int) for an int argument, because its parameter is an exact match. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Collections.Generic; + using TUnit.Core; + + public class MyClass + { + [MethodDataSource(nameof(Data), Arguments = new object[] { 1 })] + [Test] + public void MyTest(int value) + { + } + + public static IEnumerable Data(int value) => [1]; + public static IEnumerable Data(IComparable value) => [2]; + public static IEnumerable Data(IConvertible value) => [3]; + } + """ + ); + } }