From bb8d01083fcf0b8021f1c8dbe379a9786df8a72c Mon Sep 17 00:00:00 2001 From: John Verheij <170433325+JohnVerheij@users.noreply.github.com> Date: Wed, 13 May 2026 23:11:32 +0200 Subject: [PATCH] fix(sourcegen): merge generic parameter lists in [AssertionExtension] emit AssertionExtensionGenerator emitted the covariant receiver-type parameter and the assertion class's own type parameter as two adjacent generic-parameter blocks, e.g. `` rather than ``. This is invalid C# syntax and fails to compile for any [AssertionExtension]-decorated class that derives from Assertion while also declaring its own generic type parameter. Merge both lists into a single generic-parameter block before emitting the method signature. Add a ConcreteReceiverWithExtraGenericAssertion test fixture (anchored on System.Exception as the BCL non-sealed receiver) and a companion test method. The test pins the absence of adjacent generic-parameter blocks (DoesNotContain `"><"`) and adds an inline compile-clean gate: parse + compile the generated source through Roslyn and assert no error-severity diagnostic. The compile-clean check catches the whole class of emit-syntax bugs regardless of specific diagnostic id, so this regression cannot return silently. --- ...erWithExtraGeneric.DotNet10_0.verified.txt | 33 ++++++++++++ ...verWithExtraGeneric.DotNet8_0.verified.txt | 33 ++++++++++++ ...verWithExtraGeneric.DotNet9_0.verified.txt | 33 ++++++++++++ ...ceiverWithExtraGeneric.Net4_7.verified.txt | 33 ++++++++++++ .../AssertionExtensionGeneratorTests.cs | 54 +++++++++++++++++++ ...ncreteReceiverWithExtraGenericAssertion.cs | 32 +++++++++++ .../Generators/AssertionExtensionGenerator.cs | 15 +++++- 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet10_0.verified.txt create mode 100644 TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet8_0.verified.txt create mode 100644 TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet9_0.verified.txt create mode 100644 TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.Net4_7.verified.txt create mode 100644 TUnit.Assertions.SourceGenerator.Tests/TestData/ConcreteReceiverWithExtraGenericAssertion.cs diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet10_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet10_0.verified.txt new file mode 100644 index 0000000000..5585abcba2 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet10_0.verified.txt @@ -0,0 +1,33 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for ConcreteReceiverWithExtraGenericAssertion. +/// +public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions +{ + + /// + /// Extension method for ConcreteReceiverWithExtraGenericAssertion. + /// + public static ConcreteReceiverWithExtraGenericAssertion ConcreteReceiverWithExtraGenericMatches(this IAssertionSource source, System.Func predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null) + where TActual : System.Exception + { + source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches("); + source.Context.ExpressionBuilder.Append(predicateExpression); + source.Context.ExpressionBuilder.Append(")"); + return new ConcreteReceiverWithExtraGenericAssertion(source.Context.Map(static x => (System.Exception?)x), predicate); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet8_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet8_0.verified.txt new file mode 100644 index 0000000000..5585abcba2 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet8_0.verified.txt @@ -0,0 +1,33 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for ConcreteReceiverWithExtraGenericAssertion. +/// +public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions +{ + + /// + /// Extension method for ConcreteReceiverWithExtraGenericAssertion. + /// + public static ConcreteReceiverWithExtraGenericAssertion ConcreteReceiverWithExtraGenericMatches(this IAssertionSource source, System.Func predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null) + where TActual : System.Exception + { + source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches("); + source.Context.ExpressionBuilder.Append(predicateExpression); + source.Context.ExpressionBuilder.Append(")"); + return new ConcreteReceiverWithExtraGenericAssertion(source.Context.Map(static x => (System.Exception?)x), predicate); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet9_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet9_0.verified.txt new file mode 100644 index 0000000000..5585abcba2 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.DotNet9_0.verified.txt @@ -0,0 +1,33 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for ConcreteReceiverWithExtraGenericAssertion. +/// +public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions +{ + + /// + /// Extension method for ConcreteReceiverWithExtraGenericAssertion. + /// + public static ConcreteReceiverWithExtraGenericAssertion ConcreteReceiverWithExtraGenericMatches(this IAssertionSource source, System.Func predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null) + where TActual : System.Exception + { + source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches("); + source.Context.ExpressionBuilder.Append(predicateExpression); + source.Context.ExpressionBuilder.Append(")"); + return new ConcreteReceiverWithExtraGenericAssertion(source.Context.Map(static x => (System.Exception?)x), predicate); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.Net4_7.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.Net4_7.verified.txt new file mode 100644 index 0000000000..5585abcba2 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ConcreteReceiverWithExtraGeneric.Net4_7.verified.txt @@ -0,0 +1,33 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for ConcreteReceiverWithExtraGenericAssertion. +/// +public static partial class ConcreteReceiverWithExtraGenericAssertionExtensions +{ + + /// + /// Extension method for ConcreteReceiverWithExtraGenericAssertion. + /// + public static ConcreteReceiverWithExtraGenericAssertion ConcreteReceiverWithExtraGenericMatches(this IAssertionSource source, System.Func predicate, [CallerArgumentExpression(nameof(predicate))] string? predicateExpression = null) + where TActual : System.Exception + { + source.Context.ExpressionBuilder.Append(".ConcreteReceiverWithExtraGenericMatches("); + source.Context.ExpressionBuilder.Append(predicateExpression); + source.Context.ExpressionBuilder.Append(")"); + return new ConcreteReceiverWithExtraGenericAssertion(source.Context.Map(static x => (System.Exception?)x), predicate); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs index 95b8029a5e..7ecadf520d 100644 --- a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using TUnit.Assertions.SourceGenerator.Generators; using TUnit.Assertions.SourceGenerator.Tests.Options; @@ -49,6 +51,58 @@ public Task MultipleGenericParameters() => RunTest( await Assert.That(extensionFile!).Contains(""); }); + [Test] + public Task ConcreteReceiverWithExtraGeneric() => RunTest( + Path.Combine(Sourcy.Git.RootDirectory.FullName, + "TUnit.Assertions.SourceGenerator.Tests", + "TestData", + "ConcreteReceiverWithExtraGenericAssertion.cs"), + async generatedFiles => + { + await Assert.That(generatedFiles).Count().IsEqualTo(1); + var extensionFile = generatedFiles.FirstOrDefault(f => f.Contains("ConcreteReceiverWithExtraGenericMatches")); + await Assert.That(extensionFile).IsNotNull(); + await Assert.That(extensionFile!).Contains("ConcreteReceiverWithExtraGenericMatches"); + + // The covariant receiver-type parameter (if applied) and the class's own type + // parameter must appear in a single merged generic parameter list. Two adjacent + // blocks ( or ) is invalid C# syntax. The compile-clean check + // below is the structural guard; this surface-level assertion pins the absence + // of adjacent generic-parameter blocks regardless of whether covariance fired + // for this fixture's compilation context. + await Assert.That(extensionFile!).DoesNotContain("><"); + + // Compile-clean gate: parse + compile the generated source through Roslyn and + // assert no error-severity diagnostic. Catches the entire class of emit-syntax + // bugs (mis-paired brackets, wrong default rendering, adjacent generic blocks) + // regardless of specific diagnostic id. + var trees = generatedFiles + .Select(source => CSharpSyntaxTree.ParseText(source)) + .ToArray(); + + var compilation = CSharpCompilation.Create( + "CompileCheck", + trees, + ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + // On the net472 leg of CI, the Polyfill assembly's CallerArgumentExpressionAttribute + // is declared internal, which produces a CS0122 ('inaccessible due to its protection + // level') false positive when Roslyn compiles the generator's output through the test + // project's reference set. Filter CS0122 only on net472; the emit shape is still + // pinned by the `.Net4_7` snapshot file. On modern TFMs the BCL attribute is public, + // so CS0122 (if it ever appears) would be a genuine signal and is not filtered. + var errors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) +#if NETFRAMEWORK + .Where(d => !string.Equals(d.Id, "CS0122", StringComparison.Ordinal)) +#endif + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToArray(); + + await Assert.That(errors).IsEmpty(); + }); + [Test] public Task AssertionWithOptionalParameter() => RunTest( Path.Combine(Sourcy.Git.RootDirectory.FullName, diff --git a/TUnit.Assertions.SourceGenerator.Tests/TestData/ConcreteReceiverWithExtraGenericAssertion.cs b/TUnit.Assertions.SourceGenerator.Tests/TestData/ConcreteReceiverWithExtraGenericAssertion.cs new file mode 100644 index 0000000000..474737ce20 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/TestData/ConcreteReceiverWithExtraGenericAssertion.cs @@ -0,0 +1,32 @@ +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Tests.TestData; + +/// Test case: an -decorated +/// subclass over a CONCRETE receiver type that ALSO declares +/// its own generic type parameter on the class. The generated extension method must merge +/// both the covariant receiver-type parameter and the class's own type parameter into a +/// single generic parameter list (e.g. <TActual, T>), NOT emit them as two +/// adjacent blocks (<TActual><T>) which is invalid C# syntax. +/// Uses as the receiver to anchor the fixture to a BCL +/// non-sealed class (the covariance candidate the generator looks for) without adding +/// a stand-in type to the test surface. +[AssertionExtension("ConcreteReceiverWithExtraGenericMatches")] +public class ConcreteReceiverWithExtraGenericAssertion : Assertion +{ + private readonly Func _predicate; + + public ConcreteReceiverWithExtraGenericAssertion(AssertionContext context, Func predicate) + : base(context) + { + _predicate = predicate; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + return Task.FromResult(AssertionResult.Passed); + } + + protected override string GetExpectation() => "to match the predicate"; +} diff --git a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs index 929563d84a..a3f3fed290 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs @@ -384,11 +384,22 @@ private static void GenerateExtensionMethod( } sourceBuilder.Append($" public static {returnType} {methodName}"); + + // Merge the covariant receiver-type parameter (if any) with the assertion class's own + // type parameters into a single generic parameter list. Emitting them as two adjacent + // blocks produces invalid C# when both are present (e.g. an Assertion + // subclass that also declares its own : the receiver-type covariance adds TActual, + // the class adds T, and the method signature must be , not ). + var methodGenericParams = new List(); if (genericTypeParam != null) { - sourceBuilder.Append($"<{genericTypeParam}>"); + methodGenericParams.Add(genericTypeParam); + } + methodGenericParams.AddRange(genericParams); + if (methodGenericParams.Count > 0) + { + sourceBuilder.Append($"<{string.Join(", ", methodGenericParams)}>"); } - sourceBuilder.Append(genericParamsString); sourceBuilder.Append("("); sourceBuilder.Append($"this {sourceType} source");