Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
142 changes: 140 additions & 2 deletions src/Analyzers/Activities/MatchingInputOutputTypeActivityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ public override void Initialize(AnalysisContext context)
IMethodSymbol taskActivityRunAsync = knownSymbols.TaskActivityBase.GetMembers("RunAsync").OfType<IMethodSymbol>().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<ActivityInvocation> invocations = [];
context.RegisterOperationAction(
Expand Down Expand Up @@ -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;
}
Comment on lines +167 to +171
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FunctionContext check (lines 167-171) lacks test coverage. Consider adding a test case that verifies an activity method with a FunctionContext parameter marked with [ActivityTrigger] doesn't generate diagnostics, confirming that DI parameters are properly excluded from input type validation.

Copilot uses AI. Check for mistakes.

ITypeSymbol? inputType = inputParam.Type;

ITypeSymbol? outputType = methodSymbol.ReturnType;
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -330,6 +341,133 @@ public override void Initialize(AnalysisContext context)
});
}

/// <summary>
/// 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.
/// </summary>
static bool AreTypesCompatible(Compilation compilation, ITypeSymbol? sourceType, ITypeSymbol? targetType)
{
// Both null = compatible
if (sourceType == null && targetType == null)
{
return true;
}

// One is null, the other isn't = not compatible
if (sourceType == null || targetType == null)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking - does this logic support passing null as input to a nullable function parameter? That was also an issue with this analyzer previously

{
return false;
}

// Check if types are exactly equal
if (SymbolEqualityComparer.Default.Equals(sourceType, targetType))
{
return true;
}

// Check if source type can be converted to target type (handles inheritance, interface implementation, etc.)
Conversion conversion = compilation.ClassifyConversion(sourceType, targetType);
if (conversion.IsImplicit || conversion.IsIdentity)
{
return true;
}

// Special handling for collection types since ClassifyConversion doesn't always recognize
// generic interface implementations (e.g., List<T> to IReadOnlyList<T>)
if (IsCollectionTypeCompatible(sourceType, targetType))
{
return true;
}

return false;
}

/// <summary>
/// Checks if the source collection type is compatible with the target collection type.
/// Handles common scenarios like List to IReadOnlyList, arrays to IEnumerable, etc.
/// </summary>
static bool IsCollectionTypeCompatible(ITypeSymbol sourceType, ITypeSymbol targetType)
{
// Check if source is an array and target is a collection interface
if (sourceType is IArrayTypeSymbol sourceArray && targetType is INamedTypeSymbol targetNamed)
{
return IsArrayCompatibleWithCollectionInterface(sourceArray, targetNamed);
}

// Both must be generic named types
if (sourceType is not INamedTypeSymbol sourceNamed || targetType is not INamedTypeSymbol targetNamedType)
{
return false;
}

// Both must be generic types with the same type arguments
if (!sourceNamed.IsGenericType || !targetNamedType.IsGenericType)
{
return false;
}

if (sourceNamed.TypeArguments.Length != targetNamedType.TypeArguments.Length)
{
return false;
}

// Check if type arguments are compatible (could be different but compatible types)
for (int i = 0; i < sourceNamed.TypeArguments.Length; i++)
{
if (!SymbolEqualityComparer.Default.Equals(sourceNamed.TypeArguments[i], targetNamedType.TypeArguments[i]))
{
// Type arguments must match exactly for collections (we don't support covariance/contravariance here)
return false;
}
}
Comment on lines +436 to +444
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on line 436 "Check if type arguments are compatible (could be different but compatible types)" is misleading, as the implementation (lines 437-444) requires exact type argument equality, not just compatibility. Consider updating the comment to: "Check if type arguments match exactly (we don't support covariance/contravariance for collections)" to better reflect the actual behavior.

Copilot uses AI. Check for mistakes.

// Check if source type implements or derives from target type
// This handles: List<T> → IReadOnlyList<T>, List<T> → IEnumerable<T>, etc.
return ImplementsInterface(sourceNamed, targetNamedType);
}

/// <summary>
/// Checks if an array type is compatible with a collection interface.
/// </summary>
static bool IsArrayCompatibleWithCollectionInterface(IArrayTypeSymbol arrayType, INamedTypeSymbol targetInterface)
{
if (!targetInterface.IsGenericType || targetInterface.TypeArguments.Length != 1)
{
return false;
}

// Check if array element type matches the generic type argument
if (!SymbolEqualityComparer.Default.Equals(arrayType.ElementType, targetInterface.TypeArguments[0]))
{
return false;
}

// Array implements: IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyCollection<T>, IReadOnlyList<T>
string targetName = targetInterface.OriginalDefinition.ToDisplayString();
return targetName == "System.Collections.Generic.IEnumerable<T>" ||
targetName == "System.Collections.Generic.ICollection<T>" ||
targetName == "System.Collections.Generic.IList<T>" ||
targetName == "System.Collections.Generic.IReadOnlyCollection<T>" ||
targetName == "System.Collections.Generic.IReadOnlyList<T>";
Comment on lines +467 to +473
Copy link

Copilot AI Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The array interface compatibility check uses string comparison of type names, which may not be robust across different compilation contexts or type forwarding scenarios. Consider using GetTypeByMetadataName to get reference symbols for the standard collection interfaces and comparing using SymbolEqualityComparer.Default.Equals instead of string comparison. This would be more reliable and less prone to edge cases with type representation differences.

Copilot uses AI. Check for mistakes.
}

/// <summary>
/// Checks if the source type implements the target interface.
/// </summary>
static bool ImplementsInterface(INamedTypeSymbol sourceType, INamedTypeSymbol targetInterface)
{
// Check all interfaces implemented by the source type
foreach (INamedTypeSymbol @interface in sourceType.AllInterfaces)
{
if (SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, targetInterface.OriginalDefinition))
{
return true;
}
}
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

return false;
}

struct ActivityInvocation
{
public string Name { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(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<int>(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<T> → IReadOnlyList<T> - NO WARNINGS expected.
// This tests the exact scenario from the issue: passing List<int> to an activity expecting IReadOnlyList<int>.
// 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<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int sum = await context.CallActivityAsync<int>(nameof(SumActivity), numbers);
}
}

public class SumActivity : TaskActivity<IReadOnlyList<int>, int>
{
public override Task<int> RunAsync(TaskActivityContext context, IReadOnlyList<int> 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<object>(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<int>(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()
{
Expand Down
Loading