From a16d5e9cdcd477d0ec200e8bb78ada1404e2638c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:13:50 +0100 Subject: [PATCH 1/3] fix: resolve CS0121 IsEqualTo ambiguity on .NET 8 SDK (#6296) The cross-type IsEqualTo / IsNotEqualTo overloads were deprioritized against their same-type siblings only via [OverloadResolutionPriority]. That attribute is honored by the Roslyn compiler shipped in the .NET 9 SDK and later, NOT by LangVersion, so consumers who pin the .NET 8 SDK via global.json (whose Roslyn predates ORP) still got CS0121 on same-type calls like Assert.That(guid).IsEqualTo(guid) -- even with the LangVersion=latest bump from #6282, which is a no-op here. Give the cross-type overloads a trailing `params object[]` so they are applicable only in expanded form and lose to the normal-form same-type overload via the "normal beats expanded" tie-break, which every Roslyn version honors. The [CallerArgumentExpression] capture is preserved; ORP is kept as a redundant signal. Genuine cross-type value-object calls (e.g. Assert.That(productCode).IsEqualTo("X")) are unaffected. Verified end-to-end by packing the fix and consuming it from a project pinned to SDK 8.0.422: both same-type and value-object calls now compile and pass. PublicAPI snapshots updated; regression tests added. --- TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs | 66 +++++++++++++++++++ .../ImplicitConversionEqualityExtensions.cs | 37 ++++++++--- ...Has_No_API_Changes.DotNet10_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 4 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 4 +- 6 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs diff --git a/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs new file mode 100644 index 0000000000..a9b9156537 --- /dev/null +++ b/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs @@ -0,0 +1,66 @@ +namespace TUnit.Assertions.Tests.Bugs; + +/// +/// Regression tests for GitHub issue #6296: +/// await Assert.That(guid).IsEqualTo(guid) failed to compile with +/// CS0121: The call is ambiguous between IsEqualTo<TValue, TOther> and +/// IsEqualTo<TValue> for consumers building with the .NET 8 SDK. +/// +/// The cross-type IsEqualTo<TValue, TOther> overload (added for value-object +/// ergonomics, see ) is equally applicable to the same-type +/// overload when the expected value is the same type as the source (Guid vs Guid). It was +/// previously deprioritized only by [OverloadResolutionPriority], which is honored +/// solely by the Roslyn that ships with the .NET 9 SDK and later — NOT by LangVersion. The +/// .NET 8 SDK's compiler ignores it, so the call stayed ambiguous regardless of LangVersion. +/// +/// The fix gives the cross-type overloads a trailing params object[], making them +/// applicable only in expanded form so they lose to the normal-form same-type overload via +/// the compiler-version-independent "normal beats expanded" tie-break. The body of each test +/// below would not compile before the fix when built with the .NET 8 SDK — successful +/// compilation IS the regression assertion; the runtime assertions confirm the same-type +/// overload (default equality), not the implicit-conversion overload, is the one selected. +/// +public class Issue6296Tests +{ + [Test] + public async Task Guid_IsEqualTo_SameType_IsNotAmbiguous() + { + var value = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var same = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + await Assert.That(value).IsEqualTo(same); + } + + [Test] + public async Task Guid_IsNotEqualTo_SameType_IsNotAmbiguous() + { + var value = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var other = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + await Assert.That(value).IsNotEqualTo(other); + } + + [Test] + public async Task Int_IsEqualTo_SameType_IsNotAmbiguous() + { + await Assert.That(42).IsEqualTo(42); + } + + [Test] + public async Task DateTime_IsEqualTo_SameType_IsNotAmbiguous() + { + var now = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc); + + await Assert.That(now).IsEqualTo(now); + } + + [Test] + public async Task Guid_IsEqualTo_SameType_Fails_When_Different() + { + var value = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var other = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + await Assert.That(async () => await Assert.That(value).IsEqualTo(other)) + .Throws(); + } +} diff --git a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs index 70bdb4ed0e..e2d0af2504 100644 --- a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs +++ b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs @@ -1,6 +1,19 @@ -// These overloads rely on [OverloadResolutionPriority] (honored only by C# 13+) to lose to the -// source-generated same-type IsEqualTo / IsNotEqualTo. TUnit raises consumer LangVersion so the -// attribute is honored on every target (see TUnit.Assertions.props). Issues #5765, #6276, #6280. +// These cross-type overloads must lose to the source-generated same-type IsEqualTo / IsNotEqualTo +// when the expected argument is the same type as the source (e.g. Guid vs Guid), where both are +// applicable. Two layered mechanisms achieve that: +// +// 1. The trailing `params object[]` makes each overload applicable only in EXPANDED form, so a +// same-type call binds to the normal-form same-type overload via the C# "normal beats +// expanded" tie-break (§12.6.4.2). This is honored by EVERY Roslyn version, including the +// .NET 8 SDK's compiler, which is the only mechanism that fixes #6296. +// 2. [OverloadResolutionPriority(-1)] is kept as a redundant signal for completeness on modern +// compilers, but it is NOT load-bearing — it is honored only by Roslyn 4.12+ (.NET 9 SDK and +// later), so consumers pinned to the .NET 8 SDK via global.json never see its effect. +// +// History: #5765 / #6276 / #6280 tried to fix this purely via ORP + a LangVersion bump in +// TUnit.Assertions.props. That bump is a no-op for the real failure because ORP honoring tracks +// the build SDK's Roslyn version, not LangVersion — see #6296. The `params` tie-break is what +// makes this compiler-version-independent. The unused `_` parameter is never read. using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -13,9 +26,11 @@ namespace TUnit.Assertions.Extensions; /// /// Adds IsEqualTo overloads accepting an expected value of a different type when the /// source type defines an implicit conversion operator to that type. Defined as a partial of -/// the source-generated EqualsAssertionExtensions so it lives in the same containing -/// type as the original overload — required for -/// to disambiguate calls where both overloads are applicable (e.g. enum or value-type sources). +/// the source-generated EqualsAssertionExtensions so it shares the containing type. When +/// both this and the same-type overload are applicable (e.g. enum or value-type sources), the +/// trailing params array makes this overload lose to the same-type one on every compiler; +/// see the file header for why alone is not +/// sufficient (issue #6296). /// /// Enables ergonomic comparison of wrapper Value Objects against their wrapped primitive, /// e.g. await Assert.That(productCode).IsEqualTo("Example") when @@ -34,7 +49,10 @@ public static partial class EqualsAssertionExtensions public static EqualsAssertion IsEqualTo( this IAssertionSource source, TOther? expected, - [CallerArgumentExpression(nameof(expected))] string? expectedExpression = null) + [CallerArgumentExpression(nameof(expected))] string? expectedExpression = null, + // Forces expanded-form applicability so a same-type call loses to the same-type + // IsEqualTo overload on every compiler. Never read. See file header. + params object[] _) { var converter = ImplicitConversionCache.GetConverter(); source.Context.ExpressionBuilder.Append($".IsEqualTo({expectedExpression})"); @@ -62,7 +80,10 @@ public static partial class NotEqualsAssertionExtensions public static NotEqualsAssertion IsNotEqualTo( this IAssertionSource source, TOther? notExpected, - [CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null) + [CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null, + // Forces expanded-form applicability so a same-type call loses to the same-type + // IsNotEqualTo overload on every compiler. Never read. See file header. + params object[] _) { var converter = ImplicitConversionCache.GetConverter(); source.Context.ExpressionBuilder.Append($".IsNotEqualTo({notExpectedExpression})"); diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index ab8556bcd1..cf178871dd 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -4357,7 +4357,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } } public static class EquatableAssertionExtensions { @@ -5310,7 +5310,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 010201b3c4..52e34bd369 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -4301,7 +4301,7 @@ namespace .Extensions public static . IsEqualTo(this . source, TValue? expected, . comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { } [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } } public static class EquatableAssertionExtensions { @@ -5230,7 +5230,7 @@ namespace .Extensions public static . IsNotEqualTo(this . source, TValue notExpected, .? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { } [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index af2f4e2b1f..e2e99a0a57 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -4357,7 +4357,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } } public static class EquatableAssertionExtensions { @@ -5310,7 +5310,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 2c799fc620..b36c9d22e7 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -3822,7 +3822,7 @@ namespace .Extensions { public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expectedExpression = null) { } public static . IsEqualTo(this . source, TValue? expected, . comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { } - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } } public static class EquatableAssertionExtensions { @@ -4651,7 +4651,7 @@ namespace .Extensions public static class NotEqualsAssertionExtensions { public static . IsNotEqualTo(this . source, TValue notExpected, .? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { } - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } } public static class NotEquivalentToAssertionExtensions { From 5389928af6fccbc3d79ee31ff3a295549a82a42e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 24 Jun 2026 23:23:30 +0100 Subject: [PATCH 2/3] docs: trim duplicated rationale comments in cross-type IsEqualTo overloads --- .../ImplicitConversionEqualityExtensions.cs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs index e2d0af2504..02f46a6db4 100644 --- a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs +++ b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs @@ -26,11 +26,9 @@ namespace TUnit.Assertions.Extensions; /// /// Adds IsEqualTo overloads accepting an expected value of a different type when the /// source type defines an implicit conversion operator to that type. Defined as a partial of -/// the source-generated EqualsAssertionExtensions so it shares the containing type. When -/// both this and the same-type overload are applicable (e.g. enum or value-type sources), the -/// trailing params array makes this overload lose to the same-type one on every compiler; -/// see the file header for why alone is not -/// sufficient (issue #6296). +/// the source-generated EqualsAssertionExtensions so it shares the containing type. The +/// trailing params array on each overload keeps it from displacing the same-type overload +/// for same-type calls (e.g. enum or value-type sources) — see the file header for the why. /// /// Enables ergonomic comparison of wrapper Value Objects against their wrapped primitive, /// e.g. await Assert.That(productCode).IsEqualTo("Example") when @@ -50,8 +48,7 @@ public static EqualsAssertion IsEqualTo( this IAssertionSource source, TOther? expected, [CallerArgumentExpression(nameof(expected))] string? expectedExpression = null, - // Forces expanded-form applicability so a same-type call loses to the same-type - // IsEqualTo overload on every compiler. Never read. See file header. + // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. See file header. params object[] _) { var converter = ImplicitConversionCache.GetConverter(); @@ -81,8 +78,7 @@ public static NotEqualsAssertion IsNotEqualTo( this IAssertionSource source, TOther? notExpected, [CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null, - // Forces expanded-form applicability so a same-type call loses to the same-type - // IsNotEqualTo overload on every compiler. Never read. See file header. + // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. See file header. params object[] _) { var converter = ImplicitConversionCache.GetConverter(); From 9da71befec6253e9182b6459f06bc7a869b41223 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 25 Jun 2026 00:08:21 +0100 Subject: [PATCH 3/3] fix: prevent params marker from swallowing trailing args (#6313 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-type IsEqualTo/IsNotEqualTo overloads used `params object[] _` to force expanded-form-only applicability (so same-type calls win the "normal beats expanded" tie-break). But `object[]` is applicable to ANY trailing argument, so a call like Assert.That(productCode).IsEqualTo("x", StringComparer.OrdinalIgnoreCase) bound to the cross-type overload and silently DISCARDED the comparer — a false-pass footgun for a test framework. Before #6296 this was a compile error. Replace the element type with a dedicated `CrossTypeOverloadMarker` that has no accessible constructor. The empty-expanded-form tie-break still works (verified on the .NET 8 SDK's Roslyn), but a stray trailing argument is a compile error again instead of being swallowed. - Add regression tests confirming the same-type-with-comparer overload still binds and the comparer is applied. - Regenerate PublicAPI snapshots (all 4 TFMs). --- TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs | 34 ++++++++++++--- .../ImplicitConversionEqualityExtensions.cs | 41 +++++++++++++++---- ...Has_No_API_Changes.DotNet10_0.verified.txt | 5 ++- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 5 ++- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 5 ++- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 5 ++- 6 files changed, 73 insertions(+), 22 deletions(-) diff --git a/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs b/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs index a9b9156537..333e0ec261 100644 --- a/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs +++ b/TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs @@ -13,12 +13,15 @@ namespace TUnit.Assertions.Tests.Bugs; /// solely by the Roslyn that ships with the .NET 9 SDK and later — NOT by LangVersion. The /// .NET 8 SDK's compiler ignores it, so the call stayed ambiguous regardless of LangVersion. /// -/// The fix gives the cross-type overloads a trailing params object[], making them -/// applicable only in expanded form so they lose to the normal-form same-type overload via -/// the compiler-version-independent "normal beats expanded" tie-break. The body of each test -/// below would not compile before the fix when built with the .NET 8 SDK — successful -/// compilation IS the regression assertion; the runtime assertions confirm the same-type -/// overload (default equality), not the implicit-conversion overload, is the one selected. +/// The fix gives the cross-type overloads a trailing params CrossTypeOverloadMarker[], +/// making them applicable only in expanded form so they lose to the normal-form same-type +/// overload via the compiler-version-independent "normal beats expanded" tie-break. The marker +/// element type (rather than object) has no accessible constructor, so a stray trailing +/// argument — e.g. IsEqualTo("x", StringComparer.Ordinal) on a value-object source — is a +/// compile error instead of being silently swallowed and discarded (PR #6313 review). The body +/// of each test below would not compile before the fix when built with the .NET 8 SDK — +/// successful compilation IS the regression assertion; the runtime assertions confirm the +/// same-type overload (default equality), not the implicit-conversion overload, is selected. /// public class Issue6296Tests { @@ -63,4 +66,23 @@ public async Task Guid_IsEqualTo_SameType_Fails_When_Different() await Assert.That(async () => await Assert.That(value).IsEqualTo(other)) .Throws(); } + + // The expanded-form marker on the cross-type overload must not steal a genuine + // same-type-with-comparer call: the second positional argument is an IEqualityComparer, + // which would land in the marker's params array if the marker were `params object[]`. + // CrossTypeOverloadMarker has no accessible ctor, so this binds to the real same-type + // comparer overload and the comparer is actually applied (PR #6313 review, finding #1). + [Test] + public async Task String_IsEqualTo_SameType_WithComparer_BindsComparerOverload() + { + await Assert.That("VALUE").IsEqualTo("value", StringComparer.OrdinalIgnoreCase); + } + + [Test] + public async Task String_IsEqualTo_SameType_WithComparer_Fails_When_ComparerRejects() + { + await Assert.That(async () => + await Assert.That("VALUE").IsEqualTo("value", StringComparer.Ordinal)) + .Throws(); + } } diff --git a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs index 02f46a6db4..acb8ba98c7 100644 --- a/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs +++ b/TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs @@ -2,19 +2,28 @@ // when the expected argument is the same type as the source (e.g. Guid vs Guid), where both are // applicable. Two layered mechanisms achieve that: // -// 1. The trailing `params object[]` makes each overload applicable only in EXPANDED form, so a -// same-type call binds to the normal-form same-type overload via the C# "normal beats -// expanded" tie-break (§12.6.4.2). This is honored by EVERY Roslyn version, including the -// .NET 8 SDK's compiler, which is the only mechanism that fixes #6296. +// 1. The trailing `params CrossTypeOverloadMarker[]` makes each overload applicable only in +// EXPANDED form, so a same-type call binds to the normal-form same-type overload via the C# +// "normal beats expanded" tie-break (§12.6.4.2). This is honored by EVERY Roslyn version, +// including the .NET 8 SDK's compiler, which is the only mechanism that fixes #6296. // 2. [OverloadResolutionPriority(-1)] is kept as a redundant signal for completeness on modern // compilers, but it is NOT load-bearing — it is honored only by Roslyn 4.12+ (.NET 9 SDK and // later), so consumers pinned to the .NET 8 SDK via global.json never see its effect. // +// Why CrossTypeOverloadMarker and not `params object[]`: an `object[]` marker is applicable in +// expanded form to ANY trailing argument, so a call like +// `Assert.That(productCode).IsEqualTo("x", StringComparer.OrdinalIgnoreCase)` would silently bind +// here and DISCARD the comparer — a false-pass footgun for a test framework (PR #6313 review). +// CrossTypeOverloadMarker has no accessible constructor, so callers can never supply an element: +// the overload stays applicable only with ZERO trailing args (the tie-break case), and any stray +// trailing argument is a compile error again, exactly as it was before #6296. +// // History: #5765 / #6276 / #6280 tried to fix this purely via ORP + a LangVersion bump in // TUnit.Assertions.props. That bump is a no-op for the real failure because ORP honoring tracks // the build SDK's Roslyn version, not LangVersion — see #6296. The `params` tie-break is what // makes this compiler-version-independent. The unused `_` parameter is never read. using System.Collections.Concurrent; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.CompilerServices; @@ -48,8 +57,9 @@ public static EqualsAssertion IsEqualTo( this IAssertionSource source, TOther? expected, [CallerArgumentExpression(nameof(expected))] string? expectedExpression = null, - // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. See file header. - params object[] _) + // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. + // Element type has no accessible ctor, so stray trailing args stay a compile error. See file header. + params CrossTypeOverloadMarker[] _) { var converter = ImplicitConversionCache.GetConverter(); source.Context.ExpressionBuilder.Append($".IsEqualTo({expectedExpression})"); @@ -78,8 +88,9 @@ public static NotEqualsAssertion IsNotEqualTo( this IAssertionSource source, TOther? notExpected, [CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null, - // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. See file header. - params object[] _) + // Expanded-form-only marker so a same-type call loses to the same-type overload; never read. + // Element type has no accessible ctor, so stray trailing args stay a compile error. See file header. + params CrossTypeOverloadMarker[] _) { var converter = ImplicitConversionCache.GetConverter(); source.Context.ExpressionBuilder.Append($".IsNotEqualTo({notExpectedExpression})"); @@ -88,6 +99,20 @@ public static NotEqualsAssertion IsNotEqualTo( } } +/// +/// Marker element type for the trailing params on the cross-type +/// IsEqualTo / IsNotEqualTo overloads. Its only purpose is to make those overloads +/// applicable solely in expanded form so a same-type call loses to the same-type overload +/// (see ). It has no accessible constructor, so callers can +/// never pass an element — the overloads bind only with zero trailing arguments, and any stray +/// trailing argument remains a compile error rather than being silently discarded. Never instantiated. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public sealed class CrossTypeOverloadMarker +{ + private CrossTypeOverloadMarker() { } +} + /// /// Caches user-defined implicit conversion operators from TFrom to TTo. /// Reflection lookup is performed once per type pair and the resulting delegate is reused. diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index cf178871dd..6c79349f71 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -3203,6 +3203,7 @@ namespace .Extensions protected override .<.> CheckAsync(.<.Cookie> metadata) { } protected override string GetExpectation() { } } + public sealed class CrossTypeOverloadMarker { } public static class CultureInfoAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -4357,7 +4358,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { } } public static class EquatableAssertionExtensions { @@ -5310,7 +5311,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 52e34bd369..7ea0eac6d1 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -3168,6 +3168,7 @@ namespace .Extensions protected override .<.> CheckAsync(.<.Cookie> metadata) { } protected override string GetExpectation() { } } + public sealed class CrossTypeOverloadMarker { } public static class CultureInfoAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -4301,7 +4302,7 @@ namespace .Extensions public static . IsEqualTo(this . source, TValue? expected, . comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { } [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { } } public static class EquatableAssertionExtensions { @@ -5230,7 +5231,7 @@ namespace .Extensions public static . IsNotEqualTo(this . source, TValue notExpected, .? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { } [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index e2e99a0a57..3329f0025d 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -3203,6 +3203,7 @@ namespace .Extensions protected override .<.> CheckAsync(.<.Cookie> metadata) { } protected override string GetExpectation() { } } + public sealed class CrossTypeOverloadMarker { } public static class CultureInfoAssertionExtensions { [.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")] @@ -4357,7 +4358,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { } } public static class EquatableAssertionExtensions { @@ -5310,7 +5311,7 @@ namespace .Extensions [.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" + "efined operators.")] [.(-1)] - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { } } public static class NotEquivalentToAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index b36c9d22e7..dedaac056f 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -2845,6 +2845,7 @@ namespace .Extensions protected override .<.> CheckAsync(.<.Cookie> metadata) { } protected override string GetExpectation() { } } + public sealed class CrossTypeOverloadMarker { } public static class CultureInfoAssertionExtensions { public static ._IsEnglish_Assertion IsEnglish(this . source) @@ -3822,7 +3823,7 @@ namespace .Extensions { public static . IsEqualTo(this . source, TValue? expected, [.("expected")] string? expectedExpression = null) { } public static . IsEqualTo(this . source, TValue? expected, . comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { } - public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params object[] _) { } + public static . IsEqualTo(this . source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { } } public static class EquatableAssertionExtensions { @@ -4651,7 +4652,7 @@ namespace .Extensions public static class NotEqualsAssertionExtensions { public static . IsNotEqualTo(this . source, TValue notExpected, .? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { } - public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params object[] _) { } + public static . IsNotEqualTo(this . source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { } } public static class NotEquivalentToAssertionExtensions {