Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 / build

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)

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