diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationAnalyzer.cs new file mode 100644 index 000000000..935602d93 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationAnalyzer.cs @@ -0,0 +1,549 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// Analyzes Dapr Actor classes and their interfaces for correct serialization attribute usage. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ActorSerializationAnalyzer : DiagnosticAnalyzer +{ + private static readonly LocalizableString ActorInterfaceMissingIActorTitle = new LocalizableResourceString(nameof(Resources.ActorInterfaceMissingIActorTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorInterfaceMissingIActorMessageFormat = new LocalizableResourceString(nameof(Resources.ActorInterfaceMissingIActorMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorInterfaceMissingIActorDescription = new LocalizableResourceString(nameof(Resources.ActorInterfaceMissingIActorDescription), Resources.ResourceManager, typeof(Resources)); + + /// Actor interface should inherit from IActor. + public static readonly DiagnosticDescriptor ActorInterfaceMissingIActor = new( + "DAPR1405", + ActorInterfaceMissingIActorTitle, + ActorInterfaceMissingIActorMessageFormat, + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: ActorInterfaceMissingIActorDescription); + + private static readonly LocalizableString EnumMissingEnumMemberAttributeTitle = new LocalizableResourceString(nameof(Resources.EnumMissingEnumMemberAttributeTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString EnumMissingEnumMemberAttributeMessageFormat = new LocalizableResourceString(nameof(Resources.EnumMissingEnumMemberAttributeMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString EnumMissingEnumMemberAttributeDescription = new LocalizableResourceString(nameof(Resources.EnumMissingEnumMemberAttributeDescription), Resources.ResourceManager, typeof(Resources)); + + /// Enum members in Actor types should use EnumMember attribute. + public static readonly DiagnosticDescriptor EnumMissingEnumMemberAttribute = new( + "DAPR1406", + EnumMissingEnumMemberAttributeTitle, + EnumMissingEnumMemberAttributeMessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: EnumMissingEnumMemberAttributeDescription); + + private static readonly LocalizableString WeaklyTypedActorJsonPropertyRecommendationTitle = new LocalizableResourceString(nameof(Resources.WeaklyTypedActorJsonPropertyRecommendationTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString WeaklyTypedActorJsonPropertyRecommendationMessageFormat = new LocalizableResourceString(nameof(Resources.WeaklyTypedActorJsonPropertyRecommendationMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString WeaklyTypedActorJsonPropertyRecommendationDescription = new LocalizableResourceString(nameof(Resources.WeaklyTypedActorJsonPropertyRecommendationDescription), Resources.ResourceManager, typeof(Resources)); + + /// Consider using JsonPropertyName for property name consistency. + public static readonly DiagnosticDescriptor WeaklyTypedActorJsonPropertyRecommendation = new( + "DAPR1407", + WeaklyTypedActorJsonPropertyRecommendationTitle, + WeaklyTypedActorJsonPropertyRecommendationMessageFormat, + "Usage", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: WeaklyTypedActorJsonPropertyRecommendationDescription); + + private static readonly LocalizableString ComplexTypeInActorNeedsAttributesTitle = new LocalizableResourceString(nameof(Resources.ComplexTypeInActorNeedsAttributesTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ComplexTypeInActorNeedsAttributesMessageFormat = new LocalizableResourceString(nameof(Resources.ComplexTypeInActorNeedsAttributesMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ComplexTypeInActorNeedsAttributesDescription = new LocalizableResourceString(nameof(Resources.ComplexTypeInActorNeedsAttributesDescription), Resources.ResourceManager, typeof(Resources)); + + /// Complex types used in Actor methods need serialization attributes. + public static readonly DiagnosticDescriptor ComplexTypeInActorNeedsAttributes = new( + "DAPR1408", + ComplexTypeInActorNeedsAttributesTitle, + ComplexTypeInActorNeedsAttributesMessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ComplexTypeInActorNeedsAttributesDescription); + + private static readonly LocalizableString ActorMethodParameterNeedsValidationTitle = new LocalizableResourceString(nameof(Resources.ActorMethodParameterNeedsValidationTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorMethodParameterNeedsValidationMessageFormat = new LocalizableResourceString(nameof(Resources.ActorMethodParameterNeedsValidationMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorMethodParameterNeedsValidationDescription = new LocalizableResourceString(nameof(Resources.ActorMethodParameterNeedsValidationDescription), Resources.ResourceManager, typeof(Resources)); + + /// Actor method parameter needs proper serialization attributes. + public static readonly DiagnosticDescriptor ActorMethodParameterNeedsValidation = new( + "DAPR1409", + ActorMethodParameterNeedsValidationTitle, + ActorMethodParameterNeedsValidationMessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ActorMethodParameterNeedsValidationDescription); + + private static readonly LocalizableString ActorMethodReturnTypeNeedsValidationTitle = new LocalizableResourceString(nameof(Resources.ActorMethodReturnTypeNeedsValidationTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorMethodReturnTypeNeedsValidationMessageFormat = new LocalizableResourceString(nameof(Resources.ActorMethodReturnTypeNeedsValidationMessageFormat), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableString ActorMethodReturnTypeNeedsValidationDescription = new LocalizableResourceString(nameof(Resources.ActorMethodReturnTypeNeedsValidationDescription), Resources.ResourceManager, typeof(Resources)); + + /// Actor method return type needs proper serialization attributes. + public static readonly DiagnosticDescriptor ActorMethodReturnTypeNeedsValidation = new( + "DAPR1410", + ActorMethodReturnTypeNeedsValidationTitle, + ActorMethodReturnTypeNeedsValidationMessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: ActorMethodReturnTypeNeedsValidationDescription); + + /// Collection types in Actor methods need element type validation. + internal static readonly DiagnosticDescriptor CollectionTypeInActorNeedsElementValidation = new( + "DAPR1411", + "Collection types in Actor methods need element type validation", + "Collection type '{0}' in Actor method contains elements of type '{1}' which needs proper serialization attributes", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Collection types used in Actor methods should contain elements with proper serialization attributes."); + + /// Record types should use DataContract and DataMember attributes for Actor serialization. + internal static readonly DiagnosticDescriptor RecordTypeNeedsDataContractAttributes = new( + "DAPR1412", + "Record types should use DataContract and DataMember attributes for Actor serialization", + "Record '{0}' should be decorated with [DataContract] and have [DataMember] attributes on properties for proper Actor serialization", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Record types used in Actor methods should have [DataContract] attribute and [DataMember] attributes on all properties for reliable serialization."); + + /// Actor class implementation should implement an interface that inherits from IActor. + internal static readonly DiagnosticDescriptor ActorClassMissingInterface = new( + "DAPR1413", + "Actor class implementation should implement an interface that inherits from IActor", + "Actor class '{0}' should implement an interface that inherits from IActor", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Actor class implementations should implement an interface that inherits from IActor for proper Actor pattern implementation."); + + /// All types must either expose a public parameterless constructor or be decorated with the DataContractAttribute attribute. + internal static readonly DiagnosticDescriptor TypeMissingParameterlessConstructorOrDataContract = new( + "DAPR1414", + "All types must either expose a public parameterless constructor or be decorated with the DataContractAttribute attribute", + "Type '{0}' must either have a public parameterless constructor or be decorated with [DataContract] attribute for proper serialization", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "All types used in Actor methods must either expose a public parameterless constructor or be decorated with the DataContractAttribute attribute for reliable serialization."); + + /// + public override ImmutableArray SupportedDiagnostics => + [ + ActorInterfaceMissingIActor, + EnumMissingEnumMemberAttribute, + WeaklyTypedActorJsonPropertyRecommendation, + ComplexTypeInActorNeedsAttributes, + ActorMethodParameterNeedsValidation, + ActorMethodReturnTypeNeedsValidation, + CollectionTypeInActorNeedsElementValidation, + RecordTypeNeedsDataContractAttributes, + ActorClassMissingInterface, + TypeMissingParameterlessConstructorOrDataContract +, + ]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, SyntaxKind.ClassDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeInterfaceDeclaration, SyntaxKind.InterfaceDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeEnumDeclaration, SyntaxKind.EnumDeclaration); + } + + private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration); + + if (classSymbol == null) + { + return; + } + + if (!InheritsFromActor(classSymbol)) + { + return; + } + + CheckActorInterfaces(context, classDeclaration, classSymbol); + CheckActorClassImplementsIActorInterface(context, classDeclaration, classSymbol); + CheckActorMethodTypes(context, classSymbol); + } + + private static void AnalyzeInterfaceDeclaration(SyntaxNodeAnalysisContext context) + { + var interfaceDeclaration = (InterfaceDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + var interfaceSymbol = semanticModel.GetDeclaredSymbol(interfaceDeclaration); + + if (interfaceSymbol == null) + { + return; + } + + if (interfaceDeclaration.Identifier.ValueText.StartsWith("I") && interfaceDeclaration.Identifier.ValueText.EndsWith("Actor") && !InheritsFromIActor(interfaceSymbol)) + { + var diagnostic = Diagnostic.Create( + ActorInterfaceMissingIActor, + interfaceDeclaration.Identifier.GetLocation(), + interfaceSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static void AnalyzeEnumDeclaration(SyntaxNodeAnalysisContext context) + { + var enumDeclaration = (EnumDeclarationSyntax)context.Node; + var semanticModel = context.SemanticModel; + var enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclaration); + + if (enumSymbol == null) + { + return; + } + + foreach (var member in enumSymbol.GetMembers().OfType()) + { + if (HasAttribute(member, "EnumMemberAttribute", "EnumMember")) + { + continue; + } + + var memberDeclaration = enumDeclaration.Members + .FirstOrDefault(m => m.Identifier.ValueText == member.Name); + + if (memberDeclaration != null) + { + var diagnostic = Diagnostic.Create( + EnumMissingEnumMemberAttribute, + memberDeclaration.Identifier.GetLocation(), + member.Name, + enumSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static bool InheritsFromActor(INamedTypeSymbol classSymbol) + { + var baseType = classSymbol.BaseType; + while (baseType != null) + { + if (baseType.Name == "Actor" && baseType.ContainingNamespace?.ToDisplayString() == "Dapr.Actors.Runtime") + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + private static bool InheritsFromIActor(INamedTypeSymbol interfaceSymbol) + { + return interfaceSymbol.AllInterfaces.Any(i => + i.Name == "IActor" && i.ContainingNamespace?.ToDisplayString() == "Dapr.Actors"); + } + + private static bool HasAttribute(ISymbol symbol, params string[] attributeNames) + { + return symbol.GetAttributes().Any(attr => + attributeNames.Contains(attr.AttributeClass?.Name) || + attributeNames.Contains(attr.AttributeClass?.MetadataName)); + } + + private static void CheckActorInterfaces(SyntaxNodeAnalysisContext context, ClassDeclarationSyntax classDeclaration, INamedTypeSymbol classSymbol) + { + foreach (var interfaceType in classSymbol.Interfaces) + { + if (interfaceType.Name.StartsWith("I") && interfaceType.Name.EndsWith("Actor") && !InheritsFromIActor(interfaceType)) + { + var diagnostic = Diagnostic.Create( + ActorInterfaceMissingIActor, + classDeclaration.Identifier.GetLocation(), + interfaceType.Name); + context.ReportDiagnostic(diagnostic); + } + } + } + + private static void CheckActorClassImplementsIActorInterface(SyntaxNodeAnalysisContext context, ClassDeclarationSyntax classDeclaration, INamedTypeSymbol classSymbol) + { + var implementsIActorInterface = classSymbol.Interfaces.Any(interfaceType => InheritsFromIActor(interfaceType)); + + if (!implementsIActorInterface) + { + var diagnostic = Diagnostic.Create( + ActorClassMissingInterface, + classDeclaration.Identifier.GetLocation(), + classSymbol.Name); + context.ReportDiagnostic(diagnostic); + } + } + + private static void CheckActorMethodTypes(SyntaxNodeAnalysisContext context, INamedTypeSymbol classSymbol) + { + var iActorInterfaces = classSymbol.AllInterfaces.Where(InheritsFromIActor).ToList(); + + foreach (var interfaceMethod in iActorInterfaces.SelectMany(i => i.GetMembers().OfType())) + { + var implementation = classSymbol.FindImplementationForInterfaceMember(interfaceMethod) as IMethodSymbol; + if (implementation == null) + { + continue; + } + + if (!implementation.ReturnsVoid) + { + CheckMethodReturnType(context, implementation); + } + + foreach (var parameter in implementation.Parameters) + { + CheckMethodParameter(context, implementation, parameter); + } + } + } + + private static void CheckMethodReturnType(SyntaxNodeAnalysisContext context, IMethodSymbol method) + { + var returnType = method.ReturnType; + var location = method.Locations.FirstOrDefault(); + + if (location == null) + { + return; + } + + if (returnType is INamedTypeSymbol namedReturnType && + namedReturnType.IsGenericType && + namedReturnType.Name == "Task" && + namedReturnType.TypeArguments.Length == 1) + { + returnType = namedReturnType.TypeArguments[0]; + } + + ValidateTypeForSerialization(context, returnType, location, method.Name, isParameter: false); + } + + private static void CheckMethodParameter(SyntaxNodeAnalysisContext context, IMethodSymbol method, IParameterSymbol parameter) + { + var location = parameter.Locations.FirstOrDefault(); + if (location == null) + { + return; + } + + ValidateTypeForSerialization(context, parameter.Type, location, method.Name, isParameter: true, parameter.Name); + } + + private static void ValidateTypeForSerialization(SyntaxNodeAnalysisContext context, ITypeSymbol type, Location location, string methodName, bool isParameter, string? parameterName = null) + { + if (IsPrimitiveOrKnownType(type)) + { + return; + } + + if (IsCollectionType(type)) + { + CheckCollectionElementType(context, type, location, methodName, isParameter, parameterName); + return; + } + + if (type is not INamedTypeSymbol namedType || + (namedType.TypeKind != TypeKind.Class && namedType.TypeKind != TypeKind.Struct)) + { + return; + } + + if (namedType.IsRecord) + { + CheckRecordSymbolForDataContractAttributes(context, namedType, location); + return; + } + + if (!HasParameterlessConstructorOrDataContract(namedType)) + { + context.ReportDiagnostic(Diagnostic.Create( + TypeMissingParameterlessConstructorOrDataContract, + location, + namedType.Name)); + } + + if (!HasProperSerializationAttributes(namedType)) + { + if (isParameter) + { + context.ReportDiagnostic(Diagnostic.Create( + ActorMethodParameterNeedsValidation, + location, + parameterName, + type.Name, + methodName)); + } + else + { + context.ReportDiagnostic(Diagnostic.Create( + ActorMethodReturnTypeNeedsValidation, + location, + type.Name, + methodName)); + } + } + } + + private static bool IsCollectionType(ITypeSymbol type) + { + if (type is not INamedTypeSymbol namedType) + { + return false; + } + + var collectionTypeNames = new[] + { + "IEnumerable", "ICollection", "IList", "IDictionary", + "List", "Array", "Dictionary", "HashSet", "Queue", "Stack" + }; + + return collectionTypeNames.Any(name => + namedType.Name == name || + namedType.AllInterfaces.Any(i => i.Name == name)) || + type.TypeKind == TypeKind.Array; + } + + private static void CheckCollectionElementType(SyntaxNodeAnalysisContext context, ITypeSymbol collectionType, Location location, string methodName, bool isParameter, string? parameterName) + { + IEnumerable elementTypes = Enumerable.Empty(); + + if (collectionType.TypeKind == TypeKind.Array && collectionType is IArrayTypeSymbol arrayType) + { + elementTypes = new[] { arrayType.ElementType }; + } + else if (collectionType is INamedTypeSymbol namedType && namedType.IsGenericType) + { + // For generic collections (including Dictionary and IDictionary), + // validate all type arguments, not just the first one. + elementTypes = namedType.TypeArguments; + } + + foreach (var elementType in elementTypes) + { + if (elementType == null || IsPrimitiveOrKnownType(elementType)) + { + continue; + } + + if (elementType is INamedTypeSymbol namedElementType && + (namedElementType.TypeKind == TypeKind.Class || namedElementType.TypeKind == TypeKind.Struct) && + !HasProperSerializationAttributes(namedElementType)) + { + context.ReportDiagnostic(Diagnostic.Create( + CollectionTypeInActorNeedsElementValidation, + location, + collectionType.Name, + elementType.Name)); + } + } + } + + private static bool HasProperSerializationAttributes(INamedTypeSymbol type) + { + return HasAttribute(type, "DataContractAttribute", "DataContract") || + HasAttribute(type, "SerializableAttribute", "Serializable") || + HasAttribute(type, "JsonObjectAttribute", "JsonObject") || + IsPrimitiveOrKnownType(type); + } + + private static bool IsPrimitiveOrKnownType(ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Enum) + { + return true; + } + + var typeName = type.ToDisplayString(); + var knownTypes = new[] + { + "byte", "sbyte", "short", "int", "long", "ushort", "uint", "ulong", + "float", "double", "bool", "char", "decimal", "object", "string", + "System.DateTime", "System.TimeSpan", "System.Guid", "System.Uri", + "System.Xml.XmlQualifiedName", "System.Threading.Tasks.Task", "void" + }; + + return knownTypes.Contains(typeName) || + typeName.StartsWith("System.Threading.Tasks.Task<") || + typeName == "System.Void"; + } + + private static void CheckRecordSymbolForDataContractAttributes(SyntaxNodeAnalysisContext context, INamedTypeSymbol recordType, Location usageLocation) + { + if (!HasAttribute(recordType, "DataContractAttribute", "DataContract")) + { + context.ReportDiagnostic(Diagnostic.Create( + RecordTypeNeedsDataContractAttributes, + usageLocation, + recordType.Name)); + return; + } + + foreach (var property in recordType.GetMembers().OfType() + .Where(p => p.DeclaredAccessibility == Accessibility.Public && !HasDataMemberAttribute(p))) + { + var propLocation = property.Locations.FirstOrDefault() ?? usageLocation; + context.ReportDiagnostic(Diagnostic.Create( + RecordTypeNeedsDataContractAttributes, + propLocation, + recordType.Name)); + } + } + + private static bool HasDataMemberAttribute(IPropertySymbol property) => + HasAttribute(property, "DataMemberAttribute", "DataMember"); + + private static bool HasParameterlessConstructorOrDataContract(INamedTypeSymbol type) + { + if (HasAttribute(type, "DataContractAttribute", "DataContract")) + { + return true; + } + + var constructors = type.Constructors; + + if (!constructors.Any() && type.TypeKind == TypeKind.Class) + { + return true; + } + + return constructors.Any(c => + c.DeclaredAccessibility == Accessibility.Public && + c.Parameters.Length == 0); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationCodeFixProvider.cs new file mode 100644 index 000000000..622d3f8c7 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorSerializationCodeFixProvider.cs @@ -0,0 +1,338 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides code fixes for Actor serialization diagnostics. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ActorSerializationCodeFixProvider)), Shared] +public sealed class ActorSerializationCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create( + ActorSerializationAnalyzer.ActorInterfaceMissingIActor.Id, + ActorSerializationAnalyzer.EnumMissingEnumMemberAttribute.Id, + ActorSerializationAnalyzer.WeaklyTypedActorJsonPropertyRecommendation.Id, + ActorSerializationAnalyzer.ComplexTypeInActorNeedsAttributes.Id, + "DAPR1409", + "DAPR1410", + ActorSerializationAnalyzer.RecordTypeNeedsDataContractAttributes.Id + ); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root == null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var diagnosticSpan = diagnostic.Location.SourceSpan; + var node = root.FindNode(diagnosticSpan); + + switch (diagnostic.Id) + { + case "DAPR1405": + RegisterAddIActorInterfaceFix(context, root, node, diagnostic); + break; + + case "DAPR1406": + RegisterAddEnumMemberFix(context, root, node, diagnostic); + break; + + case "DAPR1407": + RegisterAddJsonPropertyNameFix(context, root, node, diagnostic); + break; + + case "DAPR1408": + case "DAPR1409": + case "DAPR1410": + RegisterAddDataContractFix(context, root, node, diagnostic); + break; + + case "DAPR1412": + RegisterAddRecordDataContractFix(context, root, node, diagnostic); + break; + } + } + } + + private static void RegisterAddIActorInterfaceFix(CodeFixContext context, SyntaxNode root, SyntaxNode node, Diagnostic diagnostic) + { + if (node is not InterfaceDeclarationSyntax interfaceDeclaration) + { + return; + } + + var action = CodeAction.Create( + title: "Add IActor inheritance", + createChangedDocument: c => AddIActorInheritance(context.Document, root, interfaceDeclaration, c), + equivalenceKey: "AddIActor"); + + context.RegisterCodeFix(action, diagnostic); + } + + private static void RegisterAddEnumMemberFix(CodeFixContext context, SyntaxNode root, SyntaxNode node, Diagnostic diagnostic) + { + if (node is not EnumMemberDeclarationSyntax enumMemberDeclaration) + { + return; + } + + var action = CodeAction.Create( + title: "Add [EnumMember] attribute", + createChangedDocument: c => AddEnumMemberAttribute(context.Document, root, enumMemberDeclaration, c), + equivalenceKey: "AddEnumMember"); + + context.RegisterCodeFix(action, diagnostic); + } + + private static void RegisterAddJsonPropertyNameFix(CodeFixContext context, SyntaxNode root, SyntaxNode node, Diagnostic diagnostic) + { + if (node is not PropertyDeclarationSyntax propertyDeclaration) + { + return; + } + + var action = CodeAction.Create( + title: "Add [JsonPropertyName] attribute", + createChangedDocument: c => AddJsonPropertyNameAttribute(context.Document, root, propertyDeclaration, c), + equivalenceKey: "AddJsonPropertyName"); + + context.RegisterCodeFix(action, diagnostic); + } + + private static void RegisterAddDataContractFix(CodeFixContext context, SyntaxNode root, SyntaxNode node, Diagnostic diagnostic) + { + if (node is ClassDeclarationSyntax classDeclaration) + { + var action = CodeAction.Create( + title: "Add [DataContract] attribute", + createChangedDocument: c => AddDataContractAttribute(context.Document, root, classDeclaration, c), + equivalenceKey: "AddDataContract"); + + context.RegisterCodeFix(action, diagnostic); + } + else if (node is StructDeclarationSyntax structDeclaration) + { + var action = CodeAction.Create( + title: "Add [DataContract] attribute", + createChangedDocument: c => AddDataContractAttributeToStruct(context.Document, root, structDeclaration, c), + equivalenceKey: "AddDataContractStruct"); + + context.RegisterCodeFix(action, diagnostic); + } + } + + private static void RegisterAddRecordDataContractFix(CodeFixContext context, SyntaxNode root, SyntaxNode node, Diagnostic diagnostic) + { + if (node is RecordDeclarationSyntax recordDeclaration) + { + var action = CodeAction.Create( + title: "Add [DataContract] and [DataMember] attributes", + createChangedDocument: c => AddDataContractToRecord(context.Document, root, recordDeclaration, c), + equivalenceKey: "AddDataContractRecord"); + + context.RegisterCodeFix(action, diagnostic); + } + else if (node is ParameterSyntax parameter) + { + var parentRecord = parameter.Ancestors().OfType().FirstOrDefault(); + if (parentRecord != null) + { + var action = CodeAction.Create( + title: "Add [DataMember] attribute to parameter", + createChangedDocument: c => AddDataMemberToRecordParameter(context.Document, root, parameter, c), + equivalenceKey: "AddDataMemberParameter"); + + context.RegisterCodeFix(action, diagnostic); + } + } + } + + private static Task AddIActorInheritance(Document document, SyntaxNode root, InterfaceDeclarationSyntax interfaceDeclaration, CancellationToken cancellationToken) + { + var iactorType = SyntaxFactory.SimpleBaseType(SyntaxFactory.IdentifierName("IActor")); + + BaseListSyntax baseList; + if (interfaceDeclaration.BaseList == null) + { + baseList = SyntaxFactory.BaseList(SyntaxFactory.SingletonSeparatedList(iactorType)); + } + else + { + baseList = interfaceDeclaration.BaseList.AddTypes(iactorType); + } + + var newInterfaceDeclaration = interfaceDeclaration.WithBaseList(baseList); + var newRoot = root.ReplaceNode(interfaceDeclaration, newInterfaceDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "Dapr.Actors"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddEnumMemberAttribute(Document document, SyntaxNode root, EnumMemberDeclarationSyntax enumMemberDeclaration, CancellationToken cancellationToken) + { + var enumMemberAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("EnumMember")); + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(enumMemberAttribute)); + + var newEnumMemberDeclaration = enumMemberDeclaration.AddAttributeLists(attributeList); + var newRoot = root.ReplaceNode(enumMemberDeclaration, newEnumMemberDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "System.Runtime.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddJsonPropertyNameAttribute(Document document, SyntaxNode root, PropertyDeclarationSyntax propertyDeclaration, CancellationToken cancellationToken) + { + var propertyName = propertyDeclaration.Identifier.ValueText; + if (string.IsNullOrEmpty(propertyName)) + { + return Task.FromResult(document); + } + + var camelCaseName = char.ToLowerInvariant(propertyName[0]) + propertyName.Substring(1); + + var jsonPropertyNameAttribute = SyntaxFactory.Attribute( + SyntaxFactory.IdentifierName("JsonPropertyName"), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(camelCaseName)))))); + + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(jsonPropertyNameAttribute)); + + var newPropertyDeclaration = propertyDeclaration.AddAttributeLists(attributeList); + var newRoot = root.ReplaceNode(propertyDeclaration, newPropertyDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "System.Text.Json.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddDataContractAttribute(Document document, SyntaxNode root, ClassDeclarationSyntax classDeclaration, CancellationToken cancellationToken) + { + var dataContractAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("DataContract")); + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataContractAttribute)); + + var newClassDeclaration = classDeclaration.AddAttributeLists(attributeList); + var newRoot = root.ReplaceNode(classDeclaration, newClassDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "System.Runtime.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddDataContractAttributeToStruct(Document document, SyntaxNode root, StructDeclarationSyntax structDeclaration, CancellationToken cancellationToken) + { + var dataContractAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("DataContract")); + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataContractAttribute)); + + var newStructDeclaration = structDeclaration.AddAttributeLists(attributeList); + var newRoot = root.ReplaceNode(structDeclaration, newStructDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "System.Runtime.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddDataContractToRecord(Document document, SyntaxNode root, RecordDeclarationSyntax recordDeclaration, CancellationToken cancellationToken) + { + var newRoot = root; + + var dataContractAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("DataContract")); + var dataContractAttributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(dataContractAttribute)); + + var newRecordDeclaration = recordDeclaration.AddAttributeLists(dataContractAttributeList); + + if (recordDeclaration.ParameterList != null) + { + var newParameters = new List(); + + foreach (var parameter in recordDeclaration.ParameterList.Parameters) + { + if (!parameter.AttributeLists.Any(al => al.Attributes.Any(a => a.Name.ToString().Contains("DataMember")))) + { + var dataMemberAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("DataMember")); + var dataMemberAttributeList = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList(dataMemberAttribute)) + .WithTarget(SyntaxFactory.AttributeTargetSpecifier(SyntaxFactory.Token(SyntaxKind.PropertyKeyword))); + + newParameters.Add(parameter.AddAttributeLists(dataMemberAttributeList)); + } + else + { + newParameters.Add(parameter); + } + } + + var newParameterList = SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(newParameters)); + newRecordDeclaration = newRecordDeclaration.WithParameterList(newParameterList); + } + + newRoot = newRoot.ReplaceNode(recordDeclaration, newRecordDeclaration); + + newRoot = AddUsingIfMissing(newRoot, "System.Runtime.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static Task AddDataMemberToRecordParameter(Document document, SyntaxNode root, ParameterSyntax parameter, CancellationToken cancellationToken) + { + var dataMemberAttribute = SyntaxFactory.Attribute(SyntaxFactory.IdentifierName("DataMember")); + var attributeList = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList(dataMemberAttribute)) + .WithTarget(SyntaxFactory.AttributeTargetSpecifier(SyntaxFactory.Token(SyntaxKind.PropertyKeyword))); + + var newParameter = parameter.AddAttributeLists(attributeList); + var newRoot = root.ReplaceNode(parameter, newParameter); + + newRoot = AddUsingIfMissing(newRoot, "System.Runtime.Serialization"); + + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + + private static SyntaxNode AddUsingIfMissing(SyntaxNode root, string namespaceName) + { + if (root is CompilationUnitSyntax compilationUnit && !HasUsingDirective(compilationUnit, namespaceName)) + { + var usingDirective = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName)); + return compilationUnit.AddUsings(usingDirective); + } + + return root; + } + + private static bool HasUsingDirective(CompilationUnitSyntax compilationUnit, string namespaceName) => + compilationUnit.Usings.Any(u => u.Name?.ToString() == namespaceName); +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md index b96f00659..44f7cd748 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md @@ -1 +1,16 @@ -; Unshipped analyzer release \ No newline at end of file +; Unshipped analyzer release + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DAPR1405 | Usage | Error | Actor interface should inherit from IActor. +DAPR1406 | Usage | Warning | Enum members in Actor types should use EnumMember attribute. +DAPR1407 | Usage | Info | Consider using JsonPropertyName for property name consistency. +DAPR1408 | Usage | Warning | Complex types used in Actor methods need serialization attributes. +DAPR1409 | Usage | Warning | Actor method parameter needs proper serialization attributes. +DAPR1410 | Usage | Warning | Actor method return type needs proper serialization attributes. +DAPR1411 | Usage | Warning | Collection types in Actor methods need element type validation. +DAPR1412 | Usage | Warning | Record types should use DataContract and DataMember attributes for Actor serialization. +DAPR1413 | Usage | Error | Actor class implementation should implement an interface that inherits from IActor. +DAPR1414 | Usage | Error | All types must either expose a public parameterless constructor or be decorated with the DataContractAttribute attribute. \ No newline at end of file diff --git a/test/Dapr.Actors.Analyzers.Test/ActorSerializationAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Test/ActorSerializationAnalyzerTests.cs new file mode 100644 index 000000000..17c1b6a87 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/ActorSerializationAnalyzerTests.cs @@ -0,0 +1,337 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Dapr.Actors.Analyzers.Tests; + +public class ActorSerializationAnalyzerTests +{ +#if NET8_0 + private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net90; +#elif NET10_0 + private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net100; +#endif + + private static CSharpAnalyzerTest CreateTest() => + new() + { + ReferenceAssemblies = assemblies.AddPackages([new("Dapr.Actors", "1.16.1")]) + }; + + [Fact] + public async Task ActorInterface_WithoutIActor_ShouldReportDAPR1405() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public interface ITestActor + { + Task GetDataAsync(); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task GetDataAsync() => Task.FromResult("data"); + } + """; + + // From AnalyzeInterfaceDeclaration: interface identifier location + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorInterfaceMissingIActor) + .WithSpan(5, 18, 5, 28) + .WithArguments("ITestActor")); + + // From CheckActorInterfaces: class identifier location + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorInterfaceMissingIActor) + .WithSpan(10, 14, 10, 23) + .WithArguments("ITestActor")); + + // From CheckActorClassImplementsIActorInterface: class identifier location + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorClassMissingInterface) + .WithSpan(10, 14, 10, 23) + .WithArguments("TestActor")); + + await context.RunAsync(); + } + + [Fact] + public async Task ActorInterface_WithIActor_ShouldNotReportDAPR1405() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public interface ITestActor : IActor + { + Task GetDataAsync(); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task GetDataAsync() => Task.FromResult("data"); + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task EnumWithoutEnumMember_ShouldReportDAPR1406() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + + namespace Test + { + public enum TestEnum + { + Value1, + Value2 + } + } + """; + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.EnumMissingEnumMemberAttribute) + .WithSpan(8, 9, 8, 15) + .WithArguments("Value1", "TestEnum")); + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.EnumMissingEnumMemberAttribute) + .WithSpan(9, 9, 9, 15) + .WithArguments("Value2", "TestEnum")); + + await context.RunAsync(); + } + + [Fact] + public async Task EnumWithEnumMember_ShouldNotReportDAPR1406() + { + var context = CreateTest(); + context.TestCode = """ + using System.Runtime.Serialization; + + namespace Test + { + public enum Season + { + [EnumMember] + Spring, + [EnumMember] + Summer + } + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task ActorMethodWithComplexParameter_ShouldReportDAPR1409() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public class ComplexType + { + public string Name { get; set; } = string.Empty; + } + + public interface ITestActor : IActor + { + Task ProcessDataAsync(ComplexType data); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task ProcessDataAsync(ComplexType data) => Task.CompletedTask; + } + """; + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorMethodParameterNeedsValidation) + .WithSpan(18, 46, 18, 50) + .WithArguments("data", "ComplexType", "ProcessDataAsync")); + + await context.RunAsync(); + } + + [Fact] + public async Task ActorMethodWithComplexReturnType_ShouldReportDAPR1410() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public class ComplexResult + { + public string Value { get; set; } = string.Empty; + } + + public interface ITestActor : IActor + { + Task GetResultAsync(); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task GetResultAsync() => Task.FromResult(new ComplexResult()); + } + """; + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorMethodReturnTypeNeedsValidation) + .WithSpan(18, 32, 18, 46) + .WithArguments("ComplexResult", "GetResultAsync")); + + await context.RunAsync(); + } + + [Fact] + public async Task ActorMethodWithDataContractType_ShouldNotReportDAPR1409() + { + var context = CreateTest(); + context.TestCode = """ + using System.Runtime.Serialization; + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + [DataContract] + public class ComplexType + { + [DataMember] + public string Name { get; set; } = string.Empty; + } + + public interface ITestActor : IActor + { + Task ProcessDataAsync(ComplexType data); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task ProcessDataAsync(ComplexType data) => Task.CompletedTask; + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task ActorMethodWithPrimitiveTypes_ShouldNotReportDiagnostics() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public interface ITestActor : IActor + { + Task ProcessAsync(string input, int count); + } + + public class TestActor : Actor, ITestActor + { + public TestActor(ActorHost host) : base(host) { } + public Task ProcessAsync(string input, int count) => Task.FromResult(input); + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task ActorClassWithoutIActorInterface_ShouldReportDAPR1413() + { + var context = CreateTest(); + context.TestCode = """ + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + + public class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) { } + public Task GetDataAsync() => Task.FromResult("data"); + } + """; + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.ActorClassMissingInterface) + .WithSpan(4, 14, 4, 23) + .WithArguments("TestActor")); + + await context.RunAsync(); + } + + [Fact] + public async Task RecordWithoutDataContract_ShouldReportDAPR1412() + { + var context = CreateTest(); + context.TestCode = """ + using System; + using System.Threading.Tasks; + using Dapr.Actors; + using Dapr.Actors.Runtime; + + public record Doodad(Guid Id, string Name); + + public interface IDoodadActor : IActor + { + Task GetDoodadAsync(); + } + + public class DoodadActor : Actor, IDoodadActor + { + public DoodadActor(ActorHost host) : base(host) { } + public Task GetDoodadAsync() => Task.FromResult(new Doodad(Guid.NewGuid(), "test")); + } + """; + + context.ExpectedDiagnostics.Add( + new DiagnosticResult(ActorSerializationAnalyzer.RecordTypeNeedsDataContractAttributes) + .WithSpan(16, 25, 16, 39) + .WithArguments("Doodad")); + + await context.RunAsync(); + } +}