From a9f562085754bf30e2bd0fc99f34e1124ba3baa1 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 21 Sep 2026 10:09:26 +0200 Subject: [PATCH 1/2] fix(subscriptions): generate valid converter arms for keyword message types (#593, #594) ConsumeContextConverterGenerator formatted type names with FullyQualifiedFormat, which renders keyword types as `string` / `object` without a global:: prefix, and then prepended global:: itself. The resulting `global::string` is not valid C#, so a keyword message type, or an array of one, broke the build with CS1041. Format names without UseSpecialTypes so these types are emitted by their metadata names (global::System.String). Skip `dynamic` altogether: it can be neither qualified nor used in a type pattern, so it produced `global::dynamic` (CS0400). With `object` now compiling, its arm could be ordered ahead of an interface arm and subsume it (CS8510): an interface converts to object, but object is not among its base types, so both had specificity zero. Count object for interfaces, which leaves object as the only type with no supertypes, so its arm always goes last. Co-Authored-By: Claude Fable 5.1 --- .../ConsumeContextConverterGenerator.cs | 14 +++- .../ConsumeContextConverterGeneratorTests.cs | 69 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs b/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs index 898c3ed5..42324be4 100644 --- a/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs +++ b/src/Core/gen/Eventuous.Subscriptions.Generators/ConsumeContextConverterGenerator.cs @@ -14,6 +14,11 @@ public sealed class ConsumeContextConverterGenerator : IIncrementalGenerator { const string InterfaceName = "IMessageConsumeContext"; const string InterfaceFqn = $"{InterfaceNamespace}.{InterfaceName}`1"; + // Keyword types must be emitted by their metadata names (System.String), because they cannot be qualified with global:: + static readonly SymbolDisplayFormat TypeNameFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions & ~SymbolDisplayMiscellaneousOptions.UseSpecialTypes + ); + readonly struct KnownSymbols(INamedTypeSymbol? messageConsumeContext, INamedTypeSymbol? baseEventHandler) { public INamedTypeSymbol? MessageConsumeContext { get; } = messageConsumeContext; public INamedTypeSymbol? BaseEventHandler { get; } = baseEventHandler; @@ -142,7 +147,9 @@ static bool IsPotentialUsage(SyntaxNode node, CancellationToken _) { // so emitting switch arms by this count in descending order keeps the arm of a type ahead of the arms of its // supertypes. Types related only through generic variance or array covariance get the same count. static int GetSpecificity(ITypeSymbol symbol) { - var count = symbol.AllInterfaces.Length; + // An interface converts to object although object is not among its base types, so count it explicitly. + // It keeps object the only type with no supertypes, so its arm always goes last. + var count = symbol.AllInterfaces.Length + (symbol.TypeKind == TypeKind.Interface ? 1 : 0); for (var baseType = symbol.BaseType; baseType != null; baseType = baseType.BaseType) count++; @@ -153,12 +160,15 @@ static int GetSpecificity(ITypeSymbol symbol) { // Skip unresolved generic type parameters (e.g. T in IMessageConsumeContext) if (symbol.TypeKind == TypeKind.TypeParameter) return null; + // Skip dynamic as it can't be used in a type pattern + if (symbol.TypeKind == TypeKind.Dynamic) return null; + // Skip types that are inaccessible from module-level generated code // (e.g. private nested classes can't be referenced from the generated converter) if (!IsAccessibleFromGeneratedCode(symbol)) return null; // Use fully qualified name with global:: prefix - var name = symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var name = symbol.ToDisplayString(TypeNameFormat); return name.StartsWith("global::", StringComparison.Ordinal) ? name : $"global::{name}"; } diff --git a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs index 924f4525..bfd07c02 100644 --- a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs +++ b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs @@ -86,6 +86,75 @@ public static void Animals(IMessageConsumeContext> ctx) { } await Assert.That(ArmIndex(generated, "global::Foo.IEnvelope")).IsLessThan(ArmIndex(generated, "global::Foo.IEnvelope")); } + [Test] + [Arguments("string", "global::System.String")] + [Arguments("object", "global::System.Object")] + [Arguments("string[]", "global::System.String[]")] + public async Task Should_emit_compilable_arm_for_keyword_message_type(string messageType, string expectedArmType) { + // Issue #593: keyword types have no namespace to qualify, and 'global::string' is not valid C# + var source = $$""" + using Eventuous.Subscriptions.Context; + + namespace Foo; + + public static class Usages { + public static void Use(IMessageConsumeContext<{{messageType}}> ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(ArmIndex(generated, expectedArmType)).IsGreaterThanOrEqualTo(0); + } + + [Test] + public async Task Should_not_emit_arm_for_dynamic_message_type() { + // 'dynamic' can be neither qualified nor used in a type pattern + const string source = """ + using Eventuous; + using Eventuous.Subscriptions.Context; + + namespace Foo; + + [EventType("V1.RoomBooked")] + public record RoomBooked; + + public static class Usages { + public static void Use(IMessageConsumeContext ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(generated).DoesNotContain("dynamic"); + } + + [Test] + public async Task Should_emit_object_after_interfaces() { + // Issue #594: an interface converts to object although object is not among its base types, + // and object is discovered first here + const string source = """ + using Eventuous.Subscriptions.Context; + + namespace Foo; + + public interface IFoo; + + public static class Usages { + public static void Any(IMessageConsumeContext ctx) { } + + public static void Foo(IMessageConsumeContext ctx) { } + } + """; + + var (generated, errors) = RunGenerator(source); + + await Assert.That(errors).IsEmpty(); + await Assert.That(ArmIndex(generated, "global::Foo.IFoo")).IsLessThan(ArmIndex(generated, "global::System.Object")); + } + static int ArmIndex(string generated, string typeName) { var index = generated.IndexOf($"{typeName} =>", StringComparison.Ordinal); From 9d354471bba1cc708ed288adabe9f4583c1aaac1 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Mon, 21 Sep 2026 12:28:19 +0200 Subject: [PATCH 2/2] test(subscriptions): cover keyword types nested in generic arguments Formatting type names without UseSpecialTypes also changes how a keyword nested in generic arguments is rendered: List is now emitted as List. Pin that in the keyword message type test; the case fails when the old name format is restored. Co-Authored-By: Claude Fable 5.1 --- .../ConsumeContextConverterGeneratorTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs index bfd07c02..667da1c7 100644 --- a/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs +++ b/src/Core/test/Eventuous.Tests.Subscriptions/ConsumeContextConverterGeneratorTests.cs @@ -90,6 +90,7 @@ public static void Animals(IMessageConsumeContext> ctx) { } [Arguments("string", "global::System.String")] [Arguments("object", "global::System.Object")] [Arguments("string[]", "global::System.String[]")] + [Arguments("System.Collections.Generic.List", "global::System.Collections.Generic.List")] public async Task Should_emit_compilable_arm_for_keyword_message_type(string messageType, string expectedArmType) { // Issue #593: keyword types have no namespace to qualify, and 'global::string' is not valid C# var source = $$"""