From ec50f47afb4980e1c143faedf3d4981d96f906f6 Mon Sep 17 00:00:00 2001 From: Mohammad Taghavi Date: Fri, 10 Apr 2026 00:41:36 +0200 Subject: [PATCH 1/3] Add DAPR1303 and DAPR1304 workflow type safety analyzer Signed-off-by: Mohammad Taghavi --- .../AnalyzerReleases.Unshipped.md | 6 + .../Resources.Designer.cs | 36 ++ src/Dapr.Workflow.Analyzers/Resources.resx | 14 +- .../WorkflowTypeSafetyAnalyzer.cs | 424 ++++++++++++++++++ .../Dapr.Workflow.Analyzers.Test/Utilities.cs | 4 +- ...ctivityRegistrationCodeFixProviderTests.cs | 10 +- .../WorkflowTypeSafetyAnalyzerTests.cs | 174 +++++++ 7 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs create mode 100644 test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs diff --git a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Unshipped.md index b1b99aaf2..6ff5c0f5b 100644 --- a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,3 +1,9 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DAPR1303 | Usage | Warning | The provided input type does not match the target workflow or activity input type +DAPR1304 | Usage | Warning | The requested output type does not match the target workflow or activity output type diff --git a/src/Dapr.Workflow.Analyzers/Resources.Designer.cs b/src/Dapr.Workflow.Analyzers/Resources.Designer.cs index 42e524df6..5566fe834 100644 --- a/src/Dapr.Workflow.Analyzers/Resources.Designer.cs +++ b/src/Dapr.Workflow.Analyzers/Resources.Designer.cs @@ -94,5 +94,41 @@ internal static string DAPR1302Title { return ResourceManager.GetString("DAPR1302Title", resourceCulture); } } + + /// + /// Looks up a localized string similar to The provided input type '{0}' does not match the expected input type '{1}' for {2} '{3}'. + /// + internal static string DAPR1303MessageFormat { + get { + return ResourceManager.GetString("DAPR1303MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The provided input type does not match the target workflow or activity input type. + /// + internal static string DAPR1303Title { + get { + return ResourceManager.GetString("DAPR1303Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The requested output type '{0}' does not match the declared output type '{1}' for {2} '{3}'. + /// + internal static string DAPR1304MessageFormat { + get { + return ResourceManager.GetString("DAPR1304MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The requested output type does not match the target workflow or activity output type. + /// + internal static string DAPR1304Title { + get { + return ResourceManager.GetString("DAPR1304Title", resourceCulture); + } + } } } diff --git a/src/Dapr.Workflow.Analyzers/Resources.resx b/src/Dapr.Workflow.Analyzers/Resources.resx index 12af45514..f332492bf 100644 --- a/src/Dapr.Workflow.Analyzers/Resources.resx +++ b/src/Dapr.Workflow.Analyzers/Resources.resx @@ -30,4 +30,16 @@ The workflow activity type '{0}' is not registered with the dependency injection provider - \ No newline at end of file + + The provided input type does not match the target workflow or activity input type + + + The provided input type '{0}' does not match the expected input type '{1}' for {2} '{3}' + + + The requested output type does not match the target workflow or activity output type + + + The requested output type '{0}' does not match the declared output type '{1}' for {2} '{3}' + + diff --git a/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs new file mode 100644 index 000000000..2ee683005 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs @@ -0,0 +1,424 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Validate that the input and output types to and from a workflow and workflow activity match on either side of the operation. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class WorkflowTypeSafetyAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor InputTypeMismatchDescriptor = new( + id: "DAPR1303", + title: new LocalizableResourceString(nameof(Resources.DAPR1303Title), Resources.ResourceManager, + typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1303MessageFormat), Resources.ResourceManager, + typeof(Resources)), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor OutputTypeMismatchDescriptor = new( + id: "DAPR1304", + title: new LocalizableResourceString(nameof(Resources.DAPR1304Title), Resources.ResourceManager, + typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1304MessageFormat), Resources.ResourceManager, + typeof(Resources)), + category: "Usage", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// Gets the diagnostics supported by this analyzer. + /// + public override ImmutableArray SupportedDiagnostics => + [ + InputTypeMismatchDescriptor, + OutputTypeMismatchDescriptor + ]; + + /// + /// Initializes analyzer actions. + /// + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is not IMethodSymbol + methodSymbol) + { + return; + } + + methodSymbol = methodSymbol.ReducedFrom ?? methodSymbol; + + switch (methodSymbol.Name) + { + case "ScheduleNewWorkflowAsync": + AnalyzeWorkflowInput(invocation, methodSymbol, context); + break; + case "CallChildWorkflowAsync": + AnalyzeWorkflowInput(invocation, methodSymbol, context); + AnalyzeWorkflowOutput(invocation, methodSymbol, context); + break; + case "CallActivityAsync": + AnalyzeActivityInput(invocation, methodSymbol, context); + AnalyzeActivityOutput(invocation, methodSymbol, context); + break; + } + } + + private static void AnalyzeWorkflowInput( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context) + { + if (!TryGetTargetType(invocation, methodSymbol, context, "name", "workflowName", out var workflowType)) + { + return; + } + + if (!TryGetWorkflowTypeArguments(workflowType, out var expectedInputType, out _)) + { + return; + } + + var inputArgument = GetArgument(invocation, methodSymbol, "input"); + if (inputArgument is null || !TryGetExpressionType(context.SemanticModel, inputArgument.Expression, + context.CancellationToken, out var actualInputType)) + { + return; + } + + if (IsCompatible(actualInputType, expectedInputType, context.SemanticModel.Compilation)) + { + return; + } + + var diagnostic = Diagnostic.Create( + InputTypeMismatchDescriptor, + inputArgument.GetLocation(), + ToDisplayString(actualInputType), + ToDisplayString(expectedInputType), + "workflow", + workflowType.Name); + + context.ReportDiagnostic(diagnostic); + } + + private static void AnalyzeWorkflowOutput( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context) + { + if (!TryGetTargetType(invocation, methodSymbol, context, "workflowName", out var workflowType)) + { + return; + } + + if (!TryGetWorkflowTypeArguments(workflowType, out _, out var declaredOutputType)) + { + return; + } + + if (!TryGetRequestedOutputType(methodSymbol, out var requestedOutputType)) + { + return; + } + + if (IsCompatible(declaredOutputType, requestedOutputType, context.SemanticModel.Compilation)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + OutputTypeMismatchDescriptor, + GetOutputDiagnosticLocation(invocation), + ToDisplayString(requestedOutputType), + ToDisplayString(declaredOutputType), + "workflow", + workflowType.Name)); + } + + private static void AnalyzeActivityInput( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context) + { + if (!TryGetTargetType(invocation, methodSymbol, context, "name", out var activityType)) + { + return; + } + + if (!TryGetActivityTypeArguments(activityType, out var expectedInputType, out _)) + { + return; + } + + var inputArgument = GetArgument(invocation, methodSymbol, "input"); + if (inputArgument is null || !TryGetExpressionType(context.SemanticModel, inputArgument.Expression, + context.CancellationToken, out var actualInputType)) + { + return; + } + + if (IsCompatible(actualInputType, expectedInputType, context.SemanticModel.Compilation)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + InputTypeMismatchDescriptor, + inputArgument.GetLocation(), + ToDisplayString(actualInputType), + ToDisplayString(expectedInputType), + "workflow activity", + activityType.Name)); + } + + private static void AnalyzeActivityOutput( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context) + { + if (!TryGetTargetType(invocation, methodSymbol, context, "name", out var activityType)) + { + return; + } + + if (!TryGetActivityTypeArguments(activityType, out _, out var declaredOutputType)) + { + return; + } + + if (!TryGetRequestedOutputType(methodSymbol, out var requestedOutputType)) + { + return; + } + + if (IsCompatible(declaredOutputType, requestedOutputType, context.SemanticModel.Compilation)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + OutputTypeMismatchDescriptor, + GetOutputDiagnosticLocation(invocation), + ToDisplayString(requestedOutputType), + ToDisplayString(declaredOutputType), + "workflow activity", + activityType.Name)); + } + + private static bool TryGetTargetType( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context, + string parameterName, + out INamedTypeSymbol targetType) + { + targetType = null!; + var argument = GetArgument(invocation, methodSymbol, parameterName); + if (argument is null) + { + return false; + } + + return TryGetTypeFromNameof(argument.Expression, context.SemanticModel, context.CancellationToken, + out targetType); + } + + private static bool TryGetTargetType( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + SyntaxNodeAnalysisContext context, + string firstParameterName, + string secondParameterName, + out INamedTypeSymbol targetType) + { + targetType = null!; + return TryGetTargetType(invocation, methodSymbol, context, firstParameterName, out targetType) || + TryGetTargetType(invocation, methodSymbol, context, secondParameterName, out targetType); + } + + private static bool TryGetTypeFromNameof( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out INamedTypeSymbol targetType) + { + targetType = null!; + + if (expression is not InvocationExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } nameofIdentifier + } nameofInvocation) + { + return false; + } + + _ = nameofIdentifier; + var targetExpression = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (targetExpression is null) + { + return false; + } + + var resolvedType = semanticModel.GetSymbolInfo(targetExpression, cancellationToken).Symbol as INamedTypeSymbol; + if (resolvedType is null) + { + return false; + } + + targetType = resolvedType; + return true; + } + + private static ArgumentSyntax? GetArgument( + InvocationExpressionSyntax invocation, + IMethodSymbol methodSymbol, + string parameterName) + { + var positionalIndex = 0; + foreach (var argument in invocation.ArgumentList.Arguments) + { + if (argument.NameColon is not null) + { + if (argument.NameColon.Name.Identifier.Text == parameterName) + { + return argument; + } + + continue; + } + + if (positionalIndex >= methodSymbol.Parameters.Length) + { + return null; + } + + if (methodSymbol.Parameters[positionalIndex].Name == parameterName) + { + return argument; + } + + positionalIndex++; + } + + return null; + } + + private static bool TryGetWorkflowTypeArguments( + INamedTypeSymbol workflowType, + out ITypeSymbol inputType, + out ITypeSymbol outputType) + { + inputType = null!; + outputType = null!; + var genericBase = FindGenericBaseType(workflowType, "Dapr.Workflow.Workflow`2"); + if (genericBase is null) + { + return false; + } + + inputType = genericBase.TypeArguments[0]; + outputType = genericBase.TypeArguments[1]; + return true; + } + + private static bool TryGetActivityTypeArguments( + INamedTypeSymbol activityType, + out ITypeSymbol inputType, + out ITypeSymbol outputType) + { + inputType = null!; + outputType = null!; + var genericBase = FindGenericBaseType(activityType, "Dapr.Workflow.WorkflowActivity`2"); + if (genericBase is null) + { + return false; + } + + inputType = genericBase.TypeArguments[0]; + outputType = genericBase.TypeArguments[1]; + return true; + } + + private static INamedTypeSymbol? FindGenericBaseType(INamedTypeSymbol type, string metadataName) + { + for (var current = type; current is not null; current = current.BaseType) + { + if ($"{current.OriginalDefinition.ContainingNamespace.ToDisplayString()}.{current.OriginalDefinition.MetadataName}" == + metadataName) + { + return current; + } + } + + return null; + } + + private static bool TryGetRequestedOutputType(IMethodSymbol methodSymbol, out ITypeSymbol requestedOutputType) + { + requestedOutputType = null!; + if (!methodSymbol.IsGenericMethod || methodSymbol.TypeArguments.Length != 1) + { + return false; + } + + requestedOutputType = methodSymbol.TypeArguments[0]; + return true; + } + + private static bool TryGetExpressionType( + SemanticModel semanticModel, + ExpressionSyntax expression, + CancellationToken cancellationToken, + out ITypeSymbol expressionType) + { + expressionType = null!; + if (expression.IsKind(SyntaxKind.NullLiteralExpression)) + { + return false; + } + + var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken); + expressionType = typeInfo.Type ?? typeInfo.ConvertedType!; + return expressionType is not null; + } + + private static bool IsCompatible(ITypeSymbol actualType, ITypeSymbol expectedType, Compilation compilation) + { + if (SymbolEqualityComparer.Default.Equals(actualType, expectedType)) + { + return true; + } + + var conversion = compilation.ClassifyConversion(actualType, expectedType); + return conversion.Exists && conversion.IsImplicit; + } + + private static Location GetOutputDiagnosticLocation(InvocationExpressionSyntax invocation) + { + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + { + return memberAccess.Name.GetLocation(); + } + + return invocation.GetLocation(); + } + + private static string ToDisplayString(ITypeSymbol typeSymbol) => + typeSymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); +} diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs index 0b971489a..3344691da 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -11,13 +11,15 @@ internal static class Utilities internal static ImmutableArray GetAnalyzers() => [ new WorkflowRegistrationAnalyzer(), - new WorkflowActivityRegistrationAnalyzer() + new WorkflowActivityRegistrationAnalyzer(), + new WorkflowTypeSafetyAnalyzer() ]; internal static IReadOnlyList GetReferences() { var metadataReferences = TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowActivityRegistrationAnalyzer)).ToList(); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowRegistrationAnalyzer))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowTypeSafetyAnalyzer))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(TimeSpan))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowActivity<,>))); diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs index 9944a578e..109353883 100644 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs @@ -24,7 +24,7 @@ public static void Main() }); } } - + class OrderProcessingWorkflow : Workflow { public override async Task RunAsync(WorkflowContext context, OrderPayload order) @@ -37,9 +37,9 @@ public override async Task RunAsync(WorkflowContext context, OrderP record OrderPayload; record OrderResult(string Message) { }; record Notification { public Notification(string message) { } }; - internal sealed class NotifyActivity : WorkflowActivity + internal sealed class NotifyActivity : WorkflowActivity { - public override async Task RunAsync(WorkflowActivityContext context, string input) + public override async Task RunAsync(WorkflowActivityContext context, Notification input) { await Task.Delay(TimeSpan.FromSeconds(15)); return true; @@ -78,9 +78,9 @@ public override async Task RunAsync(WorkflowContext context, OrderP record OrderPayload; record OrderResult(string Message) { }; record Notification { public Notification(string message) { } }; - internal sealed class NotifyActivity : WorkflowActivity + internal sealed class NotifyActivity : WorkflowActivity { - public override async Task RunAsync(WorkflowActivityContext context, string input) + public override async Task RunAsync(WorkflowActivityContext context, Notification input) { await Task.Delay(TimeSpan.FromSeconds(15)); return true; diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs new file mode 100644 index 000000000..61f5db587 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs @@ -0,0 +1,174 @@ +using Dapr.Analyzers.Common; + +namespace Dapr.Workflow.Analyzers.Test; + +public sealed class WorkflowTypeSafetyAnalyzerTests +{ + [Fact] + public async Task VerifyWorkflowInputTypeMismatch() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public sealed class OrderWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); +} + +public sealed class WorkflowStarter +{ + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); + } +} +"""; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) + .WithSpan(13, 71, 13, 85) + .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyActivityInputTypeMismatch() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public sealed class NotifyActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); +} + +public sealed class ParentWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, string input) + { + return await context.CallActivityAsync(nameof(NotifyActivity), "wrong"); + } +} +"""; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) + .WithSpan(13, 80, 13, 87) + .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow activity 'NotifyActivity'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyActivityOutputTypeMismatch() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public sealed class NotifyActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); +} + +public sealed class ParentWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, string input) + { + int result = await context.CallActivityAsync(nameof(NotifyActivity), 42); + return result.ToString(); + } +} +"""; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) + .WithSpan(13, 36, 13, 58) + .WithMessage("The requested output type 'int' does not match the declared output type 'string' for workflow activity 'NotifyActivity'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyChildWorkflowOutputTypeMismatch() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public sealed class ChildWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); +} + +public sealed class ParentWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, int input) + { + var result = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); + return result.ToString(); + } +} +"""; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) + .WithSpan(13, 36, 13, 63) + .WithMessage("The requested output type 'int' does not match the declared output type 'string' for workflow 'ChildWorkflow'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyCompatibleTypesDoNotReport() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public class Notification +{ +} + +public sealed class ImportantNotification : Notification +{ +} + +public sealed class NotifyActivity : WorkflowActivity +{ + public override Task RunAsync(WorkflowActivityContext context, Notification input) => + Task.FromResult(new ImportantNotification()); +} + +public sealed class ChildWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, Notification input) => + Task.FromResult(new ImportantNotification()); +} + +public sealed class ParentWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, ImportantNotification input) + { + Notification activityResult = await context.CallActivityAsync(nameof(NotifyActivity), input); + Notification childResult = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); + return activityResult ?? childResult; + } +} + +public sealed class WorkflowStarter +{ + public Task StartAsync(DaprWorkflowClient client, ImportantNotification input) + { + return client.ScheduleNewWorkflowAsync(nameof(ChildWorkflow), input: input); + } +} +"""; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } +} From 5af17edc5e2b90f664aeb91b3c3a0436e67aa55f Mon Sep 17 00:00:00 2001 From: Mohammad Taghavi Date: Fri, 10 Apr 2026 00:41:56 +0200 Subject: [PATCH 2/3] Refactor workflow type safety analyzer to use IOperation Signed-off-by: Mohammad Taghavi --- .../CompilationExtensions.cs | 27 ++ .../WorkflowTypeSafetyAnalyzer.cs | 377 +++++++++--------- .../Dapr.Workflow.Analyzers.Test/Utilities.cs | 2 + .../WorkflowTypeSafetyAnalyzerTests.cs | 29 ++ 4 files changed, 257 insertions(+), 178 deletions(-) create mode 100644 src/Dapr.Workflow.Analyzers/CompilationExtensions.cs diff --git a/src/Dapr.Workflow.Analyzers/CompilationExtensions.cs b/src/Dapr.Workflow.Analyzers/CompilationExtensions.cs new file mode 100644 index 000000000..a4cf449e3 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/CompilationExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis; + +namespace Dapr.Workflow.Analyzers; + +internal static class CompilationExtensions +{ + private const string DaprWorkflowClientMetadataName = "Dapr.Workflow.DaprWorkflowClient"; + private const string IDaprWorkflowClientMetadataName = "Dapr.Workflow.IDaprWorkflowClient"; + private const string WorkflowContextMetadataName = "Dapr.Workflow.WorkflowContext"; + private const string WorkflowBaseMetadataName = "Dapr.Workflow.Workflow`2"; + private const string WorkflowActivityBaseMetadataName = "Dapr.Workflow.WorkflowActivity`2"; + + internal static INamedTypeSymbol? GetDaprWorkflowClientType(this Compilation compilation) => + compilation.GetTypeByMetadataName(DaprWorkflowClientMetadataName); + + internal static INamedTypeSymbol? GetIDaprWorkflowClientType(this Compilation compilation) => + compilation.GetTypeByMetadataName(IDaprWorkflowClientMetadataName); + + internal static INamedTypeSymbol? GetWorkflowContextType(this Compilation compilation) => + compilation.GetTypeByMetadataName(WorkflowContextMetadataName); + + internal static INamedTypeSymbol? GetWorkflowBaseType(this Compilation compilation) => + compilation.GetTypeByMetadataName(WorkflowBaseMetadataName); + + internal static INamedTypeSymbol? GetWorkflowActivityBaseType(this Compilation compilation) => + compilation.GetTypeByMetadataName(WorkflowActivityBaseMetadataName); +} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs index 2ee683005..4af2f2ed4 100644 --- a/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs +++ b/src/Dapr.Workflow.Analyzers/WorkflowTypeSafetyAnalyzer.cs @@ -1,33 +1,30 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace Dapr.Workflow.Analyzers; /// -/// Validate that the input and output types to and from a workflow and workflow activity match on either side of the operation. +/// Validates that the input and output types used with Dapr workflow and workflow activity calls +/// match the declared generic input/output types of the target workflow/activity type. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class WorkflowTypeSafetyAnalyzer : DiagnosticAnalyzer { internal static readonly DiagnosticDescriptor InputTypeMismatchDescriptor = new( id: "DAPR1303", - title: new LocalizableResourceString(nameof(Resources.DAPR1303Title), Resources.ResourceManager, - typeof(Resources)), - messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1303MessageFormat), Resources.ResourceManager, - typeof(Resources)), + title: new LocalizableResourceString(nameof(Resources.DAPR1303Title), Resources.ResourceManager, typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1303MessageFormat), Resources.ResourceManager, typeof(Resources)), category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); internal static readonly DiagnosticDescriptor OutputTypeMismatchDescriptor = new( id: "DAPR1304", - title: new LocalizableResourceString(nameof(Resources.DAPR1304Title), Resources.ResourceManager, - typeof(Resources)), - messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1304MessageFormat), Resources.ResourceManager, - typeof(Resources)), + title: new LocalizableResourceString(nameof(Resources.DAPR1304Title), Resources.ResourceManager, typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR1304MessageFormat), Resources.ResourceManager, typeof(Resources)), category: "Usage", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -49,102 +46,160 @@ public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + + context.RegisterCompilationStartAction(static compilationStartContext => + { + var compilation = compilationStartContext.Compilation; + + var daprWorkflowClientType = compilation.GetDaprWorkflowClientType(); + var iDaprWorkflowClientType = compilation.GetIDaprWorkflowClientType(); + var workflowContextType = compilation.GetWorkflowContextType(); + var workflowBaseType = compilation.GetWorkflowBaseType(); + var workflowActivityBaseType = compilation.GetWorkflowActivityBaseType(); + + if (daprWorkflowClientType is null || + iDaprWorkflowClientType is null || + workflowContextType is null || + workflowBaseType is null || + workflowActivityBaseType is null) + { + return; + } + + compilationStartContext.RegisterOperationAction( + operationContext => AnalyzeInvocation( + operationContext, + daprWorkflowClientType, + iDaprWorkflowClientType, + workflowContextType, + workflowBaseType, + workflowActivityBaseType), + OperationKind.Invocation); + }); } - private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + private static void AnalyzeInvocation( + OperationAnalysisContext context, + INamedTypeSymbol daprWorkflowClientType, + INamedTypeSymbol iDaprWorkflowClientType, + INamedTypeSymbol workflowContextType, + INamedTypeSymbol workflowBaseType, + INamedTypeSymbol workflowActivityBaseType) { - var invocation = (InvocationExpressionSyntax)context.Node; - if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is not IMethodSymbol - methodSymbol) + context.CancellationToken.ThrowIfCancellationRequested(); + + var invocation = (IInvocationOperation)context.Operation; + var targetMethod = invocation.TargetMethod.ReducedFrom ?? invocation.TargetMethod; + + if (IsScheduleNewWorkflowAsync(targetMethod, daprWorkflowClientType, iDaprWorkflowClientType)) { + AnalyzeWorkflowInput(invocation, context, workflowBaseType); return; } - methodSymbol = methodSymbol.ReducedFrom ?? methodSymbol; - - switch (methodSymbol.Name) - { - case "ScheduleNewWorkflowAsync": - AnalyzeWorkflowInput(invocation, methodSymbol, context); - break; - case "CallChildWorkflowAsync": - AnalyzeWorkflowInput(invocation, methodSymbol, context); - AnalyzeWorkflowOutput(invocation, methodSymbol, context); - break; - case "CallActivityAsync": - AnalyzeActivityInput(invocation, methodSymbol, context); - AnalyzeActivityOutput(invocation, methodSymbol, context); - break; + if (IsCallChildWorkflowAsync(targetMethod, workflowContextType)) + { + AnalyzeWorkflowInput(invocation, context, workflowBaseType); + AnalyzeWorkflowOutput(invocation, context, workflowBaseType); + return; + } + + if (IsCallActivityAsync(targetMethod, workflowContextType)) + { + AnalyzeActivityInput(invocation, context, workflowActivityBaseType); + AnalyzeActivityOutput(invocation, context, workflowActivityBaseType); } } + private static bool IsScheduleNewWorkflowAsync( + IMethodSymbol method, + INamedTypeSymbol daprWorkflowClientType, + INamedTypeSymbol iDaprWorkflowClientType) => + method.Name == "ScheduleNewWorkflowAsync" && + (SymbolEqualityComparer.Default.Equals(method.ContainingType, daprWorkflowClientType) || + SymbolEqualityComparer.Default.Equals(method.ContainingType, iDaprWorkflowClientType)); + + private static bool IsCallChildWorkflowAsync( + IMethodSymbol method, + INamedTypeSymbol workflowContextType) => + method.Name == "CallChildWorkflowAsync" && + SymbolEqualityComparer.Default.Equals(method.ContainingType, workflowContextType); + + private static bool IsCallActivityAsync( + IMethodSymbol method, + INamedTypeSymbol workflowContextType) => + method.Name == "CallActivityAsync" && + SymbolEqualityComparer.Default.Equals(method.ContainingType, workflowContextType); + private static void AnalyzeWorkflowInput( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context) + IInvocationOperation invocation, + OperationAnalysisContext context, + INamedTypeSymbol workflowBaseType) { - if (!TryGetTargetType(invocation, methodSymbol, context, "name", "workflowName", out var workflowType)) + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetTargetTypeFromNameof(invocation, "name", context.CancellationToken, out var workflowType) && + !TryGetTargetTypeFromNameof(invocation, "workflowName", context.CancellationToken, out workflowType)) { return; } - if (!TryGetWorkflowTypeArguments(workflowType, out var expectedInputType, out _)) + if (!TryGetGenericArgumentsFromBaseType(workflowType, workflowBaseType, out var expectedInputType, out _)) { return; } - var inputArgument = GetArgument(invocation, methodSymbol, "input"); - if (inputArgument is null || !TryGetExpressionType(context.SemanticModel, inputArgument.Expression, - context.CancellationToken, out var actualInputType)) + var inputArgument = GetArgument(invocation, "input"); + if (inputArgument is null || + !TryGetExpressionType(inputArgument.Value, out var actualInputType)) { return; } - if (IsCompatible(actualInputType, expectedInputType, context.SemanticModel.Compilation)) + if (IsCompatible(actualInputType, expectedInputType, context.Compilation)) { return; } - var diagnostic = Diagnostic.Create( + context.ReportDiagnostic(Diagnostic.Create( InputTypeMismatchDescriptor, - inputArgument.GetLocation(), + inputArgument.Syntax.GetLocation(), ToDisplayString(actualInputType), ToDisplayString(expectedInputType), "workflow", - workflowType.Name); - - context.ReportDiagnostic(diagnostic); + workflowType.Name)); } private static void AnalyzeWorkflowOutput( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context) + IInvocationOperation invocation, + OperationAnalysisContext context, + INamedTypeSymbol workflowBaseType) { - if (!TryGetTargetType(invocation, methodSymbol, context, "workflowName", out var workflowType)) + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetTargetTypeFromNameof(invocation, "workflowName", context.CancellationToken, out var workflowType)) { return; } - if (!TryGetWorkflowTypeArguments(workflowType, out _, out var declaredOutputType)) + if (!TryGetGenericArgumentsFromBaseType(workflowType, workflowBaseType, out _, out var declaredOutputType)) { return; } - if (!TryGetRequestedOutputType(methodSymbol, out var requestedOutputType)) + if (!TryGetRequestedOutputType(invocation.TargetMethod, out var requestedOutputType)) { return; } - if (IsCompatible(declaredOutputType, requestedOutputType, context.SemanticModel.Compilation)) + if (IsCompatible(declaredOutputType, requestedOutputType, context.Compilation)) { return; } context.ReportDiagnostic(Diagnostic.Create( OutputTypeMismatchDescriptor, - GetOutputDiagnosticLocation(invocation), + GetInvocationNameLocation(invocation), ToDisplayString(requestedOutputType), ToDisplayString(declaredOutputType), "workflow", @@ -152,35 +207,37 @@ private static void AnalyzeWorkflowOutput( } private static void AnalyzeActivityInput( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context) + IInvocationOperation invocation, + OperationAnalysisContext context, + INamedTypeSymbol workflowActivityBaseType) { - if (!TryGetTargetType(invocation, methodSymbol, context, "name", out var activityType)) + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetTargetTypeFromNameof(invocation, "name", context.CancellationToken, out var activityType)) { return; } - if (!TryGetActivityTypeArguments(activityType, out var expectedInputType, out _)) + if (!TryGetGenericArgumentsFromBaseType(activityType, workflowActivityBaseType, out var expectedInputType, out _)) { return; } - var inputArgument = GetArgument(invocation, methodSymbol, "input"); - if (inputArgument is null || !TryGetExpressionType(context.SemanticModel, inputArgument.Expression, - context.CancellationToken, out var actualInputType)) + var inputArgument = GetArgument(invocation, "input"); + if (inputArgument is null || + !TryGetExpressionType(inputArgument.Value, out var actualInputType)) { return; } - if (IsCompatible(actualInputType, expectedInputType, context.SemanticModel.Compilation)) + if (IsCompatible(actualInputType, expectedInputType, context.Compilation)) { return; } context.ReportDiagnostic(Diagnostic.Create( InputTypeMismatchDescriptor, - inputArgument.GetLocation(), + inputArgument.Syntax.GetLocation(), ToDisplayString(actualInputType), ToDisplayString(expectedInputType), "workflow activity", @@ -188,190 +245,140 @@ private static void AnalyzeActivityInput( } private static void AnalyzeActivityOutput( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context) + IInvocationOperation invocation, + OperationAnalysisContext context, + INamedTypeSymbol workflowActivityBaseType) { - if (!TryGetTargetType(invocation, methodSymbol, context, "name", out var activityType)) + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!TryGetTargetTypeFromNameof(invocation, "name", context.CancellationToken, out var activityType)) { return; } - if (!TryGetActivityTypeArguments(activityType, out _, out var declaredOutputType)) + if (!TryGetGenericArgumentsFromBaseType(activityType, workflowActivityBaseType, out _, out var declaredOutputType)) { return; } - if (!TryGetRequestedOutputType(methodSymbol, out var requestedOutputType)) + if (!TryGetRequestedOutputType(invocation.TargetMethod, out var requestedOutputType)) { return; } - if (IsCompatible(declaredOutputType, requestedOutputType, context.SemanticModel.Compilation)) + if (IsCompatible(declaredOutputType, requestedOutputType, context.Compilation)) { return; } context.ReportDiagnostic(Diagnostic.Create( OutputTypeMismatchDescriptor, - GetOutputDiagnosticLocation(invocation), + GetInvocationNameLocation(invocation), ToDisplayString(requestedOutputType), ToDisplayString(declaredOutputType), "workflow activity", activityType.Name)); } - private static bool TryGetTargetType( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context, + private static bool TryGetTargetTypeFromNameof( + IInvocationOperation invocation, string parameterName, + CancellationToken cancellationToken, out INamedTypeSymbol targetType) { + cancellationToken.ThrowIfCancellationRequested(); + targetType = null!; - var argument = GetArgument(invocation, methodSymbol, parameterName); + var argument = GetArgument(invocation, parameterName); if (argument is null) { return false; } - return TryGetTypeFromNameof(argument.Expression, context.SemanticModel, context.CancellationToken, - out targetType); - } - - private static bool TryGetTargetType( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, - SyntaxNodeAnalysisContext context, - string firstParameterName, - string secondParameterName, - out INamedTypeSymbol targetType) - { - targetType = null!; - return TryGetTargetType(invocation, methodSymbol, context, firstParameterName, out targetType) || - TryGetTargetType(invocation, methodSymbol, context, secondParameterName, out targetType); + return TryGetTypeFromNameof(argument.Value, cancellationToken, out targetType); } private static bool TryGetTypeFromNameof( - ExpressionSyntax expression, - SemanticModel semanticModel, + IOperation operation, CancellationToken cancellationToken, out INamedTypeSymbol targetType) { + cancellationToken.ThrowIfCancellationRequested(); + targetType = null!; - if (expression is not InvocationExpressionSyntax - { - Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } nameofIdentifier - } nameofInvocation) + if (operation is not INameOfOperation nameOfOperation) { return false; } - _ = nameofIdentifier; - var targetExpression = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; - if (targetExpression is null) + var argumentOperation = nameOfOperation.Argument; + var semanticModel = argumentOperation.SemanticModel; + if (semanticModel is null) { return false; } - var resolvedType = semanticModel.GetSymbolInfo(targetExpression, cancellationToken).Symbol as INamedTypeSymbol; - if (resolvedType is null) + var symbolInfo = semanticModel.GetSymbolInfo(argumentOperation.Syntax, cancellationToken).Symbol; + if (symbolInfo is INamedTypeSymbol namedTypeFromSymbol) { - return false; + targetType = namedTypeFromSymbol; + return true; } - targetType = resolvedType; - return true; + if (argumentOperation.Type is INamedTypeSymbol namedType) + { + targetType = namedType; + return true; + } + + return false; } - private static ArgumentSyntax? GetArgument( - InvocationExpressionSyntax invocation, - IMethodSymbol methodSymbol, + private static IArgumentOperation? GetArgument( + IInvocationOperation invocation, string parameterName) { - var positionalIndex = 0; - foreach (var argument in invocation.ArgumentList.Arguments) + foreach (var argument in invocation.Arguments) { - if (argument.NameColon is not null) - { - if (argument.NameColon.Name.Identifier.Text == parameterName) - { - return argument; - } - - continue; - } - - if (positionalIndex >= methodSymbol.Parameters.Length) - { - return null; - } - - if (methodSymbol.Parameters[positionalIndex].Name == parameterName) + if (argument.Parameter?.Name == parameterName) { return argument; } - - positionalIndex++; } return null; } - private static bool TryGetWorkflowTypeArguments( - INamedTypeSymbol workflowType, - out ITypeSymbol inputType, - out ITypeSymbol outputType) - { - inputType = null!; - outputType = null!; - var genericBase = FindGenericBaseType(workflowType, "Dapr.Workflow.Workflow`2"); - if (genericBase is null) - { - return false; - } - - inputType = genericBase.TypeArguments[0]; - outputType = genericBase.TypeArguments[1]; - return true; - } - - private static bool TryGetActivityTypeArguments( - INamedTypeSymbol activityType, + private static bool TryGetGenericArgumentsFromBaseType( + INamedTypeSymbol candidateType, + INamedTypeSymbol genericBaseDefinition, out ITypeSymbol inputType, out ITypeSymbol outputType) { inputType = null!; outputType = null!; - var genericBase = FindGenericBaseType(activityType, "Dapr.Workflow.WorkflowActivity`2"); - if (genericBase is null) - { - return false; - } - - inputType = genericBase.TypeArguments[0]; - outputType = genericBase.TypeArguments[1]; - return true; - } - private static INamedTypeSymbol? FindGenericBaseType(INamedTypeSymbol type, string metadataName) - { - for (var current = type; current is not null; current = current.BaseType) + for (INamedTypeSymbol? current = candidateType; current is not null; current = current.BaseType) { - if ($"{current.OriginalDefinition.ContainingNamespace.ToDisplayString()}.{current.OriginalDefinition.MetadataName}" == - metadataName) + if (current.IsGenericType && + SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, genericBaseDefinition)) { - return current; + inputType = current.TypeArguments[0]; + outputType = current.TypeArguments[1]; + return true; } } - return null; + return false; } - private static bool TryGetRequestedOutputType(IMethodSymbol methodSymbol, out ITypeSymbol requestedOutputType) + private static bool TryGetRequestedOutputType( + IMethodSymbol methodSymbol, + out ITypeSymbol requestedOutputType) { requestedOutputType = null!; + if (!methodSymbol.IsGenericMethod || methodSymbol.TypeArguments.Length != 1) { return false; @@ -382,23 +389,35 @@ private static bool TryGetRequestedOutputType(IMethodSymbol methodSymbol, out IT } private static bool TryGetExpressionType( - SemanticModel semanticModel, - ExpressionSyntax expression, - CancellationToken cancellationToken, + IOperation operation, out ITypeSymbol expressionType) { expressionType = null!; - if (expression.IsKind(SyntaxKind.NullLiteralExpression)) + + if (operation.ConstantValue.HasValue && operation.ConstantValue.Value is null) { return false; } - var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken); - expressionType = typeInfo.Type ?? typeInfo.ConvertedType!; + if (operation is IConversionOperation { IsImplicit: true, Operand: { } operand }) + { + if (operand.ConstantValue.HasValue && operand.ConstantValue.Value is null) + { + return false; + } + + expressionType = operand.Type!; + return expressionType is not null; + } + + expressionType = operation.Type!; return expressionType is not null; } - private static bool IsCompatible(ITypeSymbol actualType, ITypeSymbol expectedType, Compilation compilation) + private static bool IsCompatible( + ITypeSymbol actualType, + ITypeSymbol expectedType, + Compilation compilation) { if (SymbolEqualityComparer.Default.Equals(actualType, expectedType)) { @@ -409,14 +428,16 @@ private static bool IsCompatible(ITypeSymbol actualType, ITypeSymbol expectedTyp return conversion.Exists && conversion.IsImplicit; } - private static Location GetOutputDiagnosticLocation(InvocationExpressionSyntax invocation) + private static Location GetInvocationNameLocation(IInvocationOperation invocation) { - if (invocation.Expression is MemberAccessExpressionSyntax memberAccess) + return invocation.Syntax switch { - return memberAccess.Name.GetLocation(); - } - - return invocation.GetLocation(); + Microsoft.CodeAnalysis.CSharp.Syntax.InvocationExpressionSyntax + { + Expression: Microsoft.CodeAnalysis.CSharp.Syntax.MemberAccessExpressionSyntax memberAccess + } => memberAccess.Name.GetLocation(), + _ => invocation.Syntax.GetLocation() + }; } private static string ToDisplayString(ITypeSymbol typeSymbol) => diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs index 3344691da..3845dd160 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.Diagnostics; using System.Collections.Immutable; using Dapr.Analyzers.Common; +using Dapr.Common; using Microsoft.Extensions.Hosting; namespace Dapr.Workflow.Analyzers.Test; @@ -23,6 +24,7 @@ internal static IReadOnlyList GetReferences() metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(TimeSpan))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowActivity<,>))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(IDaprClient))); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location)); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(DaprWorkflowClient).Assembly.Location)); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.ServiceCollection).Assembly.Location)); diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs index 61f5db587..c81137487 100644 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs @@ -33,6 +33,35 @@ public Task StartAsync(DaprWorkflowClient client) await analyzer.VerifyAnalyzerAsync(testCode, expected); } + [Fact] + public async Task VerifyWorkflowInputTypeMismatch_WhenUsingWorkflowClientInterface() + { + const string testCode = """ +using Dapr.Workflow; +using System.Threading.Tasks; + +public sealed class OrderWorkflow : Workflow +{ + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); +} + +public sealed class WorkflowStarter +{ + public Task StartAsync(IDaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); + } +} +"""; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) + .WithSpan(13, 71, 13, 85) + .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + [Fact] public async Task VerifyActivityInputTypeMismatch() { From c24a72a7affa556b1d7b6d91929407fc111d5d74 Mon Sep 17 00:00:00 2001 From: Mohammad Taghavi Date: Fri, 10 Apr 2026 00:42:29 +0200 Subject: [PATCH 3/3] Add tests for workflow and activity type safety analyzer Signed-off-by: Mohammad Taghavi --- .../WorkflowTypeSafetyAnalyzerTests.cs | 585 ++++++++++++++---- 1 file changed, 468 insertions(+), 117 deletions(-) diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs index c81137487..69951ae58 100644 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowTypeSafetyAnalyzerTests.cs @@ -8,26 +8,27 @@ public sealed class WorkflowTypeSafetyAnalyzerTests public async Task VerifyWorkflowInputTypeMismatch() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; - -public sealed class OrderWorkflow : Workflow -{ - public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); -} - -public sealed class WorkflowStarter -{ - public Task StartAsync(DaprWorkflowClient client) - { - return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); - } -} -"""; + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); + } + } + """; var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) .WithSpan(13, 71, 13, 85) - .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); + .WithMessage( + "The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); await analyzer.VerifyAnalyzerAsync(testCode, expected); @@ -37,26 +38,27 @@ public Task StartAsync(DaprWorkflowClient client) public async Task VerifyWorkflowInputTypeMismatch_WhenUsingWorkflowClientInterface() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; - -public sealed class OrderWorkflow : Workflow -{ - public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); -} - -public sealed class WorkflowStarter -{ - public Task StartAsync(IDaprWorkflowClient client) - { - return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); - } -} -"""; + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(IDaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: "wrong"); + } + } + """; var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) .WithSpan(13, 71, 13, 85) - .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); + .WithMessage( + "The provided input type 'string' does not match the expected input type 'int' for workflow 'OrderWorkflow'"); var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); await analyzer.VerifyAnalyzerAsync(testCode, expected); @@ -66,26 +68,27 @@ public Task StartAsync(IDaprWorkflowClient client) public async Task VerifyActivityInputTypeMismatch() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; - -public sealed class NotifyActivity : WorkflowActivity -{ - public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); -} - -public sealed class ParentWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, string input) - { - return await context.CallActivityAsync(nameof(NotifyActivity), "wrong"); - } -} -"""; + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + return await context.CallActivityAsync(nameof(NotifyActivity), "wrong"); + } + } + """; var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) .WithSpan(13, 80, 13, 87) - .WithMessage("The provided input type 'string' does not match the expected input type 'int' for workflow activity 'NotifyActivity'"); + .WithMessage( + "The provided input type 'string' does not match the expected input type 'int' for workflow activity 'NotifyActivity'"); var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); await analyzer.VerifyAnalyzerAsync(testCode, expected); @@ -95,109 +98,457 @@ public override async Task RunAsync(WorkflowContext context, string inpu public async Task VerifyActivityOutputTypeMismatch() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + int result = await context.CallActivityAsync(nameof(NotifyActivity), 42); + return result.ToString(); + } + } + """; -public sealed class NotifyActivity : WorkflowActivity -{ - public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); -} + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) + .WithSpan(13, 36, 13, 58) + .WithMessage( + "The requested output type 'int' does not match the declared output type 'string' for workflow activity 'NotifyActivity'"); -public sealed class ParentWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, string input) - { - int result = await context.CallActivityAsync(nameof(NotifyActivity), 42); - return result.ToString(); + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); } -} -"""; + + [Fact] + public async Task VerifyChildWorkflowOutputTypeMismatch() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class ChildWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, int input) + { + var result = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); + return result.ToString(); + } + } + """; var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) - .WithSpan(13, 36, 13, 58) - .WithMessage("The requested output type 'int' does not match the declared output type 'string' for workflow activity 'NotifyActivity'"); + .WithSpan(13, 36, 13, 63) + .WithMessage( + "The requested output type 'int' does not match the declared output type 'string' for workflow 'ChildWorkflow'"); var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); await analyzer.VerifyAnalyzerAsync(testCode, expected); } [Fact] - public async Task VerifyChildWorkflowOutputTypeMismatch() + public async Task VerifyCompatibleTypesDoNotReport() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; + using Dapr.Workflow; + using System.Threading.Tasks; + + public class Notification + { + } + + public sealed class ImportantNotification : Notification + { + } + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, Notification input) => + Task.FromResult(new ImportantNotification()); + } + + public sealed class ChildWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, Notification input) => + Task.FromResult(new ImportantNotification()); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, ImportantNotification input) + { + Notification activityResult = await context.CallActivityAsync(nameof(NotifyActivity), input); + Notification childResult = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); + return activityResult ?? childResult; + } + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client, ImportantNotification input) + { + return client.ScheduleNewWorkflowAsync(nameof(ChildWorkflow), input: input); + } + } + """; -public sealed class ChildWorkflow : Workflow -{ - public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); -} + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } -public sealed class ParentWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, int input) + [Fact] + public async Task VerifyWorkflowInputMismatch_WithStringLiteralName_DoesNotReport() { - var result = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); - return result.ToString(); + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync("OrderWorkflow", input: "wrong"); + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); } -} -"""; - var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) - .WithSpan(13, 36, 13, 63) - .WithMessage("The requested output type 'int' does not match the declared output type 'string' for workflow 'ChildWorkflow'"); + [Fact] + public async Task VerifyActivityInputMismatch_WithConstStringName_DoesNotReport() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class ParentWorkflow : Workflow + { + private const string ActivityName = "NotifyActivity"; + + public override async Task RunAsync(WorkflowContext context, string input) + { + return await context.CallActivityAsync(ActivityName, "wrong"); + } + } + """; var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); - await analyzer.VerifyAnalyzerAsync(testCode, expected); + await analyzer.VerifyAnalyzerAsync(testCode); } [Fact] - public async Task VerifyCompatibleTypesDoNotReport() + public async Task VerifyWorkflowInputNull_DoesNotReport() { const string testCode = """ -using Dapr.Workflow; -using System.Threading.Tasks; + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, int input) => Task.FromResult(input.ToString()); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: null); + } + } + """; -public class Notification -{ -} + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } -public sealed class ImportantNotification : Notification -{ -} + [Fact] + public async Task VerifyUnrelatedScheduleNewWorkflowAsync_DoesNotReport() + { + const string testCode = """ + using System.Threading.Tasks; + + public sealed class OrderWorkflow + { + } + + public sealed class FakeClient + { + public Task ScheduleNewWorkflowAsync(string name, object input) => Task.CompletedTask; + } + + public sealed class WorkflowStarter + { + public Task StartAsync(FakeClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), "wrong"); + } + } + """; -public sealed class NotifyActivity : WorkflowActivity -{ - public override Task RunAsync(WorkflowActivityContext context, Notification input) => - Task.FromResult(new ImportantNotification()); -} + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } -public sealed class ChildWorkflow : Workflow -{ - public override Task RunAsync(WorkflowContext context, Notification input) => - Task.FromResult(new ImportantNotification()); -} + [Fact] + public async Task VerifyUnrelatedCallActivityAsync_DoesNotReport() + { + const string testCode = """ + using System.Threading.Tasks; + + public sealed class FakeContext + { + public Task CallActivityAsync(string name, object input) => Task.FromResult(default(T)!); + } + + public sealed class ParentWorkflow + { + public async Task RunAsync(FakeContext context) + { + return await context.CallActivityAsync(nameof(ParentWorkflow), "wrong"); + } + } + """; -public sealed class ParentWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, ImportantNotification input) + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task VerifyUnrelatedCallChildWorkflowAsync_DoesNotReport() { - Notification activityResult = await context.CallActivityAsync(nameof(NotifyActivity), input); - Notification childResult = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); - return activityResult ?? childResult; + const string testCode = """ + using System.Threading.Tasks; + + public sealed class FakeContext + { + public Task CallChildWorkflowAsync(string workflowName, object input) => Task.FromResult(default(T)!); + } + + public sealed class ChildWorkflow + { + } + + public sealed class ParentWorkflow + { + public async Task RunAsync(FakeContext context, int input) + { + var result = await context.CallChildWorkflowAsync(nameof(ChildWorkflow), input); + return result.ToString(); + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); } -} -public sealed class WorkflowStarter -{ - public Task StartAsync(DaprWorkflowClient client, ImportantNotification input) + [Fact] + public async Task VerifyActivityOutputDerivedRequestedFromBaseDeclared_Reports() { - return client.ScheduleNewWorkflowAsync(nameof(ChildWorkflow), input: input); + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public class Notification + { + } + + public sealed class ImportantNotification : Notification + { + } + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) => + Task.FromResult(new Notification()); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + ImportantNotification result = await context.CallActivityAsync(nameof(NotifyActivity), 42); + return result.ToString(); + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) + .WithSpan(22, 54, 22, 94) + .WithMessage( + "The requested output type 'ImportantNotification' does not match the declared output type 'Notification' for workflow activity 'NotifyActivity'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); } -} -"""; + + [Fact] + public async Task VerifyWorkflowInputCompatibleBaseType_DoesNotReport() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public class Notification + { + } + + public sealed class ImportantNotification : Notification + { + } + + public sealed class ChildWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, Notification input) => + Task.FromResult("ok"); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client, ImportantNotification input) + { + return client.ScheduleNewWorkflowAsync(nameof(ChildWorkflow), input: input); + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task VerifyWorkflowTupleInputCompatible_DoesNotReport() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow<(int Id, string Name), string> + { + public override Task RunAsync(WorkflowContext context, (int Id, string Name) input) + => Task.FromResult(input.Name); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: (1, "test")); + } + } + """; var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); await analyzer.VerifyAnalyzerAsync(testCode); } + + [Fact] + public async Task VerifyWorkflowTupleInputMismatch_Reports() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class OrderWorkflow : Workflow<(int Id, string Name), string> + { + public override Task RunAsync(WorkflowContext context, (int Id, string Name) input) + => Task.FromResult(input.Name); + } + + public sealed class WorkflowStarter + { + public Task StartAsync(DaprWorkflowClient client) + { + return client.ScheduleNewWorkflowAsync(nameof(OrderWorkflow), input: ("wrong", "test")); + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.InputTypeMismatchDescriptor) + .WithSpan(14, 71, 14, 95) + .WithMessage("The provided input type '(string, string)' does not match the expected input type '(int Id, string Name)' for workflow 'OrderWorkflow'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyActivityTupleOutputCompatible_DoesNotReport() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task<(int Code, string Message)> RunAsync(WorkflowActivityContext context, int input) + => Task.FromResult((input, "ok")); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + var result = await context.CallActivityAsync<(int Code, string Message)>(nameof(NotifyActivity), 42); + return result.Message; + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task VerifyActivityTupleOutputMismatch_Reports() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + public sealed class NotifyActivity : WorkflowActivity + { + public override Task<(int Code, string Message)> RunAsync(WorkflowActivityContext context, int input) + => Task.FromResult((input, "ok")); + } + + public sealed class ParentWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + var result = await context.CallActivityAsync<(string Code, string Message)>(nameof(NotifyActivity), 42); + return result.Message; + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(WorkflowTypeSafetyAnalyzer.OutputTypeMismatchDescriptor) + .WithSpan(14, 36, 14, 84) + .WithMessage("The requested output type '(string Code, string Message)' does not match the declared output type '(int Code, string Message)' for workflow activity 'NotifyActivity'"); + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, expected); + } }