Skip to content

Commit 80abedc

Browse files
CopilotYunchuWang
andcommitted
Add FunctionNotFoundAnalyzer to detect calls to non-existent activities and sub-orchestrations
Co-authored-by: YunchuWang <[email protected]>
1 parent 31ced09 commit 80abedc

File tree

4 files changed

+702
-1
lines changed

4 files changed

+702
-1
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Concurrent;
5+
using System.Collections.Immutable;
6+
using System.Diagnostics;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
using Microsoft.CodeAnalysis.Operations;
11+
12+
namespace Microsoft.DurableTask.Analyzers.Activities;
13+
14+
/// <summary>
15+
/// Analyzer that detects calls to non-existent activities and sub-orchestrations.
16+
/// </summary>
17+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
18+
public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer
19+
{
20+
/// <summary>
21+
/// The diagnostic ID for the diagnostic that reports when an activity call references a function that doesn't exist.
22+
/// </summary>
23+
public const string ActivityNotFoundDiagnosticId = "DURABLE2003";
24+
25+
/// <summary>
26+
/// The diagnostic ID for the diagnostic that reports when a sub-orchestration call references a function that doesn't exist.
27+
/// </summary>
28+
public const string SubOrchestrationNotFoundDiagnosticId = "DURABLE2004";
29+
30+
static readonly LocalizableString ActivityNotFoundTitle = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources));
31+
static readonly LocalizableString ActivityNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
32+
33+
static readonly LocalizableString SubOrchestrationNotFoundTitle = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources));
34+
static readonly LocalizableString SubOrchestrationNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
35+
36+
static readonly DiagnosticDescriptor ActivityNotFoundRule = new(
37+
ActivityNotFoundDiagnosticId,
38+
ActivityNotFoundTitle,
39+
ActivityNotFoundMessageFormat,
40+
AnalyzersCategories.Activity,
41+
DiagnosticSeverity.Warning,
42+
customTags: [WellKnownDiagnosticTags.CompilationEnd],
43+
isEnabledByDefault: true);
44+
45+
static readonly DiagnosticDescriptor SubOrchestrationNotFoundRule = new(
46+
SubOrchestrationNotFoundDiagnosticId,
47+
SubOrchestrationNotFoundTitle,
48+
SubOrchestrationNotFoundMessageFormat,
49+
AnalyzersCategories.Orchestration,
50+
DiagnosticSeverity.Warning,
51+
customTags: [WellKnownDiagnosticTags.CompilationEnd],
52+
isEnabledByDefault: true);
53+
54+
/// <inheritdoc/>
55+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule];
56+
57+
/// <inheritdoc/>
58+
public override void Initialize(AnalysisContext context)
59+
{
60+
context.EnableConcurrentExecution();
61+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
62+
63+
context.RegisterCompilationStartAction(context =>
64+
{
65+
KnownTypeSymbols knownSymbols = new(context.Compilation);
66+
67+
if (knownSymbols.TaskOrchestrationContext == null ||
68+
knownSymbols.Task == null || knownSymbols.TaskT == null)
69+
{
70+
// Core symbols not available in this compilation, skip analysis
71+
return;
72+
}
73+
74+
// Activity-related symbols (may be null if activities aren't used)
75+
IMethodSymbol? taskActivityRunAsync = knownSymbols.TaskActivityBase?.GetMembers("RunAsync").OfType<IMethodSymbol>().SingleOrDefault();
76+
77+
// Search for Activity and Sub-Orchestrator invocations
78+
ConcurrentBag<FunctionInvocation> activityInvocations = [];
79+
ConcurrentBag<FunctionInvocation> subOrchestrationInvocations = [];
80+
81+
context.RegisterOperationAction(
82+
ctx =>
83+
{
84+
ctx.CancellationToken.ThrowIfCancellationRequested();
85+
86+
if (ctx.Operation is not IInvocationOperation invocationOperation)
87+
{
88+
return;
89+
}
90+
91+
IMethodSymbol targetMethod = invocationOperation.TargetMethod;
92+
93+
// Check for CallActivityAsync
94+
if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallActivityAsync"))
95+
{
96+
string? activityName = ExtractFunctionName(invocationOperation, "name", ctx);
97+
if (activityName != null)
98+
{
99+
activityInvocations.Add(new FunctionInvocation
100+
{
101+
Name = activityName,
102+
InvocationSyntaxNode = invocationOperation.Syntax,
103+
});
104+
}
105+
}
106+
107+
// Check for CallSubOrchestratorAsync
108+
if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallSubOrchestratorAsync"))
109+
{
110+
string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx);
111+
if (orchestratorName != null)
112+
{
113+
subOrchestrationInvocations.Add(new FunctionInvocation
114+
{
115+
Name = orchestratorName,
116+
InvocationSyntaxNode = invocationOperation.Syntax,
117+
});
118+
}
119+
}
120+
},
121+
OperationKind.Invocation);
122+
123+
// Search for Activity definitions
124+
ConcurrentBag<string> activityNames = [];
125+
ConcurrentBag<string> orchestratorNames = [];
126+
127+
// Search for Durable Functions Activities and Orchestrators definitions (via [Function] attribute)
128+
context.RegisterSymbolAction(
129+
ctx =>
130+
{
131+
ctx.CancellationToken.ThrowIfCancellationRequested();
132+
133+
if (ctx.Symbol is not IMethodSymbol methodSymbol)
134+
{
135+
return;
136+
}
137+
138+
// Check for Activity defined via [ActivityTrigger]
139+
if (knownSymbols.ActivityTriggerAttribute != null &&
140+
methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.ActivityTriggerAttribute) &&
141+
knownSymbols.FunctionNameAttribute != null &&
142+
methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName))
143+
{
144+
activityNames.Add(functionName);
145+
}
146+
147+
// Check for Orchestrator defined via [OrchestrationTrigger]
148+
if (knownSymbols.FunctionOrchestrationAttribute != null &&
149+
methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute) &&
150+
knownSymbols.FunctionNameAttribute != null &&
151+
methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string orchestratorFunctionName))
152+
{
153+
orchestratorNames.Add(orchestratorFunctionName);
154+
}
155+
},
156+
SymbolKind.Method);
157+
158+
// Search for TaskActivity<TInput, TOutput> definitions (class-based syntax)
159+
context.RegisterSyntaxNodeAction(
160+
ctx =>
161+
{
162+
ctx.CancellationToken.ThrowIfCancellationRequested();
163+
164+
if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol)
165+
{
166+
return;
167+
}
168+
169+
if (classSymbol.IsAbstract)
170+
{
171+
return;
172+
}
173+
174+
// Check for TaskActivity<TInput, TOutput> derived classes
175+
if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null)
176+
{
177+
IMethodSymbol? methodOverridingRunAsync = FindOverridingMethod(classSymbol, taskActivityRunAsync);
178+
if (methodOverridingRunAsync != null)
179+
{
180+
activityNames.Add(classSymbol.Name);
181+
}
182+
}
183+
184+
// Check for ITaskOrchestrator implementations (class-based orchestrators)
185+
if (knownSymbols.TaskOrchestratorInterface != null &&
186+
classSymbol.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, knownSymbols.TaskOrchestratorInterface)))
187+
{
188+
orchestratorNames.Add(classSymbol.Name);
189+
}
190+
},
191+
SyntaxKind.ClassDeclaration);
192+
193+
// Search for Func/Action activities directly registered through DurableTaskRegistry
194+
context.RegisterOperationAction(
195+
ctx =>
196+
{
197+
ctx.CancellationToken.ThrowIfCancellationRequested();
198+
199+
if (ctx.Operation is not IInvocationOperation invocation)
200+
{
201+
return;
202+
}
203+
204+
if (knownSymbols.DurableTaskRegistry == null ||
205+
!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry))
206+
{
207+
return;
208+
}
209+
210+
// Handle AddActivityFunc registrations
211+
if (invocation.TargetMethod.Name == "AddActivityFunc")
212+
{
213+
string? name = ExtractFunctionName(invocation, "name", ctx);
214+
if (name != null)
215+
{
216+
activityNames.Add(name);
217+
}
218+
}
219+
220+
// Handle AddOrchestratorFunc registrations
221+
if (invocation.TargetMethod.Name == "AddOrchestratorFunc")
222+
{
223+
string? name = ExtractFunctionName(invocation, "name", ctx);
224+
if (name != null)
225+
{
226+
orchestratorNames.Add(name);
227+
}
228+
}
229+
},
230+
OperationKind.Invocation);
231+
232+
// At the end of the compilation, we correlate the invocations with the definitions
233+
context.RegisterCompilationEndAction(ctx =>
234+
{
235+
// Create lookup sets for faster searching
236+
HashSet<string> definedActivities = new(activityNames);
237+
HashSet<string> definedOrchestrators = new(orchestratorNames);
238+
239+
// Report diagnostics for activities not found
240+
foreach (FunctionInvocation invocation in activityInvocations)
241+
{
242+
if (!definedActivities.Contains(invocation.Name))
243+
{
244+
Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic(
245+
ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name);
246+
ctx.ReportDiagnostic(diagnostic);
247+
}
248+
}
249+
250+
// Report diagnostics for sub-orchestrators not found
251+
foreach (FunctionInvocation invocation in subOrchestrationInvocations)
252+
{
253+
if (!definedOrchestrators.Contains(invocation.Name))
254+
{
255+
Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic(
256+
SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name);
257+
ctx.ReportDiagnostic(diagnostic);
258+
}
259+
}
260+
});
261+
});
262+
}
263+
264+
static string? ExtractFunctionName(IInvocationOperation invocationOperation, string parameterName, OperationAnalysisContext ctx)
265+
{
266+
IArgumentOperation? nameArgumentOperation = invocationOperation.Arguments.SingleOrDefault(a => a.Parameter?.Name == parameterName);
267+
if (nameArgumentOperation == null)
268+
{
269+
return null;
270+
}
271+
272+
// extracts the constant value from the argument (e.g.: it can be a nameof, string literal or const field)
273+
Optional<object?> constant = ctx.Operation.SemanticModel!.GetConstantValue(nameArgumentOperation.Value.Syntax);
274+
if (!constant.HasValue)
275+
{
276+
// not a constant value, we cannot correlate this invocation to an existent function in compile time
277+
return null;
278+
}
279+
280+
return constant.Value?.ToString();
281+
}
282+
283+
static IMethodSymbol? FindOverridingMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind)
284+
{
285+
INamedTypeSymbol? baseType = classSymbol;
286+
while (baseType != null)
287+
{
288+
foreach (IMethodSymbol method in baseType.GetMembers().OfType<IMethodSymbol>())
289+
{
290+
if (SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind))
291+
{
292+
return method.OverriddenMethod;
293+
}
294+
}
295+
296+
baseType = baseType.BaseType;
297+
}
298+
299+
return null;
300+
}
301+
302+
struct FunctionInvocation
303+
{
304+
public string Name { get; set; }
305+
306+
public SyntaxNode InvocationSyntaxNode { get; set; }
307+
}
308+
}
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,9 @@
11
; Unshipped analyzer release
2-
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
2+
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
3+
4+
### New Rules
5+
6+
Rule ID | Category | Severity | Notes
7+
--------|----------|----------|-------
8+
DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation.
9+
DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation.

src/Analyzers/Resources.resx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,4 +198,16 @@
198198
<data name="UseInsteadFixerTitle" xml:space="preserve">
199199
<value>Use '{0}' instead of '{1}'</value>
200200
</data>
201+
<data name="ActivityNotFoundAnalyzerMessageFormat" xml:space="preserve">
202+
<value>The activity function '{0}' was not found in the current compilation. Ensure the activity is defined and the name matches exactly.</value>
203+
</data>
204+
<data name="ActivityNotFoundAnalyzerTitle" xml:space="preserve">
205+
<value>Activity function not found</value>
206+
</data>
207+
<data name="SubOrchestrationNotFoundAnalyzerMessageFormat" xml:space="preserve">
208+
<value>The sub-orchestration '{0}' was not found in the current compilation. Ensure the orchestrator is defined and the name matches exactly.</value>
209+
</data>
210+
<data name="SubOrchestrationNotFoundAnalyzerTitle" xml:space="preserve">
211+
<value>Sub-orchestration not found</value>
212+
</data>
201213
</root>

0 commit comments

Comments
 (0)