From 5e41604a8ad063ea3079506d1d41d3ca89d55f46 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 5 Sep 2026 14:10:17 +0000 Subject: [PATCH 1/2] Serialization.V2: pin cross-assembly generator behavior, fix AKKASG023 mislabel, add fix-it hints Part of #8384. - CrossAssemblyBaselineSpec: six two-assembly characterization tests for types declared in a referenced assembly - A non-generic [AkkaSerializable] type missing from the message table now reports AKKASG007, not AKKASG023 (which stays for real closed generics) - AKKASG003, AKKASG007, and AKKASG015 now say how to fix the problem; the cross-assembly variants name the type and its assembly - design.md line 199 corrected: a closed-generic registration over a definition from a referenced assembly already works --- .../design.md | 2 +- .../AkkaSerializerGenerator.cs | 195 +++++++- .../AkkaSerializerGeneratorDiagnosticsSpec.cs | 77 +++ .../CrossAssemblyBaselineSpec.cs | 452 ++++++++++++++++++ 4 files changed, 703 insertions(+), 23 deletions(-) create mode 100644 src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs diff --git a/openspec/changes/messagepack-sourcegen-validation/design.md b/openspec/changes/messagepack-sourcegen-validation/design.md index 0856e571ce1..00efcb5f4ba 100644 --- a/openspec/changes/messagepack-sourcegen-validation/design.md +++ b/openspec/changes/messagepack-sourcegen-validation/design.md @@ -196,7 +196,7 @@ Two structural shapes were added after the decisions above, both following the c **Closed generic registrations.** A Roslyn source generator is syntax-driven and cannot reify an open generic type, so a generic `[AkkaSerializable]` type (e.g. `Wrapper`) is never itself serialized — the open definition exists only to host the `[AkkaField]` schema its closed constructions share. `[AkkaSerializable>(Manifest = ...)]`, applied to the `[AkkaSerializer]` class, registers one closed construction; each registration then behaves exactly like an ordinary top-level message, with its own manifest and dispatch arm, and its generic fields resolved against the concrete type arguments. A generic definition that implements the serializer's protocol interface with no registered closed construction fails compilation (`AKKASG022`), and a field typed as an unregistered closed construction fails compilation (`AKKASG023`) — the source-generation analog of System.Text.Json rejecting an unbound generic in `[JsonSerializable]`. -Both features are scoped to the current compilation, the same rule every other generator lookup follows (see the cross-assembly discussion in Decision 8): a union member type or a closed-generic registration declared only in a referenced assembly is invisible to this generator. +Scope differs between the two features, and `CrossAssemblyBaselineSpec` pins the current behavior. A union member type declared only in a referenced assembly is invisible to this generator. It fails AKKASG015 even when it carries `[AkkaSerializable]` and a manifest in that assembly. A closed-generic registration is not scoped that way. `[AkkaSerializable]` resolves the generic definition through symbol APIs, not through the per-compilation syntax registry that backs the union check. So a closed construction over a generic definition that lives only in a referenced assembly (`Wrapper` in A, `[AkkaSerializable>]` on B's serializer) compiles and generates correctly today. What fails across assemblies is a non-generic `[AkkaSerializable]` type used as a nested field, and a union member declared in a referenced assembly. Before this branch the nested-field case was misreported as AKKASG023 (unregistered closed generic). It now reports AKKASG007, and both diagnostics name the type, its assembly, and the fixes that work today. ### 14. Always-Explicit Serializer Identity diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index a9f7359d68d..c009879efda 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -60,6 +60,22 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// Same id/title/severity as , used instead of it ONLY when + /// the unsupported field type is an interface, an abstract class, or a type parameter -- the + /// shapes a forgotten [AkkaEnvelopePayload] or [AkkaUnion] declaration usually + /// produces, as opposed to a genuinely unrepresentable type. A second descriptor variant (not a + /// format-string branch) keeps 's message byte-identical for + /// every other unsupported kind. + /// + private static readonly DiagnosticDescriptor UnsupportedFieldTypePolymorphic = new( + "AKKASG003", + "Unsupported field type", + "Property '{0}' on type '{1}' has unsupported generated serializer field type '{2}'. Mark the property [AkkaEnvelopePayload], or declare a closed member set with [AkkaUnion].", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + private static readonly DiagnosticDescriptor MissingFields = new( "AKKASG004", "No serializable fields", @@ -92,6 +108,27 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// Same id/title/severity as , used instead of + /// it ONLY when the offending nested type's ContainingAssembly is not the compilation + /// being generated for: this generator only ever reads [AkkaSerializable]/[AkkaField] + /// schemas out of syntax declared in the current compilation, so the type may already carry both + /// attributes in its own assembly and STILL be unreadable from here -- annotating it there fixes + /// nothing today. The message must therefore name only the two fixes that actually work + /// (register a formatter, or move/redeclare the type in this assembly) and must not claim the + /// type lacks the attributes. A second descriptor variant (not a format-string branch) keeps + /// 's message byte-identical for same-assembly types. + /// + private static readonly DiagnosticDescriptor MissingNestedSerializableDefinitionCrossAssembly = new( + "AKKASG007", + "Nested value object serialization definition is required", + "Property '{0}' on type '{1}' uses nested value object type '{2}', which is declared in assembly '{3}'. " + + "This generator cannot read a schema from a referenced assembly yet. " + + "Register [AkkaSerializerFormatter<{2}, TFormatter>] on '{4}', or declare the type in this assembly.", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + // The `where TFormatter : IAkkaMessagePackFormatter` constraint on // AkkaSerializerFormatterAttribute now makes interface conformance a // compile-time error at the attribute usage site, so this narrows to the one thing a generic @@ -163,6 +200,24 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); + /// + /// Same id/title/severity as , used instead of it ONLY + /// when the declared union member type's ContainingAssembly is not the compilation being + /// generated for -- see for why + /// this generator can never resolve such a member from here even when it genuinely carries + /// [AkkaSerializable] and a manifest in its own assembly, and for why the message must + /// name only the fixes that work today rather than claim the member lacks the attribute. + /// + private static readonly DiagnosticDescriptor UnionMemberNotSerializableCrossAssembly = new( + "AKKASG015", + "Union member type is not serializable", + "Union member '{0}' on property '{1}' of type '{2}' is declared in assembly '{3}'. " + + "This generator cannot read a schema from a referenced assembly yet. " + + "Register [AkkaSerializerFormatter<{0}, TFormatter>] on '{4}', or declare the member in this assembly.", + "Akka.Serialization.V2", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + private static readonly DiagnosticDescriptor UnionMemberMissingManifest = new( "AKKASG016", "Union member manifest is required", @@ -1448,7 +1503,8 @@ private static ImmutableArray ExtractUnionMembers( compilation.HasImplicitConversion(memberType, member.Type), isSupported: true, isSealed: memberType.IsSealed || memberType.IsValueType, - isAbstract: memberType.IsAbstract)); + isAbstract: memberType.IsAbstract, + foreignAssemblyName: GetForeignAssemblyName(memberType, knownTypes))); } return builder.ToImmutable(); @@ -1561,6 +1617,21 @@ private static void CollectObjectTypeNames(TypeMapping mapping, HashSet CollectObjectTypeNames(argument, into); } + /// + /// Same traversal as , but keyed by the full mapping rather + /// than just its type name, so 's "Object mapping resolves to no + /// known message" check can still read off the + /// mapping it found -- information the plain of names would discard. + /// + private static void CollectObjectMappings(TypeMapping mapping, Dictionary into) + { + if (mapping.Kind == FieldKind.Object && !into.ContainsKey(mapping.TypeFullName)) + into[mapping.TypeFullName] = mapping; + + foreach (var argument in mapping.TypeArguments) + CollectObjectMappings(argument, into); + } + private static bool ValidateMessages(SourceProductionContext context, SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages, ImmutableDictionary messagesByType) { var isValid = true; @@ -1630,13 +1701,17 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Unsupported)) { - context.ReportDiagnostic(Diagnostic.Create(UnsupportedFieldType, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); + context.ReportDiagnostic(field.Mapping.SuggestsEnvelopeOrUnion + ? Diagnostic.Create(UnsupportedFieldTypePolymorphic, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName)) + : Diagnostic.Create(UnsupportedFieldType, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); isValid = false; } foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.MissingSerializableDefinition)) { - context.ReportDiagnostic(Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); + context.ReportDiagnostic(field.Mapping.ForeignAssemblyName.Length > 0 + ? Diagnostic.Create(MissingNestedSerializableDefinitionCrossAssembly, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName), field.Mapping.ForeignAssemblyName, serializer.ClassName) + : Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); isValid = false; } @@ -1648,22 +1723,38 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.Union)) { - if (!ValidateUnionField(context, message, field, messagesByType)) + if (!ValidateUnionField(context, message, field, messagesByType, serializer.ClassName)) isValid = false; } // An Object mapping that resolves to no known message would generate a call to a - // nonexistent Write/Read/SizeOf method. The only way to hit this from within one - // compilation is a closed generic [AkkaSerializable] field type whose construction was - // never registered with [AkkaSerializable] (non-generic [AkkaSerializable] - // types are always extracted). AKKASG023 names the fix. + // nonexistent Write/Read/SizeOf method. That happens two ways: a genuine closed generic + // [AkkaSerializable] field type whose construction was never registered with + // [AkkaSerializable] (its message-dictionary key is arity-aware, so it contains '<' -- + // see GetMessageDictionaryKey -- and AKKASG023 names the fix), or a non-generic + // [AkkaSerializable] type that simply has no syntax in THIS compilation (for example, one + // declared -- and correctly annotated -- only in a referenced assembly): that key never + // contains '<', and it is AKKASG007 that names the fix, with a cross-assembly hint when + // the type's declaring assembly says so. foreach (var field in message.Fields) { - var objectTypeNames = new HashSet(StringComparer.Ordinal); - CollectObjectTypeNames(field.Mapping, objectTypeNames); - foreach (var objectTypeName in objectTypeNames.Where(typeName => !messagesByType.ContainsKey(typeName))) + var objectMappings = new Dictionary(StringComparer.Ordinal); + CollectObjectMappings(field.Mapping, objectMappings); + foreach (var pair in objectMappings.Where(entry => !messagesByType.ContainsKey(entry.Key))) { - context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), serializer.ClassName)); + var objectTypeName = pair.Key; + if (objectTypeName.Contains('<')) + { + context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), serializer.ClassName)); + } + else + { + var foreignAssemblyName = pair.Value.ForeignAssemblyName; + context.ReportDiagnostic(foreignAssemblyName.Length > 0 + ? Diagnostic.Create(MissingNestedSerializableDefinitionCrossAssembly, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), foreignAssemblyName, serializer.ClassName) + : Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName))); + } + isValid = false; } } @@ -1724,7 +1815,8 @@ private static bool ValidateUnionField( SourceProductionContext context, MessageInfo message, FieldInfo field, - ImmutableDictionary messagesByType) + ImmutableDictionary messagesByType, + string serializerClassName) { var isValid = true; @@ -1745,7 +1837,9 @@ private static bool ValidateUnionField( { if (!member.IsSupported || !messagesByType.TryGetValue(member.TypeFullName, out var memberMessage)) { - context.ReportDiagnostic(Diagnostic.Create(UnionMemberNotSerializable, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); + context.ReportDiagnostic(member.ForeignAssemblyName.Length > 0 + ? Diagnostic.Create(UnionMemberNotSerializableCrossAssembly, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName), member.ForeignAssemblyName, serializerClassName) + : Diagnostic.Create(UnionMemberNotSerializable, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); isValid = false; continue; } @@ -3385,7 +3479,7 @@ private static TypeMapping MapTypeCore(ITypeSymbol type, KnownTypes knownTypes) // construction resolves to its registered [AkkaSerializable] message -- or, if // unregistered, fails AKKASG023 instead of silently dropping its type arguments. if (type is INamedTypeSymbol namedType && namedType.OriginalDefinition.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute))) - return new TypeMapping(FieldKind.Object, GetMessageDictionaryKey(namedType), namedType.IsValueType); + return new TypeMapping(FieldKind.Object, GetMessageDictionaryKey(namedType), namedType.IsValueType, foreignAssemblyName: GetForeignAssemblyName(namedType, knownTypes)); var mapping = type.SpecialType switch { @@ -3409,9 +3503,23 @@ private static TypeMapping MapTypeCore(ITypeSymbol type, KnownTypes knownTypes) return collectionMapping; if (type is INamedTypeSymbol { IsGenericType: false, TypeKind: TypeKind.Class or TypeKind.Struct } missingNestedType) - return new TypeMapping(FieldKind.MissingSerializableDefinition, GetFullyQualifiedTypeName(missingNestedType)); + return new TypeMapping(FieldKind.MissingSerializableDefinition, GetFullyQualifiedTypeName(missingNestedType), foreignAssemblyName: GetForeignAssemblyName(missingNestedType, knownTypes)); + + // AKKASG003 on an interface, an abstract class, or a type parameter is usually a forgotten + // [AkkaEnvelopePayload]/[AkkaUnion] declaration rather than a genuinely unrepresentable + // type -- flag it so ValidateMessages can point authors at both fixes instead of leaving + // them to guess. + var suggestsEnvelopeOrUnion = type.TypeKind == TypeKind.TypeParameter + || (type is INamedTypeSymbol { IsAbstract: true } && type.TypeKind is TypeKind.Interface or TypeKind.Class); + return suggestsEnvelopeOrUnion ? new TypeMapping(FieldKind.Unsupported, suggestsEnvelopeOrUnion: true) : mapping; + } - return mapping; + private static string GetForeignAssemblyName(ISymbol symbol, KnownTypes knownTypes) + { + var assembly = symbol.ContainingAssembly; + return assembly != null && !SymbolEqualityComparer.Default.Equals(assembly, knownTypes.CompilationAssembly) + ? assembly.Name + : string.Empty; } /// @@ -4056,6 +4164,7 @@ private sealed class KnownTypes { private KnownTypes(Compilation compilation) { + CompilationAssembly = compilation.Assembly; FieldAttribute = compilation.GetTypeByMetadataName(FieldAttributeFullName); EnvelopePayloadAttribute = compilation.GetTypeByMetadataName(EnvelopePayloadAttributeFullName); UnionAttribute = compilation.GetTypeByMetadataName(UnionAttributeFullName); @@ -4074,6 +4183,14 @@ private KnownTypes(Compilation compilation) ImmutableDictionaryOfKeyValue = compilation.GetTypeByMetadataName("System.Collections.Immutable.ImmutableDictionary`2"); } + /// + /// The assembly of the compilation this generator run is producing output for. Used only to + /// tell apart a type declared locally from one declared in a referenced assembly (the + /// AKKASG007/AKKASG015 cross-assembly hint) -- never carried into any extracted model, so it + /// does not affect incremental caching. + /// + public IAssemblySymbol CompilationAssembly { get; } + public INamedTypeSymbol? FieldAttribute { get; } public INamedTypeSymbol? EnvelopePayloadAttribute { get; } public INamedTypeSymbol? UnionAttribute { get; } @@ -4471,7 +4588,9 @@ public TypeMapping( string declaredTypeName = "", bool isNullable = false, ImmutableArray typeArguments = default, - string enumUnderlyingTypeName = "") + string enumUnderlyingTypeName = "", + string foreignAssemblyName = "", + bool suggestsEnvelopeOrUnion = false) { Kind = kind; TypeFullName = typeFullName; @@ -4480,6 +4599,8 @@ public TypeMapping( IsNullable = isNullable; TypeArguments = typeArguments.IsDefault ? ImmutableArray.Empty : typeArguments; EnumUnderlyingTypeName = enumUnderlyingTypeName; + ForeignAssemblyName = foreignAssemblyName; + SuggestsEnvelopeOrUnion = suggestsEnvelopeOrUnion; } public FieldKind Kind { get; } @@ -4527,11 +4648,27 @@ public TypeMapping( /// public string EnumUnderlyingTypeName { get; } + /// + /// For and : + /// the name of the type's declaring assembly, but ONLY when that assembly is not the one this + /// generator is producing output for -- empty for a same-assembly type. Drives the AKKASG007 + /// cross-assembly hint variant; unused for every other kind. + /// + public string ForeignAssemblyName { get; } + + /// + /// For : whether the field's static type is an interface, an + /// abstract class, or a type parameter -- the shapes a forgotten [AkkaEnvelopePayload] or + /// [AkkaUnion] declaration usually produces. Drives the AKKASG003 hint variant; unused + /// for every other kind. + /// + public bool SuggestsEnvelopeOrUnion { get; } + public TypeMapping WithTypeFullName(string typeFullName) - => new(Kind, typeFullName, IsValueType, DeclaredTypeName, IsNullable, TypeArguments, EnumUnderlyingTypeName); + => new(Kind, typeFullName, IsValueType, DeclaredTypeName, IsNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion); public TypeMapping AsCollectionElement(string declaredTypeName, bool isNullable) - => new(Kind, TypeFullName, IsValueType, declaredTypeName, isNullable, TypeArguments, EnumUnderlyingTypeName); + => new(Kind, TypeFullName, IsValueType, declaredTypeName, isNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion); // Explicit IEquatable implementation: the compiler-provided struct equality would compare // the TypeArguments ImmutableArray by underlying-array REFERENCE, breaking value equality @@ -4544,6 +4681,8 @@ public bool Equals(TypeMapping other) && string.Equals(DeclaredTypeName, other.DeclaredTypeName, StringComparison.Ordinal) && IsNullable == other.IsNullable && string.Equals(EnumUnderlyingTypeName, other.EnumUnderlyingTypeName, StringComparison.Ordinal) + && string.Equals(ForeignAssemblyName, other.ForeignAssemblyName, StringComparison.Ordinal) + && SuggestsEnvelopeOrUnion == other.SuggestsEnvelopeOrUnion && ValueEquality.SequenceEquals(TypeArguments, other.TypeArguments); } @@ -4558,6 +4697,8 @@ public override int GetHashCode() hash = ValueEquality.Combine(hash, DeclaredTypeName); hash = ValueEquality.Combine(hash, IsNullable); hash = ValueEquality.Combine(hash, EnumUnderlyingTypeName); + hash = ValueEquality.Combine(hash, ForeignAssemblyName); + hash = ValueEquality.Combine(hash, SuggestsEnvelopeOrUnion); hash = ValueEquality.Combine(hash, TypeArguments); return hash; } @@ -4674,7 +4815,7 @@ private enum FieldKind /// private sealed class UnionMemberInfo : IEquatable { - public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, bool isSupported, bool isSealed, bool isAbstract) + public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, bool isSupported, bool isSealed, bool isAbstract, string foreignAssemblyName = "") { TypeFullName = typeFullName; IsValueType = isValueType; @@ -4682,6 +4823,7 @@ public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, IsSupported = isSupported; IsSealed = isSealed; IsAbstract = isAbstract; + ForeignAssemblyName = foreignAssemblyName; } /// Message-dictionary key for the member type (arity-aware for generics). @@ -4705,6 +4847,13 @@ public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, /// public bool IsAbstract { get; } + /// + /// The member type's declaring assembly name, but ONLY when it is not the compilation this + /// generator is producing output for -- empty for a same-assembly member. Drives the + /// AKKASG015 cross-assembly hint variant. + /// + public string ForeignAssemblyName { get; } + public bool Equals(UnionMemberInfo? other) { if (ReferenceEquals(this, other)) @@ -4718,7 +4867,8 @@ public bool Equals(UnionMemberInfo? other) && IsAssignable == other.IsAssignable && IsSupported == other.IsSupported && IsSealed == other.IsSealed - && IsAbstract == other.IsAbstract; + && IsAbstract == other.IsAbstract + && string.Equals(ForeignAssemblyName, other.ForeignAssemblyName, StringComparison.Ordinal); } public override bool Equals(object? obj) => Equals(obj as UnionMemberInfo); @@ -4732,6 +4882,7 @@ public override int GetHashCode() hash = ValueEquality.Combine(hash, IsSupported); hash = ValueEquality.Combine(hash, IsSealed); hash = ValueEquality.Combine(hash, IsAbstract); + hash = ValueEquality.Combine(hash, ForeignAssemblyName); return hash; } } diff --git a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs index 6294b72ecb1..ed01b4fb0f6 100644 --- a/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/AkkaSerializerGeneratorDiagnosticsSpec.cs @@ -2455,6 +2455,83 @@ public sealed record ExoticImmutable([property: AkkaField(1)] ImmutableSortedSet diagnostics.Should().Contain(diagnostic => diagnostic.Id == "AKKASG003" && diagnostic.Severity == DiagnosticSeverity.Error); } + [Fact(DisplayName = "Generator should append the [AkkaEnvelopePayload]/[AkkaUnion] hint to AKKASG003 when the unsupported field type is an interface")] + public void Generator_should_append_polymorphism_hint_to_AKKASG003_for_interface_field() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + public interface IUnannotated + { + } + + [AkkaSerializer("sample", 199001)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] IUnannotated Value) : IProtocol; + """; + + var diagnostics = RunGenerator(source); + + // Same id/title/severity as every other AKKASG003 -- this is a second descriptor variant, + // not a format-string branch, so a genuinely unrepresentable type's message (asserted + // elsewhere in this file) stays byte-identical to before this hint existed. + diagnostics.Should().Contain(diagnostic => + diagnostic.Id == "AKKASG003" && + diagnostic.Severity == DiagnosticSeverity.Error && + diagnostic.GetMessage(null).Contains("Mark the property [AkkaEnvelopePayload], or declare a closed member set with [AkkaUnion].", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Generator should not append the cross-assembly hint to AKKASG007 when the nested type is declared in this same compilation")] + public void Generator_should_not_append_cross_assembly_hint_to_AKKASG007_for_same_assembly_type() + { + const string source = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + + namespace DiagnosticSample; + + public interface IProtocol + { + } + + [AkkaSerializer("sample", 199002)] + public sealed partial class SampleSerializer : AkkaSerializer + { + public static partial SerializerRegistration CreateRegistration(); + } + + [AkkaSerializable(Manifest = "outer-v1")] + public sealed record Outer([property: AkkaField(1)] Inner Inner) : IProtocol; + + public sealed record Inner([property: AkkaField(1)] string Value); + """; + + var diagnostics = RunGenerator(source); + + var diagnostic = diagnostics.FirstOrDefault(d => d.Id == "AKKASG007" && d.Severity == DiagnosticSeverity.Error); + diagnostic.Should().NotBeNull(); + + // The cross-assembly descriptor variant only fires when the nested type's ContainingAssembly + // differs from the compilation being generated for; Inner is declared right here, so the + // message must stay exactly the pre-hint text -- no assembly name, no formatter suggestion. + diagnostic!.GetMessage(null).Contains("is declared in assembly", StringComparison.Ordinal).Should().BeFalse(); + diagnostic.GetMessage(null).Contains("AkkaSerializerFormatter<", StringComparison.Ordinal).Should().BeFalse(); + } + private static ImmutableArray RunGenerator(string source) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); diff --git a/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs b/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs new file mode 100644 index 00000000000..5adbe50f0ac --- /dev/null +++ b/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs @@ -0,0 +1,452 @@ +//----------------------------------------------------------------------- +// +// Copyright (C) 2009-2022 Lightbend Inc. +// Copyright (C) 2013-2026 .NET Foundation +// +//----------------------------------------------------------------------- + +#nullable enable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Reflection; +using Akka.Actor; +using Akka.Serialization.V2.Generators; +using FluentAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Akka.Serialization.V2.Tests; + +/// +/// CHARACTERIZATION tests (issue #8384, PR 1): pin what +/// ACTUALLY does today when a serializable type lives in a REFERENCED assembly rather than the +/// compilation that hosts the [AkkaSerializer] module. These assert observed behavior, not +/// intended behavior -- see the design.md Decision 13 note this spec verifies (the "scoped to the +/// current compilation" claim). +/// +/// +/// The harness compiles a small "assembly A" from source into an in-memory +/// (no generator run against it -- A never hosts a serializer), then compiles "assembly B" +/// referencing A plus the same base references +/// uses, and runs the generator against B exactly as that spec does. +/// +public sealed class CrossAssemblyBaselineSpec +{ + [Fact(DisplayName = "Cross-assembly baseline: nested field type declared and [AkkaSerializable] in a referenced assembly fails AKKASG007 with a cross-assembly hint naming the type and its assembly, never the AKKASG023 closed-generic mislabel")] + public void Nested_field_type_from_referenced_assembly() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case1.AssemblyA; + + [AkkaSerializable] + public sealed record Money([property: AkkaField(1)] long Cents); + """; + + const string sourceB = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case1.AssemblyA; + + namespace CrossAssemblyBaseline.Case1.AssemblyB; + + public interface IShop { } + + [AkkaSerializable(Manifest = "pay-v1")] + public sealed record Pay([property: AkkaField(1)] Money Amount) : IShop; + + [AkkaSerializer("shop", 130001)] + public sealed partial class ShopSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case1.A"); + var (generatorDiagnostics, compileDiagnostics, _) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case1.B"); + var all = generatorDiagnostics.AddRange(compileDiagnostics); + + // CONTRACT: [AkkaSerializable] detection reads attributes off the type SYMBOL, which is + // visible cross-assembly, so Money maps to FieldKind.Object; but the "known message" + // registry (messagesByType) is built from the syntax provider, which only sees declarations + // local to B, so the Object mapping still resolves to no known message. That case is now + // told apart from a genuine unregistered closed generic by the message-dictionary key + // shape (Money's key carries no '<'), so it reports AKKASG007 -- naming the offending type + // and its declaring assembly, and pointing at the two fixes that actually work today -- + // never AKKASG023. Money already carries [AkkaSerializable] and [AkkaField] in assembly A, + // so the message must NOT claim the type lacks them (annotating it there fixes nothing + // until this generator can read schemas across assemblies). + all.Should().NotContain(d => d.Id == "AKKASG023"); + all.Should().Contain(d => + d.Id == "AKKASG007" && + d.Severity == DiagnosticSeverity.Error && + d.GetMessage(null).Contains("Amount", StringComparison.Ordinal) && + d.GetMessage(null).Contains("Money", StringComparison.Ordinal) && + d.GetMessage(null).Contains("CrossAssemblyBaseline.Case1.A", StringComparison.Ordinal) && + d.GetMessage(null).Contains("AkkaSerializerFormatter", StringComparison.Ordinal) && + d.GetMessage(null).Contains("ShopSerializer", StringComparison.Ordinal) && + !d.GetMessage(null).Contains("Add [AkkaSerializable]", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Cross-assembly baseline: union members declared and [AkkaSerializable] in a referenced assembly fail AKKASG015 (not found), even though the type-level [AkkaUnion] declaration on the interface IS discovered")] + public void Union_members_from_referenced_assembly() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case2.AssemblyA; + + [AkkaUnion(typeof(Placed), typeof(Cancelled))] + public interface IOrderEvent { } + + [AkkaSerializable(Manifest = "placed-v1")] + public sealed record Placed([property: AkkaField(1)] string OrderId) : IOrderEvent; + + [AkkaSerializable(Manifest = "cancelled-v1")] + public sealed record Cancelled([property: AkkaField(1)] string OrderId) : IOrderEvent; + """; + + const string sourceB = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case2.AssemblyA; + + namespace CrossAssemblyBaseline.Case2.AssemblyB; + + public interface IOrders { } + + [AkkaSerializable(Manifest = "order-notice-v1")] + public sealed record OrderNotice([property: AkkaField(1)] IOrderEvent Event) : IOrders; + + [AkkaSerializer("orders", 130002)] + public sealed partial class OrdersSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case2.A"); + var (generatorDiagnostics, compileDiagnostics, _) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case2.B"); + var all = generatorDiagnostics.AddRange(compileDiagnostics); + + // CONTRACT: the type-level [AkkaUnion(...)] on IOrderEvent IS found (symbol-based attribute + // discovery works cross-assembly), so the field maps to FieldKind.Union and AKKASG003 never + // fires. But union member validation resolves each member against the same syntax-provider + // `messagesByType`, which knows nothing about Placed/Cancelled -- so both members fail as + // "not serializable" (AKKASG015), each now carrying the cross-assembly hint naming the + // member type, its declaring assembly, and the two fixes that actually work today. Placed + // and Cancelled already carry [AkkaSerializable] and a manifest in assembly A, so the + // message must NOT claim otherwise. + all.Should().NotContain(d => d.Id == "AKKASG003"); + all.Should().Contain(d => + d.Id == "AKKASG015" && + d.GetMessage(null).Contains("Placed", StringComparison.Ordinal) && + d.GetMessage(null).Contains("CrossAssemblyBaseline.Case2.A", StringComparison.Ordinal) && + d.GetMessage(null).Contains("AkkaSerializerFormatter", StringComparison.Ordinal) && + d.GetMessage(null).Contains("OrdersSerializer", StringComparison.Ordinal) && + !d.GetMessage(null).Contains("Add [AkkaSerializable]", StringComparison.Ordinal)); + all.Should().Contain(d => + d.Id == "AKKASG015" && + d.GetMessage(null).Contains("Cancelled", StringComparison.Ordinal) && + d.GetMessage(null).Contains("CrossAssemblyBaseline.Case2.A", StringComparison.Ordinal) && + d.GetMessage(null).Contains("AkkaSerializerFormatter", StringComparison.Ordinal) && + d.GetMessage(null).Contains("OrdersSerializer", StringComparison.Ordinal) && + !d.GetMessage(null).Contains("Add [AkkaSerializable]", StringComparison.Ordinal)); + all.Count(d => d.Id == "AKKASG015").Should().Be(2); + } + + [Fact(DisplayName = "Cross-assembly baseline: a generic [AkkaSerializable] definition from a referenced assembly compiles clean and emits Wrapper helpers when closed-generic-registered in B, but still fails AKKASG023 when it is not")] + public void Generic_definition_from_referenced_assembly() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case3.AssemblyA; + + [AkkaSerializable] + public sealed record Wrapper( + [property: AkkaField(1)] string Id, + [property: AkkaField(2)] T Body); + """; + + const string sourceBRegistered = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case3.AssemblyA; + + namespace CrossAssemblyBaseline.Case3.AssemblyB; + + public interface IWrap { } + + [AkkaSerializable(Manifest = "holder-v1")] + public sealed record Holder([property: AkkaField(1)] Wrapper Count) : IWrap; + + [AkkaSerializer("wrap", 130003)] + [AkkaSerializable>(Manifest = "wrap-int-v1")] + public sealed partial class WrapSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case3.A"); + var (registeredGeneratorDiagnostics, registeredCompileDiagnostics, generatedSource) = + RunGeneratorAgainstB(sourceBRegistered, assemblyA, "CrossAssemblyBaseline.Case3.Registered"); + var registeredAll = registeredGeneratorDiagnostics.AddRange(registeredCompileDiagnostics); + + // OBSERVED: closed-generic registration extraction reads the generic DEFINITION's + // [AkkaSerializable]/[AkkaField] data straight off the type symbol (not syntax), and the + // registered construction Wrapper is injected into messagesByType directly by that + // extraction step rather than the syntax provider -- sidestepping the gap cases 1/2 hit. + // No errors; the helper methods are emitted. + registeredAll.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + generatedSource.Should().Contain("WriteWrapperInt"); + generatedSource.Should().Contain("ReadWrapperInt"); + + // Second variant: same referenced-assembly definition and field usage, but with NO + // [AkkaSerializable>] registration on the serializer. + const string sourceBUnregistered = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case3.AssemblyA; + + namespace CrossAssemblyBaseline.Case3.AssemblyB; + + public interface IWrap { } + + [AkkaSerializable(Manifest = "holder-v1")] + public sealed record Holder([property: AkkaField(1)] Wrapper Count) : IWrap; + + [AkkaSerializer("wrap", 130004)] + public sealed partial class WrapSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var (unregisteredGeneratorDiagnostics, unregisteredCompileDiagnostics, _) = + RunGeneratorAgainstB(sourceBUnregistered, assemblyA, "CrossAssemblyBaseline.Case3.Unregistered"); + var unregisteredAll = unregisteredGeneratorDiagnostics.AddRange(unregisteredCompileDiagnostics); + + unregisteredAll.Should().Contain(d => + d.Id == "AKKASG023" && + d.Severity == DiagnosticSeverity.Error && + d.GetMessage(null).Contains("Wrapper", StringComparison.Ordinal)); + } + + [Fact(DisplayName = "Cross-assembly baseline: registering a closed generic construction of an unreachable, non-protocol referenced-assembly definition fires AKKASG034 and suppresses ALL emission for that serializer")] + public void Unreachable_closed_generic_registration_of_customer_envelope() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case4.AssemblyA; + + [AkkaSerializable] + public sealed record Envelope( + [property: AkkaField(1)] T Message, + [property: AkkaField(2)] string TraceId); + """; + + const string sourceB = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case4.AssemblyA; + + namespace CrossAssemblyBaseline.Case4.AssemblyB; + + public interface IComms { } + + [AkkaSerializable(Manifest = "dmac")] + public sealed record AcceptCassette([property: AkkaField(1)] int Layer) : IComms; + + [AkkaSerializer("comms", 130005)] + [AkkaSerializable>(Manifest = "env-dmac")] + public sealed partial class CommsSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case4.A"); + var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case4.B"); + var all = generatorDiagnostics.AddRange(compileDiagnostics); + + // Envelope implements nothing and is not a field of any message reachable + // from a top-level message -- the registration has no effect either way, regardless of + // where the generic definition lives. + all.Should().Contain(d => + d.Id == "AKKASG034" && + d.Severity == DiagnosticSeverity.Error && + d.GetMessage(null).Contains("Envelope", StringComparison.Ordinal) && + d.GetMessage(null).Contains("IComms", StringComparison.Ordinal)); + + // OBSERVED (not what the task brief assumed): AKKASG034 fails ValidateClosedGenericProtocolCoverage, + // which makes the pipeline `continue` past AddSource for THIS SERIALIZER ENTIRELY -- there is + // no partial CommsSerializer file at all, so there's no "binds only IComms" list to inspect. + generatedSource.Should().BeEmpty("AKKASG034 fails the whole serializer's coverage check, so the pipeline skips AddSource for CommsSerializer entirely"); + } + + [Fact(DisplayName = "Cross-assembly baseline: [AkkaEnvelopePayload] on a generic property, substituted through a referenced-assembly definition and made reachable, is still recognized as an envelope payload")] + public void Envelope_payload_on_generic_property_from_referenced_assembly() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case5.AssemblyA; + + [AkkaSerializable] + public sealed record Envelope( + [property: AkkaField(1), AkkaEnvelopePayload] T Message, + [property: AkkaField(2)] string TraceId); + """; + + const string sourceB = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case5.AssemblyA; + + namespace CrossAssemblyBaseline.Case5.AssemblyB; + + public interface IComms { } + + [AkkaSerializable(Manifest = "dmac")] + public sealed record AcceptCassette([property: AkkaField(1)] int Layer) : IComms; + + [AkkaSerializable(Manifest = "holder-v1")] + public sealed record Holder([property: AkkaField(1)] Envelope Inner) : IComms; + + [AkkaSerializer("comms", 130006)] + [AkkaSerializable>(Manifest = "env-any")] + public sealed partial class CommsSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case5.A"); + var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case5.B"); + var all = generatorDiagnostics.AddRange(compileDiagnostics); + + // OBSERVED: Roslyn's substituted-member symbol (Envelope.Message) returns the + // ORIGINAL DEFINITION's attributes, including [AkkaEnvelopePayload], regardless of which + // assembly declared the generic definition -- so the generator treats the field as an + // envelope payload boundary instead of failing AKKASG003 (T substituted to an interface + // would otherwise be unsupported). + all.Should().NotContain(d => d.Id == "AKKASG003"); + all.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + generatedSource.Should().Contain("WriteEnvelopePayload"); + } + + [Fact(DisplayName = "Cross-assembly baseline: a protocol implementor declared only in a referenced assembly is invisible to AKKASG029's compilation-local scan, and gets no Manifest dispatch arm")] + public void Protocol_implementor_declared_only_in_referenced_assembly() + { + const string sourceA = """ + #nullable enable + using Akka.Serialization.V2; + + namespace CrossAssemblyBaseline.Case6.AssemblyA; + + public interface IOrders { } + + [AkkaSerializable(Manifest = "placed-v1")] + public sealed record OrderPlaced([property: AkkaField(1)] string OrderId) : IOrders; + """; + + const string sourceB = """ + #nullable enable + using Akka.Actor; + using Akka.Serialization.V2; + using CrossAssemblyBaseline.Case6.AssemblyA; + + namespace CrossAssemblyBaseline.Case6.AssemblyB; + + [AkkaSerializer("orders", 130007)] + public sealed partial class OrdersSerializer : AkkaSerializer + { public static partial SerializerRegistration CreateRegistration(); } + """; + + var assemblyA = CompileAssemblyAToReference(sourceA, "CrossAssemblyBaseline.Case6.A"); + var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case6.B"); + var all = generatorDiagnostics.AddRange(compileDiagnostics); + + // AKKASG029's protocol-coverage walk only enumerates named types DECLARED IN THIS + // COMPILATION, so OrderPlaced (declared only in A) is never visited or flagged, even + // though it implements IOrders. The generated switch has no knowledge OrderPlaced exists. + all.Should().NotContain(d => d.Id == "AKKASG029"); + generatedSource.Should().NotContain("OrderPlaced"); + all.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); + } + + private static MetadataReference CompileAssemblyAToReference(string source, string assemblyName) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); + var syntaxTree = CSharpSyntaxTree.ParseText(source, parseOptions); + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { syntaxTree }, + CreateMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + var errors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToImmutableArray(); + errors.Should().BeEmpty($"assembly A ('{assemblyName}') must compile with no errors -- it does no generator work, it only supplies types for B to reference"); + + using var stream = new MemoryStream(); + var emitResult = compilation.Emit(stream); + emitResult.Success.Should().BeTrue($"assembly A ('{assemblyName}') must emit successfully"); + + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + private static (ImmutableArray GeneratorDiagnostics, ImmutableArray CompileDiagnostics, string GeneratedSource) RunGeneratorAgainstB( + string sourceB, MetadataReference assemblyAReference, string assemblyName) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp12); + var syntaxTree = CSharpSyntaxTree.ParseText(sourceB, parseOptions); + var references = CreateMetadataReferences().Append(assemblyAReference); + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + new[] { new AkkaSerializerGenerator().AsSourceGenerator() }, + parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out var generatorDiagnostics); + + var runResult = driver.GetRunResult(); + var generatedSource = string.Join( + Environment.NewLine, + runResult.GeneratedTrees.Select(tree => tree.ToString())); + + return (generatorDiagnostics, updatedCompilation.GetDiagnostics(), generatedSource); + } + + // Reused verbatim from AkkaSerializerGeneratorDiagnosticsSpec: the same base reference set, so + // the [AkkaSerializer]/[AkkaSerializable] attributes and runtime types resolve identically. + private static IEnumerable CreateMetadataReferences() + { + var trustedAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator) + .Where(File.Exists) + .Select(path => MetadataReference.CreateFromFile(path)) ?? Enumerable.Empty(); + + var explicitAssemblies = new[] + { + typeof(ActorSystem).Assembly, + typeof(AkkaSerializerAttribute<>).Assembly, + typeof(SerializerV2).Assembly, + typeof(ImmutableHashSet<>).Assembly, + Assembly.GetExecutingAssembly() + }; + + return trustedAssemblies.Concat(explicitAssemblies.Select(assembly => MetadataReference.CreateFromFile(assembly.Location))) + .GroupBy(reference => reference.Display) + .Select(group => group.First()); + } +} From 90ad3a2d9f4c102271e7fdb141a68ec061be8253 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 5 Sep 2026 17:31:22 +0000 Subject: [PATCH 2/2] Serialization.V2: reduce cognitive load of the cross-assembly diagnostic changes Review follow-up on PR #8512. No behavior change: same diagnostics, same message text, same 269 passing tests, no golden or snapshot changes. AkkaSerializerGenerator.cs: - Replace the objectTypeName.Contains('<') string test with an explicit TypeMapping.IsGenericConstruction bool, set once from INamedTypeSymbol.IsGenericType where the Object mapping is created, and threaded through equality/hash/With* like the other mapping flags. - Merge CollectObjectTypeNames and CollectObjectMappings into one EnumerateObjectMappings traversal that yields mappings; callers that only need names project TypeFullName themselves. - Collapse the three report sites that each chose between a same-assembly and cross-assembly descriptor (or AKKASG023 vs AKKASG007) into two helpers, ReportMissingNestedSchema and ReportUnionMemberNotSerializable, each carrying a short plain-English comment for its decision table. No report site branches more than once now. - Trim several overlong XML doc comments picked up along the way. CrossAssemblyBaselineSpec.cs: - Rewrite the case comments in plain, short-sentence English. Case 1 gets an ASCII diagram of the symbol-vs-syntax mechanism that produces AKKASG007 instead of AKKASG023; the other five cases get short prose. Generator diff against akkadotnet/dev: 175 insertions / 32 deletions (net +143), down from 173 / 22 (net +151) before this pass. --- .../AkkaSerializerGenerator.cs | 194 +++++++++--------- .../CrossAssemblyBaselineSpec.cs | 110 +++++----- 2 files changed, 153 insertions(+), 151 deletions(-) diff --git a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs index c009879efda..328951d62ae 100644 --- a/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs +++ b/src/core/Akka.Serialization.V2.Generators/AkkaSerializerGenerator.cs @@ -60,14 +60,8 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - /// - /// Same id/title/severity as , used instead of it ONLY when - /// the unsupported field type is an interface, an abstract class, or a type parameter -- the - /// shapes a forgotten [AkkaEnvelopePayload] or [AkkaUnion] declaration usually - /// produces, as opposed to a genuinely unrepresentable type. A second descriptor variant (not a - /// format-string branch) keeps 's message byte-identical for - /// every other unsupported kind. - /// + // Same id/title/severity as UnsupportedFieldType. Used only for an interface, abstract class, + // or type parameter field, which is usually a forgotten [AkkaEnvelopePayload]/[AkkaUnion]. private static readonly DiagnosticDescriptor UnsupportedFieldTypePolymorphic = new( "AKKASG003", "Unsupported field type", @@ -108,17 +102,11 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - /// - /// Same id/title/severity as , used instead of - /// it ONLY when the offending nested type's ContainingAssembly is not the compilation - /// being generated for: this generator only ever reads [AkkaSerializable]/[AkkaField] - /// schemas out of syntax declared in the current compilation, so the type may already carry both - /// attributes in its own assembly and STILL be unreadable from here -- annotating it there fixes - /// nothing today. The message must therefore name only the two fixes that actually work - /// (register a formatter, or move/redeclare the type in this assembly) and must not claim the - /// type lacks the attributes. A second descriptor variant (not a format-string branch) keeps - /// 's message byte-identical for same-assembly types. - /// + // Same id/title/severity as MissingNestedSerializableDefinition. Used only when the nested + // type's assembly is not the one being compiled. This generator can only read a schema from + // the current compilation, so the type may already carry both attributes in its own assembly + // and still be unreadable from here. The message must name only the fixes that work today, and + // must not claim the type lacks the attributes. private static readonly DiagnosticDescriptor MissingNestedSerializableDefinitionCrossAssembly = new( "AKKASG007", "Nested value object serialization definition is required", @@ -200,14 +188,9 @@ public sealed class AkkaSerializerGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - /// - /// Same id/title/severity as , used instead of it ONLY - /// when the declared union member type's ContainingAssembly is not the compilation being - /// generated for -- see for why - /// this generator can never resolve such a member from here even when it genuinely carries - /// [AkkaSerializable] and a manifest in its own assembly, and for why the message must - /// name only the fixes that work today rather than claim the member lacks the attribute. - /// + // Same id/title/severity as UnionMemberNotSerializable. Used only when the member type's + // assembly is not the one being compiled. See MissingNestedSerializableDefinitionCrossAssembly + // for why the member may already carry [AkkaSerializable] and still be unreadable from here. private static readonly DiagnosticDescriptor UnionMemberNotSerializableCrossAssembly = new( "AKKASG015", "Union member type is not serializable", @@ -1581,7 +1564,8 @@ private static ImmutableArray CollectReachableMessages( var referencedObjectTypes = new HashSet(StringComparer.Ordinal); foreach (var field in message.Fields) { - CollectObjectTypeNames(field.Mapping, referencedObjectTypes); + foreach (var objectMapping in EnumerateObjectMappings(field.Mapping)) + referencedObjectTypes.Add(objectMapping.TypeFullName); // Union members are reachable exactly like nested Object fields: each member needs // its Write/Read/SizeOf methods generated for the union dispatch to call into. @@ -1602,34 +1586,20 @@ private static ImmutableArray CollectReachableMessages( return messages.ToImmutable(); } - /// - /// Collects every type name reachable through a mapping, descending - /// into collection element/key/value mappings so that a nested [AkkaSerializable] type used - /// only inside a collection (for example the element of a List<Reading>) is still - /// reached and gets its Write/Read/SizeOf methods generated. - /// - private static void CollectObjectTypeNames(TypeMapping mapping, HashSet into) + // Walks a mapping and its collection element/key/value mappings, yielding every Object mapping + // found. A nested [AkkaSerializable] type used only inside a collection (a List + // element, say) is found this way too. Yields the full mapping, not just its name, so a caller + // can read its flags; a caller that only needs names can project TypeFullName itself. + private static IEnumerable EnumerateObjectMappings(TypeMapping mapping) { if (mapping.Kind == FieldKind.Object) - into.Add(mapping.TypeFullName); - - foreach (var argument in mapping.TypeArguments) - CollectObjectTypeNames(argument, into); - } - - /// - /// Same traversal as , but keyed by the full mapping rather - /// than just its type name, so 's "Object mapping resolves to no - /// known message" check can still read off the - /// mapping it found -- information the plain of names would discard. - /// - private static void CollectObjectMappings(TypeMapping mapping, Dictionary into) - { - if (mapping.Kind == FieldKind.Object && !into.ContainsKey(mapping.TypeFullName)) - into[mapping.TypeFullName] = mapping; + yield return mapping; foreach (var argument in mapping.TypeArguments) - CollectObjectMappings(argument, into); + { + foreach (var nested in EnumerateObjectMappings(argument)) + yield return nested; + } } private static bool ValidateMessages(SourceProductionContext context, SerializerInfo serializer, ImmutableArray topLevelMessages, ImmutableArray reachableMessages, ImmutableDictionary messagesByType) @@ -1709,9 +1679,7 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer foreach (var field in message.Fields.Where(field => field.Mapping.Kind == FieldKind.MissingSerializableDefinition)) { - context.ReportDiagnostic(field.Mapping.ForeignAssemblyName.Length > 0 - ? Diagnostic.Create(MissingNestedSerializableDefinitionCrossAssembly, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName), field.Mapping.ForeignAssemblyName, serializer.ClassName) - : Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(field.TypeFullName))); + ReportMissingNestedSchema(context, message, field.Name, field.TypeFullName, field.Mapping, serializer.ClassName); isValid = false; } @@ -1728,33 +1696,19 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer } // An Object mapping that resolves to no known message would generate a call to a - // nonexistent Write/Read/SizeOf method. That happens two ways: a genuine closed generic - // [AkkaSerializable] field type whose construction was never registered with - // [AkkaSerializable] (its message-dictionary key is arity-aware, so it contains '<' -- - // see GetMessageDictionaryKey -- and AKKASG023 names the fix), or a non-generic - // [AkkaSerializable] type that simply has no syntax in THIS compilation (for example, one - // declared -- and correctly annotated -- only in a referenced assembly): that key never - // contains '<', and it is AKKASG007 that names the fix, with a cross-assembly hint when - // the type's declaring assembly says so. + // nonexistent Write/Read/SizeOf method. ReportMissingNestedSchema tells apart the two + // ways that happens: a genuine unregistered closed generic construction (AKKASG023), or + // a non-generic type with no syntax in THIS compilation (AKKASG007, cross-assembly + // wording when the type's declaring assembly says so). foreach (var field in message.Fields) { - var objectMappings = new Dictionary(StringComparer.Ordinal); - CollectObjectMappings(field.Mapping, objectMappings); - foreach (var pair in objectMappings.Where(entry => !messagesByType.ContainsKey(entry.Key))) + var seenTypeNames = new HashSet(StringComparer.Ordinal); + foreach (var objectMapping in EnumerateObjectMappings(field.Mapping)) { - var objectTypeName = pair.Key; - if (objectTypeName.Contains('<')) - { - context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), serializer.ClassName)); - } - else - { - var foreignAssemblyName = pair.Value.ForeignAssemblyName; - context.ReportDiagnostic(foreignAssemblyName.Length > 0 - ? Diagnostic.Create(MissingNestedSerializableDefinitionCrossAssembly, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName), foreignAssemblyName, serializer.ClassName) - : Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, field.Name, ToDisplayName(message.FullyQualifiedName), ToDisplayName(objectTypeName))); - } + if (!seenTypeNames.Add(objectMapping.TypeFullName) || messagesByType.ContainsKey(objectMapping.TypeFullName)) + continue; + ReportMissingNestedSchema(context, message, field.Name, objectMapping.TypeFullName, objectMapping, serializer.ClassName); isValid = false; } } @@ -1775,6 +1729,29 @@ private static bool ValidateMessages(SourceProductionContext context, Serializer return isValid; } + // Decision table for a nested type this generator cannot serialize today: a closed generic + // construction reports AKKASG023 (register it with [AkkaSerializable]); a type declared in a + // referenced assembly reports the AKKASG007 cross-assembly wording; anything else reports the + // plain AKKASG007 message. + private static void ReportMissingNestedSchema( + SourceProductionContext context, + MessageInfo message, + string fieldName, + string typeFullName, + TypeMapping mapping, + string serializerClassName) + { + if (mapping.IsGenericConstruction) + { + context.ReportDiagnostic(Diagnostic.Create(UnregisteredClosedGenericField, Location.None, fieldName, ToDisplayName(message.FullyQualifiedName), ToDisplayName(typeFullName), serializerClassName)); + return; + } + + context.ReportDiagnostic(mapping.ForeignAssemblyName.Length > 0 + ? Diagnostic.Create(MissingNestedSerializableDefinitionCrossAssembly, Location.None, fieldName, ToDisplayName(message.FullyQualifiedName), ToDisplayName(typeFullName), mapping.ForeignAssemblyName, serializerClassName) + : Diagnostic.Create(MissingNestedSerializableDefinition, Location.None, fieldName, ToDisplayName(message.FullyQualifiedName), ToDisplayName(typeFullName))); + } + /// /// Fires AKKASG034 when a valid [AkkaSerializable<T>] registration's construction /// neither implements the serializer's protocol (so it can never become a top-level message) @@ -1811,6 +1788,15 @@ private static bool ValidateClosedGenericProtocolCoverage(SourceProductionContex return isValid; } + // Reports AKKASG015 for a union member with no known message. A referenced-assembly member + // gets the cross-assembly wording; a same-assembly member gets the plain message. + private static void ReportUnionMemberNotSerializable(SourceProductionContext context, MessageInfo message, string fieldName, UnionMemberInfo member, string serializerClassName) + { + context.ReportDiagnostic(member.ForeignAssemblyName.Length > 0 + ? Diagnostic.Create(UnionMemberNotSerializableCrossAssembly, Location.None, ToDisplayName(member.TypeFullName), fieldName, ToDisplayName(message.FullyQualifiedName), member.ForeignAssemblyName, serializerClassName) + : Diagnostic.Create(UnionMemberNotSerializable, Location.None, ToDisplayName(member.TypeFullName), fieldName, ToDisplayName(message.FullyQualifiedName))); + } + private static bool ValidateUnionField( SourceProductionContext context, MessageInfo message, @@ -1837,9 +1823,7 @@ private static bool ValidateUnionField( { if (!member.IsSupported || !messagesByType.TryGetValue(member.TypeFullName, out var memberMessage)) { - context.ReportDiagnostic(member.ForeignAssemblyName.Length > 0 - ? Diagnostic.Create(UnionMemberNotSerializableCrossAssembly, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName), member.ForeignAssemblyName, serializerClassName) - : Diagnostic.Create(UnionMemberNotSerializable, Location.None, ToDisplayName(member.TypeFullName), field.Name, ToDisplayName(message.FullyQualifiedName))); + ReportUnionMemberNotSerializable(context, message, field.Name, member, serializerClassName); isValid = false; continue; } @@ -3479,7 +3463,12 @@ private static TypeMapping MapTypeCore(ITypeSymbol type, KnownTypes knownTypes) // construction resolves to its registered [AkkaSerializable] message -- or, if // unregistered, fails AKKASG023 instead of silently dropping its type arguments. if (type is INamedTypeSymbol namedType && namedType.OriginalDefinition.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, knownTypes.SerializableAttribute))) - return new TypeMapping(FieldKind.Object, GetMessageDictionaryKey(namedType), namedType.IsValueType, foreignAssemblyName: GetForeignAssemblyName(namedType, knownTypes)); + return new TypeMapping( + FieldKind.Object, + GetMessageDictionaryKey(namedType), + namedType.IsValueType, + foreignAssemblyName: GetForeignAssemblyName(namedType, knownTypes), + isGenericConstruction: namedType.IsGenericType); var mapping = type.SpecialType switch { @@ -4590,7 +4579,8 @@ public TypeMapping( ImmutableArray typeArguments = default, string enumUnderlyingTypeName = "", string foreignAssemblyName = "", - bool suggestsEnvelopeOrUnion = false) + bool suggestsEnvelopeOrUnion = false, + bool isGenericConstruction = false) { Kind = kind; TypeFullName = typeFullName; @@ -4601,6 +4591,7 @@ public TypeMapping( EnumUnderlyingTypeName = enumUnderlyingTypeName; ForeignAssemblyName = foreignAssemblyName; SuggestsEnvelopeOrUnion = suggestsEnvelopeOrUnion; + IsGenericConstruction = isGenericConstruction; } public FieldKind Kind { get; } @@ -4648,27 +4639,29 @@ public TypeMapping( /// public string EnumUnderlyingTypeName { get; } - /// - /// For and : - /// the name of the type's declaring assembly, but ONLY when that assembly is not the one this - /// generator is producing output for -- empty for a same-assembly type. Drives the AKKASG007 - /// cross-assembly hint variant; unused for every other kind. - /// + // For Object/MissingSerializableDefinition: the type's declaring assembly name, but only + // when it is not the one this generator is producing output for. Empty otherwise. Drives + // the AKKASG007 cross-assembly hint. public string ForeignAssemblyName { get; } + // For Unsupported: whether the field's static type is an interface, abstract class, or type + // parameter, the shapes a forgotten [AkkaEnvelopePayload]/[AkkaUnion] usually produces. + // Drives the AKKASG003 hint. + public bool SuggestsEnvelopeOrUnion { get; } + /// - /// For : whether the field's static type is an interface, an - /// abstract class, or a type parameter -- the shapes a forgotten [AkkaEnvelopePayload] or - /// [AkkaUnion] declaration usually produces. Drives the AKKASG003 hint variant; unused - /// for every other kind. + /// For : whether the type is a closed construction of a generic + /// [AkkaSerializable] definition (for example Wrapper<int>), from + /// INamedTypeSymbol.IsGenericType. Picks AKKASG023 versus AKKASG007 when the type is + /// missing from this compilation's message table. False for every other kind. /// - public bool SuggestsEnvelopeOrUnion { get; } + public bool IsGenericConstruction { get; } public TypeMapping WithTypeFullName(string typeFullName) - => new(Kind, typeFullName, IsValueType, DeclaredTypeName, IsNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion); + => new(Kind, typeFullName, IsValueType, DeclaredTypeName, IsNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion, IsGenericConstruction); public TypeMapping AsCollectionElement(string declaredTypeName, bool isNullable) - => new(Kind, TypeFullName, IsValueType, declaredTypeName, isNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion); + => new(Kind, TypeFullName, IsValueType, declaredTypeName, isNullable, TypeArguments, EnumUnderlyingTypeName, ForeignAssemblyName, SuggestsEnvelopeOrUnion, IsGenericConstruction); // Explicit IEquatable implementation: the compiler-provided struct equality would compare // the TypeArguments ImmutableArray by underlying-array REFERENCE, breaking value equality @@ -4683,6 +4676,7 @@ public bool Equals(TypeMapping other) && string.Equals(EnumUnderlyingTypeName, other.EnumUnderlyingTypeName, StringComparison.Ordinal) && string.Equals(ForeignAssemblyName, other.ForeignAssemblyName, StringComparison.Ordinal) && SuggestsEnvelopeOrUnion == other.SuggestsEnvelopeOrUnion + && IsGenericConstruction == other.IsGenericConstruction && ValueEquality.SequenceEquals(TypeArguments, other.TypeArguments); } @@ -4699,6 +4693,7 @@ public override int GetHashCode() hash = ValueEquality.Combine(hash, EnumUnderlyingTypeName); hash = ValueEquality.Combine(hash, ForeignAssemblyName); hash = ValueEquality.Combine(hash, SuggestsEnvelopeOrUnion); + hash = ValueEquality.Combine(hash, IsGenericConstruction); hash = ValueEquality.Combine(hash, TypeArguments); return hash; } @@ -4847,11 +4842,8 @@ public UnionMemberInfo(string typeFullName, bool isValueType, bool isAssignable, /// public bool IsAbstract { get; } - /// - /// The member type's declaring assembly name, but ONLY when it is not the compilation this - /// generator is producing output for -- empty for a same-assembly member. Drives the - /// AKKASG015 cross-assembly hint variant. - /// + // The member type's declaring assembly name, but only when it is not the compilation this + // generator is producing output for. Empty otherwise. Drives the AKKASG015 cross-assembly hint. public string ForeignAssemblyName { get; } public bool Equals(UnionMemberInfo? other) diff --git a/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs b/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs index 5adbe50f0ac..6fcc79438c8 100644 --- a/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs +++ b/src/core/Akka.Serialization.V2.Tests/CrossAssemblyBaselineSpec.cs @@ -22,17 +22,15 @@ namespace Akka.Serialization.V2.Tests; /// -/// CHARACTERIZATION tests (issue #8384, PR 1): pin what -/// ACTUALLY does today when a serializable type lives in a REFERENCED assembly rather than the -/// compilation that hosts the [AkkaSerializer] module. These assert observed behavior, not -/// intended behavior -- see the design.md Decision 13 note this spec verifies (the "scoped to the -/// current compilation" claim). +/// These tests pin what does today when a serializable type +/// lives in a referenced assembly, not in the compilation that hosts the serializer. They check +/// real behavior, not ideal behavior. See design.md Decision 13. /// /// -/// The harness compiles a small "assembly A" from source into an in-memory -/// (no generator run against it -- A never hosts a serializer), then compiles "assembly B" -/// referencing A plus the same base references -/// uses, and runs the generator against B exactly as that spec does. +/// The harness builds a small "assembly A" from source into an in-memory +/// . Assembly A never runs the generator. It only supplies types. +/// The harness then builds "assembly B", which references A and runs the generator, using the +/// same base references as . /// public sealed class CrossAssemblyBaselineSpec { @@ -71,16 +69,27 @@ public sealed partial class ShopSerializer : AkkaSerializer var (generatorDiagnostics, compileDiagnostics, _) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case1.B"); var all = generatorDiagnostics.AddRange(compileDiagnostics); - // CONTRACT: [AkkaSerializable] detection reads attributes off the type SYMBOL, which is - // visible cross-assembly, so Money maps to FieldKind.Object; but the "known message" - // registry (messagesByType) is built from the syntax provider, which only sees declarations - // local to B, so the Object mapping still resolves to no known message. That case is now - // told apart from a genuine unregistered closed generic by the message-dictionary key - // shape (Money's key carries no '<'), so it reports AKKASG007 -- naming the offending type - // and its declaring assembly, and pointing at the two fixes that actually work today -- - // never AKKASG023. Money already carries [AkkaSerializable] and [AkkaField] in assembly A, - // so the message must NOT claim the type lacks them (annotating it there fixes nothing - // until this generator can read schemas across assemblies). + // Why this case reports AKKASG007 and not AKKASG023. + // + // Assembly A (referenced) Assembly B (this compilation) + // +-------------------------+ +-------------------------------+ + // | [AkkaSerializable] | | [AkkaSerializable] Pay | + // | record Money(...) | <------ | [AkkaField(1)] Money Amount | + // +-------------------------+ property+-------------------------------+ + // ^ type symbol ^ syntax tree + // | | + // Attribute check reads the SYMBOL. messagesByType is built from SYNTAX. + // It sees [AkkaSerializable] on Money. It sees only types declared in B. + // Money maps to FieldKind.Object. Money is not in the table. + // + // Result: an Object mapping with no known message. An unregistered closed generic + // produces the same state. That is why the generator once reported AKKASG023 here. + // The mapping now records whether the type is a generic construction. Money is not. + // So the generator reports AKKASG007. The message names Money and assembly A. It + // offers the two fixes that work today: register a formatter on ShopSerializer, or + // declare the type in B. Money already carries the attributes in A, so the message + // must not tell the user to add them. Adding them there fixes nothing until the + // generator can read a schema from a referenced assembly. all.Should().NotContain(d => d.Id == "AKKASG023"); all.Should().Contain(d => d.Id == "AKKASG007" && @@ -134,14 +143,12 @@ public sealed partial class OrdersSerializer : AkkaSerializer var (generatorDiagnostics, compileDiagnostics, _) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case2.B"); var all = generatorDiagnostics.AddRange(compileDiagnostics); - // CONTRACT: the type-level [AkkaUnion(...)] on IOrderEvent IS found (symbol-based attribute - // discovery works cross-assembly), so the field maps to FieldKind.Union and AKKASG003 never - // fires. But union member validation resolves each member against the same syntax-provider - // `messagesByType`, which knows nothing about Placed/Cancelled -- so both members fail as - // "not serializable" (AKKASG015), each now carrying the cross-assembly hint naming the - // member type, its declaring assembly, and the two fixes that actually work today. Placed - // and Cancelled already carry [AkkaSerializable] and a manifest in assembly A, so the - // message must NOT claim otherwise. + // Why this case reports AKKASG015 for each member. + // The generator reads [AkkaUnion] off the IOrderEvent symbol. This works across assemblies. + // So the field maps to FieldKind.Union, not Unsupported. AKKASG003 never fires. + // Each member is checked against messagesByType, which only holds types declared here. + // Placed and Cancelled live in A, so both fail as AKKASG015, even though both already + // carry [AkkaSerializable] and a manifest there. all.Should().NotContain(d => d.Id == "AKKASG003"); all.Should().Contain(d => d.Id == "AKKASG015" && @@ -199,17 +206,16 @@ public sealed partial class WrapSerializer : AkkaSerializer RunGeneratorAgainstB(sourceBRegistered, assemblyA, "CrossAssemblyBaseline.Case3.Registered"); var registeredAll = registeredGeneratorDiagnostics.AddRange(registeredCompileDiagnostics); - // OBSERVED: closed-generic registration extraction reads the generic DEFINITION's - // [AkkaSerializable]/[AkkaField] data straight off the type symbol (not syntax), and the - // registered construction Wrapper is injected into messagesByType directly by that - // extraction step rather than the syntax provider -- sidestepping the gap cases 1/2 hit. - // No errors; the helper methods are emitted. + // Why registering the closed construction works. + // [AkkaSerializable>] reads the Wrapper definition off the type symbol, + // not off syntax. The registration step adds Wrapper to messagesByType itself. + // This avoids the gap that cases 1 and 2 hit. No errors. The generator emits the write + // and read helpers. registeredAll.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); generatedSource.Should().Contain("WriteWrapperInt"); generatedSource.Should().Contain("ReadWrapperInt"); - // Second variant: same referenced-assembly definition and field usage, but with NO - // [AkkaSerializable>] registration on the serializer. + // Same setup as above, but with no [AkkaSerializable>] registration. const string sourceBUnregistered = """ #nullable enable using Akka.Actor; @@ -232,6 +238,8 @@ public sealed partial class WrapSerializer : AkkaSerializer RunGeneratorAgainstB(sourceBUnregistered, assemblyA, "CrossAssemblyBaseline.Case3.Unregistered"); var unregisteredAll = unregisteredGeneratorDiagnostics.AddRange(unregisteredCompileDiagnostics); + // Without the registration, Wrapper is missing from messagesByType, and it is a + // generic construction. So this reports AKKASG023, not AKKASG007. unregisteredAll.Should().Contain(d => d.Id == "AKKASG023" && d.Severity == DiagnosticSeverity.Error && @@ -276,18 +284,19 @@ public sealed partial class CommsSerializer : AkkaSerializer var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case4.B"); var all = generatorDiagnostics.AddRange(compileDiagnostics); - // Envelope implements nothing and is not a field of any message reachable - // from a top-level message -- the registration has no effect either way, regardless of - // where the generic definition lives. + // Why this case reports AKKASG034. + // Envelope does not implement IComms. It is not a field of any reachable + // message. The registration has no effect, no matter where the generic definition lives. all.Should().Contain(d => d.Id == "AKKASG034" && d.Severity == DiagnosticSeverity.Error && d.GetMessage(null).Contains("Envelope", StringComparison.Ordinal) && d.GetMessage(null).Contains("IComms", StringComparison.Ordinal)); - // OBSERVED (not what the task brief assumed): AKKASG034 fails ValidateClosedGenericProtocolCoverage, - // which makes the pipeline `continue` past AddSource for THIS SERIALIZER ENTIRELY -- there is - // no partial CommsSerializer file at all, so there's no "binds only IComms" list to inspect. + // Why generatedSource is empty. + // AKKASG034 fails the coverage check for the whole serializer. The generator then skips + // AddSource for CommsSerializer. No partial file is written, so there is no registration + // list to inspect here. generatedSource.Should().BeEmpty("AKKASG034 fails the whole serializer's coverage check, so the pipeline skips AddSource for CommsSerializer entirely"); } @@ -332,11 +341,11 @@ public sealed partial class CommsSerializer : AkkaSerializer var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case5.B"); var all = generatorDiagnostics.AddRange(compileDiagnostics); - // OBSERVED: Roslyn's substituted-member symbol (Envelope.Message) returns the - // ORIGINAL DEFINITION's attributes, including [AkkaEnvelopePayload], regardless of which - // assembly declared the generic definition -- so the generator treats the field as an - // envelope payload boundary instead of failing AKKASG003 (T substituted to an interface - // would otherwise be unsupported). + // Why this field is an envelope payload, not AKKASG003. + // Envelope.Message is a substituted member. Roslyn returns the original + // definition's attributes for it, including [AkkaEnvelopePayload]. This holds no matter + // which assembly declared the generic definition. So the generator treats the field as + // an envelope payload. Without this, T substituted to an interface would be unsupported. all.Should().NotContain(d => d.Id == "AKKASG003"); all.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); generatedSource.Should().Contain("WriteEnvelopePayload"); @@ -374,9 +383,10 @@ public sealed partial class OrdersSerializer : AkkaSerializer var (generatorDiagnostics, compileDiagnostics, generatedSource) = RunGeneratorAgainstB(sourceB, assemblyA, "CrossAssemblyBaseline.Case6.B"); var all = generatorDiagnostics.AddRange(compileDiagnostics); - // AKKASG029's protocol-coverage walk only enumerates named types DECLARED IN THIS - // COMPILATION, so OrderPlaced (declared only in A) is never visited or flagged, even - // though it implements IOrders. The generated switch has no knowledge OrderPlaced exists. + // Why AKKASG029 does not fire here. + // The protocol-coverage check only looks at types declared in this compilation. + // OrderPlaced is declared only in A, so the check never sees it, even though OrderPlaced + // implements IOrders. The generated Manifest switch has no case for OrderPlaced. all.Should().NotContain(d => d.Id == "AKKASG029"); generatedSource.Should().NotContain("OrderPlaced"); all.Where(d => d.Severity == DiagnosticSeverity.Error).Should().BeEmpty(); @@ -427,8 +437,8 @@ private static (ImmutableArray GeneratorDiagnostics, ImmutableArray< return (generatorDiagnostics, updatedCompilation.GetDiagnostics(), generatedSource); } - // Reused verbatim from AkkaSerializerGeneratorDiagnosticsSpec: the same base reference set, so - // the [AkkaSerializer]/[AkkaSerializable] attributes and runtime types resolve identically. + // Same base reference set as AkkaSerializerGeneratorDiagnosticsSpec. The [AkkaSerializer] and + // [AkkaSerializable] attributes and runtime types resolve the same way here. private static IEnumerable CreateMetadataReferences() { var trustedAssemblies = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))?