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