diff --git a/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs b/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs index d749df24d..0772565bd 100644 --- a/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs +++ b/src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs @@ -75,6 +75,9 @@ public override void Initialize(AnalysisContext context) IMethodSymbol taskActivityRunAsync = knownSymbols.TaskActivityBase.GetMembers("RunAsync").OfType().Single(); INamedTypeSymbol voidSymbol = context.Compilation.GetSpecialType(SpecialType.System_Void); + // Get common DI types that should not be treated as activity input + INamedTypeSymbol? functionContextSymbol = context.Compilation.GetTypeByMetadataName("Microsoft.Azure.Functions.Worker.FunctionContext"); + // Search for Activity invocations ConcurrentBag invocations = []; context.RegisterOperationAction( @@ -161,6 +164,12 @@ public override void Initialize(AnalysisContext context) return; } + // If the parameter is FunctionContext, skip validation for this activity (it's a DI parameter, not real input) + if (functionContextSymbol != null && SymbolEqualityComparer.Default.Equals(inputParam.Type, functionContextSymbol)) + { + return; + } + ITypeSymbol? inputType = inputParam.Type; ITypeSymbol? outputType = methodSymbol.ReturnType; @@ -306,7 +315,8 @@ public override void Initialize(AnalysisContext context) continue; } - if (!SymbolEqualityComparer.Default.Equals(invocation.InputType, activity.InputType)) + // Check input type compatibility + if (!AreTypesCompatible(ctx.Compilation, invocation.InputType, activity.InputType)) { string actual = invocation.InputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none"; string expected = activity.InputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none"; @@ -316,7 +326,8 @@ public override void Initialize(AnalysisContext context) ctx.ReportDiagnostic(diagnostic); } - if (!SymbolEqualityComparer.Default.Equals(invocation.OutputType, activity.OutputType)) + // Check output type compatibility + if (!AreTypesCompatible(ctx.Compilation, activity.OutputType, invocation.OutputType)) { string actual = invocation.OutputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none"; string expected = activity.OutputType?.ToDisplayString(SymbolDisplayFormat.CSharpShortErrorMessageFormat) ?? "none"; @@ -330,6 +341,48 @@ public override void Initialize(AnalysisContext context) }); } + /// + /// Checks if the source type is compatible with (can be assigned to) the target type. + /// This handles polymorphism, interface implementation, inheritance, and collection type compatibility. + /// + static bool AreTypesCompatible(Compilation compilation, ITypeSymbol? sourceType, ITypeSymbol? targetType) + { + // Both null = compatible (no input/output on both sides) + if (sourceType == null && targetType == null) + { + return true; + } + + // Special case: null (no input/output provided) can be passed to explicitly nullable parameters + // This handles nullable value types (int?) and nullable reference types (string?) + if (sourceType == null && targetType != null) + { + // Check if target is a nullable value type (Nullable) + if (targetType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return true; + } + + // Check if target is a nullable reference type (string?) + if (targetType.NullableAnnotation == NullableAnnotation.Annotated) + { + return true; + } + + // Not nullable, so null input is incompatible + return false; + } + + // If targetType is null but sourceType is not, they're incompatible + if (targetType == null && sourceType != null) + { + return false; + } + + var conversion = compilation.ClassifyConversion(sourceType!, targetType!); + return conversion.IsImplicit || conversion.IsIdentity; + } + struct ActivityInvocation { public string Name { get; set; } diff --git a/test/Analyzers.Tests/Activities/MatchingInputOutputTypeActivityAnalyzerTests.cs b/test/Analyzers.Tests/Activities/MatchingInputOutputTypeActivityAnalyzerTests.cs index 2970f6adf..d10eb6ba6 100644 --- a/test/Analyzers.Tests/Activities/MatchingInputOutputTypeActivityAnalyzerTests.cs +++ b/test/Analyzers.Tests/Activities/MatchingInputOutputTypeActivityAnalyzerTests.cs @@ -406,6 +406,147 @@ async Task Method(TaskOrchestrationContext context) await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); } + [Fact] + // Verifies that when FunctionContext is marked with [ActivityTrigger], + // calling the activity without input produces NO DURABLE2001/DURABLE2002 warnings. + // This fixes the issue where FunctionContext was incorrectly treated as required input. + public async Task DurableFunctionActivityWithFunctionContextAsActivityTrigger_CalledWithoutInput() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + int num = await context.CallActivityAsync(nameof(GetNumber)); +} + +[Function(nameof(GetNumber))] +int GetNumber([ActivityTrigger] FunctionContext context) +{ + return 42; +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + // Verifies that when FunctionContext is marked with [ActivityTrigger], + // calling the activity WITH input also produces NO DURABLE2001/DURABLE2002 warnings. + // The analyzer completely skips validation for FunctionContext activities. + public async Task DurableFunctionActivityWithFunctionContextAsActivityTrigger_CalledWithInput() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + int num = await context.CallActivityAsync(nameof(GetNumber), ""someInput""); +} + +[Function(nameof(GetNumber))] +int GetNumber([ActivityTrigger] FunctionContext context) +{ + return 42; +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + // Verifies that polymorphism is supported for input types - NO WARNINGS expected. + // A derived type (Exception) should be assignable to a base type (object). + // This tests that the analyzer uses type compatibility rather than exact type matching. + public async Task DurableFunctionActivityWithPolymorphicInput_DerivedToBase() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + Exception ex = new Exception(""error""); + await context.CallActivityAsync(nameof(LogError), ex); +} + +[Function(nameof(LogError))] +void LogError([ActivityTrigger] object error) +{ +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + // Verifies that collection type compatibility works: List → IReadOnlyList - NO WARNINGS expected. + // This tests the exact scenario from the issue: passing List to an activity expecting IReadOnlyList. + // Uses TaskActivity<> pattern since it works better with generic collection types. + public async Task TaskActivityWithCollectionPolymorphism_ListToIReadOnlyList() + { + string code = Wrapper.WrapTaskOrchestrator(@" +using System.Collections.Generic; + +public class Caller { + async Task Method(TaskOrchestrationContext context) + { + List numbers = new List { 1, 2, 3, 4, 5 }; + int sum = await context.CallActivityAsync(nameof(SumActivity), numbers); + } +} + +public class SumActivity : TaskActivity, int> +{ + public override Task RunAsync(TaskActivityContext context, IReadOnlyList numbers) + { + return Task.FromResult(42); + } +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + // Verifies that polymorphism is supported for output types - NO WARNINGS expected. + // When an activity returns string but the caller expects object, no warning should occur. + // This tests covariance - a more specific return type is acceptable. + public async Task DurableFunctionActivityWithPolymorphicOutput_StringToObject() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + object result = await context.CallActivityAsync(nameof(GetValue), ""input""); +} + +[Function(nameof(GetValue))] +string GetValue([ActivityTrigger] string input) +{ + return ""hello""; +} +"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code); + } + + [Fact] + // Verifies that truly incompatible types still produce DURABLE2001 warnings. + // Passing string when int is expected should fail - this is not a valid conversion. + // This test ensures the analyzer still catches real type mismatches. + public async Task DurableFunctionActivityWithIncompatibleTypes_ShouldFail() + { + string code = Wrapper.WrapDurableFunctionOrchestration(@" +async Task Method(TaskOrchestrationContext context) +{ + await {|#0:context.CallActivityAsync(nameof(GetNumber), ""text"")|}; +} + +[Function(nameof(GetNumber))] +int GetNumber([ActivityTrigger] int value) +{ + return value; +} +"); + + DiagnosticResult expected = BuildInputDiagnostic().WithLocation(0).WithArguments("string", "int", "GetNumber"); + + await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected); + } + static DiagnosticResult BuildInputDiagnostic() {