Skip to content
294 changes: 294 additions & 0 deletions src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Microsoft.DurableTask.Analyzers.Activities;

/// <summary>
/// Analyzer that detects calls to non-existent activities and sub-orchestrations.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]

Check warning on line 16 in src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

This compiler extension should not be implemented in an assembly containing a reference to Microsoft.CodeAnalysis.Workspaces. The Microsoft.CodeAnalysis.Workspaces assembly is not provided during command line compilation scenarios, so references to it could cause the compiler extension to behave unpredictably. (https://github.com/dotnet/roslyn-analyzers/blob/main/docs/rules/RS1038.md)
public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// The diagnostic ID for the diagnostic that reports when an activity call references a function that doesn't exist.
/// </summary>
public const string ActivityNotFoundDiagnosticId = "DURABLE2003";

/// <summary>
/// The diagnostic ID for the diagnostic that reports when a sub-orchestration call references a function that doesn't exist.
/// </summary>
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);

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule];

/// <inheritdoc/>
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<IMethodSymbol>().SingleOrDefault();

// Search for Activity and Sub-Orchestrator invocations
ConcurrentBag<FunctionInvocation> activityInvocations = [];
ConcurrentBag<FunctionInvocation> 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(activityName, invocationOperation.Syntax));
}
}

// Check for CallSubOrchestratorAsync
if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallSubOrchestratorAsync"))
{
string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx);
if (orchestratorName != null)
{
subOrchestrationInvocations.Add(new FunctionInvocation(orchestratorName, invocationOperation.Syntax));
}
}
},
OperationKind.Invocation);

// Search for Activity definitions
ConcurrentBag<string> activityNames = [];
ConcurrentBag<string> 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<TInput, TOutput> 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<TInput, TOutput> derived classes
if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null &&
ClassOverridesMethod(classSymbol, taskActivityRunAsync))
{
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<string> definedActivities = new(activityNames);
HashSet<string> definedOrchestrators = new(orchestratorNames);

// Report diagnostics for activities not found
foreach (FunctionInvocation invocation in activityInvocations.Where(i => !definedActivities.Contains(i.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.Where(i => !definedOrchestrators.Contains(i.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;
}

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<object?> 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
return null;
}

return constant.Value?.ToString();
}

static bool ClassOverridesMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind)
{
INamedTypeSymbol? baseType = classSymbol;
while (baseType != null)
{
if (baseType.GetMembers().OfType<IMethodSymbol>()
.Any(method => SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind)))
{
return true;
}

baseType = baseType.BaseType;
}

return false;
}

readonly struct FunctionInvocation(string name, SyntaxNode invocationSyntaxNode)
{
public string Name { get; } = name;

public SyntaxNode InvocationSyntaxNode { get; } = invocationSyntaxNode;
}
}
9 changes: 8 additions & 1 deletion src/Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
; 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.
12 changes: 12 additions & 0 deletions src/Analyzers/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,16 @@
<data name="UseInsteadFixerTitle" xml:space="preserve">
<value>Use '{0}' instead of '{1}'</value>
</data>
<data name="ActivityNotFoundAnalyzerMessageFormat" xml:space="preserve">
<value>The activity function '{0}' was not found in the current compilation. Ensure the activity is defined and the name matches exactly.</value>
</data>
<data name="ActivityNotFoundAnalyzerTitle" xml:space="preserve">
<value>Activity function not found</value>
</data>
<data name="SubOrchestrationNotFoundAnalyzerMessageFormat" xml:space="preserve">
<value>The sub-orchestration '{0}' was not found in the current compilation. Ensure the orchestrator is defined and the name matches exactly.</value>
</data>
<data name="SubOrchestrationNotFoundAnalyzerTitle" xml:space="preserve">
<value>Sub-orchestration not found</value>
</data>
</root>
Loading
Loading