Skip to content

Commit 61274c2

Browse files
authored
Fix orchestration analyzer to detect non-function orchestrations correctly (#572)
1 parent 27ebd4b commit 61274c2

File tree

13 files changed

+316
-108
lines changed

13 files changed

+316
-108
lines changed

samples/ConsoleApp/ConsoleApp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<!-- Using p2p references so we can show latest changes in samples. -->
2121
<ProjectReference Include="$(SrcRoot)Client/Grpc/Client.Grpc.csproj" />
2222
<ProjectReference Include="$(SrcRoot)Worker/Grpc/Worker.Grpc.csproj" />
23+
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2324
</ItemGroup>
2425

2526
</Project>

samples/ConsoleAppMinimal/ConsoleAppMinimal.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<!-- Using p2p references so we can show latest changes in samples. -->
2121
<ProjectReference Include="$(SrcRoot)Client/Grpc/Client.Grpc.csproj" />
2222
<ProjectReference Include="$(SrcRoot)Worker/Grpc/Worker.Grpc.csproj" />
23+
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2324
</ItemGroup>
2425

2526
</Project>

samples/ExceptionPropertiesSample/ExceptionPropertiesSample.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<ProjectReference Include="$(SrcRoot)Client/AzureManaged/Client.AzureManaged.csproj" />
2323
<ProjectReference Include="$(SrcRoot)Worker/Grpc/Worker.Grpc.csproj" />
2424
<ProjectReference Include="$(SrcRoot)Worker/AzureManaged/Worker.AzureManaged.csproj" />
25+
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2526
</ItemGroup>
2627

2728
</Project>
28-

samples/LargePayloadConsoleApp/LargePayloadConsoleApp.csproj

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
<ProjectReference Include="$(SrcRoot)Client/AzureManaged/Client.AzureManaged.csproj" />
1818
<ProjectReference Include="$(SrcRoot)Worker/AzureManaged/Worker.AzureManaged.csproj" />
1919
<ProjectReference Include="$(SrcRoot)Extensions/AzureBlobPayloads/AzureBlobPayloads.csproj" />
20+
<ProjectReference Include="$(SrcRoot)Analyzers/Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2021
</ItemGroup>
2122

2223
</Project>
23-
24-

samples/ScheduleConsoleApp/Orchestrators/StockPriceOrchestrator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public override async Task<string> RunAsync(TaskOrchestrationContext context, st
1818

1919
logger.LogInformation("Current price for {symbol} is ${price:F2}", symbol, currentPrice);
2020

21-
return $"Stock {symbol} price: ${currentPrice:F2} at {DateTime.UtcNow}";
21+
return $"Stock {symbol} price: ${currentPrice:F2} at {context.CurrentUtcDateTime}";
2222
}
2323
catch (Exception ex)
2424
{

samples/ScheduleConsoleApp/ScheduleConsoleApp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717
<ProjectReference Include="..\..\src\Client\AzureManaged\Client.AzureManaged.csproj" />
1818
<ProjectReference Include="..\..\src\Worker\AzureManaged\Worker.AzureManaged.csproj" />
1919
<ProjectReference Include="..\..\src\ScheduledTasks\ScheduledTasks.csproj" />
20+
<ProjectReference Include="..\..\src\Analyzers\Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2021
</ItemGroup>
2122
</Project>

samples/ScheduleWebApp/ScheduleWebApp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@
1818
<ProjectReference Include="..\..\src\Client\AzureManaged\Client.AzureManaged.csproj" />
1919
<ProjectReference Include="..\..\src\Worker\AzureManaged\Worker.AzureManaged.csproj" />
2020
<ProjectReference Include="..\..\src\ScheduledTasks\ScheduledTasks.csproj" />
21+
<ProjectReference Include="..\..\src\Analyzers\Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2122
</ItemGroup>
2223
</Project>

src/Analyzers/Orchestration/OrchestrationAnalyzer.cs

Lines changed: 108 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@ public override void Initialize(AnalysisContext context)
2727
{
2828
KnownTypeSymbols knownSymbols = new(context.Compilation);
2929

30-
if (knownSymbols.FunctionOrchestrationAttribute == null || knownSymbols.FunctionNameAttribute == null ||
31-
knownSymbols.TaskOrchestratorInterface == null ||
32-
knownSymbols.DurableTaskRegistry == null)
30+
// Check if at least one orchestration type can be detected
31+
bool canAnalyzeDurableFunctions = knownSymbols.FunctionOrchestrationAttribute != null && knownSymbols.FunctionNameAttribute != null;
32+
bool canAnalyzeTaskOrchestrator = knownSymbols.TaskOrchestratorInterface != null && knownSymbols.TaskOrchestrationContext != null;
33+
bool canAnalyzeFuncOrchestrator = knownSymbols.DurableTaskRegistry != null;
34+
35+
if (!canAnalyzeDurableFunctions && !canAnalyzeTaskOrchestrator && !canAnalyzeFuncOrchestrator)
3336
{
34-
// symbols not available in this compilation, skip analysis
37+
// no symbols available in this compilation, skip analysis
3538
return;
3639
}
3740

@@ -42,124 +45,133 @@ public override void Initialize(AnalysisContext context)
4245
}
4346

4447
// look for Durable Functions Orchestrations
45-
context.RegisterSyntaxNodeAction(
46-
ctx =>
48+
if (canAnalyzeDurableFunctions)
4749
{
48-
ctx.CancellationToken.ThrowIfCancellationRequested();
49-
50-
if (ctx.ContainingSymbol is not IMethodSymbol methodSymbol)
50+
context.RegisterSyntaxNodeAction(
51+
ctx =>
5152
{
52-
return;
53-
}
53+
ctx.CancellationToken.ThrowIfCancellationRequested();
5454

55-
if (!methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute))
56-
{
57-
return;
58-
}
55+
if (ctx.ContainingSymbol is not IMethodSymbol methodSymbol)
56+
{
57+
return;
58+
}
5959

60-
if (!methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName))
61-
{
62-
return;
63-
}
60+
if (!methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute!))
61+
{
62+
return;
63+
}
64+
65+
if (!methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute!, out string functionName))
66+
{
67+
return;
68+
}
6469

65-
var rootMethodSyntax = (MethodDeclarationSyntax)ctx.Node;
70+
var rootMethodSyntax = (MethodDeclarationSyntax)ctx.Node;
6671

67-
visitor.VisitDurableFunction(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic);
68-
},
69-
SyntaxKind.MethodDeclaration);
72+
visitor.VisitDurableFunction(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic);
73+
},
74+
SyntaxKind.MethodDeclaration);
75+
}
7076

7177
// look for ITaskOrchestrator/TaskOrchestrator`2 Orchestrations
72-
context.RegisterSyntaxNodeAction(
73-
ctx =>
78+
if (canAnalyzeTaskOrchestrator)
7479
{
75-
ctx.CancellationToken.ThrowIfCancellationRequested();
76-
77-
if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol)
80+
context.RegisterSyntaxNodeAction(
81+
ctx =>
7882
{
79-
return;
80-
}
83+
ctx.CancellationToken.ThrowIfCancellationRequested();
8184

82-
bool implementsITaskOrchestrator = classSymbol.AllInterfaces.Any(i => i.Equals(knownSymbols.TaskOrchestratorInterface, SymbolEqualityComparer.Default));
83-
if (!implementsITaskOrchestrator)
84-
{
85-
return;
86-
}
85+
if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol)
86+
{
87+
return;
88+
}
8789

88-
IEnumerable<IMethodSymbol> orchestrationMethods = classSymbol.GetMembers().OfType<IMethodSymbol>()
89-
.Where(m => m.Parameters.Any(p => p.Type.Equals(knownSymbols.TaskOrchestrationContext, SymbolEqualityComparer.Default)));
90+
bool implementsITaskOrchestrator = classSymbol.AllInterfaces.Any(i => i.Equals(knownSymbols.TaskOrchestratorInterface, SymbolEqualityComparer.Default));
91+
if (!implementsITaskOrchestrator)
92+
{
93+
return;
94+
}
9095

91-
string functionName = classSymbol.Name;
96+
IEnumerable<IMethodSymbol> orchestrationMethods = classSymbol.GetMembers().OfType<IMethodSymbol>()
97+
.Where(m => m.Parameters.Any(p => p.Type.Equals(knownSymbols.TaskOrchestrationContext, SymbolEqualityComparer.Default)));
9298

93-
foreach (IMethodSymbol? methodSymbol in orchestrationMethods)
94-
{
95-
IEnumerable<MethodDeclarationSyntax> methodSyntaxes = methodSymbol.GetSyntaxNodes();
96-
foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes)
99+
string functionName = classSymbol.Name;
100+
101+
foreach (IMethodSymbol? methodSymbol in orchestrationMethods)
97102
{
98-
visitor.VisitTaskOrchestrator(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic);
103+
IEnumerable<MethodDeclarationSyntax> methodSyntaxes = methodSymbol.GetSyntaxNodes();
104+
foreach (MethodDeclarationSyntax rootMethodSyntax in methodSyntaxes)
105+
{
106+
visitor.VisitTaskOrchestrator(ctx.SemanticModel, rootMethodSyntax, methodSymbol, functionName, ctx.ReportDiagnostic);
107+
}
99108
}
100-
}
101-
},
102-
SyntaxKind.ClassDeclaration);
109+
},
110+
SyntaxKind.ClassDeclaration);
111+
}
103112

104113
// look for OrchestratorFunc Orchestrations
105-
context.RegisterOperationAction(
106-
ctx =>
114+
if (canAnalyzeFuncOrchestrator)
107115
{
108-
if (ctx.Operation is not IInvocationOperation invocation)
116+
context.RegisterOperationAction(
117+
ctx =>
109118
{
110-
return;
111-
}
119+
if (ctx.Operation is not IInvocationOperation invocation)
120+
{
121+
return;
122+
}
112123

113-
if (!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry))
114-
{
115-
return;
116-
}
124+
if (!SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry))
125+
{
126+
return;
127+
}
117128

118-
// there are 8 AddOrchestratorFunc overloads
119-
if (invocation.TargetMethod.Name != "AddOrchestratorFunc")
120-
{
121-
return;
122-
}
129+
// there are 8 AddOrchestratorFunc overloads
130+
if (invocation.TargetMethod.Name != "AddOrchestratorFunc")
131+
{
132+
return;
133+
}
123134

124-
// all overloads have the parameter 'orchestrator', either as an Action or a Func
125-
IArgumentOperation orchestratorArgument = invocation.Arguments.First(a => a.Parameter!.Name == "orchestrator");
126-
if (orchestratorArgument.Value is not IDelegateCreationOperation delegateCreationOperation)
127-
{
128-
return;
129-
}
135+
// all overloads have the parameter 'orchestrator', either as an Action or a Func
136+
IArgumentOperation orchestratorArgument = invocation.Arguments.First(a => a.Parameter!.Name == "orchestrator");
137+
if (orchestratorArgument.Value is not IDelegateCreationOperation delegateCreationOperation)
138+
{
139+
return;
140+
}
130141

131-
// obtains the method symbol from the delegate creation operation
132-
IMethodSymbol? methodSymbol = null;
133-
SyntaxNode? methodSyntax = null;
134-
switch (delegateCreationOperation.Target)
135-
{
136-
case IAnonymousFunctionOperation lambdaOperation:
137-
// use the containing symbol of the lambda (e.g. the class declaring it) as the method symbol
138-
methodSymbol = ctx.ContainingSymbol as IMethodSymbol;
139-
methodSyntax = delegateCreationOperation.Syntax;
140-
break;
141-
case IMethodReferenceOperation methodReferenceOperation:
142-
// use the method reference as the method symbol
143-
methodSymbol = methodReferenceOperation.Method;
144-
methodSyntax = methodReferenceOperation.Method.DeclaringSyntaxReferences.First().GetSyntax();
145-
break;
146-
default:
147-
break;
148-
}
149-
150-
if (methodSymbol == null || methodSyntax == null)
151-
{
152-
return;
153-
}
142+
// obtains the method symbol from the delegate creation operation
143+
IMethodSymbol? methodSymbol = null;
144+
SyntaxNode? methodSyntax = null;
145+
switch (delegateCreationOperation.Target)
146+
{
147+
case IAnonymousFunctionOperation _:
148+
// use the containing symbol of the lambda (e.g. the class declaring it) as the method symbol
149+
methodSymbol = ctx.ContainingSymbol as IMethodSymbol;
150+
methodSyntax = delegateCreationOperation.Syntax;
151+
break;
152+
case IMethodReferenceOperation methodReferenceOperation:
153+
// use the method reference as the method symbol
154+
methodSymbol = methodReferenceOperation.Method;
155+
methodSyntax = methodReferenceOperation.Method.DeclaringSyntaxReferences.First().GetSyntax();
156+
break;
157+
default:
158+
break;
159+
}
154160

155-
// try to get the name of the orchestration from the method call, otherwise use the containing type name
156-
IArgumentOperation nameArgument = invocation.Arguments.First(a => a.Parameter!.Name == "name");
157-
Optional<object?> name = nameArgument.GetConstantValueFromAttribute(ctx.Operation.SemanticModel!, ctx.CancellationToken);
158-
string orchestrationName = name.Value?.ToString() ?? methodSymbol.Name;
161+
if (methodSymbol == null || methodSyntax == null)
162+
{
163+
return;
164+
}
159165

160-
visitor.VisitFuncOrchestrator(ctx.Operation.SemanticModel!, methodSyntax, methodSymbol, orchestrationName, ctx.ReportDiagnostic);
161-
},
162-
OperationKind.Invocation);
166+
// try to get the name of the orchestration from the method call, otherwise use the containing type name
167+
IArgumentOperation nameArgument = invocation.Arguments.First(a => a.Parameter!.Name == "name");
168+
Optional<object?> name = nameArgument.GetConstantValueFromAttribute(ctx.Operation.SemanticModel!, ctx.CancellationToken);
169+
string orchestrationName = name.Value?.ToString() ?? methodSymbol.Name;
170+
171+
visitor.VisitFuncOrchestrator(ctx.Operation.SemanticModel!, methodSyntax, methodSymbol, orchestrationName, ctx.ReportDiagnostic);
172+
},
173+
OperationKind.Invocation);
174+
}
163175
});
164176
}
165177
}

test/Analyzers.Tests/Orchestration/DateTimeOrchestrationAnalyzerTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,58 @@ public async Task FuncOrchestratorWithDateTimeOffsetHasDiag()
453453
await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
454454
}
455455

456+
[Fact]
457+
public async Task TaskOrchestratorSdkOnlyHasDiag()
458+
{
459+
// Tests that the analyzer works with SDK-only references (without Azure Functions assemblies)
460+
string code = Wrapper.WrapTaskOrchestratorSdkOnly(@"
461+
public class MyOrchestrator : TaskOrchestrator<string, DateTime>
462+
{
463+
public override Task<DateTime> RunAsync(TaskOrchestrationContext context, string input)
464+
{
465+
return Task.FromResult({|#0:DateTime.Now|});
466+
}
467+
}
468+
");
469+
470+
string fix = Wrapper.WrapTaskOrchestratorSdkOnly(@"
471+
public class MyOrchestrator : TaskOrchestrator<string, DateTime>
472+
{
473+
public override Task<DateTime> RunAsync(TaskOrchestrationContext context, string input)
474+
{
475+
return Task.FromResult(context.CurrentUtcDateTime);
476+
}
477+
}
478+
");
479+
480+
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("RunAsync", "System.DateTime.Now", "MyOrchestrator");
481+
482+
await VerifyCS.VerifySdkOnlyCodeFixAsync(code, expected, fix);
483+
}
484+
485+
[Fact]
486+
public async Task FuncOrchestratorSdkOnlyWithLambdaHasDiag()
487+
{
488+
// Tests that the analyzer works with SDK-only references (without Azure Functions assemblies)
489+
string code = Wrapper.WrapFuncOrchestratorSdkOnly(@"
490+
tasks.AddOrchestratorFunc(""HelloSequence"", context =>
491+
{
492+
return {|#0:DateTime.Now|};
493+
});
494+
");
495+
496+
string fix = Wrapper.WrapFuncOrchestratorSdkOnly(@"
497+
tasks.AddOrchestratorFunc(""HelloSequence"", context =>
498+
{
499+
return context.CurrentUtcDateTime;
500+
});
501+
");
502+
503+
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Main", "System.DateTime.Now", "HelloSequence");
504+
505+
await VerifyCS.VerifySdkOnlyCodeFixAsync(code, expected, fix);
506+
}
507+
456508
static DiagnosticResult BuildDiagnostic()
457509
{
458510
return VerifyCS.Diagnostic(DateTimeOrchestrationAnalyzer.DiagnosticId);

test/Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.Durable.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation.
1+
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

44
using Microsoft.CodeAnalysis.Diagnostics;
@@ -30,4 +30,23 @@ public static async Task VerifyDurableTaskAnalyzerAsync(string source, Action<Te
3030

3131
await test.RunAsync(CancellationToken.None);
3232
}
33+
34+
/// <summary>
35+
/// Runs analyzer test with SDK-only references (without Azure Functions assemblies).
36+
/// Used to test orchestration detection in non-function scenarios.
37+
/// </summary>
38+
public static async Task VerifySdkOnlyAnalyzerAsync(string source, Action<Test>? configureTest = null, params DiagnosticResult[] expected)
39+
{
40+
Test test = new()
41+
{
42+
TestCode = source,
43+
ReferenceAssemblies = References.SdkOnlyAssemblies,
44+
};
45+
46+
test.ExpectedDiagnostics.AddRange(expected);
47+
48+
configureTest?.Invoke(test);
49+
50+
await test.RunAsync(CancellationToken.None);
51+
}
3352
}

0 commit comments

Comments
 (0)