diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs index c3677fab6..bef1c5c3e 100644 --- a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs @@ -1,6 +1,8 @@ using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis; +using System.Collections.Concurrent; using System.Collections.Immutable; +using System.Threading; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -12,6 +14,9 @@ namespace Dapr.Workflow.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class WorkflowRegistrationAnalyzer : DiagnosticAnalyzer { + private const string WorkflowVersioningExtensionsMetadataName = + "Dapr.Workflow.Versioning.WorkflowVersioningServiceCollectionExtensions"; + internal static readonly DiagnosticDescriptor WorkflowDiagnosticDescriptor = new( id: "DAPR1301", title: new LocalizableResourceString(nameof(Resources.DAPR1301Title), Resources.ResourceManager, typeof(Resources)), @@ -33,76 +38,139 @@ public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeWorkflowRegistration, SyntaxKind.InvocationExpression); + context.RegisterCompilationStartAction(compilationContext => + { + var versioningExtensionsType = compilationContext.Compilation + .GetTypeByMetadataName(WorkflowVersioningExtensionsMetadataName); + + if (versioningExtensionsType is null) + { + // Versioning package is not referenced; use the direct reporting path. + compilationContext.RegisterSyntaxNodeAction(AnalyzeWorkflowRegistration, SyntaxKind.InvocationExpression); + return; + } + + // Versioning package is referenced. Use the deferred-diagnostics pattern so that + // AddDaprWorkflowVersioning can be verified semantically (avoiding RS1030) while + // still checking explicit RegisterWorkflow registrations per workflow call. + // Node actions can execute concurrently, so both collections are thread-safe and + // results are only acted on in the compilation-end action (which runs after all + // node actions have finished). + int versioningCalled = 0; + var pendingDiagnostics = new ConcurrentBag(); + + compilationContext.RegisterSyntaxNodeAction(nodeContext => + { + var invocation = (InvocationExpressionSyntax)nodeContext.Node; + + // Semantic check: verify the call resolves to the Dapr versioning extension method. + if (invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflowVersioning" && + nodeContext.SemanticModel.GetSymbolInfo(invocation, nodeContext.CancellationToken).Symbol is IMethodSymbol method && + SymbolEqualityComparer.Default.Equals(method.ContainingType, versioningExtensionsType)) + { + Interlocked.Exchange(ref versioningCalled, 1); + } + }, SyntaxKind.InvocationExpression); + + compilationContext.RegisterSyntaxNodeAction(nodeContext => + { + // Collect potential DAPR1301 diagnostics; explicit RegisterWorkflow + // registrations are still respected here. + var diagnostic = TryBuildWorkflowDiagnostic(nodeContext); + if (diagnostic is not null) + pendingDiagnostics.Add(diagnostic); + }, SyntaxKind.InvocationExpression); + + compilationContext.RegisterCompilationEndAction(endContext => + { + // If AddDaprWorkflowVersioning was confirmed, all workflows are auto-registered + // by the source generator — suppress any pending DAPR1301 diagnostics. + if (Volatile.Read(ref versioningCalled) == 1) + return; + + foreach (var d in pendingDiagnostics) + endContext.ReportDiagnostic(d); + }); + }); } private static void AnalyzeWorkflowRegistration(SyntaxNodeAnalysisContext context) + { + var diagnostic = TryBuildWorkflowDiagnostic(context); + if (diagnostic is not null) + context.ReportDiagnostic(diagnostic); + } + + private static Diagnostic? TryBuildWorkflowDiagnostic(SyntaxNodeAnalysisContext context) { var invocationExpr = (InvocationExpressionSyntax)context.Node; if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) - return; + return null; if (memberAccessExpr.Name.Identifier.Text != "ScheduleNewWorkflowAsync") - return; + return null; var argumentList = invocationExpr.ArgumentList.Arguments; if (argumentList.Count == 0) - return; + return null; var firstArgument = argumentList[0].Expression; if (firstArgument is not InvocationExpressionSyntax nameofInvocation || - nameofInvocation.Expression is not IdentifierNameSyntax { Identifier.Text : "nameof"} || - nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not {} nameofArgExpr) - return; - + nameofInvocation.Expression is not IdentifierNameSyntax { Identifier.Text: "nameof" } || + nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not { } nameofArgExpr) + return null; + if (context.SemanticModel.GetSymbolInfo(nameofArgExpr, context.CancellationToken).Symbol is not INamedTypeSymbol workflowTypeSymbol) - return; - - var isRegistered = CheckIfWorkflowIsRegistered(workflowTypeSymbol, context.SemanticModel, context.CancellationToken); - if (isRegistered) - { - return; - } - - var workflowName = workflowTypeSymbol.Name; - var diagnostic = Diagnostic.Create(WorkflowDiagnosticDescriptor, firstArgument.GetLocation(), workflowName); - context.ReportDiagnostic(diagnostic); + return null; + + if (CheckIfWorkflowIsRegistered(workflowTypeSymbol, context.SemanticModel, context.CancellationToken)) + return null; + + return Diagnostic.Create(WorkflowDiagnosticDescriptor, firstArgument.GetLocation(), workflowTypeSymbol.Name); } private static bool CheckIfWorkflowIsRegistered(INamedTypeSymbol workflowType, SemanticModel semanticModel, CancellationToken cancellationToken) { - var methodInvocations = new List(); foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) { var root = syntaxTree.GetRoot(cancellationToken); - methodInvocations.AddRange(root.DescendantNodes().OfType()); - } + var isSameTree = syntaxTree == semanticModel.SyntaxTree; - foreach (var invocation in methodInvocations) - { - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + foreach (var invocation in root.DescendantNodes().OfType()) { - continue; - } + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } - if (memberAccess.Name is not GenericNameSyntax genericName || - genericName.Identifier.Text != "RegisterWorkflow" || - genericName.TypeArgumentList.Arguments.Count == 0) - { - continue; - } + if (memberAccess.Name is not GenericNameSyntax genericName || + genericName.Identifier.Text != "RegisterWorkflow" || + genericName.TypeArgumentList.Arguments.Count == 0) + { + continue; + } - var typeArgSyntax = genericName.TypeArgumentList.Arguments[0]; - var typeArgSymbol = semanticModel.GetSymbolInfo(typeArgSyntax, cancellationToken).Symbol as INamedTypeSymbol; - if (typeArgSymbol is null) - { - continue; - } + var typeArgSyntax = genericName.TypeArgumentList.Arguments[0]; - if (SymbolEqualityComparer.Default.Equals(typeArgSymbol, workflowType)) - { - return true; + if (isSameTree) + { + // Use full semantic comparison for nodes in the same tree. + var typeArgSymbol = semanticModel.GetSymbolInfo(typeArgSyntax, cancellationToken).Symbol as INamedTypeSymbol; + if (typeArgSymbol is not null && SymbolEqualityComparer.Default.Equals(typeArgSymbol, workflowType)) + return true; + } + else + { + // For nodes in other trees we cannot use this semantic model (RS1030 prevents + // calling Compilation.GetSemanticModel). Fall back to a syntactic name + // comparison, which is sufficient for the common case of non-generic workflow + // types with distinct names. + if (typeArgSyntax is IdentifierNameSyntax identifierName && + identifierName.Identifier.Text == workflowType.Name) + return true; + } } } diff --git a/test/Dapr.Workflow.Analyzers.Test/Dapr.Workflow.Analyzers.Test.csproj b/test/Dapr.Workflow.Analyzers.Test/Dapr.Workflow.Analyzers.Test.csproj index 92daa4429..4108031e2 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Dapr.Workflow.Analyzers.Test.csproj +++ b/test/Dapr.Workflow.Analyzers.Test/Dapr.Workflow.Analyzers.Test.csproj @@ -29,6 +29,8 @@ + + diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs index 3845dd160..d860082bf 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -3,6 +3,7 @@ using System.Collections.Immutable; using Dapr.Analyzers.Common; using Dapr.Common; +using Dapr.Workflow.Versioning; using Microsoft.Extensions.Hosting; namespace Dapr.Workflow.Analyzers.Test; @@ -25,6 +26,7 @@ internal static IReadOnlyList GetReferences() metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowActivity<,>))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(IDaprClient))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowVersioningServiceCollectionExtensions))); 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/WorkflowRegistrationAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs index ee684db2e..a3ecbc4c8 100644 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs @@ -38,6 +38,99 @@ record OrderResult(string message) { } await analyzer.VerifyAnalyzerAsync(testCode, expected); } + [Fact] + public async Task VerifyWorkflowNotRegisteredButVersioningPresent() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult("Order processed"); + } + } + + class UseWorkflow() + { + public async Task RunWorkflow(DaprWorkflowClient client, OrderPayload order) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, order); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + const string startupCode = """ + using Dapr.Workflow.Versioning; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflowVersioning(); + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + + [Fact] + public async Task VerifyWorkflowRegisteredWithVersioningPresent() + { + const string testCode = """ + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult("Order processed"); + } + } + + class UseWorkflow() + { + public async Task RunWorkflow(DaprWorkflowClient client, OrderPayload order) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, order); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + const string startupCode = """ + using Dapr.Workflow; + using Dapr.Workflow.Versioning; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflowVersioning(); + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + } + } + """; + + var analyzer = new VerifyAnalyzer(Utilities.GetReferences()); + await analyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + [Fact] public async Task VerifyWorkflowRegistered() {