From 3ca7ca1583e59c7fef2da3fbdfce5f845e8876d3 Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:59:36 -0800 Subject: [PATCH 1/2] fix: resolve Moq1203 false positive for delegate-based overloads (#910) GetSymbolInfo on MemberAccessExpressionSyntax lacks argument context, so Roslyn cannot resolve delegate-based overloads like ReturnsAsync((MyValue val) => val). Query the parent InvocationExpressionSyntax instead, and add a name-based fallback for cases where IsInstanceOf still cannot match the constructed generic method against known Moq symbols. Co-Authored-By: Claude Opus 4.6 --- ...odSetupShouldSpecifyReturnValueAnalyzer.cs | 35 +++++++++++++++++-- ...upShouldSpecifyReturnValueAnalyzerTests.cs | 25 ++++++------- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs index 487a4fa2d..14b2a107f 100644 --- a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs +++ b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs @@ -164,9 +164,19 @@ private static bool HasReturnValueSpecification( { cancellationToken.ThrowIfCancellationRequested(); - SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(memberAccess, cancellationToken); - - if (HasReturnValueSymbol(symbolInfo, knownSymbols)) + InvocationExpressionSyntax? invocation = memberAccess.Parent as InvocationExpressionSyntax; + SymbolInfo symbolInfo = invocation != null + ? semanticModel.GetSymbolInfo(invocation, cancellationToken) + : semanticModel.GetSymbolInfo(memberAccess, cancellationToken); + + // First try semantic symbol matching (exact). Then fall back to method + // name matching when Roslyn resolves a symbol that IsInstanceOf cannot + // match (e.g., delegate-based overloads with constructed generic types). + // The name-based fallback is safe because this walk only visits methods + // chained from a verified Setup() call, and we verify the named method + // exists in the Moq compilation. + if (HasReturnValueSymbol(symbolInfo, knownSymbols) + || IsKnownReturnValueMethodName(memberAccess.Name.Identifier.ValueText, knownSymbols)) { return true; } @@ -177,6 +187,25 @@ private static bool HasReturnValueSpecification( return false; } + /// + /// Checks whether a method name corresponds to a known Moq return value specification + /// method that exists in the compilation. Used as a last-resort fallback when Roslyn + /// cannot resolve symbols at all (e.g., delegate-based overloads with failed type inference). + /// + private static bool IsKnownReturnValueMethodName(string methodName, MoqKnownSymbols knownSymbols) + { + return methodName switch + { + "Returns" => !knownSymbols.IReturnsReturns.IsEmpty + || !knownSymbols.IReturns1Returns.IsEmpty + || !knownSymbols.IReturns2Returns.IsEmpty, + "ReturnsAsync" => !knownSymbols.ReturnsExtensionsReturnsAsync.IsEmpty, + "Throws" => !knownSymbols.IThrowsThrows.IsEmpty, + "ThrowsAsync" => !knownSymbols.ReturnsExtensionsThrowsAsync.IsEmpty, + _ => false, + }; + } + /// /// Determines whether the given resolves to a Moq return value /// specification method. Checks the resolved symbol first, then falls back to scanning diff --git a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs index c5d531bce..047c78a0b 100644 --- a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs @@ -364,6 +364,17 @@ public static IEnumerable CustomReturnTypeTestData() mock.Setup(x => x.GetAsync()).ThrowsAsync(new InvalidOperationException()); """, "public record MyValue(string Name);", "Task GetAsync();"], + // Delegate-based ReturnsAsync with MockBehavior.Strict + [""" + var databaseMock = new Mock(MockBehavior.Strict); + databaseMock.Setup(mock => mock.SaveAsync(It.IsAny())).ReturnsAsync((MyValue val) => val); + """, "public record MyValue;", "Task SaveAsync(MyValue value);"], + + // Delegate-based ReturnsAsync with default MockBehavior, inline + [""" + new Mock().Setup(x => x.SaveAsync(It.IsAny())).ReturnsAsync((MyValue val) => val); + """, "public record MyValue;", "Task SaveAsync(MyValue value);"], + ]; return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); @@ -393,20 +404,6 @@ public static IEnumerable CustomReturnTypeMissingReturnValueTestData() var mock = new Mock(MockBehavior.Strict); {|Moq1203:mock.Setup(x => x.SaveAsync(It.IsAny()))|}; """, "public record MyValue;", "Task SaveAsync(MyValue value);"], - - // Known false positive: delegate-based ReturnsAsync IS a return value specification, - // but the analyzer does not recognize it. Reported by DamienCassou: - // https://github.com/rjmurillo/moq.analyzers/issues/849#issuecomment-3925720443 - // When fixed, move these to CustomReturnTypeTestData without the diagnostic markup. - [""" - var databaseMock = new Mock(MockBehavior.Strict); - {|Moq1203:databaseMock.Setup(mock => mock.SaveAsync(It.IsAny()))|}.ReturnsAsync((MyValue val) => val); - """, "public record MyValue;", "Task SaveAsync(MyValue value);"], - - // Same false positive with default MockBehavior, no variable - [""" - {|Moq1203:new Mock().Setup(x => x.SaveAsync(It.IsAny()))|}.ReturnsAsync((MyValue val) => val); - """, "public record MyValue;", "Task SaveAsync(MyValue value);"], ]; return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); From 0b38a6201b055a60e808cb3817f6bd3e0f5996ae Mon Sep 17 00:00:00 2001 From: Richard Murillo <6811113+rjmurillo@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:59:42 -0800 Subject: [PATCH 2/2] fix: resolve Moq1206 false negative for delegate-based overloads (#911) IsReturnsMethodCallWithAsyncLambda queried GetSymbolInfo on the MemberAccessExpressionSyntax, missing argument context. Switch to querying the InvocationExpressionSyntax and add CandidateSymbols fallback (previously missing entirely), so delegate-typed async lambdas like Returns(async (string x) => x) are detected. Co-Authored-By: Claude Opus 4.6 --- ...syncShouldBeUsedForAsyncMethodsAnalyzer.cs | 18 +++---- ...houldBeUsedForAsyncMethodsAnalyzerTests.cs | 47 +++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs b/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs index f5e2529cd..e93ed2939 100644 --- a/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs +++ b/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs @@ -73,19 +73,21 @@ private static void Analyze(SyntaxNodeAnalysisContext context) private static bool IsReturnsMethodCallWithAsyncLambda(InvocationExpressionSyntax invocation, SemanticModel semanticModel, MoqKnownSymbols knownSymbols) { - if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + if (invocation.Expression is not MemberAccessExpressionSyntax) { return false; } - // Check if this is a Returns method call - SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(memberAccess); - if (symbolInfo.Symbol is not IMethodSymbol method) - { - return false; - } + // Query the invocation (not the MemberAccessExpressionSyntax) so Roslyn has argument context + // for overload resolution. Fall back to CandidateSymbols for delegate overloads. + SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(invocation); + bool isReturnsMethod = symbolInfo.Symbol is IMethodSymbol method + ? method.IsMoqReturnsMethod(knownSymbols) + : symbolInfo.CandidateSymbols + .OfType() + .Any(m => m.IsMoqReturnsMethod(knownSymbols)); - if (!method.IsMoqReturnsMethod(knownSymbols)) + if (!isReturnsMethod) { return false; } diff --git a/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs b/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs index 184703e5a..8cca0806a 100644 --- a/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs @@ -62,6 +62,19 @@ public static IEnumerable TestData() return valid.Concat(invalid); } + // Delegate-typed async lambdas cause overload resolution failure in Roslyn. + // The analyzer must fall back to CandidateSymbols to detect Returns usage. + public static IEnumerable DelegateOverloadTestData() + { + IEnumerable data = new object[][] + { + // Async delegate lambda in Returns for Task method with parameter (should flag) + ["""new Mock().Setup(c => c.ProcessAsync(It.IsAny())).{|Moq1206:Returns(async (string x) => x)|};"""], + }; + + return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); + } + [Theory] [MemberData(nameof(TestData))] public async Task ShouldAnalyzeReturnsAsyncUsage(string referenceAssemblyGroup, string @namespace, string mock) @@ -95,6 +108,40 @@ await Verifier.VerifyAnalyzerAsync( referenceAssemblyGroup); } + [Theory] + [MemberData(nameof(DelegateOverloadTestData))] + public async Task ShouldFlagAsyncDelegateLambdaInReturns(string referenceAssemblyGroup, string @namespace, string mock) + { + string source = + $$""" + {{@namespace}} + + public class AsyncClient + { + public virtual Task DoAsync() => Task.CompletedTask; + public virtual Task GetAsync() => Task.FromResult(string.Empty); + public virtual ValueTask DoValueTaskAsync() => ValueTask.CompletedTask; + public virtual ValueTask GetValueTaskAsync() => ValueTask.FromResult(string.Empty); + public virtual string GetSync() => string.Empty; + public virtual Task ProcessAsync(string input) => Task.FromResult(input); + } + + internal class UnitTest + { + private void Test() + { + {{mock}} + } + } + """; + + output.WriteLine(source); + + await Verifier.VerifyAnalyzerAsync( + source, + referenceAssemblyGroup); + } + [Theory] [MemberData(nameof(DoppelgangerTestHelper.GetAllCustomMockData), MemberType = typeof(DoppelgangerTestHelper))] public async Task ShouldPassIfCustomMockClassIsUsed(string mockCode)