From 88a6bf60d16a78344d72712ce16e3b503c7f1cc5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:15:57 +0000 Subject: [PATCH 1/3] Fix open array binding inside tuple data-source parameters (#7539) --- .../Extensions/CompilationExtensions.cs | 69 +++++++++++++ .../GenericTupleArrayMethodDataSourceTests.cs | 96 +++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs diff --git a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs index ba9794246bd..e7af0067725 100644 --- a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs +++ b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs @@ -19,6 +19,12 @@ public static bool HasImplicitConversionOrGenericParameter(this Compilation comp return true; } + if (argumentType is not null && parameterType is not null + && ContainsArray(parameterType) && ContainsTypeParameter(parameterType)) + { + return compilation.MatchesOpenArrayShape(argumentType, parameterType); + } + if (parameterType?.IsGenericDefinition() == false) { if (argumentType is null) @@ -49,6 +55,69 @@ public static bool HasImplicitConversionOrGenericParameter(this Compilation comp return compilation.HasImplicitConversion(argumentType?.OriginalDefinition, parameterType?.OriginalDefinition); } + private static bool ContainsArray(ITypeSymbol type) => type switch + { + IArrayTypeSymbol => true, + INamedTypeSymbol { IsTupleType: true } tuple => tuple.TupleElements.Any(x => ContainsArray(x.Type)), + _ => false + }; + + private static bool MatchesOpenArrayShape(this Compilation compilation, ITypeSymbol argumentType, + ITypeSymbol parameterType, bool requiresIdentity = false) + { + if (ContainsTypeParameter(parameterType)) + { + var conversion = compilation.ClassifyConversion(argumentType, parameterType); + if (conversion.IsIdentity || (!requiresIdentity && conversion.IsImplicit)) + { + return true; + } + + if (parameterType is IArrayTypeSymbol targetArray) + { + if (argumentType is not IArrayTypeSymbol sourceArray + || sourceArray.Rank != targetArray.Rank || sourceArray.IsSZArray != targetArray.IsSZArray) + { + return false; + } + + return compilation.MatchesOpenArrayShape(sourceArray.ElementType, targetArray.ElementType, requiresIdentity: true); + } + + if (parameterType is INamedTypeSymbol { IsTupleType: true } targetTuple + && argumentType is INamedTypeSymbol { IsTupleType: true } sourceTuple) + { + if (sourceTuple.TupleElements.Length != targetTuple.TupleElements.Length) + { + return false; + } + + for (var i = 0; i < sourceTuple.TupleElements.Length; i++) + { + if (!compilation.MatchesOpenArrayShape(sourceTuple.TupleElements[i].Type, + targetTuple.TupleElements[i].Type, requiresIdentity)) + { + return false; + } + } + + return true; + } + + // Inferring nested generic arguments, constraints and shared bounds is deliberately out of scope. + return true; + } + + // Value-type array elements (including tuples) are invariant. Their fields cannot + // use the numeric or reference conversions available to a standalone tuple. + if (requiresIdentity) + { + return SymbolEqualityComparer.Default.Equals(argumentType, parameterType); + } + + return compilation.HasImplicitConversionOrGenericParameter(argumentType, parameterType); + } + /// /// Whether a data source value of can bind to a test parameter of /// . A bare type-parameter target accepts any argument type, because the diff --git a/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs new file mode 100644 index 00000000000..660936cc51e --- /dev/null +++ b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs @@ -0,0 +1,96 @@ +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Analyzers.Extensions; +using Assert = TUnit.Assertions.Assert; +using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.Analyzers.Tests; + +public class GenericTupleArrayMethodDataSourceTests : BaseAnalyzerTests +{ + [Test] + public async Task RankOneNonSzArrayDoesNotBindToOpenVector() + { + // C# cannot spell T[*]. Import a field with that signature from metadata. + var metadata = new MetadataBuilder(); + metadata.AddModule(0, metadata.GetOrAddString("ArrayShapes"), metadata.GetOrAddGuid(Guid.NewGuid()), default, default); + metadata.AddAssembly(metadata.GetOrAddString("ArrayShapes"), new Version(1, 0), default, default, default, default); + metadata.AddTypeDefinition(TypeAttributes.NotPublic, default, metadata.GetOrAddString(""), + default, MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1)); + var type = metadata.AddTypeDefinition(TypeAttributes.Public, default, metadata.GetOrAddString("Shapes`1"), + default, MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1)); + metadata.AddGenericParameter(type, GenericParameterAttributes.None, metadata.GetOrAddString("T"), 0); + // FIELD, ARRAY, VAR 0, rank 1, no sizes, no lower bounds. + metadata.AddFieldDefinition(FieldAttributes.Public, metadata.GetOrAddString("NonVector"), + metadata.GetOrAddBlob(new byte[] { 0x06, 0x14, 0x13, 0x00, 0x01, 0x00, 0x00 })); + var pe = new ManagedPEBuilder(new PEHeaderBuilder(), new MetadataRootBuilder(metadata), new BlobBuilder()); + var image = new BlobBuilder(); + pe.Serialize(image); + var compilation = CSharpCompilation.Create("Consumer", + references: [MetadataReference.CreateFromImage(image.ToImmutableArray())]); + var shapes = compilation.GetTypeByMetadataName("Shapes`1")!; + var nonVector = (IArrayTypeSymbol)((IFieldSymbol)shapes.GetMembers("NonVector").Single()).Type; + var vector = compilation.CreateArrayTypeSymbol(shapes.TypeParameters[0]); + + await Assert.That(nonVector.Rank).IsEqualTo(1); + await Assert.That(nonVector.IsSZArray).IsFalse(); + await Assert.That(compilation.HasImplicitConversionOrGenericParameter(nonVector, vector)).IsFalse(); + await Assert.That(compilation.HasImplicitConversionOrGenericParameter(vector, nonVector)).IsFalse(); + } + + [Test] + [Arguments("(int, int)[]", "(T, long)[]", true)] + [Arguments("(int, string[])[]", "(T, object[])[]", true)] + [Arguments("(int, int)[]", "(T, int)[]", false)] + [Arguments("int[]", "T[]", false)] + [Arguments("int[][]", "T[][]", false)] + [Arguments("int[,]", "T[,]", false)] + [Arguments("int[]", "T[,]", true)] + [Arguments("int", "T[]", true)] + [Arguments("int[]", "string[]", true)] + [Arguments("(int, int[])", "(T, T[])", false)] + [Arguments("(int, (int, int[][]))", "(T, (T, T[][]))", false)] + [Arguments("(int, string[])", "(T, int[])", true)] + [Arguments("List[]", "IEnumerable[]", false)] + [Arguments("IConsumer[]", "IConsumer[]", false)] + [Arguments("(int, string[])", "(T, object[])", false)] + public async Task TupleArrayElements(string sourceElement, string targetElement, bool expectDiagnostic) + { + var attribute = expectDiagnostic ? "{|#0:MethodDataSource(nameof(Row))|}" : "MethodDataSource(nameof(Row))"; + var source = $$""" + #pragma warning disable TUnit0046 // Mutable array rows are intentional in these binding tests. + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using TUnit.Core; + using TUnit.Core.Interfaces; + public interface IConsumer { } + public class C + { + public static (Fixture, CancellationToken, {{sourceElement}}) Row() => default; + [Test, {{attribute}}] + public void Test((Fixture, CancellationToken, {{targetElement}}) row) { } + public struct Fixture : IAsyncInitializer + { + public Task InitializeAsync() => Task.CompletedTask; + } + } + """; + + if (expectDiagnostic) + { + await Verifier.VerifyAnalyzerAsync(source, + Verifier.Diagnostic(Rules.WrongArgumentTypeTestData).WithLocation(0) + .WithArguments($"Fixture, CancellationToken, {sourceElement}", $"(Fixture, CancellationToken, {targetElement})")); + } + else + { + await Verifier.VerifyAnalyzerAsync(source); + } + } +} From f0a13600d3cd81d449bc837b42a48d5d247c4983 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:21:05 +0000 Subject: [PATCH 2/3] Reject non-tuple sources for open tuple array targets (#7539) --- src/TUnit.Analyzers/Extensions/CompilationExtensions.cs | 7 ++++--- .../GenericTupleArrayMethodDataSourceTests.cs | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs index e7af0067725..e771cf82d7a 100644 --- a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs +++ b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs @@ -22,6 +22,7 @@ public static bool HasImplicitConversionOrGenericParameter(this Compilation comp if (argumentType is not null && parameterType is not null && ContainsArray(parameterType) && ContainsTypeParameter(parameterType)) { + // Constraint and general inference validation remain separate (#7520). return compilation.MatchesOpenArrayShape(argumentType, parameterType); } @@ -84,10 +85,10 @@ private static bool MatchesOpenArrayShape(this Compilation compilation, ITypeSym return compilation.MatchesOpenArrayShape(sourceArray.ElementType, targetArray.ElementType, requiresIdentity: true); } - if (parameterType is INamedTypeSymbol { IsTupleType: true } targetTuple - && argumentType is INamedTypeSymbol { IsTupleType: true } sourceTuple) + if (parameterType is INamedTypeSymbol { IsTupleType: true } targetTuple) { - if (sourceTuple.TupleElements.Length != targetTuple.TupleElements.Length) + if (argumentType is not INamedTypeSymbol { IsTupleType: true } sourceTuple + || sourceTuple.TupleElements.Length != targetTuple.TupleElements.Length) { return false; } diff --git a/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs index 660936cc51e..26f46d27a04 100644 --- a/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs +++ b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs @@ -48,6 +48,11 @@ public async Task RankOneNonSzArrayDoesNotBindToOpenVector() [Arguments("(int, int)[]", "(T, int)[]", false)] [Arguments("int[]", "T[]", false)] [Arguments("int[][]", "T[][]", false)] + [Arguments("string[][]", "T[][]", false)] + [Arguments("int", "(T, int[])", true)] + [Arguments("int[]", "(T, int[])", true)] + [Arguments("int[]", "(T, int[])[]", true)] + [Arguments("(int, int, int[])", "(T, int[])", true)] [Arguments("int[,]", "T[,]", false)] [Arguments("int[]", "T[,]", true)] [Arguments("int", "T[]", true)] From 7d5938da26b033a4eca045dd9fd33ab0e8de1cdc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:25:40 +0000 Subject: [PATCH 3/3] Fix identity conversion matching in tuple arrays (#7539) --- src/TUnit.Analyzers/Extensions/CompilationExtensions.cs | 3 ++- .../GenericTupleArrayMethodDataSourceTests.cs | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs index e771cf82d7a..991d8794df1 100644 --- a/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs +++ b/src/TUnit.Analyzers/Extensions/CompilationExtensions.cs @@ -56,6 +56,7 @@ public static bool HasImplicitConversionOrGenericParameter(this Compilation comp return compilation.HasImplicitConversion(argumentType?.OriginalDefinition, parameterType?.OriginalDefinition); } + // Nested generic arguments deliberately retain the existing inference behavior (#7520). private static bool ContainsArray(ITypeSymbol type) => type switch { IArrayTypeSymbol => true, @@ -113,7 +114,7 @@ private static bool MatchesOpenArrayShape(this Compilation compilation, ITypeSym // use the numeric or reference conversions available to a standalone tuple. if (requiresIdentity) { - return SymbolEqualityComparer.Default.Equals(argumentType, parameterType); + return compilation.ClassifyConversion(argumentType, parameterType).IsIdentity; } return compilation.HasImplicitConversionOrGenericParameter(argumentType, parameterType); diff --git a/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs index 26f46d27a04..fa33a117702 100644 --- a/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs +++ b/tests/TUnit.Analyzers.Tests/GenericTupleArrayMethodDataSourceTests.cs @@ -46,6 +46,10 @@ public async Task RankOneNonSzArrayDoesNotBindToOpenVector() [Arguments("(int, int)[]", "(T, long)[]", true)] [Arguments("(int, string[])[]", "(T, object[])[]", true)] [Arguments("(int, int)[]", "(T, int)[]", false)] + [Arguments("(int, dynamic)[]", "(T, object)[]", false)] + [Arguments("(int, object)[]", "(T, dynamic)[]", false)] + [Arguments("(int, string)[]", "(T, object)[]", true)] + [Arguments("(int, (int, dynamic)[])", "(T, (T, object)[])", false)] [Arguments("int[]", "T[]", false)] [Arguments("int[][]", "T[][]", false)] [Arguments("string[][]", "T[][]", false)] @@ -58,6 +62,8 @@ public async Task RankOneNonSzArrayDoesNotBindToOpenVector() [Arguments("int", "T[]", true)] [Arguments("int[]", "string[]", true)] [Arguments("(int, int[])", "(T, T[])", false)] + // Shared-bound inference remains permissive until #7520. + [Arguments("(int, string[])", "(T, T[])", false)] [Arguments("(int, (int, int[][]))", "(T, (T, T[][]))", false)] [Arguments("(int, string[])", "(T, int[])", true)] [Arguments("List[]", "IEnumerable[]", false)]