diff --git a/Directory.Packages.props b/Directory.Packages.props index e249a871f..5ea87d5a5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + @@ -17,12 +17,14 @@ - - - + + + + + - + @@ -33,7 +35,7 @@ - + @@ -45,8 +47,8 @@ - - - + + + \ No newline at end of file diff --git a/all.sln b/all.sln index eabc532e9..57601210d 100644 --- a/all.sln +++ b/all.sln @@ -155,7 +155,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobsSample", "examples\Jobs EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Test", "test\Dapr.Workflow.Test\Dapr.Workflow.Test.csproj", "{E90114C6-86FC-43B8-AE5C-D9273CF21FE4}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Messaging", "Messaging", "{8DB002D2-19E9-4342-A86B-025A367DF3D1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Actors.Analyzers", "src\Dapr.Actors.Analyzers\Dapr.Actors.Analyzers\Dapr.Actors.Analyzers.csproj", "{E49C822C-E921-48DF-897B-3E603CA596D2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Actors.Analyzers.Tests", "test\Dapr.Actors.Analyzers.Tests\Dapr.Actors.Analyzers.Tests.csproj", "{A2C0F203-11FF-4B7F-A94F-B9FD873573FE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -405,6 +407,14 @@ Global {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Debug|Any CPU.Build.0 = Debug|Any CPU {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Release|Any CPU.ActiveCfg = Release|Any CPU {E90114C6-86FC-43B8-AE5C-D9273CF21FE4}.Release|Any CPU.Build.0 = Release|Any CPU + {E49C822C-E921-48DF-897B-3E603CA596D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E49C822C-E921-48DF-897B-3E603CA596D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E49C822C-E921-48DF-897B-3E603CA596D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E49C822C-E921-48DF-897B-3E603CA596D2}.Release|Any CPU.Build.0 = Release|Any CPU + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -478,8 +488,8 @@ Global {D9697361-232F-465D-A136-4561E0E88488} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {9CAF360E-5AD3-4C4F-89A0-327EEB70D673} = {D9697361-232F-465D-A136-4561E0E88488} {E90114C6-86FC-43B8-AE5C-D9273CF21FE4} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {8DB002D2-19E9-4342-A86B-025A367DF3D1} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} - {290D1278-F613-4DF3-9DF5-F37E38CDC363} = {8DB002D2-19E9-4342-A86B-025A367DF3D1} + {E49C822C-E921-48DF-897B-3E603CA596D2} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE} = {DD020B34-460F-455F-8D17-CF4A949F100B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..bc73dcb0b --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ + + +## Release 1.16.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------------------------------------------------------------------------------ +DAPR4001 | Usage | Warning | Actor timer method invocations require the named callback method to exist on type. \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..b96f00659 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1 @@ +; Unshipped analyzer release \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs new file mode 100644 index 000000000..dcc08b3c1 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dapr.Actors.Analyzers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2" )] diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj new file mode 100644 index 000000000..a3c751c38 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj @@ -0,0 +1,37 @@ + + + + netstandard2.0 + + false + enable + enable + This package contains Roslyn analyzers for Dapr.Actors. + + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + + + True + True + Resources.resx + + + + diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Properties/launchSettings.json b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Properties/launchSettings.json new file mode 100644 index 000000000..a4b2b510e --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "DebugRoslynAnalyzers": { + "commandName": "DebugRoslynComponent", + "targetProject": "../Dapr.Actors.Analyzers.Sample/Dapr.Actors.Analyzers.Sample.csproj" + } + } +} \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Readme.md b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Readme.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs new file mode 100644 index 000000000..af3dd797d --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs @@ -0,0 +1,80 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Dapr.Actors.Analyzers.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Actor timer method invocations require the named callback '{0}' method to exist on type '{1}'. + /// + internal static string DAPR4001MessageFormat { + get { + return ResourceManager.GetString("DAPR4001MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Actor timer method invocations require the named callback method to exist on type. + /// + internal static string DAPR4001Title { + get { + return ResourceManager.GetString("DAPR4001Title", resourceCulture); + } + } + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx new file mode 100644 index 000000000..47f6c667e --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx @@ -0,0 +1,27 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Actor timer method invocations require the named callback method to exist on type + + + Actor timer method invocations require the named callback '{0}' method to exist on type '{1}' + + \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs new file mode 100644 index 000000000..59640fd58 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// An analyzer for Dapr Actors that validates that whenever a register is registered, the method specified to invoke +/// as the callback should actually exist on the type. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TimerMethodPresentAnalyzer : DiagnosticAnalyzer +{ + /// + /// The rule validated by the analyzer. + /// + public static readonly DiagnosticDescriptor DaprTimerCallbackMethodRule = new( + id: "DAPR4001", + title: new LocalizableResourceString(nameof(Resources.DAPR4001Title), Resources.ResourceManager, typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR4001MessageFormat), Resources.ResourceManager, typeof(Resources)), + category: "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DaprTimerCallbackMethodRule); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeSyntaxNode, SyntaxKind.InvocationExpression); + } + + private void AnalyzeSyntaxNode(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + + var methodSymbol = context.SemanticModel.GetSymbolInfo(invocationExpr.Expression).Symbol as IMethodSymbol; + + if (methodSymbol is null + || methodSymbol.Name != "RegisterTimerAsync" + || methodSymbol.ContainingType.Name != "Actor" + || methodSymbol.ContainingType.ContainingNamespace.ToDisplayString() != "Dapr.Actors.Runtime") + { + return; + } + + var ancestorType = invocationExpr.FirstAncestorOrSelf(); + if (ancestorType is null) + { + return; + } + var ancestorTypeSymbol = context.SemanticModel.GetDeclaredSymbol(ancestorType); + if (ancestorTypeSymbol is null) + { + return; + } + var ancestorTypeName = ancestorTypeSymbol?.Name; + + var arguments = invocationExpr.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return; + } + + var secondArgument = arguments[1].Expression; + var methodNameValue = context.SemanticModel.GetConstantValue(secondArgument); + if (!methodNameValue.HasValue || methodNameValue.Value is not string methodName) + { + return; + } + + var members = ancestorTypeSymbol?.GetMembers().OfType().ToList(); + var methodExists = members?.Any(m => m.Name == methodName) == true; + if (!methodExists) + { + //Get the type that contains our RegisterTimerAsync method + + var diagnostic = Diagnostic.Create(SupportedDiagnostics[0], secondArgument.GetLocation(), methodName, + ancestorTypeName); + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj b/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj new file mode 100644 index 000000000..d801c2dfd --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj @@ -0,0 +1,35 @@ + + + + enable + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs new file mode 100644 index 000000000..bdcf0e469 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs @@ -0,0 +1,148 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Dapr.Actors.Analyzers.Tests; + +public class TimerMethodPresentAnalyzerTests +{ + #if NET8_0 + private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net80; + #elif NET9_0 + private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net90; + #endif + + [Fact] + public async Task TestActor_TimerRegistration_NotPresent() + { + var context = new CSharpAnalyzerTest(); + context.ReferenceAssemblies = assemblies.AddPackages([ + new ("Dapr.Actors", "1.15.3") + ]); + + context.TestCode = """ + using System; + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + internal sealed class TestActorTimerRegistrationNotPresent(ActorHost host) : Actor(host) + { + public async Task DoSomethingAsync() + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + } + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task TestActor_TimerRegistration_NameOfCallbackPresent() + { + var context = new CSharpAnalyzerTest(); + context.ReferenceAssemblies = assemblies.AddPackages([ + new ("Dapr.Actors", "1.15.3") + ]); + + + context.TestCode = """ + using System; + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + internal sealed class TestActorTimerRegistrationTimerCallbackPresent(ActorHost host) : Actor(host) + { + public async Task DoSomethingAsync() + { + await RegisterTimerAsync("MyTimer", nameof(TimerCallback), null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(10)); + } + + private static async Task TimerCallback(byte[] data) + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + } + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task TestActor_TimerRegistration_LiteralCallbackPresent() + { + var context = new CSharpAnalyzerTest(); + context.ReferenceAssemblies = assemblies.AddPackages([ + new ("Dapr.Actors", "1.15.3") + ]); + + + context.TestCode = """ + using System; + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + internal sealed class TestActorTimerRegistrationTimerCallbackPresent(ActorHost host) : Actor(host) + { + public async Task DoSomethingAsync() + { + await RegisterTimerAsync("MyTimer", "TimerCallback", null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(10)); + } + + private static async Task TimerCallback(byte[] data) + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + } + } + """; + + context.ExpectedDiagnostics.Clear(); + await context.RunAsync(); + } + + [Fact] + public async Task TestActor_TimerRegistration_CallbackNotPresent() + { + var context = new CSharpAnalyzerTest(); + context.ReferenceAssemblies = assemblies.AddPackages([ + new ("Dapr.Actors", "1.15.3") + ]); + + context.TestCode = """ + using System; + using System.Threading.Tasks; + using Dapr.Actors.Runtime; + internal sealed class TestActorTimerRegistrationTimerCallbackNotPresent(ActorHost host) : Actor(host) + { + public async Task DoSomethingAsync() + { + await RegisterTimerAsync("MyTimer", "TimerCallback", null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(10)); + } + } + """; + + context.ExpectedDiagnostics.Add(new DiagnosticResult(TimerMethodPresentAnalyzer.DaprTimerCallbackMethodRule) + .WithSpan(8, 45, 8, 60) + .WithArguments("TimerCallback", "TestActorTimerRegistrationTimerCallbackNotPresent")); + await context.RunAsync(); + } +} + + + + + +