diff --git a/Microsoft.DurableTask.sln b/Microsoft.DurableTask.sln index bec404b26..f465bd667 100644 --- a/Microsoft.DurableTask.sln +++ b/Microsoft.DurableTask.sln @@ -67,7 +67,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "test\Benchmar EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers", "src\Analyzers\Analyzers.csproj", "{998E9D97-BD36-4A9D-81FC-5DAC1CE40083}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers.Test", "test\Analyzers.Test\Analyzers.Test.csproj", "{541FCCCE-1059-4691-B027-F761CD80DE92}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers.Tests", "test\Analyzers.Tests\Analyzers.Tests.csproj", "{541FCCCE-1059-4691-B027-F761CD80DE92}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/Analyzers/AnalyzerReleases.Shipped.md b/src/Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..60b59dd99 --- /dev/null +++ b/src/Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..5f2748e4f --- /dev/null +++ b/src/Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; 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 +--------|----------|----------|------- +DURABLE0001 | Orchestration | Warning | DateTimeOrchestrationAnalyzer \ No newline at end of file diff --git a/src/Analyzers/Analyzers.csproj b/src/Analyzers/Analyzers.csproj index c49a7f0eb..f019c44c6 100644 --- a/src/Analyzers/Analyzers.csproj +++ b/src/Analyzers/Analyzers.csproj @@ -3,6 +3,7 @@ netstandard2.0 true + true false @@ -29,6 +30,7 @@ + diff --git a/src/Analyzers/AnalyzersCategories.cs b/src/Analyzers/AnalyzersCategories.cs new file mode 100644 index 000000000..2f2b1a6bd --- /dev/null +++ b/src/Analyzers/AnalyzersCategories.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DurableTask.Analyzers; + +/// +/// Provides a set of well-known categories that are used by the analyzers diagnostics. +/// +static class AnalyzersCategories +{ + /// + /// The category for the orchestration related analyzers. + /// + public const string Orchestration = "Orchestration"; +} diff --git a/src/Analyzers/KnownTypeSymbols.cs b/src/Analyzers/KnownTypeSymbols.cs new file mode 100644 index 000000000..81a45191e --- /dev/null +++ b/src/Analyzers/KnownTypeSymbols.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.DurableTask.Analyzers; + +/// +/// Provides a set of well-known types that are used by the analyzers. +/// Inspired by KnownTypeSymbols class in +/// System.Text.Json.SourceGeneration source code. +/// Lazy initialization is used to avoid the the initialization of all types during class construction, since not all symbols are used by all analyzers. +/// +sealed class KnownTypeSymbols(Compilation compilation) +{ + readonly Compilation compilation = compilation; + + INamedTypeSymbol? functionOrchestrationAttribute; + INamedTypeSymbol? functionNameAttribute; + INamedTypeSymbol? taskOrchestratorInterface; + INamedTypeSymbol? taskOrchestratorBaseClass; + INamedTypeSymbol? durableTaskRegistry; + + /// + /// Gets an OrchestrationTriggerAttribute type symbol. + /// + public INamedTypeSymbol? FunctionOrchestrationAttribute => this.GetOrResolveFullyQualifiedType("Microsoft.Azure.Functions.Worker.OrchestrationTriggerAttribute", ref this.functionOrchestrationAttribute); + + /// + /// Gets a FunctionNameAttribute type symbol. + /// + public INamedTypeSymbol? FunctionNameAttribute => this.GetOrResolveFullyQualifiedType("Microsoft.Azure.Functions.Worker.FunctionAttribute", ref this.functionNameAttribute); + + /// + /// Gets an ITaskOrchestrator type symbol. + /// + public INamedTypeSymbol? TaskOrchestratorInterface => this.GetOrResolveFullyQualifiedType("Microsoft.DurableTask.ITaskOrchestrator", ref this.taskOrchestratorInterface); + + /// + /// Gets a TaskOrchestrator type symbol. + /// + public INamedTypeSymbol? TaskOrchestratorBaseClass => this.GetOrResolveFullyQualifiedType("Microsoft.DurableTask.TaskOrchestrator`2", ref this.taskOrchestratorBaseClass); + + /// + /// Gets a DurableTaskRegistry type symbol. + /// + public INamedTypeSymbol? DurableTaskRegistry => this.GetOrResolveFullyQualifiedType("Microsoft.DurableTask.DurableTaskRegistry", ref this.durableTaskRegistry); + + INamedTypeSymbol? GetOrResolveFullyQualifiedType(string fullyQualifiedName, ref INamedTypeSymbol? field) + { + if (field != null) + { + return field; + } + + return field = this.compilation.GetTypeByMetadataName(fullyQualifiedName); + } +} diff --git a/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs new file mode 100644 index 000000000..04ccdc213 --- /dev/null +++ b/src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.DurableTask.Analyzers.Orchestration; + +/// +/// Analyzer that reports a warning when a non-deterministic DateTime property is used in an orchestration method. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer +{ + /// + /// Diagnostic ID supported for the analyzer. + /// + public const string DiagnosticId = "DURABLE0001"; + + static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.DateTimeOrchestrationAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.DateTimeOrchestrationAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + Title, + MessageFormat, + AnalyzersCategories.Orchestration, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + public override ImmutableArray SupportedDiagnostics => [Rule]; + + /// + protected override void RegisterAdditionalCompilationStartAction(CompilationStartAnalysisContext context, OrchestrationAnalysisResult orchestrationAnalysisResult) + { + INamedTypeSymbol systemDateTimeSymbol = context.Compilation.GetSpecialType(SpecialType.System_DateTime); + + // stores the symbols (such as methods) and the DateTime references used in them + ConcurrentBag<(ISymbol Symbol, IPropertyReferenceOperation Operation)> dateTimeUsage = []; + + // search for usages of DateTime.Now, DateTime.UtcNow, DateTime.Today and store them + context.RegisterOperationAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + var operation = (IPropertyReferenceOperation)ctx.Operation; + IPropertySymbol property = operation.Property; + + if (!property.ContainingSymbol.Equals(systemDateTimeSymbol, SymbolEqualityComparer.Default)) + { + return; + } + + if (property.Name is nameof(DateTime.Now) or nameof(DateTime.UtcNow) or nameof(DateTime.Today)) + { + ISymbol method = ctx.ContainingSymbol; + dateTimeUsage.Add((method, operation)); + } + }, + OperationKind.PropertyReference); + + // compare whether the found DateTime usages occur in methods invoked by orchestrations + context.RegisterCompilationEndAction(ctx => + { + foreach ((ISymbol symbol, IPropertyReferenceOperation operation) in dateTimeUsage) + { + if (symbol is IMethodSymbol method) + { + if (orchestrationAnalysisResult.OrchestrationsByMethod.TryGetValue(method, out ConcurrentBag orchestrations)) + { + string methodName = symbol.Name; + string dateTimePropertyName = operation.Property.ToString(); + string orchestrationNames = string.Join(", ", orchestrations.Select(o => o.Name).OrderBy(n => n)); + + // e.g.: "The method 'Method1' uses 'System.Date.Now' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'" + ctx.ReportDiagnostic(Rule, operation, methodName, dateTimePropertyName, orchestrationNames); + } + } + } + }); + } +} diff --git a/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs b/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs new file mode 100644 index 000000000..ad1450474 --- /dev/null +++ b/src/Analyzers/Orchestration/OrchestrationAnalyzer.cs @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.DurableTask.Analyzers.Orchestration; + +/// +/// Base class for analyzers that analyze orchestrations. +/// +public abstract class OrchestrationAnalyzer : DiagnosticAnalyzer +{ + /// + public override void Initialize(AnalysisContext context) + { + // this analyzer uses concurrent collections/operations, so we can enable actions concurrent execution to improve performance + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(context => + { + KnownTypeSymbols knownSymbols = new(context.Compilation); + + if (knownSymbols.FunctionOrchestrationAttribute == null || knownSymbols.FunctionNameAttribute == null || + knownSymbols.TaskOrchestratorInterface == null || knownSymbols.TaskOrchestratorBaseClass == null || + knownSymbols.DurableTaskRegistry == null) + { + // symbols not available in this compilation, skip analysis + return; + } + + IMethodSymbol? runAsyncTaskOrchestratorInterface = knownSymbols.TaskOrchestratorInterface.GetMembers("RunAsync").OfType().FirstOrDefault(); + IMethodSymbol? runAsyncTaskOrchestratorBase = knownSymbols.TaskOrchestratorBaseClass.GetMembers("RunAsync").OfType().FirstOrDefault(); + if (runAsyncTaskOrchestratorInterface == null || runAsyncTaskOrchestratorBase == null) + { + return; + } + + OrchestrationAnalysisResult result = new(); + + // look for Durable Functions Orchestrations + context.RegisterSyntaxNodeAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.ContainingSymbol is not IMethodSymbol methodSymbol) + { + return; + } + + if (!methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute)) + { + return; + } + + if (!methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName)) + { + return; + } + + AnalyzedOrchestration orchestration = new(functionName); + var rootMethodSyntax = (MethodDeclarationSyntax)ctx.Node; + + FindInvokedMethods(ctx.SemanticModel, rootMethodSyntax, methodSymbol, orchestration, result); + }, + SyntaxKind.MethodDeclaration); + + // look for TaskOrchestrator`2 Orchestrations + context.RegisterSyntaxNodeAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + { + return; + } + + if (!classSymbol.BaseTypeIsConstructedFrom(knownSymbols.TaskOrchestratorBaseClass)) + { + return; + } + + // Get the method that overrides TaskOrchestrator.RunAsync + IMethodSymbol? methodSymbol = classSymbol.GetOverridenMethod(runAsyncTaskOrchestratorBase); + if (methodSymbol == null) + { + return; + } + + AnalyzedOrchestration orchestration = new(classSymbol.Name); + + IEnumerable methodSyntaxes = methodSymbol.GetSyntaxNodes(); + foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes) + { + FindInvokedMethods(ctx.SemanticModel, rootMethodSyntax, methodSymbol, orchestration, result); + } + }, + SyntaxKind.ClassDeclaration); + + // look for ITaskOrchestrator Orchestrations + context.RegisterSyntaxNodeAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + { + return; + } + + // Gets the method that implements ITaskOrchestrator.RunAsync + if (classSymbol.FindImplementationForInterfaceMember(runAsyncTaskOrchestratorInterface) is not IMethodSymbol methodSymbol) + { + return; + } + + // Skip if the found method is implemented in TaskOrchestrator + if (methodSymbol.ContainingType.ConstructedFrom.Equals(knownSymbols.TaskOrchestratorBaseClass, SymbolEqualityComparer.Default)) + { + return; + } + + AnalyzedOrchestration orchestration = new(classSymbol.Name); + + IEnumerable methodSyntaxes = methodSymbol.GetSyntaxNodes(); + foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes) + { + FindInvokedMethods(ctx.SemanticModel, rootMethodSyntax, methodSymbol, orchestration, result); + } + }, + SyntaxKind.ClassDeclaration); + + // look for OrchestratorFunc Orchestrations + context.RegisterOperationAction( + ctx => + { + if (ctx.Operation is not IInvocationOperation invocation) + { + return; + } + + if (!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry)) + { + return; + } + + // there are 8 AddOrchestratorFunc overloads + if (invocation.TargetMethod.Name != "AddOrchestratorFunc") + { + return; + } + + // all overloads have the parameter 'orchestrator', either as an Action or a Func + IArgumentOperation orchestratorArgument = invocation.Arguments.First(a => a.Parameter!.Name == "orchestrator"); + if (orchestratorArgument.Value is not IDelegateCreationOperation delegateCreationOperation) + { + return; + } + + // obtains the method symbol from the delegate creation operation + IMethodSymbol? methodSymbol = null; + switch (delegateCreationOperation.Target) + { + case IAnonymousFunctionOperation lambdaOperation: + // use the containing symbol of the lambda (e.g. the class declaring it) as the method symbol + methodSymbol = ctx.ContainingSymbol as IMethodSymbol; + break; + case IMethodReferenceOperation methodReferenceOperation: + // use the method reference as the method symbol + methodSymbol = methodReferenceOperation.Method; + break; + default: + break; + } + + if (methodSymbol == null) + { + return; + } + + // try to get the name of the orchestration from the method call, otherwise use the containing type name + IArgumentOperation nameArgument = invocation.Arguments.First(a => a.Parameter!.Name == "name"); + Optional name = nameArgument.GetConstantValueFromAttribute(ctx.Operation.SemanticModel!, ctx.CancellationToken); + string orchestrationName = name.Value?.ToString() ?? methodSymbol.Name; + + AnalyzedOrchestration orchestration = new(orchestrationName); + + SyntaxNode funcRootSyntax = delegateCreationOperation.Syntax; + + FindInvokedMethods(ctx.Operation.SemanticModel!, funcRootSyntax, methodSymbol, orchestration, result); + }, + OperationKind.Invocation); + + // allows concrete implementations to register specific actions/analysis and then check if they happen in any of the orchestration methods + this.RegisterAdditionalCompilationStartAction(context, result); + }); + } + + /// + /// Register additional actions to be executed after the compilation has started. + /// It is expected from a concrete implementation of to register a + /// + /// and then compare that to any discovered violations happened in any of the symbols in . + /// + /// Context originally provided by . + /// Collection of symbols referenced by orchestrations. + protected abstract void RegisterAdditionalCompilationStartAction(CompilationStartAnalysisContext context, OrchestrationAnalysisResult orchestrationAnalysisResult); + + // Recursively find all methods invoked by the orchestration root method and call the appropriate visitor method + static void FindInvokedMethods( + SemanticModel semanticModel, + SyntaxNode callerSyntax, + IMethodSymbol callerSymbol, + AnalyzedOrchestration rootOrchestration, + OrchestrationAnalysisResult result) + { + // add the visited method to the list of orchestrations + ConcurrentBag orchestrations = result.OrchestrationsByMethod.GetOrAdd(callerSymbol, []); + if (orchestrations.Contains(rootOrchestration)) + { + // previously tracked method, leave to avoid infinite recursion + return; + } + + orchestrations.Add(rootOrchestration); + + foreach (InvocationExpressionSyntax invocationSyntax in callerSyntax.DescendantNodes().OfType()) + { + IOperation? operation = semanticModel.GetOperation(invocationSyntax); + if (operation == null || operation is not IInvocationOperation invocation) + { + continue; + } + + IMethodSymbol calleeMethodSymbol = invocation.TargetMethod; + if (calleeMethodSymbol == null) + { + continue; + } + + // iterating over multiple syntax references is needed because the same method can be declared in multiple places (e.g. partial classes) + IEnumerable calleeSyntaxes = calleeMethodSymbol.GetSyntaxNodes(); + foreach (MethodDeclarationSyntax calleeSyntax in calleeSyntaxes) + { + FindInvokedMethods(semanticModel, calleeSyntax, calleeMethodSymbol, rootOrchestration, result); + } + } + } + + /// + /// Data structure to store the result of the orchestration methods analysis. + /// + protected readonly struct OrchestrationAnalysisResult + { + /// + /// Initializes a new instance of the struct. + /// + public OrchestrationAnalysisResult() + { + this.OrchestrationsByMethod = new(SymbolEqualityComparer.Default); + } + + /// + /// Gets the orchestrations that invokes/reaches a given method. + /// + public ConcurrentDictionary> OrchestrationsByMethod { get; } + } + + /// + /// Data structure to store the orchestration data. + /// + /// Name of the orchestration. + protected readonly struct AnalyzedOrchestration(string name) + { + /// + /// Gets the name of the orchestration. + /// + public string Name { get; } = name; + } +} diff --git a/src/Analyzers/Resources.resx b/src/Analyzers/Resources.resx new file mode 100644 index 000000000..12e076813 --- /dev/null +++ b/src/Analyzers/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The method '{0}' uses '{1}' that may cause non-deterministic behavior when invoked from orchestration '{2}' + + + System.DateTime calls must be deterministic inside an orchestration + + \ No newline at end of file diff --git a/src/Analyzers/RoslynExtensions.cs b/src/Analyzers/RoslynExtensions.cs new file mode 100644 index 000000000..194449967 --- /dev/null +++ b/src/Analyzers/RoslynExtensions.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.DurableTask.Analyzers; + +/// +/// Extension methods for working with Roslyn types. +/// +static class RoslynExtensions +{ + /// + /// Tries to get the value of an attribute that has a single value. + /// + /// Convertion Type. + /// Symbol containing the annotation. + /// Attribute to look for. + /// Retrieved value from the attribute instance. + /// true if the method succeeded to retrieve the value, false otherwise. + public static bool TryGetSingleValueFromAttribute(this ISymbol? symbol, INamedTypeSymbol attributeSymbol, out T value) + { + if (symbol.TryGetConstructorArgumentsFromAttribute(attributeSymbol, out ImmutableArray constructorArguments)) + { + object? valueObj = constructorArguments.FirstOrDefault().Value; + if (valueObj != null) + { + value = (T)valueObj; + return true; + } + } + + value = default!; + return false; + } + + /// + /// Determines whether a method has a parameter with the specified attribute. + /// + /// Method symbol. + /// Attribute class symbol. + /// True if the method has the parameter, false otherwise. + public static bool ContainsAttributeInAnyMethodArguments(this IMethodSymbol methodSymbol, INamedTypeSymbol attributeSymbol) + { + return methodSymbol.Parameters + .SelectMany(p => p.GetAttributes()) + .Any(a => attributeSymbol.Equals(a.AttributeClass, SymbolEqualityComparer.Default)); + } + + /// + /// Determines whether the base type of a symbol is constructed from a specified type. + /// + /// Constructed Type Symbol. + /// Contructed From Type Symbol. + /// True if the base type is constructed from the specified type, false otherwise. + public static bool BaseTypeIsConstructedFrom(this INamedTypeSymbol symbol, ITypeSymbol type) + { + INamedTypeSymbol? baseType = symbol.BaseType; + while (baseType != null) + { + if (baseType.ConstructedFrom.Equals(type, SymbolEqualityComparer.Default)) + { + return true; + } + + baseType = baseType.BaseType; + } + + return false; + } + + /// + /// Gets the method that overrides a type's method. + /// + /// Type symbol containing the methods to look for. + /// Method to look for in the type symbol. + /// The overriden method. + public static IMethodSymbol? GetOverridenMethod(this INamedTypeSymbol typeSymbol, IMethodSymbol methodSymbol) + { + IEnumerable methods = typeSymbol.GetMembers(methodSymbol.Name).OfType(); + return methods.FirstOrDefault(m => m.OverriddenMethod != null && m.OverriddenMethod.OriginalDefinition.Equals(methodSymbol, SymbolEqualityComparer.Default)); + } + + /// + /// Gets the syntax nodes of a method symbol. + /// + /// Method symbol. + /// The collection of syntax nodes of a given method symbol. + public static IEnumerable GetSyntaxNodes(this IMethodSymbol methodSymbol) + { + return methodSymbol.DeclaringSyntaxReferences.Select(r => r.GetSyntax()).OfType(); + } + + /// + /// Gets the literal value of an argument operation. + /// + /// Argument operation. + /// Semantical model. + /// Cancellation Token. + /// The literal value of the argument. + public static Optional GetConstantValueFromAttribute(this IArgumentOperation argumentOperation, SemanticModel semanticModel, CancellationToken cancellationToken) + { + LiteralExpressionSyntax? nameLiteralSyntax = argumentOperation.Syntax.DescendantNodes().OfType().FirstOrDefault(); + + return semanticModel.GetConstantValue(nameLiteralSyntax, cancellationToken); + } + + /// + /// Reports a diagnostic for a given operation. + /// + /// Context for a compilation action. + /// Diagnostic Descriptor to be reported. + /// Operation which the location will be extracted. + /// Diagnostic message arguments to be reported. + public static void ReportDiagnostic(this CompilationAnalysisContext ctx, DiagnosticDescriptor descriptor, IOperation operation, params string[] messageArgs) + { + ctx.ReportDiagnostic(BuildDiagnostic(descriptor, operation.Syntax, messageArgs)); + } + + static Diagnostic BuildDiagnostic(DiagnosticDescriptor descriptor, SyntaxNode syntaxNode, params string[] messageArgs) + { + return Diagnostic.Create(descriptor, syntaxNode.GetLocation(), messageArgs); + } + + static bool TryGetConstructorArgumentsFromAttribute(this ISymbol? symbol, INamedTypeSymbol attributeSymbol, out ImmutableArray constructorArguments) + { + if (symbol != null) + { + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (attributeSymbol.Equals(attribute.AttributeClass, SymbolEqualityComparer.Default)) + { + constructorArguments = attribute.ConstructorArguments; + return true; + } + } + } + + return false; + } +} diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 15f3aa2fe..13d3e148b 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -18,7 +18,7 @@ - + diff --git a/test/Analyzers.Test/Analyzers.Test.csproj b/test/Analyzers.Tests/Analyzers.Tests.csproj similarity index 100% rename from test/Analyzers.Test/Analyzers.Test.csproj rename to test/Analyzers.Tests/Analyzers.Tests.csproj diff --git a/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs b/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs new file mode 100644 index 000000000..a546f6447 --- /dev/null +++ b/test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs @@ -0,0 +1,331 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Testing; +using Microsoft.DurableTask.Analyzers.Orchestration; + +using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace Microsoft.DurableTask.Analyzers.Tests.Orchestration; + +public class DateTimeOrchestrationAnalyzerTests +{ + [Fact] + public async Task EmptyCodeWithNoSymbolsAvailableHasNoDiag() + { + string code = @""; + + // checks that empty code with no assembly references of Durable Functions has no diagnostics. + // this guarantees that if someone adds our analyzer to a project that doesn't use Durable Functions, + // the analyzer won't crash/they won't get any diagnostics + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [Fact] + public async Task EmptyCodeWithSymbolsAvailableHasNoDiag() + { + string code = @""; + + // checks that empty code with access to assembly references of Durable Functions has no diagnostics + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task NonOrchestrationHasNoDiag() + { + string code = WrapDurableFunctionOrchestration(@" +void Method(){ + Console.WriteLine(DateTime.Now); +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Theory] + [InlineData("DateTime.Now")] + [InlineData("DateTime.UtcNow")] + [InlineData("DateTime.Today")] + public async Task DurableFunctionOrchestrationUsingDateTimeNonDeterministicPropertiesHasDiag(string expression) + { + string code = WrapDurableFunctionOrchestration($@" +[Function(""Run"")] +DateTime Run([OrchestrationTrigger] TaskOrchestrationContext context) +{{ + return {{|#0:{expression}|}}; +}} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run", $"System.{expression}", "Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationInvokingChainedMethodsHasDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(""Run1"")] +long Run1([OrchestrationTrigger] TaskOrchestrationContext context) => Level1(); + +[Function(""Run2"")] +long Run2([OrchestrationTrigger] TaskOrchestrationContext context) => Level1(); + +long Level1() => Level2(); + +long Level2() => Level3(); + +long Level3() => {|#0:DateTime.Now|}.Ticks; +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Level3", "System.DateTime.Now", "Run1, Run2"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationInvokingRecursiveMethodsHasSingleDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +long Run([OrchestrationTrigger] TaskOrchestrationContext context) => RecursiveMethod(0); + +long RecursiveMethod(int i){ + if (i == 10) return 1; + DateTime date = {|#0:DateTime.Now|}; + return date.Ticks + RecursiveMethod(i + 1); +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("RecursiveMethod", "System.DateTime.Now", "Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationInvokingMethodMultipleTimesHasSingleDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +void Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + _ = Method(); + _ = Method(); + _ = Method(); +} + +DateTime Method() => {|#0:DateTime.Now|}; +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Method", "System.DateTime.Now", "Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationInvokingAsyncMethodsHasDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(nameof(Run))] +async Task Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + _ = await ValueTaskInvocation(); + _ = await TaskInvocation(); +} + +static ValueTask ValueTaskInvocation() => ValueTask.FromResult({|#0:DateTime.Now|}); + +static Task TaskInvocation() => Task.FromResult({|#1:DateTime.Now|}); +"); + + DiagnosticResult valueTaskExpected = BuildDiagnostic().WithLocation(0).WithArguments("ValueTaskInvocation", "System.DateTime.Now", "Run"); + DiagnosticResult taskExpected = BuildDiagnostic().WithLocation(1).WithArguments("TaskInvocation", "System.DateTime.Now", "Run"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, valueTaskExpected, taskExpected); + } + + [Fact] + public async Task DurableFunctionOrchestrationUsingLambdasHasDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +void Run([OrchestrationTrigger] TaskOrchestrationContext context) +{ + static DateTime fn0() => {|#0:DateTime.Now|}; + Func fn1 = () => {|#1:DateTime.Now|}; + Func fn2 = days => {|#2:DateTime.Now|}.AddDays(days); + Action fn3 = days => Console.WriteLine({|#3:DateTime.Now|}.AddDays(days)); +} +"); + + DiagnosticResult[] expected = Enumerable.Range(0, 4).Select( + i => BuildDiagnostic().WithLocation(i).WithArguments($"Run", "System.DateTime.Now", "Run")).ToArray(); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionOrchestrationNotInvokingMethodHasNoDiag() + { + string code = WrapDurableFunctionOrchestration(@" +[Function(""Run"")] +DateTime Run([OrchestrationTrigger] TaskOrchestrationContext context) => new DateTime(2024, 1, 1); + +DateTime NotCalled() => DateTime.Now; +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + + [Fact] + public async Task TaskOrchestratorHasDiag() + { + string code = WrapTaskOrchestrator(@" +public class MyOrchestrator : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult(Method()); + } + + private DateTime Method() => {|#0:DateTime.Now|}; +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Method", "System.DateTime.Now", "MyOrchestrator"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task TaskOrchestratorImplementingInterfaceHasDiag() + { + string code = WrapTaskOrchestrator(@" +public class MyOrchestrator : ITaskOrchestrator +{ + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + + public Task RunAsync(TaskOrchestrationContext context, object? input) + { + return Task.FromResult((object?)Method()); + } + + private DateTime Method() => {|#0:DateTime.Now|}; +} +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Method", "System.DateTime.Now", "MyOrchestrator"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + + [Fact] + public async Task FuncOrchestratorWithLambdaHasDiag() + { + string code = WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", context => +{ + return {|#0:DateTime.Now|}; +}); +"); + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Main", "System.DateTime.Now", "HelloSequence"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task FuncOrchestratorWithMethodReferenceHasDiag() + { + string code = @" +using System; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; + +public class Program +{ + public static void Main() + { + new ServiceCollection().AddDurableTaskWorker(builder => + { + builder.AddTasks(tasks => + { + tasks.AddOrchestratorFunc(""MyRun"", MyRunAsync); + }); + }); + } + + static DateTime MyRunAsync(TaskOrchestrationContext context) + { + return {|#0:DateTime.Now|}; + } +} +"; + + DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyRunAsync", "System.DateTime.Now", "MyRun"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + + static string WrapDurableFunctionOrchestration(string code) + { + return $@" +{Usings()} +class Orchestrator +{{ +{code} +}} +"; + } + + static string WrapTaskOrchestrator(string code) + { + return $@" +{Usings()} +{code} +"; + } + + static string WrapFuncOrchestrator(string code) + { + return $@" +{Usings()} + +public class Program +{{ + public static void Main() + {{ + new ServiceCollection().AddDurableTaskWorker(builder => + {{ + builder.AddTasks(tasks => + {{ + {code} + }}); + }}); + }} +}} +"; + } + + static string Usings() + { + return $@" +using System; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Worker; +using Microsoft.Extensions.DependencyInjection; +"; + } + + static DiagnosticResult BuildDiagnostic() + { + return VerifyCS.Diagnostic(DateTimeOrchestrationAnalyzer.DiagnosticId); + } +} diff --git a/test/Analyzers.Test/Usings.cs b/test/Analyzers.Tests/Usings.cs similarity index 100% rename from test/Analyzers.Test/Usings.cs rename to test/Analyzers.Tests/Usings.cs diff --git a/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs new file mode 100644 index 000000000..89a1178a8 --- /dev/null +++ b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace Microsoft.DurableTask.Analyzers.Tests.Verifiers; + +// Includes Durable Functions NuGet packages to an analyzer test and runs it +public static partial class CSharpAnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() +{ + /// + public static async Task VerifyDurableTaskAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + Test test = new() + { + TestCode = source, + ReferenceAssemblies = ReferenceAssemblies.Net.Net60.AddPackages([ + new PackageIdentity("Microsoft.Azure.Functions.Worker", "1.21.0"), + new PackageIdentity("Microsoft.Azure.Functions.Worker.Extensions.DurableTask", "1.1.1") + ]), + }; + + test.ExpectedDiagnostics.AddRange(expected); + + await test.RunAsync(CancellationToken.None); + } +} diff --git a/test/Analyzers.Test/Verifiers/CSharpAnalyzerVerifier.cs b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs similarity index 96% rename from test/Analyzers.Test/Verifiers/CSharpAnalyzerVerifier.cs rename to test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs index c3c8e074f..7e2a5c6b5 100644 --- a/test/Analyzers.Test/Verifiers/CSharpAnalyzerVerifier.cs +++ b/test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -7,7 +7,7 @@ using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Testing.Verifiers; -namespace Microsoft.DurableTask.Analyzers.Test.Verifiers; +namespace Microsoft.DurableTask.Analyzers.Tests.Verifiers; public static partial class CSharpAnalyzerVerifier where TAnalyzer : DiagnosticAnalyzer, new() diff --git a/test/Analyzers.Test/Verifiers/CSharpCodeFixVerifier.cs b/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs similarity index 98% rename from test/Analyzers.Test/Verifiers/CSharpCodeFixVerifier.cs rename to test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs index dd6685271..87d51c9ee 100644 --- a/test/Analyzers.Test/Verifiers/CSharpCodeFixVerifier.cs +++ b/test/Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -8,7 +8,7 @@ using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Testing.Verifiers; -namespace Microsoft.DurableTask.Analyzers.Test.Verifiers; +namespace Microsoft.DurableTask.Analyzers.Tests.Verifiers; public static partial class CSharpCodeFixVerifier where TAnalyzer : DiagnosticAnalyzer, new()