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];
+ }
+ """
+ );
+ }
}