From 31ced0954db43e86f49a2ba273ef893ea4f3e473 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:04:35 +0000 Subject: [PATCH 1/4] Initial plan From 80abedc17b3dbecbd5199ba217c3fa2c0f29c517 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:14:51 +0000 Subject: [PATCH 2/4] Add FunctionNotFoundAnalyzer to detect calls to non-existent activities and sub-orchestrations Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Activities/FunctionNotFoundAnalyzer.cs | 308 +++++++++++++++ src/Analyzers/AnalyzerReleases.Unshipped.md | 9 +- src/Analyzers/Resources.resx | 12 + .../FunctionNotFoundAnalyzerTests.cs | 374 ++++++++++++++++++ 4 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs create mode 100644 test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs new file mode 100644 index 000000000..bf2b6e6db --- /dev/null +++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs @@ -0,0 +1,308 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Microsoft.DurableTask.Analyzers.Activities; + +/// +/// Analyzer that detects calls to non-existent activities and sub-orchestrations. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer +{ + /// + /// The diagnostic ID for the diagnostic that reports when an activity call references a function that doesn't exist. + /// + public const string ActivityNotFoundDiagnosticId = "DURABLE2003"; + + /// + /// The diagnostic ID for the diagnostic that reports when a sub-orchestration call references a function that doesn't exist. + /// + public const string SubOrchestrationNotFoundDiagnosticId = "DURABLE2004"; + + static readonly LocalizableString ActivityNotFoundTitle = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString ActivityNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly LocalizableString SubOrchestrationNotFoundTitle = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources)); + static readonly LocalizableString SubOrchestrationNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources)); + + static readonly DiagnosticDescriptor ActivityNotFoundRule = new( + ActivityNotFoundDiagnosticId, + ActivityNotFoundTitle, + ActivityNotFoundMessageFormat, + AnalyzersCategories.Activity, + DiagnosticSeverity.Warning, + customTags: [WellKnownDiagnosticTags.CompilationEnd], + isEnabledByDefault: true); + + static readonly DiagnosticDescriptor SubOrchestrationNotFoundRule = new( + SubOrchestrationNotFoundDiagnosticId, + SubOrchestrationNotFoundTitle, + SubOrchestrationNotFoundMessageFormat, + AnalyzersCategories.Orchestration, + DiagnosticSeverity.Warning, + customTags: [WellKnownDiagnosticTags.CompilationEnd], + isEnabledByDefault: true); + + /// + public override ImmutableArray SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(context => + { + KnownTypeSymbols knownSymbols = new(context.Compilation); + + if (knownSymbols.TaskOrchestrationContext == null || + knownSymbols.Task == null || knownSymbols.TaskT == null) + { + // Core symbols not available in this compilation, skip analysis + return; + } + + // Activity-related symbols (may be null if activities aren't used) + IMethodSymbol? taskActivityRunAsync = knownSymbols.TaskActivityBase?.GetMembers("RunAsync").OfType().SingleOrDefault(); + + // Search for Activity and Sub-Orchestrator invocations + ConcurrentBag activityInvocations = []; + ConcurrentBag subOrchestrationInvocations = []; + + context.RegisterOperationAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Operation is not IInvocationOperation invocationOperation) + { + return; + } + + IMethodSymbol targetMethod = invocationOperation.TargetMethod; + + // Check for CallActivityAsync + if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallActivityAsync")) + { + string? activityName = ExtractFunctionName(invocationOperation, "name", ctx); + if (activityName != null) + { + activityInvocations.Add(new FunctionInvocation + { + Name = activityName, + InvocationSyntaxNode = invocationOperation.Syntax, + }); + } + } + + // Check for CallSubOrchestratorAsync + if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallSubOrchestratorAsync")) + { + string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx); + if (orchestratorName != null) + { + subOrchestrationInvocations.Add(new FunctionInvocation + { + Name = orchestratorName, + InvocationSyntaxNode = invocationOperation.Syntax, + }); + } + } + }, + OperationKind.Invocation); + + // Search for Activity definitions + ConcurrentBag activityNames = []; + ConcurrentBag orchestratorNames = []; + + // Search for Durable Functions Activities and Orchestrators definitions (via [Function] attribute) + context.RegisterSymbolAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + // Check for Activity defined via [ActivityTrigger] + if (knownSymbols.ActivityTriggerAttribute != null && + methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.ActivityTriggerAttribute) && + knownSymbols.FunctionNameAttribute != null && + methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName)) + { + activityNames.Add(functionName); + } + + // Check for Orchestrator defined via [OrchestrationTrigger] + if (knownSymbols.FunctionOrchestrationAttribute != null && + methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute) && + knownSymbols.FunctionNameAttribute != null && + methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string orchestratorFunctionName)) + { + orchestratorNames.Add(orchestratorFunctionName); + } + }, + SymbolKind.Method); + + // Search for TaskActivity definitions (class-based syntax) + context.RegisterSyntaxNodeAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol) + { + return; + } + + if (classSymbol.IsAbstract) + { + return; + } + + // Check for TaskActivity derived classes + if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null) + { + IMethodSymbol? methodOverridingRunAsync = FindOverridingMethod(classSymbol, taskActivityRunAsync); + if (methodOverridingRunAsync != null) + { + activityNames.Add(classSymbol.Name); + } + } + + // Check for ITaskOrchestrator implementations (class-based orchestrators) + if (knownSymbols.TaskOrchestratorInterface != null && + classSymbol.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, knownSymbols.TaskOrchestratorInterface))) + { + orchestratorNames.Add(classSymbol.Name); + } + }, + SyntaxKind.ClassDeclaration); + + // Search for Func/Action activities directly registered through DurableTaskRegistry + context.RegisterOperationAction( + ctx => + { + ctx.CancellationToken.ThrowIfCancellationRequested(); + + if (ctx.Operation is not IInvocationOperation invocation) + { + return; + } + + if (knownSymbols.DurableTaskRegistry == null || + !SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry)) + { + return; + } + + // Handle AddActivityFunc registrations + if (invocation.TargetMethod.Name == "AddActivityFunc") + { + string? name = ExtractFunctionName(invocation, "name", ctx); + if (name != null) + { + activityNames.Add(name); + } + } + + // Handle AddOrchestratorFunc registrations + if (invocation.TargetMethod.Name == "AddOrchestratorFunc") + { + string? name = ExtractFunctionName(invocation, "name", ctx); + if (name != null) + { + orchestratorNames.Add(name); + } + } + }, + OperationKind.Invocation); + + // At the end of the compilation, we correlate the invocations with the definitions + context.RegisterCompilationEndAction(ctx => + { + // Create lookup sets for faster searching + HashSet definedActivities = new(activityNames); + HashSet definedOrchestrators = new(orchestratorNames); + + // Report diagnostics for activities not found + foreach (FunctionInvocation invocation in activityInvocations) + { + if (!definedActivities.Contains(invocation.Name)) + { + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); + } + } + + // Report diagnostics for sub-orchestrators not found + foreach (FunctionInvocation invocation in subOrchestrationInvocations) + { + if (!definedOrchestrators.Contains(invocation.Name)) + { + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); + } + } + }); + }); + } + + static string? ExtractFunctionName(IInvocationOperation invocationOperation, string parameterName, OperationAnalysisContext ctx) + { + IArgumentOperation? nameArgumentOperation = invocationOperation.Arguments.SingleOrDefault(a => a.Parameter?.Name == parameterName); + if (nameArgumentOperation == null) + { + return null; + } + + // extracts the constant value from the argument (e.g.: it can be a nameof, string literal or const field) + Optional constant = ctx.Operation.SemanticModel!.GetConstantValue(nameArgumentOperation.Value.Syntax); + if (!constant.HasValue) + { + // not a constant value, we cannot correlate this invocation to an existent function in compile time + return null; + } + + return constant.Value?.ToString(); + } + + static IMethodSymbol? FindOverridingMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind) + { + INamedTypeSymbol? baseType = classSymbol; + while (baseType != null) + { + foreach (IMethodSymbol method in baseType.GetMembers().OfType()) + { + if (SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind)) + { + return method.OverriddenMethod; + } + } + + baseType = baseType.BaseType; + } + + return null; + } + + struct FunctionInvocation + { + public string Name { get; set; } + + public SyntaxNode InvocationSyntaxNode { get; set; } + } +} diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md index 7d6ac616a..150fb7356 100644 --- a/src/Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,9 @@ ; Unshipped analyzer release -; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md \ No newline at end of file +; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation. +DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation. \ No newline at end of file diff --git a/src/Analyzers/Resources.resx b/src/Analyzers/Resources.resx index 5f8d52194..ee14969a3 100644 --- a/src/Analyzers/Resources.resx +++ b/src/Analyzers/Resources.resx @@ -198,4 +198,16 @@ Use '{0}' instead of '{1}' + + The activity function '{0}' was not found in the current compilation. Ensure the activity is defined and the name matches exactly. + + + Activity function not found + + + The sub-orchestration '{0}' was not found in the current compilation. Ensure the orchestrator is defined and the name matches exactly. + + + Sub-orchestration not found + \ No newline at end of file diff --git a/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs new file mode 100644 index 000000000..7dff2e380 --- /dev/null +++ b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.CodeAnalysis.Testing; +using Microsoft.DurableTask.Analyzers.Activities; +using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace Microsoft.DurableTask.Analyzers.Tests.Activities; + +public class FunctionNotFoundAnalyzerTests +{ + // ==================== Activity Tests ==================== + + [Fact] + public async Task DurableFunctionActivityInvocationWithMatchingActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallActivityAsync(nameof(SayHello), ""Tokyo""); +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task DurableFunctionActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|}; +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task DurableFunctionActivityInvocationWithNonExistentActivity_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(""NonExistentActivity"", ""Tokyo"")|}; +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentActivity"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task TaskActivityInvocationWithMatchingClassBasedActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class Caller { + async Task Method(TaskOrchestrationContext context) + { + await context.CallActivityAsync(nameof(MyActivity), ""Tokyo""); + } +} + +public class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string cityName) + { + return Task.FromResult(cityName); + } +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task TaskActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class Caller { + async Task Method(TaskOrchestrationContext context) + { + await {|#0:context.CallActivityAsync(""MyActiviti"", ""Tokyo"")|}; + } +} + +public class MyActivity : TaskActivity +{ + public override Task RunAsync(TaskActivityContext context, string cityName) + { + return Task.FromResult(cityName); + } +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MyActiviti"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task LambdaActivityInvocationWithMatchingActivity_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", async context => + await context.CallActivityAsync(""SayHello"", ""Tokyo"")); + +tasks.AddActivityFunc(""SayHello"", (context, city) => { }); +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task LambdaActivityInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""HelloSequence"", async context => + await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|}); + +tasks.AddActivityFunc(""SayHello"", (context, city) => { }); +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task ActivityInvocationWithConstVariableName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +const string activityName = ""WrongActivityName""; + +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(activityName, ""Tokyo"")|}; +} + +[Function(nameof(SayHello))] +void SayHello([ActivityTrigger] string name) +{ +} +"); + DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("WrongActivityName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task ActivityInvocationWithNonConstVariable_NoDiagnostic() + { + // Arrange - When using a non-const variable, we cannot determine the value at compile-time + // so no diagnostic is reported (to avoid false positives) + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + string activityName = ""NonExistentActivity""; + await context.CallActivityAsync(activityName, ""Tokyo""); +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + // ==================== Sub-Orchestration Tests ==================== + + [Fact] + public async Task SubOrchestrationInvocationWithMatchingOrchestration_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithMismatchedName_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|}; +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithNonExistentOrchestrator_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallSubOrchestratorAsync(""NonExistentOrchestrator"", ""input"")|}; +} +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentOrchestrator"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithClassBasedOrchestrator_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapTaskOrchestrator(@" +public class ParentOrchestration : TaskOrchestrator +{ + public override async Task RunAsync(TaskOrchestrationContext context, string input) + { + await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); + return ""done""; + } +} + +public class ChildOrchestration : TaskOrchestrator +{ + public override Task RunAsync(TaskOrchestrationContext context, string input) + { + return Task.FromResult(input); + } +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithLambdaOrchestrator_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""ParentOrchestration"", async context => + await context.CallSubOrchestratorAsync(""ChildOrchestration"", ""input"")); + +tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask); +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task SubOrchestrationInvocationWithMismatchedLambdaOrchestrator_ReportsDiagnostic() + { + // Arrange + string code = Wrapper.WrapFuncOrchestrator(@" +tasks.AddOrchestratorFunc(""ParentOrchestration"", async context => + await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|}); + +tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask); +"); + DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + [Fact] + public async Task SubOrchestrationInvocationWithTypedResult_NoDiagnostic() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + int result = await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input""); +} + +[Function(nameof(ChildOrchestration))] +async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context) +{ + return 42; +} +"); + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + public async Task MultipleInvocationsWithSomeMissingFunctions_ReportsMultipleDiagnostics() + { + // Arrange + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await context.CallActivityAsync(nameof(ExistingActivity), ""input""); + await {|#0:context.CallActivityAsync(""MissingActivity"", ""input"")|}; + await context.CallSubOrchestratorAsync(nameof(ExistingOrchestrator), ""input""); + await {|#1:context.CallSubOrchestratorAsync(""MissingOrchestrator"", ""input"")|}; +} + +[Function(nameof(ExistingActivity))] +void ExistingActivity([ActivityTrigger] string name) { } + +[Function(nameof(ExistingOrchestrator))] +async Task ExistingOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context) +{ + await Task.CompletedTask; +} +"); + DiagnosticResult[] expected = + [ + BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MissingActivity"), + BuildSubOrchestrationNotFoundDiagnostic().WithLocation(1).WithArguments("MissingOrchestrator") + ]; + + // Act & Assert + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + + static DiagnosticResult BuildActivityNotFoundDiagnostic() + { + return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.ActivityNotFoundDiagnosticId); + } + + static DiagnosticResult BuildSubOrchestrationNotFoundDiagnostic() + { + return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.SubOrchestrationNotFoundDiagnosticId); + } +} From ddea7ad326684a93a06ca06ac4ba97bd47e6ed00 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:23:13 +0000 Subject: [PATCH 3/4] Address code review feedback: add null check for SemanticModel, use readonly struct, remove unused import Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Activities/FunctionNotFoundAnalyzer.cs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs index bf2b6e6db..64ad174f7 100644 --- a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs +++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using System.Collections.Immutable; -using System.Diagnostics; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -96,11 +95,7 @@ public override void Initialize(AnalysisContext context) string? activityName = ExtractFunctionName(invocationOperation, "name", ctx); if (activityName != null) { - activityInvocations.Add(new FunctionInvocation - { - Name = activityName, - InvocationSyntaxNode = invocationOperation.Syntax, - }); + activityInvocations.Add(new FunctionInvocation(activityName, invocationOperation.Syntax)); } } @@ -110,11 +105,7 @@ public override void Initialize(AnalysisContext context) string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx); if (orchestratorName != null) { - subOrchestrationInvocations.Add(new FunctionInvocation - { - Name = orchestratorName, - InvocationSyntaxNode = invocationOperation.Syntax, - }); + subOrchestrationInvocations.Add(new FunctionInvocation(orchestratorName, invocationOperation.Syntax)); } } }, @@ -174,8 +165,7 @@ public override void Initialize(AnalysisContext context) // Check for TaskActivity derived classes if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null) { - IMethodSymbol? methodOverridingRunAsync = FindOverridingMethod(classSymbol, taskActivityRunAsync); - if (methodOverridingRunAsync != null) + if (ClassOverridesMethod(classSymbol, taskActivityRunAsync)) { activityNames.Add(classSymbol.Name); } @@ -269,8 +259,14 @@ public override void Initialize(AnalysisContext context) return null; } + SemanticModel? semanticModel = ctx.Operation.SemanticModel; + if (semanticModel == null) + { + return null; + } + // extracts the constant value from the argument (e.g.: it can be a nameof, string literal or const field) - Optional constant = ctx.Operation.SemanticModel!.GetConstantValue(nameArgumentOperation.Value.Syntax); + Optional constant = semanticModel.GetConstantValue(nameArgumentOperation.Value.Syntax); if (!constant.HasValue) { // not a constant value, we cannot correlate this invocation to an existent function in compile time @@ -280,7 +276,7 @@ public override void Initialize(AnalysisContext context) return constant.Value?.ToString(); } - static IMethodSymbol? FindOverridingMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind) + static bool ClassOverridesMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind) { INamedTypeSymbol? baseType = classSymbol; while (baseType != null) @@ -289,20 +285,20 @@ public override void Initialize(AnalysisContext context) { if (SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind)) { - return method.OverriddenMethod; + return true; } } baseType = baseType.BaseType; } - return null; + return false; } - struct FunctionInvocation + readonly struct FunctionInvocation(string name, SyntaxNode invocationSyntaxNode) { - public string Name { get; set; } + public string Name { get; } = name; - public SyntaxNode InvocationSyntaxNode { get; set; } + public SyntaxNode InvocationSyntaxNode { get; } = invocationSyntaxNode; } } From 7b66c0699897712f7618c18a34b4f88bf85e10e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 5 Dec 2025 17:03:05 +0000 Subject: [PATCH 4/4] Address code quality review comments: use LINQ Where/Any and combine nested if statements Co-authored-by: YunchuWang <12449837+YunchuWang@users.noreply.github.com> --- .../Activities/FunctionNotFoundAnalyzer.cs | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs index 64ad174f7..b1222d4bc 100644 --- a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs +++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs @@ -163,12 +163,10 @@ public override void Initialize(AnalysisContext context) } // Check for TaskActivity derived classes - if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null) + if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null && + ClassOverridesMethod(classSymbol, taskActivityRunAsync)) { - if (ClassOverridesMethod(classSymbol, taskActivityRunAsync)) - { - activityNames.Add(classSymbol.Name); - } + activityNames.Add(classSymbol.Name); } // Check for ITaskOrchestrator implementations (class-based orchestrators) @@ -227,25 +225,19 @@ public override void Initialize(AnalysisContext context) HashSet definedOrchestrators = new(orchestratorNames); // Report diagnostics for activities not found - foreach (FunctionInvocation invocation in activityInvocations) + foreach (FunctionInvocation invocation in activityInvocations.Where(i => !definedActivities.Contains(i.Name))) { - if (!definedActivities.Contains(invocation.Name)) - { - Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( - ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); - ctx.ReportDiagnostic(diagnostic); - } + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); } // Report diagnostics for sub-orchestrators not found - foreach (FunctionInvocation invocation in subOrchestrationInvocations) + foreach (FunctionInvocation invocation in subOrchestrationInvocations.Where(i => !definedOrchestrators.Contains(i.Name))) { - if (!definedOrchestrators.Contains(invocation.Name)) - { - Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( - SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); - ctx.ReportDiagnostic(diagnostic); - } + Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic( + SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name); + ctx.ReportDiagnostic(diagnostic); } }); }); @@ -281,12 +273,10 @@ static bool ClassOverridesMethod(INamedTypeSymbol classSymbol, IMethodSymbol met INamedTypeSymbol? baseType = classSymbol; while (baseType != null) { - foreach (IMethodSymbol method in baseType.GetMembers().OfType()) + if (baseType.GetMembers().OfType() + .Any(method => SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind))) { - if (SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind)) - { - return true; - } + return true; } baseType = baseType.BaseType;