Skip to content

feat: add Moq1208 analyzer for Returns() delegate mismatch on async methods#942

Merged
rjmurillo merged 14 commits intomainfrom
feat/moq1208-returns-delegate-mismatch
Mar 1, 2026
Merged

feat: add Moq1208 analyzer for Returns() delegate mismatch on async methods#942
rjmurillo merged 14 commits intomainfrom
feat/moq1208-returns-delegate-mismatch

Conversation

@rjmurillo
Copy link
Copy Markdown
Owner

@rjmurillo rjmurillo commented Mar 1, 2026

Summary

  • Adds new analyzer Moq1208 that detects .Returns(() => value) with a delegate whose return type does not match the async method's return type (e.g., returns int instead of Task<int>), which causes a runtime MockException
  • Adds code fix to transform .Returns(delegate) to .ReturnsAsync(delegate)
  • Includes 100 tests (82 analyzer + 18 code fix) covering all branches, edge cases, and doppelganger scenarios

Problem

mock.Setup(d => d.BarAsync()).Returns(() => 7); // Runtime MockException!
// "Invalid callback. Setup on method with return type 'Task' cannot invoke callback with return type 'int'."

Solution

The analyzer flags the mismatch at compile time. The code fix transforms:

// Before (flagged)
mock.Setup(d => d.BarAsync()).Returns(() => 7);

// After (fixed)
mock.Setup(d => d.BarAsync()).ReturnsAsync(() => 7);

Files changed

File Change
src/Common/DiagnosticIds.cs Added ReturnsDelegateMismatchOnAsyncMethod = "Moq1208"
src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs New analyzer
src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs New code fix
src/Analyzers/AnalyzerReleases.Unshipped.md Release tracking
tests/.../ReturnsDelegateShouldReturnTaskAnalyzerTests.cs 82 test cases
tests/.../ReturnsDelegateShouldReturnTaskFixerTests.cs 20 test cases (5 x 2 namespaces x 2 Moq versions)
docs/rules/Moq1208.md Rule documentation

Edge cases handled

Case Behavior
.Returns(value) (direct value, not delegate) Not flagged
.Returns(() => Task.FromResult(7)) Not flagged (correct)
.Returns(async () => 7) Not flagged (Moq1206 handles this)
Non-async method .Returns(() => 7) Not flagged
Non-generic Task return type Not flagged
.Callback().Returns(() => 7) on async method Flagged
(mock.Setup(...)).Returns(() => 7) Flagged
Delegate with parameters (string x) => x.Length Flagged

Test plan

  • Build succeeds with 0 warnings, 0 errors
  • 100 new Moq1208 tests pass
  • Full test suite passes (2604/2604, 0 failures, 0 regressions)
  • Doppelganger tests verify no false positives on custom Mock classes
  • Tests cover all analyzer branches for 100% block coverage
  • Tests run across multiple Moq versions (WithMoqReferenceAssemblyGroups)

Closes #767

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added analyzer Moq1208 (warning) detecting Returns() delegates that mismatch async setups and a quick-fix to replace with ReturnsAsync().
  • Documentation
    • Added rule documentation and updated rules index with examples, recommended fixes, and suppression/configuration guidance.
  • Tests
    • Added comprehensive analyzer and code-fix tests covering valid/invalid patterns for Task/ValueTask and delegate forms.
  • Chores
    • Added release-note entry and minor test-verifier refactor to support optional compiler diagnostics.

…ync methods

Detects when .Returns() is called with a delegate whose return type
does not match the async method's return type (e.g., returns int
instead of Task<int>), which causes a runtime MockException. Includes
a code fix to transform Returns to ReturnsAsync.

Closes #767

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 1, 2026 01:04
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 1, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds analyzer Moq1208 that detects .Returns(...) delegates that don't return Task/ValueTask for async mocked members, a code fix replacing .Returns with .ReturnsAsync, documentation and README updates, release notes, and comprehensive analyzer/fixer tests.

Changes

Cohort / File(s) Summary
Documentation
docs/rules/Moq1208.md, docs/rules/README.md, README.md
New rule documentation page and README entries describing Moq1208, examples, fixes, and suppression/configuration guidance.
Analyzer & Release Notes
src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs, src/Analyzers/AnalyzerReleases.Unshipped.md, src/Common/DiagnosticIds.cs
New diagnostic analyzer Moq1208 that locates Setup(...).Returns(...) chains, resolves mocked member return type and Returns() delegate return type, and reports a warning when the delegate return type is not Task/ValueTask; added diagnostic ID and release notes entry.
Code Fix
src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
New CodeFixProvider that replaces Returns with ReturnsAsync (preserving trivia and generic type args), registers fix for the new diagnostic, and supports FixAll.
Tests — Analyzer
tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
New analyzer tests covering valid and invalid patterns (Task/ValueTask, lambdas, anonymous delegates, method groups, overloads, chained callbacks, parenthesized setups, custom mock class scenarios).
Tests — CodeFix
tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs
New code-fix tests verifying transformation of Returns(...) to ReturnsAsync(...) across Task/ValueTask and various delegate forms, including cases requiring compiler-diagnostic suppression.
Test Helpers
tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
Test harness change: optional CompilerDiagnostics? compilerDiagnostics = null parameter added to VerifyCodeFixAsync and test setup refactored to configure then run the test instance.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(240,248,255,0.5)
    participant IDE as IDE/Compiler
    participant Analyzer as ReturnsDelegateShouldReturnTaskAnalyzer
    participant Diagnostic as DiagnosticEngine
    participant CodeFix as ReturnsDelegateShouldReturnTaskFixer
    participant Document as SourceDocument
    end
    IDE->>Analyzer: Analyze InvocationExpression + semantic model
    Analyzer->>Analyzer: Locate Setup(...) chain and Returns(...) argument
    Analyzer->>Analyzer: Resolve mocked member return type and delegate return type
    Analyzer->>Diagnostic: Report Moq1208 diagnostic spanning Returns invocation
    IDE->>CodeFix: Request fixes for Moq1208
    CodeFix->>Document: Replace `Returns` with `ReturnsAsync` (preserve trivia/generics)
    CodeFix->>IDE: Return updated document
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • MattKotsenas
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: adding analyzer Moq1208 for detecting Returns() delegate mismatch on async methods, which directly matches the primary objective.
Linked Issues check ✅ Passed The PR successfully implements all coding requirements from issue #767: detects Returns() with non-Task delegates on async methods, provides ReturnsAsync() code fix, uses semantic analysis, includes comprehensive tests across Moq versions, and documents the rule.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing Moq1208 analyzer and its supporting infrastructure; minor updates to CodeFixVerifier.cs enable optional compiler diagnostics suppression for test scenarios, which is necessary for comprehensive test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/moq1208-returns-delegate-mismatch

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enhances the Moq.Analyzers suite by adding a new diagnostic tool and an automated code fix. The primary goal is to prevent common runtime MockException errors that occur when developers incorrectly use .Returns() with synchronous delegates for asynchronous method setups. By identifying and correcting these mismatches at compile time, the change significantly improves the robustness and developer experience when writing Moq tests involving async operations.

Highlights

  • New Analyzer (Moq1208): Introduced a new analyzer, Moq1208, which detects incorrect usage of .Returns() with a synchronous delegate for async methods. Specifically, it flags cases where the delegate returns a non-Task type (e.g., int) while the mocked async method expects a Task<int> or ValueTask<int>, leading to a runtime MockException.
  • Code Fix Provided: A corresponding code fix is included that automatically transforms the problematic .Returns(() => value) calls into .ReturnsAsync(() => value), resolving the type mismatch and preventing runtime errors.
  • Comprehensive Testing: The new analyzer and code fix are thoroughly tested with 100 new test cases (82 for the analyzer and 18 for the code fix), covering various scenarios, edge cases, and doppelganger checks to ensure reliability and prevent false positives.
Changelog
  • docs/rules/Moq1208.md
    • Documented the new Moq1208 analyzer rule, including its purpose, examples of flagged patterns, recommended solutions, and methods for suppressing the warning.
  • src/Analyzers/AnalyzerReleases.Unshipped.md
    • Registered the new Moq1208 analyzer in the unshipped release notes.
  • src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
    • Implemented the ReturnsDelegateShouldReturnTaskAnalyzer to identify Returns() calls with synchronous delegates that return non-Task types for async method setups.
  • src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
    • Created a code fix provider for Moq1208 that automatically replaces Returns() with ReturnsAsync() to resolve delegate type mismatches.
  • src/Common/DiagnosticIds.cs
    • Defined the new diagnostic ID Moq1208 for the ReturnsDelegateMismatchOnAsyncMethod analyzer.
  • tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
    • Added comprehensive unit tests for the ReturnsDelegateShouldReturnTaskAnalyzer, covering valid, invalid, and edge-case scenarios, including doppelganger tests.
  • tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs
    • Implemented unit tests for the ReturnsDelegateShouldReturnTaskFixer to verify its ability to correctly transform Returns() to ReturnsAsync() in various contexts.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@coderabbitai coderabbitai bot requested a review from MattKotsenas March 1, 2026 01:05
@rjmurillo rjmurillo added documentation analyzers Change that impacts an analyzer behavior releasable labels Mar 1, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is an excellent pull request that adds a valuable new analyzer and code fix. The changes are well-structured, the code is clean, and the test coverage is impressive. The documentation for the new rule is also very clear.

I've found one area for improvement in the analyzer's implementation. The GetDelegateReturnType method in ReturnsDelegateShouldReturnTaskAnalyzer.cs currently only supports expression-bodied lambdas. It should be updated to handle block-bodied lambdas as well to cover all cases. I've left a specific comment with a suggested implementation that uses semanticModel.GetSymbolInfo on the lambda to make it more robust.

Once the analyzer is updated, it would be great to add a new test case to both the analyzer and code fix tests to cover block-bodied lambdas (e.g., ...Returns(() => { return 42; })).

Overall, great work on this feature!

@deepsource-io
Copy link
Copy Markdown

deepsource-io bot commented Mar 1, 2026

DeepSource Code Review

We reviewed changes in f3a8c8a...c5f829a on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade  

Focus Area: Hygiene
Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
C# Mar 1, 2026 7:48p.m. Review ↗

@codacy-production
Copy link
Copy Markdown

codacy-production bot commented Mar 1, 2026

Coverage summary from Codacy

See diff coverage on Codacy

Coverage variation Diff coverage
+0.23% (target: -1.00%) 93.51% (target: 95.00%)
Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (f3a8c8a) 2072 1843 88.95%
Head commit (c5f829a) 2218 (+146) 1978 (+135) 89.18% (+0.23%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#942) 154 144 93.51%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

See your quality gate settings    Change summary preferences

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a new Roslyn analyzer (Moq1208) and code fix to catch Moq async setups that use .Returns(...) with a non-Task-returning delegate, and to automatically convert those calls to .ReturnsAsync(...).

Changes:

  • Introduces Moq1208 diagnostic ID and release tracking entry.
  • Adds ReturnsDelegateShouldReturnTaskAnalyzer to detect invalid .Returns(<sync delegate>) on Task<T> / ValueTask<T> setups.
  • Adds ReturnsDelegateShouldReturnTaskFixer plus new analyzer/code-fix tests and rule documentation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Common/DiagnosticIds.cs Adds the Moq1208 diagnostic ID constant.
src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs New analyzer implementation for detecting Returns delegate mismatches on async methods.
src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs New code fix to replace .Returns(...) with .ReturnsAsync(...).
src/Analyzers/AnalyzerReleases.Unshipped.md Adds Moq1208 entry for release tracking.
tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs Adds analyzer test coverage for valid/invalid patterns.
tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs Adds code fix tests for converting .Returns to .ReturnsAsync.
docs/rules/Moq1208.md Adds documentation for the new rule.

@coderabbitai coderabbitai bot added the feature label Mar 1, 2026
- Fix false negative: block-bodied lambdas (() => { return 42; }) now
  detected alongside expression-bodied lambdas
- Replace unbounded while(true) with depth-guarded for loop in
  FindSetupInvocation to prevent runaway iteration
- Extract FindFirstReturnStatement to avoid boxing (ECS0900)
- Reword comment to fix Sonar S125 false positive
- Revert const back to static readonly per project convention (ECS0200)
- Use primary constructor in fixer tests for consistency
- Fix SA1505 blank line after opening brace
- Add block-bodied lambda tests for both analyzer and code fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/rules/Moq1208.md`:
- Around line 3-8: The Markdown in the Moq1208 rule doc needs formatting: add a
blank line before and after the table (the block starting with "| Item | Value
|") so the table is separated from surrounding text, and convert the suggested
code block at the bottom into a fenced code block with an explicit language
(e.g., ```text) so lines 12-14 declare the fence language; update the block
around the MockException example to use the fenced syntax with the language
token and ensure there is a blank line before and after that fenced block as
well.

In `@src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs`:
- Around line 12-13: The diagnostic message and description (the static fields
Message and Description in ReturnsDelegateShouldReturnTaskAnalyzer) don't
include the expected async return type, making reports ambiguous; update those
strings to add a third placeholder for the expected return type (e.g. "...should
return a {2} type, not '{1}'..." and description to mention the expected type),
and then ensure wherever the diagnostic is constructed (the
Diagnostic.Create/Report call inside ReturnsDelegateShouldReturnTaskAnalyzer's
analysis method) you pass the computed expected return type as the third
argument to fill that placeholder so the diagnostic shows the exact expected
Task/ValueTask<T> type.
- Around line 20-21: The diagnostic for Moq1208 in
ReturnsDelegateShouldReturnTaskAnalyzer is currently created with
DiagnosticSeverity.Warning; change its DiagnosticDescriptor/creation to use
DiagnosticSeverity.Error (compile-time error) so the rule enforces an
error-level diagnostic, i.e., locate the DiagnosticDescriptor or
Diagnostic.Create call for Moq1208 in ReturnsDelegateShouldReturnTaskAnalyzer
and replace DiagnosticSeverity.Warning with DiagnosticSeverity.Error.
- Around line 93-110: HasSyncDelegateArgument currently only recognizes
expression-bodied lambdas and ignores block-bodied lambdas and anonymous
delegates; update it to accept LambdaExpressionSyntax (including
ParenthesizedLambdaExpressionSyntax), and AnonymousMethodExpressionSyntax, then
determine sync-ness by: for lambdas/anonymous methods first check AsyncKeyword
(reject async), then if body is an ExpressionSyntax treat as sync, and if body
is a BlockSyntax inspect the block for a single ReturnStatementSyntax whose
Expression is not an AwaitExpression (treat as sync); mirror this same logic in
the other analyzer locations referenced around lines 203–225/212–218 so
block-bodied lambdas and anonymous delegates are analyzed, and add regression
tests covering Returns(() => { return 42; }) and Returns(delegate { return 42;
}).

In `@src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs`:
- Around line 58-71: The ReplaceReturnsWithReturnsAsync fixer currently always
renames memberAccess.Name to "ReturnsAsync", which is invalid for ValueTask<T>;
update ReplaceReturnsWithReturnsAsync to examine the mocked method return type
(the setup's generic/expected return type) and branch: if the return type is
Task<T> keep the existing rename to ReturnsAsync (preserving trivia), but if
it's ValueTask<T> instead construct a new invocation that calls Returns and
wraps the original delegate result with a ValueTask<T>(...) expression (i.e.,
replace the memberAccess/argument expression so the delegate return is wrapped
in new ValueTask<T>(result)); ensure you update root via
root.ReplaceNode(document.WithSyntaxRoot(...)) the same way but creating the
correct MemberAccessExpressionSyntax/InvocationExpressionSyntax for each branch
and preserve existing trivia from the original memberAccess and its name.

In `@tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs`:
- Around line 79-84: The test ShouldNotTriggerOnValidPatterns is using
VerifyAsync but per guidelines no-diagnostic tests must call
AllAnalyzersVerifier.VerifyAllAnalyzersAsync; update the method to await
AllAnalyzersVerifier.VerifyAllAnalyzersAsync(referenceAssemblyGroup, `@namespace`,
mock) instead of awaiting VerifyAsync, keeping the same parameters and async
signature so the test continues to run asynchronously; locate the call in
ReturnsDelegateShouldReturnTaskAnalyzerTests.ShouldNotTriggerOnValidPatterns and
replace the invocation accordingly.

ℹ️ Review info

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d29822d and 04a1051.

📒 Files selected for processing (7)
  • docs/rules/Moq1208.md
  • src/Analyzers/AnalyzerReleases.Unshipped.md
  • src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
  • src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
  • src/Common/DiagnosticIds.cs
  • tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
  • tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs

…rule indexes

Rewrote Moq1208.md to explain core Moq concepts (Setup/Returns), why the
type mismatch causes a runtime error, what ReturnsAsync does internally,
and how this rule differs from Moq1206. Added Moq1208 to both README.md
and docs/rules/README.md rule tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Resolved by another fix: Nested return statements missed in block-bodied lambdas
    • The FindFirstReturnStatement method was replaced with a semantic model approach that uses lambdaSymbol.ReturnType, which correctly handles nested returns in all control flow constructs.
Preview (4d6345d226)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
 | [Moq1205](docs/rules/Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               |
 | [Moq1206](docs/rules/Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        |
 | [Moq1207](docs/rules/Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               |
+| [Moq1208](docs/rules/Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  |
 | [Moq1210](docs/rules/Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      |
 | [Moq1300](docs/rules/Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 |
 | [Moq1301](docs/rules/Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     |

diff --git a/docs/rules/Moq1208.md b/docs/rules/Moq1208.md
new file mode 100644
--- /dev/null
+++ b/docs/rules/Moq1208.md
@@ -1,0 +1,116 @@
+# Moq1208: Returns() delegate type mismatch on async method setup
+
+| Item     | Value |
+| -------- | ----- |
+| Enabled  | True  |
+| Severity | Warning |
+| CodeFix  | True |
+
+---
+
+## What this rule checks
+
+In Moq, `.Setup()` defines what a mocked method should do when called.
+`.Returns()` specifies the value the method gives back. For example:
+
+```csharp
+mock.Setup(x => x.GetName()).Returns(() => "Alice");
+//   ^^^^^ "when GetName is called"     ^^^^^^^^^ "return Alice"
+```
+
+This rule fires when the function passed to `.Returns()` gives back a plain
+value like `int` or `string`, but the mocked method is async and returns
+`Task<int>` or `ValueTask<string>`. Moq requires the types to match exactly.
+
+### Why this matters
+
+The code compiles without errors, but the test fails at runtime with this
+exception:
+
+```
+MockException: Invalid callback. Setup on method with return type 'Task<int>'
+cannot invoke callback with return type 'int'.
+```
+
+This analyzer catches the mismatch at compile time so you don't have to debug
+a failing test to find it.
+
+### How this differs from Moq1206
+
+[Moq1206](./Moq1206.md) flags `async` lambdas in `.Returns()`, such as
+`Returns(async () => 42)`. Moq1208 flags regular (non-async) lambdas that
+return the wrong type, such as `Returns(() => 42)` on a `Task<int>` method.
+
+## Examples of patterns that are flagged by this analyzer
+
+```csharp
+public interface IService
+{
+    Task<int> GetValueAsync();       // Returns Task<int>
+    Task<string> GetNameAsync();     // Returns Task<string>
+    ValueTask<int> GetValueTaskAsync(); // Returns ValueTask<int>
+}
+
+var mock = new Mock<IService>();
+
+// GetValueAsync returns Task<int>, but the lambda returns int.
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42); // Moq1208
+
+// GetNameAsync returns Task<string>, but the lambda returns string.
+mock.Setup(x => x.GetNameAsync()).Returns(() => "hello"); // Moq1208
+
+// GetValueTaskAsync returns ValueTask<int>, but the lambda returns int.
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => 42); // Moq1208
+```
+
+## Solution
+
+### Option 1: Use ReturnsAsync (recommended)
+
+`.ReturnsAsync()` wraps the value in `Task.FromResult()` for you. This is the
+simplest fix and what the built-in code fix applies automatically.
+
+```csharp
+var mock = new Mock<IService>();
+
+// Pass a plain value. Moq wraps it in Task.FromResult() internally.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(42);
+
+// Or pass a lambda. Moq wraps the lambda's return value the same way.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(() => 42);
+```
+
+### Option 2: Wrap the value yourself
+
+If you need more control, keep `.Returns()` and wrap the value explicitly.
+
+```csharp
+var mock = new Mock<IService>();
+
+mock.Setup(x => x.GetValueAsync()).Returns(() => Task.FromResult(42));
+mock.Setup(x => x.GetNameAsync()).Returns(() => Task.FromResult("hello"));
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => new ValueTask<int>(42));
+```
+
+## Suppress a warning
+
+If you just want to suppress a single violation, add preprocessor directives to
+your source file to disable and then re-enable the rule.
+
+```csharp
+#pragma warning disable Moq1208
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42);
+#pragma warning restore Moq1208
+```
+
+To disable the rule for a file, folder, or project, set its severity to `none`
+in the
+[configuration file](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files).
+
+```ini
+[*.{cs,vb}]
+dotnet_diagnostic.Moq1208.severity = none
+```
+
+For more information, see
+[How to suppress code analysis warnings](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/suppress-warnings).

diff --git a/docs/rules/README.md b/docs/rules/README.md
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -15,6 +15,7 @@
 | [Moq1205](./Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               | [EventSetupHandlerShouldMatchEventTypeAnalyzer.cs](../../src/Analyzers/EventSetupHandlerShouldMatchEventTypeAnalyzer.cs)            |
 | [Moq1206](./Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        | [ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs](../../src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs)       |
 | [Moq1207](./Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               | [SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs) |
+| [Moq1208](./Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  | [ReturnsDelegateShouldReturnTaskAnalyzer.cs](../../src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs)                                      |
 | [Moq1210](./Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      | [VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs)       |
 | [Moq1300](./Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 | [AsShouldBeUsedOnlyForInterfaceAnalyzer.cs](../../src/Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs)                         |
 | [Moq1301](./Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     | [MockGetShouldNotTakeLiteralsAnalyzer.cs](../../src/Analyzers/MockGetShouldNotTakeLiteralsAnalyzer.cs)                             |

diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md
--- a/src/Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/Analyzers/AnalyzerReleases.Unshipped.md
@@ -17,6 +17,7 @@
 Moq1205 | Usage | Warning | EventSetupHandlerShouldMatchEventTypeAnalyzer (updated category from Moq to Usage)
 Moq1206 | Usage | Warning | ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer (updated category from Moq to Usage)
 Moq1207 | Usage | Error | SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
+Moq1208 | Usage | Warning | ReturnsDelegateShouldReturnTaskAnalyzer
 Moq1210 | Usage | Error | VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
 Moq1300 | Usage | Error | AsShouldBeUsedOnlyForInterfaceAnalyzer (updated category from Moq to Usage)
 Moq1301 | Usage | Warning | Mock.Get() should not take literals (updated category from Moq to Usage)

diff --git a/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
new file mode 100644
--- /dev/null
+++ b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
@@ -1,0 +1,236 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Moq.Analyzers;
+
+/// <summary>
+/// Returns() delegate on async method setup should return Task/ValueTask to match the mocked method's return type.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ReturnsDelegateShouldReturnTaskAnalyzer : DiagnosticAnalyzer
+{
+    private static readonly LocalizableString Title = "Moq: Returns() delegate type mismatch on async method";
+    private static readonly LocalizableString Message = "Returns() delegate for async method '{0}' should return '{2}', not '{1}'. Use ReturnsAsync() or wrap with Task.FromResult().";
+    private static readonly LocalizableString Description = "Returns() delegate on async method setup should return Task/ValueTask. Use ReturnsAsync() or wrap with Task.FromResult().";
+
+    private static readonly DiagnosticDescriptor Rule = new(
+        DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod,
+        Title,
+        Message,
+        DiagnosticCategory.Usage,
+        DiagnosticSeverity.Warning,
+        isEnabledByDefault: true,
+        description: Description,
+        helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod}.md");
+
+    /// <inheritdoc />
+    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
+
+    /// <inheritdoc />
+    public override void Initialize(AnalysisContext context)
+    {
+        context.EnableConcurrentExecution();
+        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+        context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression);
+    }
+
+    private static void Analyze(SyntaxNodeAnalysisContext context)
+    {
+        MoqKnownSymbols knownSymbols = new(context.SemanticModel.Compilation);
+
+        InvocationExpressionSyntax invocation = (InvocationExpressionSyntax)context.Node;
+
+        if (!IsReturnsMethodCallWithSyncDelegate(invocation, context.SemanticModel, knownSymbols))
+        {
+            return;
+        }
+
+        InvocationExpressionSyntax? setupInvocation = FindSetupInvocation(invocation, context.SemanticModel, knownSymbols);
+        if (setupInvocation == null)
+        {
+            return;
+        }
+
+        if (!TryGetMismatchInfo(setupInvocation, invocation, context.SemanticModel, knownSymbols, out string? methodName, out ITypeSymbol? expectedReturnType, out ITypeSymbol? delegateReturnType))
+        {
+            return;
+        }
+
+        // Report diagnostic spanning from "Returns" identifier through the closing paren
+        MemberAccessExpressionSyntax memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
+        int startPos = memberAccess.Name.SpanStart;
+        int endPos = invocation.Span.End;
+        Microsoft.CodeAnalysis.Text.TextSpan span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(startPos, endPos);
+        Location location = Location.Create(invocation.SyntaxTree, span);
+
+        string actualDisplay = delegateReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        string expectedDisplay = expectedReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        Diagnostic diagnostic = location.CreateDiagnostic(Rule, methodName, actualDisplay, expectedDisplay);
+        context.ReportDiagnostic(diagnostic);
+    }
+
+    private static bool IsReturnsMethodCallWithSyncDelegate(InvocationExpressionSyntax invocation, SemanticModel semanticModel, MoqKnownSymbols knownSymbols)
+    {
+        if (invocation.Expression is not MemberAccessExpressionSyntax)
+        {
+            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<IMethodSymbol>()
+                .Any(m => m.IsMoqReturnsMethod(knownSymbols));
+
+        if (!isReturnsMethod)
+        {
+            return false;
+        }
+
+        return HasSyncDelegateArgument(invocation);
+    }
+
+    private static bool HasSyncDelegateArgument(InvocationExpressionSyntax invocation)
+    {
+        if (invocation.ArgumentList.Arguments.Count == 0)
+        {
+            return false;
+        }
+
+        ExpressionSyntax firstArgument = invocation.ArgumentList.Arguments[0].Expression;
+
+        // Must be a lambda or delegate. Raw values (not delegates) are a different overload.
+        if (firstArgument is not LambdaExpressionSyntax lambda)
+        {
+            return false;
+        }
+
+        // Exclude async lambdas. Those are Moq1206's domain.
+        return !lambda.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword);
+    }
+
+    private static InvocationExpressionSyntax? FindSetupInvocation(InvocationExpressionSyntax returnsInvocation, SemanticModel semanticModel, MoqKnownSymbols knownSymbols)
+    {
+        // Walk up the fluent chain to find Setup. Handles patterns like:
+        // mock.Setup(...).Returns(...)
+        // mock.Setup(...).Callback(...).Returns(...)
+        if (returnsInvocation.Expression is not MemberAccessExpressionSyntax memberAccess)
+        {
+            return null;
+        }
+
+        // Moq fluent chains are short (Setup.Callback.Returns at most 3-4 deep).
+        // Guard against pathological syntax trees.
+        const int maxChainDepth = 10;
+        ExpressionSyntax current = memberAccess.Expression;
+
+        for (int depth = 0; depth < maxChainDepth; depth++)
+        {
+            ExpressionSyntax unwrapped = current.WalkDownParentheses();
+
+            if (unwrapped is not InvocationExpressionSyntax candidateInvocation)
+            {
+                return null;
+            }
+
+            if (candidateInvocation.Expression is not MemberAccessExpressionSyntax candidateMemberAccess)
+            {
+                return null;
+            }
+
+            SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(candidateMemberAccess);
+            if (symbolInfo.Symbol != null && symbolInfo.Symbol.IsMoqSetupMethod(knownSymbols))
+            {
+                return candidateInvocation;
+            }
+
+            // Continue walking up the chain (past Callback, etc.)
+            current = candidateMemberAccess.Expression;
+        }
+
+        return null;
+    }
+
+    private static bool TryGetMismatchInfo(
+        InvocationExpressionSyntax setupInvocation,
+        InvocationExpressionSyntax returnsInvocation,
+        SemanticModel semanticModel,
+        MoqKnownSymbols knownSymbols,
+        [NotNullWhen(true)] out string? methodName,
+        [NotNullWhen(true)] out ITypeSymbol? expectedReturnType,
+        [NotNullWhen(true)] out ITypeSymbol? delegateReturnType)
+    {
+        methodName = null;
+        expectedReturnType = null;
+        delegateReturnType = null;
+
+        // Get the mocked method from the Setup lambda
+        ExpressionSyntax? mockedMemberExpression = setupInvocation.FindMockedMemberExpressionFromSetupMethod();
+        if (mockedMemberExpression == null)
+        {
+            return false;
+        }
+
+        SymbolInfo mockedSymbolInfo = semanticModel.GetSymbolInfo(mockedMemberExpression);
+        if (mockedSymbolInfo.Symbol is not IMethodSymbol mockedMethod)
+        {
+            return false;
+        }
+
+        // The mocked method must return Task<T> or ValueTask<T> (generic variants).
+        // Non-generic Task/ValueTask have no inner type to mismatch against.
+        ITypeSymbol returnType = mockedMethod.ReturnType;
+        if (returnType is not INamedTypeSymbol { IsGenericType: true })
+        {
+            return false;
+        }
+
+        if (!returnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        // Get the delegate's return type from the lambda argument
+        delegateReturnType = GetDelegateReturnType(returnsInvocation, semanticModel);
+        if (delegateReturnType == null)
+        {
+            return false;
+        }
+
+        // If the delegate already returns a Task/ValueTask type, no mismatch
+        if (delegateReturnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        methodName = mockedMethod.Name;
+        expectedReturnType = returnType;
+        return true;
+    }
+
+    private static ITypeSymbol? GetDelegateReturnType(InvocationExpressionSyntax returnsInvocation, SemanticModel semanticModel)
+    {
+        if (returnsInvocation.ArgumentList.Arguments.Count == 0)
+        {
+            return null;
+        }
+
+        ExpressionSyntax firstArgument = returnsInvocation.ArgumentList.Arguments[0].Expression;
+
+        if (firstArgument is not LambdaExpressionSyntax lambda)
+        {
+            return null;
+        }
+
+        // Use the lambda's semantic symbol to get its return type.
+        // This handles both expression-bodied (() => 42) and block-bodied (() => { return 42; }) lambdas.
+        if (semanticModel.GetSymbolInfo(lambda).Symbol is IMethodSymbol lambdaSymbol)
+        {
+            return lambdaSymbol.ReturnType;
+        }
+
+        return null;
+    }
+}

diff --git a/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
new file mode 100644
--- /dev/null
+++ b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
@@ -1,0 +1,81 @@
+using System.Composition;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+
+namespace Moq.CodeFixes;
+
+/// <summary>
+/// Fixes for <see cref="DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod"/> (Moq1208).
+/// Replaces <c>.Returns(delegate)</c> with <c>.ReturnsAsync(delegate)</c> when the
+/// mocked method is async and the delegate returns the unwrapped type.
+/// </summary>
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ReturnsDelegateShouldReturnTaskFixer))]
+[Shared]
+public sealed class ReturnsDelegateShouldReturnTaskFixer : CodeFixProvider
+{
+    private static readonly string Title = "Use ReturnsAsync instead of Returns";
+
+    /// <inheritdoc />
+    public override ImmutableArray<string> FixableDiagnosticIds { get; } =
+        ImmutableArray.Create(DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod);
+
+    /// <inheritdoc />
+    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+    /// <inheritdoc />
+    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+    {
+        SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+        if (root == null)
+        {
+            return;
+        }
+
+        Diagnostic diagnostic = context.Diagnostics[0];
+
+        // The diagnostic span starts at the "Returns" identifier. Use FindToken to land
+        // on that token, then walk up to the enclosing MemberAccessExpressionSyntax.
+        SyntaxToken token = root.FindToken(diagnostic.Location.SourceSpan.Start);
+        MemberAccessExpressionSyntax? memberAccess = token.Parent?.FirstAncestorOrSelf<MemberAccessExpressionSyntax>();
+        if (memberAccess == null)
+        {
+            return;
+        }
+
+        if (!string.Equals(memberAccess.Name.Identifier.ValueText, "Returns", StringComparison.Ordinal))
+        {
+            return;
+        }
+
+        context.RegisterCodeFix(
+            CodeAction.Create(
+                title: Title,
+                createChangedDocument: _ => ReplaceReturnsWithReturnsAsync(context.Document, root, memberAccess),
+                equivalenceKey: Title),
+            diagnostic);
+    }
+
+    private static Task<Document> ReplaceReturnsWithReturnsAsync(
+        Document document,
+        SyntaxNode root,
+        MemberAccessExpressionSyntax memberAccess)
+    {
+        SimpleNameSyntax oldName = memberAccess.Name;
+
+        // Preserve generic type arguments if present (e.g., Returns<int>(...) -> ReturnsAsync<int>(...))
+        SimpleNameSyntax newName = oldName is GenericNameSyntax genericName
+            ? SyntaxFactory.GenericName(
+                SyntaxFactory.Identifier("ReturnsAsync"),
+                genericName.TypeArgumentList)
+            : (SimpleNameSyntax)SyntaxFactory.IdentifierName("ReturnsAsync");
+
+        newName = newName
+            .WithLeadingTrivia(oldName.GetLeadingTrivia())
+            .WithTrailingTrivia(oldName.GetTrailingTrivia());
+
+        MemberAccessExpressionSyntax newMemberAccess = memberAccess.WithName(newName);
+        SyntaxNode newRoot = root.ReplaceNode(memberAccess, newMemberAccess);
+
+        return Task.FromResult(document.WithSyntaxRoot(newRoot));
+    }
+}

diff --git a/src/Common/DiagnosticIds.cs b/src/Common/DiagnosticIds.cs
--- a/src/Common/DiagnosticIds.cs
+++ b/src/Common/DiagnosticIds.cs
@@ -17,6 +17,7 @@
     internal const string EventSetupHandlerShouldMatchEventType = "Moq1205";
     internal const string ReturnsAsyncShouldBeUsedForAsyncMethods = "Moq1206";
     internal const string SetupSequenceOnlyUsedForOverridableMembers = "Moq1207";
+    internal const string ReturnsDelegateMismatchOnAsyncMethod = "Moq1208";
     internal const string VerifyOnlyUsedForOverridableMembers = "Moq1210";
     internal const string AsShouldOnlyBeUsedForInterfacesRuleId = "Moq1300";
     internal const string MockGetShouldNotTakeLiterals = "Moq1301";

diff --git a/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
@@ -1,0 +1,147 @@
+using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier<Moq.Analyzers.ReturnsDelegateShouldReturnTaskAnalyzer>;
+
+namespace Moq.Analyzers.Test;
+
+public class ReturnsDelegateShouldReturnTaskAnalyzerTests(ITestOutputHelper output)
+{
+    public static IEnumerable<object[]> ValidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Delegate returns Task<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => Task.FromResult(42));"""],
+
+            // Uses ReturnsAsync (correct, different overload)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).ReturnsAsync(42);"""],
+
+            // Direct value, not a delegate
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(42);"""],
+
+            // Non-async method with sync delegate (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(() => 42);"""],
+
+            // Async lambda (Moq1206's domain, not ours)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(async () => 42);"""],
+
+            // Setup without Returns call
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync());"""],
+
+            // Delegate returns ValueTask<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).Returns(() => ValueTask.FromResult(42));"""],
+
+            // Chained Callback with correct Task return
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Callback(() => { }).Returns(() => Task.FromResult(42));"""],
+
+            // Non-generic Task method with sync delegate (no mismatch, Task has no inner type)
+            ["""new Mock<AsyncService>().Setup(c => c.DoAsync()).Returns(() => Task.CompletedTask);"""],
+
+            // Parenthesized Setup with ReturnsAsync (valid)
+            ["""(new Mock<AsyncService>().Setup(c => c.GetValueAsync())).ReturnsAsync(42);"""],
+
+            // Block-bodied lambda returning Task (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => { return Task.FromResult(42); });"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    public static IEnumerable<object[]> InvalidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Sync delegate returning int on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Sync delegate returning string on Task<string> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetNameAsync()).{|Moq1208:Returns(() => "hello")|};"""],
+
+            // Sync delegate returning int on ValueTask<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Parenthesized Setup with sync delegate mismatch
+            ["""(new Mock<AsyncService>().Setup(c => c.GetValueAsync())).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Chained Callback then Returns with sync delegate mismatch
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Callback(() => { }).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Block-bodied lambda returning wrong type on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(() => { return 42; })|};"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    public static IEnumerable<object[]> DelegateOverloadTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Sync delegate with parameter returning wrong type on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.ProcessAsync(It.IsAny<string>())).{|Moq1208:Returns((string x) => x.Length)|};"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    [Theory]
+    [MemberData(nameof(ValidTestData))]
+    public async Task ShouldNotTriggerOnValidPatterns(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock);
+    }
+
+    [Theory]
+    [MemberData(nameof(InvalidTestData))]
+    public async Task ShouldTriggerOnSyncDelegateMismatch(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock);
+    }
+
+    [Theory]
+    [MemberData(nameof(DelegateOverloadTestData))]
+    public async Task ShouldFlagSyncDelegateLambdaWithParameterInReturns(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock);
+    }
+
+    [Theory]
+    [MemberData(nameof(DoppelgangerTestHelper.GetAllCustomMockData), MemberType = typeof(DoppelgangerTestHelper))]
+    public async Task ShouldPassIfCustomMockClassIsUsed(string mockCode)
+    {
+        await Verifier.VerifyAnalyzerAsync(
+            DoppelgangerTestHelper.CreateTestCode(mockCode),
+            ReferenceAssemblyCatalog.Net80WithNewMoq);
+    }
+
+    private async Task VerifyAsync(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        string source =
+            $$"""
+              {{@namespace}}
+
+              public class AsyncService
+              {
+                  public virtual Task<int> GetValueAsync() => Task.FromResult(0);
+                  public virtual Task<string> GetNameAsync() => Task.FromResult(string.Empty);
+                  public virtual ValueTask<int> GetValueTaskAsync() => ValueTask.FromResult(0);
+                  public virtual Task DoAsync() => Task.CompletedTask;
+                  public virtual int GetSync() => 0;
+                  public virtual Task<int> ProcessAsync(string input) => Task.FromResult(input.Length);
+              }
+
+              internal class UnitTest
+              {
+                  private void Test()
+                  {
+                      {{mock}}
+                  }
+              }
+              """;
+
+        output.WriteLine(source);
+
+        await Verifier.VerifyAnalyzerAsync(
+                source,
+                referenceAssemblyGroup)
+            .ConfigureAwait(false);
+    }
+}

diff --git a/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs
@@ -1,0 +1,85 @@
+using Verifier = Moq.Analyzers.Test.Helpers.CodeFixVerifier<Moq.Analyzers.ReturnsDelegateShouldReturnTaskAnalyzer, Moq.CodeFixes.ReturnsDelegateShouldReturnTaskFixer>;
+
+namespace Moq.Analyzers.Test;
+
+public class ReturnsDelegateShouldReturnTaskFixerTests(ITestOutputHelper output)
+{
+    public static IEnumerable<object[]> TestData()
+    {
+        return new object[][]
+        {
+            // Task<int> with parameterless lambda returning int
+            [
+                """new Mock<AsyncService>().Setup(s => s.GetValueAsync()).{|Moq1208:Returns(() => 42)|};""",
+                """new Mock<AsyncService>().Setup(s => s.GetValueAsync()).ReturnsAsync(() => 42);""",
+            ],
+
+            // Task<string> with parameterless lambda returning string
+            [
+                """new Mock<AsyncService>().Setup(s => s.GetNameAsync()).{|Moq1208:Returns(() => "hello")|};""",
+                """new Mock<AsyncService>().Setup(s => s.GetNameAsync()).ReturnsAsync(() => "hello");""",
+            ],
+
+            // ValueTask<int> with parameterless lambda returning int
+            [
+                """new Mock<AsyncService>().Setup(s => s.GetValueTaskAsync()).{|Moq1208:Returns(() => 42)|};""",
+                """new Mock<AsyncService>().Setup(s => s.GetValueTaskAsync()).ReturnsAsync(() => 42);""",
+            ],
+
+            // Delegate with parameter
+            [
+                """new Mock<AsyncService>().Setup(s => s.ProcessAsync(It.IsAny<string>())).{|Moq1208:Returns((string x) => x.Length)|};""",
+                """new Mock<AsyncService>().Setup(s => s.ProcessAsync(It.IsAny<string>())).ReturnsAsync((string x) => x.Length);""",
+            ],
+
+            // Parenthesized Setup expression
+            [
+                """(new Mock<AsyncService>().Setup(s => s.GetValueAsync())).{|Moq1208:Returns(() => 42)|};""",
+                """(new Mock<AsyncService>().Setup(s => s.GetValueAsync())).ReturnsAsync(() => 42);""",
+            ],
+
+            // Block-bodied lambda returning wrong type
+            [
+                """new Mock<AsyncService>().Setup(s => s.GetValueAsync()).{|Moq1208:Returns(() => { return 42; })|};""",
+                """new Mock<AsyncService>().Setup(s => s.GetValueAsync()).ReturnsAsync(() => { return 42; });""",
+            ],
+        }.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    [Theory]
+    [MemberData(nameof(TestData))]
+    public async Task ShouldReplaceReturnsWithReturnsAsync(string referenceAssemblyGroup, string @namespace, string original, string quickFix)
+    {
+        static string Template(string ns, string mock) =>
+            $$"""
+            {{ns}}
+
+            public class AsyncService
+            {
+                public virtual Task<int> GetValueAsync() => Task.FromResult(0);
+                public virtual Task<string> GetNameAsync() => Task.FromResult(string.Empty);
+                public virtual ValueTask<int> GetValueTaskAsync() => new ValueTask<int>(0);
+                public virtual Task<int> ProcessAsync(string input) => Task.FromResult(input.Length);
+            }
+
+            internal class UnitTest
+            {
+                private void Test()
+                {
+                    {{mock}}
+                }
+            }
+            """;
+
+        string o = Template(@namespace, original);
+        string f = Template(@namespace, quickFix);
+
+        output.WriteLine("Original:");
+        output.WriteLine(o);
+        output.WriteLine(string.Empty);
+        output.WriteLine("Fixed:");
+        output.WriteLine(f);
+
+        await Verifier.VerifyCodeFixAsync(o, f, referenceAssemblyGroup);
+    }
+}

rjmurillo and others added 2 commits February 28, 2026 17:40
- Simplify GetDelegateReturnType using IMethodSymbol.ReturnType instead of
  manually inspecting expression/block bodies (handles all lambda forms)
- Include expected return type in diagnostic message for clarity
- Preserve generic type arguments in code fix (Returns<T> -> ReturnsAsync<T>)
- Fix markdown blank line before horizontal rule separator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
docs/rules/Moq1208.md (1)

30-33: ⚠️ Potential issue | 🟡 Minor

Add language specifier to fenced code block.

The exception example block should specify a language for consistent formatting.

📝 Suggested fix
-```
+```text
 MockException: Invalid callback. Setup on method with return type 'Task<int>'
 cannot invoke callback with return type 'int'.
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/rules/Moq1208.md around lines 30 - 33, The fenced code block showing
the MockException example lacks a language specifier; update the fenced block
that contains "MockException: Invalid callback. Setup on method with return type
'Task' cannot invoke callback with return type 'int'." to include a
language tag (e.g., text) after the opening backticks so the exception is
consistently formatted.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs:

  • Around line 95-112: The HasSyncDelegateArgument method currently only treats
    firstArgument as LambdaExpressionSyntax and ignores anonymous delegates
    (AnonymousMethodExpressionSyntax); either update HasSyncDelegateArgument to also
    detect and handle AnonymousMethodExpressionSyntax (check for
    AnonymousMethodExpressionSyntax and treat non-async anonymous methods as sync
    delegates) or, if you want to postpone the implementation, add a clear TODO
    comment above HasSyncDelegateArgument noting the known gap (anonymous delegates
    not handled) and referencing AnonymousMethodExpressionSyntax so future
    maintainers know what to change; keep the existing logic for excluding async
    delegates by checking the AsyncKeyword in both LambdaExpressionSyntax and
    AnonymousMethodExpressionSyntax cases.

Duplicate comments:
In @docs/rules/Moq1208.md:

  • Around line 30-33: The fenced code block showing the MockException example
    lacks a language specifier; update the fenced block that contains
    "MockException: Invalid callback. Setup on method with return type 'Task'
    cannot invoke callback with return type 'int'." to include a language tag (e.g.,
    text) after the opening backticks so the exception is consistently formatted.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

**Configuration used**: Repository UI

**Review profile**: ASSERTIVE

**Plan**: Pro

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 04a1051e66bcbba506ead09df524d9bac9194d40 and 4d6345d226a968b3e703817f2c52e72361a4dc8e.

</details>

<details>
<summary>📒 Files selected for processing (7)</summary>

* `README.md`
* `docs/rules/Moq1208.md`
* `docs/rules/README.md`
* `src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs`
* `src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs`
* `tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs`
* `tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

…atches

The analyzer previously only flagged lambda expressions. Anonymous methods
(`delegate { return 42; }`) and method groups (`Returns(GetInt)`) on async
method setups produce the same runtime MockException but were not detected.

Broadens delegate detection from LambdaExpressionSyntax to
AnonymousFunctionExpressionSyntax and adds method group resolution via
GetSymbolInfo/CandidateSymbols. Includes PR review hardening: defensive
casts, CandidateReason guards, FindSetupInvocation CandidateSymbols
fallback, ambiguous candidate consensus check, and anonymous method body
analysis for accurate return type extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
docs/rules/Moq1208.md (1)

36-39: ⚠️ Potential issue | 🟡 Minor

Add a language identifier to the fenced runtime-exception block.

Line 36 uses an unlabeled fenced block, which violates MD040.

📝 Proposed fix
-```
+```text
 MockException: Invalid callback. Setup on method with return type 'Task<int>'
 cannot invoke callback with return type 'int'.
</details>



As per coding guidelines: All documentation and markdown files must pass formatting checks; use a markdown linter if available.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @docs/rules/Moq1208.md around lines 36 - 39, The fenced code block showing
the MockException message is unlabeled (violates MD040); update the fenced block
around the runtime-exception text (the block that starts with "MockException:
Invalid callback. Setup on method with return type 'Task'") to include a
language identifier such as text (e.g., add "text" after the opening backticks)
so the markdown linter accepts it.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs:

  • Around line 29-34: Replace the syntax-node registration with an
    operation-based registration: in Initialize() stop calling
    context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression) and
    instead call context.RegisterOperationAction(AnalyzeInvocation,
    OperationKind.Invocation); implement a new
    AnalyzeInvocation(OperationAnalysisContext context) that casts context.Operation
    to IInvocationOperation and uses invocation.TargetMethod for symbol/type checks
    instead of semanticModel.GetSymbolInfo()/GetTypeInfo; update or remove the old
    Analyze(SyntaxNodeAnalysisContext) helper and adjust any references to rely on
    IInvocationOperation, OperationAnalysisContext, and TargetMethod for the
    existing symbol/type logic.
  • Around line 293-300: GetReturnTypeFromBlock currently prunes anonymous
    functions but still descends into local functions, causing it to pick up returns
    from LocalFunctionStatementSyntax; update the descendant traversal predicate in
    GetReturnTypeFromBlock so it does not descend into LocalFunctionStatementSyntax
    (along with AnonymousFunctionExpressionSyntax) when searching for
    ReturnStatementSyntax, then add a regression test in
    tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs where
    an anonymous delegate contains a local function that returns a primitive while
    the delegate returns Task.FromResult(...) to ensure the analyzer infers the
    outer delegate return type correctly.

Duplicate comments:
In @docs/rules/Moq1208.md:

  • Around line 36-39: The fenced code block showing the MockException message is
    unlabeled (violates MD040); update the fenced block around the runtime-exception
    text (the block that starts with "MockException: Invalid callback. Setup on
    method with return type 'Task'") to include a language identifier such as
    text (e.g., add "text" after the opening backticks) so the markdown linter
    accepts it.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

**Configuration used**: Repository UI

**Review profile**: ASSERTIVE

**Plan**: Pro

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 4d6345d226a968b3e703817f2c52e72361a4dc8e and 4c5e83ad2d9565de37ed3fcc927e64d73076acb5.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `docs/rules/Moq1208.md`
* `src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs`
* `tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs`
* `tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs`
* `tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskFixerTests.cs`

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

…ase tests

Remove proven-dead code paths identified by coverage analysis:
- Name-based anonymous method fallback (symbol resolution handles this)
- CandidateSymbols fallback in FindSetupInvocation (never triggered)
- Zero-argument guards where callers guarantee arguments exist
- Duplicate MemberAccessExpressionSyntax check in Analyze
- GenericNameSyntax branch in fixer (Moq's Returns is never generic)

Merge two pattern matches in FindSetupInvocation into a single
compound pattern for both InvocationExpression and MemberAccess.

Add tests for uncovered but reachable paths:
- Generic non-Task return type (IList<int>)
- Property setup (IPropertySymbol vs IMethodSymbol)
- Split setup/returns (variable reference breaks chain walk)
- Expression variable setup (non-lambda Setup argument)
- Void anonymous delegate (null delegate return type)
- Non-MemberAccess invocation (early exit path)

Analyzer: 98.6% line, 90.9% branch (2 unreachable defensive guards remain)
Fixer: 100% line/branch on ReplaceReturnsWithReturnsAsync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Missing argument count guard causes potential analyzer crash
    • Added argument count guards in both HasSyncDelegateArgument and GetDelegateReturnType methods to prevent IndexOutOfRangeException when the argument list is empty.
  • ✅ Fixed: Code fix drops generic type arguments from Returns
    • Modified ReplaceReturnsWithReturnsAsync to check if oldName is a GenericNameSyntax and preserve the TypeArgumentList when creating the ReturnsAsync replacement.
Preview (e4cf645d12)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
 | [Moq1205](docs/rules/Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               |
 | [Moq1206](docs/rules/Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        |
 | [Moq1207](docs/rules/Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               |
+| [Moq1208](docs/rules/Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  |
 | [Moq1210](docs/rules/Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      |
 | [Moq1300](docs/rules/Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 |
 | [Moq1301](docs/rules/Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     |

diff --git a/docs/rules/Moq1208.md b/docs/rules/Moq1208.md
new file mode 100644
--- /dev/null
+++ b/docs/rules/Moq1208.md
@@ -1,0 +1,133 @@
+# Moq1208: Returns() delegate type mismatch on async method setup
+
+| Item     | Value |
+| -------- | ----- |
+| Enabled  | True  |
+| Severity | Warning |
+| CodeFix  | True |
+
+---
+
+## What this rule checks
+
+In Moq, `.Setup()` defines what a mocked method should do when called.
+`.Returns()` specifies the value the method gives back. For example:
+
+```csharp
+mock.Setup(x => x.GetName()).Returns(() => "Alice");
+//   ^^^^^ "when GetName is called"     ^^^^^^^^^ "return Alice"
+```
+
+This rule fires when a delegate passed to `.Returns()` gives back a plain
+value like `int` or `string`, but the mocked method is async and returns
+`Task<int>` or `ValueTask<string>`. Moq requires the types to match exactly.
+
+The rule detects three forms of delegates:
+
+- **Lambdas**: `Returns(() => 42)`
+- **Anonymous methods**: `Returns(delegate { return 42; })`
+- **Method groups**: `Returns(GetInt)` where `GetInt` returns `int`
+
+### Why this matters
+
+The code compiles without errors, but the test fails at runtime with this
+exception:
+
+```
+MockException: Invalid callback. Setup on method with return type 'Task<int>'
+cannot invoke callback with return type 'int'.
+```
+
+This analyzer catches the mismatch at compile time so you don't have to debug
+a failing test to find it.
+
+### How this differs from Moq1206
+
+[Moq1206](./Moq1206.md) flags `async` delegates in `.Returns()`, such as
+`Returns(async () => 42)`. Moq1208 flags regular (non-async) delegates that
+return the wrong type, such as `Returns(() => 42)` on a `Task<int>` method.
+
+## Examples of patterns that are flagged by this analyzer
+
+```csharp
+public interface IService
+{
+    Task<int> GetValueAsync();       // Returns Task<int>
+    Task<string> GetNameAsync();     // Returns Task<string>
+    ValueTask<int> GetValueTaskAsync(); // Returns ValueTask<int>
+}
+
+var mock = new Mock<IService>();
+
+// Lambda returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42); // Moq1208
+
+// Lambda returning string on a Task<string> method.
+mock.Setup(x => x.GetNameAsync()).Returns(() => "hello"); // Moq1208
+
+// Anonymous method returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(delegate { return 42; }); // Moq1208
+
+// Method group returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(GetInt); // Moq1208
+// where: static int GetInt() => 42;
+
+// ValueTask<int> with wrong return type.
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => 42); // Moq1208
+```
+
+## Solution
+
+### Option 1: Use ReturnsAsync (recommended)
+
+`.ReturnsAsync()` wraps the value in `Task.FromResult()` for you. This is the
+simplest fix and what the built-in code fix applies automatically.
+
+```csharp
+var mock = new Mock<IService>();
+
+// Pass a plain value. Moq wraps it in Task.FromResult() internally.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(42);
+
+// Or pass a lambda. Moq wraps the lambda's return value the same way.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(() => 42);
+
+// Anonymous methods and method groups work the same way.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(delegate { return 42; });
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(GetInt);
+```
+
+### Option 2: Wrap the value yourself
+
+If you need more control, keep `.Returns()` and wrap the value explicitly.
+
+```csharp
+var mock = new Mock<IService>();
+
+mock.Setup(x => x.GetValueAsync()).Returns(() => Task.FromResult(42));
+mock.Setup(x => x.GetNameAsync()).Returns(() => Task.FromResult("hello"));
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => new ValueTask<int>(42));
+```
+
+## Suppress a warning
+
+If you just want to suppress a single violation, add preprocessor directives to
+your source file to disable and then re-enable the rule.
+
+```csharp
+#pragma warning disable Moq1208
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42);
+#pragma warning restore Moq1208
+```
+
+To disable the rule for a file, folder, or project, set its severity to `none`
+in the
+[configuration file](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files).
+
+```ini
+[*.{cs,vb}]
+dotnet_diagnostic.Moq1208.severity = none
+```
+
+For more information, see
+[How to suppress code analysis warnings](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/suppress-warnings).

diff --git a/docs/rules/README.md b/docs/rules/README.md
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -15,6 +15,7 @@
 | [Moq1205](./Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               | [EventSetupHandlerShouldMatchEventTypeAnalyzer.cs](../../src/Analyzers/EventSetupHandlerShouldMatchEventTypeAnalyzer.cs)            |
 | [Moq1206](./Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        | [ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs](../../src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs)       |
 | [Moq1207](./Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               | [SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs) |
+| [Moq1208](./Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  | [ReturnsDelegateShouldReturnTaskAnalyzer.cs](../../src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs)                                      |
 | [Moq1210](./Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      | [VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs)       |
 | [Moq1300](./Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 | [AsShouldBeUsedOnlyForInterfaceAnalyzer.cs](../../src/Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs)                         |
 | [Moq1301](./Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     | [MockGetShouldNotTakeLiteralsAnalyzer.cs](../../src/Analyzers/MockGetShouldNotTakeLiteralsAnalyzer.cs)                             |

diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md
--- a/src/Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/Analyzers/AnalyzerReleases.Unshipped.md
@@ -17,6 +17,7 @@
 Moq1205 | Usage | Warning | EventSetupHandlerShouldMatchEventTypeAnalyzer (updated category from Moq to Usage)
 Moq1206 | Usage | Warning | ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer (updated category from Moq to Usage)
 Moq1207 | Usage | Error | SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
+Moq1208 | Usage | Warning | ReturnsDelegateShouldReturnTaskAnalyzer
 Moq1210 | Usage | Error | VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
 Moq1300 | Usage | Error | AsShouldBeUsedOnlyForInterfaceAnalyzer (updated category from Moq to Usage)
 Moq1301 | Usage | Warning | Mock.Get() should not take literals (updated category from Moq to Usage)

diff --git a/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
new file mode 100644
--- /dev/null
+++ b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
@@ -1,0 +1,283 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Moq.Analyzers;
+
+/// <summary>
+/// Returns() delegate on async method setup should return Task/ValueTask to match the mocked method's return type.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ReturnsDelegateShouldReturnTaskAnalyzer : DiagnosticAnalyzer
+{
+    private static readonly LocalizableString Title = "Moq: Returns() delegate type mismatch on async method";
+    private static readonly LocalizableString Message = "Returns() delegate for async method '{0}' should return '{2}', not '{1}'. Use ReturnsAsync() or wrap with Task.FromResult().";
+    private static readonly LocalizableString Description = "Returns() delegate on async method setup should return Task/ValueTask. Use ReturnsAsync() or wrap with Task.FromResult().";
+
+    private static readonly DiagnosticDescriptor Rule = new(
+        DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod,
+        Title,
+        Message,
+        DiagnosticCategory.Usage,
+        DiagnosticSeverity.Warning,
+        isEnabledByDefault: true,
+        description: Description,
+        helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod}.md");
+
+    /// <inheritdoc />
+    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
+
+    /// <inheritdoc />
+    public override void Initialize(AnalysisContext context)
+    {
+        context.EnableConcurrentExecution();
+        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+        context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression);
+    }
+
+    private static void Analyze(SyntaxNodeAnalysisContext context)
+    {
+        MoqKnownSymbols knownSymbols = new(context.SemanticModel.Compilation);
+
+        InvocationExpressionSyntax invocation = (InvocationExpressionSyntax)context.Node;
+
+        if (!IsReturnsMethodCallWithSyncDelegate(invocation, context.SemanticModel, knownSymbols, out MemberAccessExpressionSyntax? memberAccess, out InvocationExpressionSyntax? setupInvocation))
+        {
+            return;
+        }
+
+        if (!TryGetMismatchInfo(setupInvocation, invocation, context.SemanticModel, knownSymbols, out string? methodName, out ITypeSymbol? expectedReturnType, out ITypeSymbol? delegateReturnType))
+        {
+            return;
+        }
+
+        // Report diagnostic spanning from "Returns" identifier through the closing paren
+        int startPos = memberAccess.Name.SpanStart;
+        int endPos = invocation.Span.End;
+        Microsoft.CodeAnalysis.Text.TextSpan span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(startPos, endPos);
+        Location location = Location.Create(invocation.SyntaxTree, span);
+
+        string actualDisplay = delegateReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        string expectedDisplay = expectedReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        Diagnostic diagnostic = location.CreateDiagnostic(Rule, methodName, actualDisplay, expectedDisplay);
+        context.ReportDiagnostic(diagnostic);
+    }
+
+    private static bool IsReturnsMethodCallWithSyncDelegate(
+        InvocationExpressionSyntax invocation,
+        SemanticModel semanticModel,
+        MoqKnownSymbols knownSymbols,
+        [NotNullWhen(true)] out MemberAccessExpressionSyntax? memberAccess,
+        [NotNullWhen(true)] out InvocationExpressionSyntax? setupInvocation)
+    {
+        memberAccess = null;
+        setupInvocation = null;
+
+        if (invocation.Expression is not MemberAccessExpressionSyntax access)
+        {
+            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.CandidateReason is CandidateReason.OverloadResolutionFailure
+              && symbolInfo.CandidateSymbols
+                  .OfType<IMethodSymbol>()
+                  .Any(m => m.IsMoqReturnsMethod(knownSymbols));
+
+        if (!isReturnsMethod)
+        {
+            return false;
+        }
+
+        if (!HasSyncDelegateArgument(invocation, semanticModel))
+        {
+            return false;
+        }
+
+        setupInvocation = FindSetupInvocation(access.Expression, semanticModel, knownSymbols);
+        if (setupInvocation == null)
+        {
+            return false;
+        }
+
+        memberAccess = access;
+        return true;
+    }
+
+    private static bool HasSyncDelegateArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel)
+    {
+        if (invocation.ArgumentList.Arguments.Count == 0)
+        {
+            return false;
+        }
+
+        ExpressionSyntax firstArgument = invocation.ArgumentList.Arguments[0].Expression;
+
+        // Lambdas and anonymous methods share AnonymousFunctionExpressionSyntax,
+        // which exposes AsyncKeyword for sync/async detection.
+        if (firstArgument is AnonymousFunctionExpressionSyntax anonymousFunction)
+        {
+            return !anonymousFunction.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword);
+        }
+
+        // Method groups require semantic resolution to distinguish from raw values.
+        return IsMethodGroupExpression(firstArgument, semanticModel);
+    }
+
+    private static bool IsMethodGroupExpression(ExpressionSyntax expression, SemanticModel semanticModel)
+    {
+        SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(expression);
+        if (symbolInfo.Symbol is IMethodSymbol)
+        {
+            return true;
+        }
+
+        // Method groups with overloads fail resolution when no single overload matches the expected delegate type
+        return symbolInfo.CandidateReason is CandidateReason.OverloadResolutionFailure or CandidateReason.MemberGroup
+            && symbolInfo.CandidateSymbols.OfType<IMethodSymbol>().Any();
+    }
+
+    private static InvocationExpressionSyntax? FindSetupInvocation(ExpressionSyntax receiver, SemanticModel semanticModel, MoqKnownSymbols knownSymbols)
+    {
+        // Walk up the fluent chain to find Setup. Handles patterns like:
+        // mock.Setup(...).Returns(...)
+        // mock.Setup(...).Callback(...).Returns(...)
+        ExpressionSyntax current = receiver;
+
+        // Moq fluent chains are short (Setup.Callback.Returns at most 3-4 deep).
+        // Guard against pathological syntax trees.
+        for (int depth = 0; depth < 10; depth++)
+        {
+            ExpressionSyntax unwrapped = current.WalkDownParentheses();
+
+            if (unwrapped is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax candidateMemberAccess } candidateInvocation)
+            {
+                return null;
+            }
+
+            SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(candidateInvocation);
+            if (symbolInfo.Symbol != null && symbolInfo.Symbol.IsMoqSetupMethod(knownSymbols))
+            {
+                return candidateInvocation;
+            }
+
+            // Continue walking up the chain (past Callback, etc.)
+            current = candidateMemberAccess.Expression;
+        }
+
+        return null;
+    }
+
+    private static bool TryGetMismatchInfo(
+        InvocationExpressionSyntax setupInvocation,
+        InvocationExpressionSyntax returnsInvocation,
+        SemanticModel semanticModel,
+        MoqKnownSymbols knownSymbols,
+        [NotNullWhen(true)] out string? methodName,
+        [NotNullWhen(true)] out ITypeSymbol? expectedReturnType,
+        [NotNullWhen(true)] out ITypeSymbol? delegateReturnType)
+    {
+        methodName = null;
+        expectedReturnType = null;
+        delegateReturnType = null;
+
+        // Get the mocked method from the Setup lambda
+        ExpressionSyntax? mockedMemberExpression = setupInvocation.FindMockedMemberExpressionFromSetupMethod();
+        if (mockedMemberExpression == null)
+        {
+            return false;
+        }
+
+        SymbolInfo mockedSymbolInfo = semanticModel.GetSymbolInfo(mockedMemberExpression);
+        if (mockedSymbolInfo.Symbol is not IMethodSymbol mockedMethod)
+        {
+            return false;
+        }
+
+        // The mocked method must return Task<T> or ValueTask<T> (generic variants).
+        // Non-generic Task/ValueTask have no inner type to mismatch against.
+        ITypeSymbol returnType = mockedMethod.ReturnType;
+        if (returnType is not INamedTypeSymbol { IsGenericType: true })
+        {
+            return false;
+        }
+
+        if (!returnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        // Get the delegate's return type from the Returns() argument
+        delegateReturnType = GetDelegateReturnType(returnsInvocation, semanticModel);
+        if (delegateReturnType == null)
+        {
+            return false;
+        }
+
+        // If the delegate already returns a Task/ValueTask type, no mismatch
+        if (delegateReturnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        methodName = mockedMethod.Name;
+        expectedReturnType = returnType;
+        return true;
+    }
+
+    private static ITypeSymbol? GetDelegateReturnType(InvocationExpressionSyntax returnsInvocation, SemanticModel semanticModel)
+    {
+        if (returnsInvocation.ArgumentList.Arguments.Count == 0)
+        {
+            return null;
+        }
+
+        ExpressionSyntax firstArgument = returnsInvocation.ArgumentList.Arguments[0].Expression;
+
+        // For anonymous methods, prefer body analysis. Roslyn may infer the return type
+        // from the target delegate type (e.g., Task<int>) for parameterless anonymous methods,
+        // masking the actual body return type (e.g., int).
+        if (firstArgument is AnonymousMethodExpressionSyntax { Body: BlockSyntax block })
+        {
+            return GetReturnTypeFromBlock(block, semanticModel);
+        }
+
+        // GetSymbolInfo resolves lambdas to IMethodSymbol even when type conversion fails.
+        // Raw values resolve to ILocalSymbol/IFieldSymbol/etc., filtered by the type check.
+        SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(firstArgument);
+        if (symbolInfo.Symbol is IMethodSymbol methodSymbol)
+        {
+            return methodSymbol.ReturnType;
+        }
+
+        // Method groups with type conversion errors may not resolve via Symbol.
+        // Fall back to CandidateSymbols only when all candidates agree on the return type.
+        IMethodSymbol[] candidates = symbolInfo.CandidateSymbols.OfType<IMethodSymbol>().ToArray();
+        if (candidates.Length > 0
+            && candidates.All(c => SymbolEqualityComparer.Default.Equals(c.ReturnType, candidates[0].ReturnType)))
+        {
+            return candidates[0].ReturnType;
+        }
+
+        return null;
+    }
+
+    private static ITypeSymbol? GetReturnTypeFromBlock(BlockSyntax block, SemanticModel semanticModel)
+    {
+        // Find the first return statement in this block,
+        // pruning nested anonymous functions so we don't pick up their returns.
+        ReturnStatementSyntax? returnStatement = block
+            .DescendantNodes(node => node is not AnonymousFunctionExpressionSyntax)
+            .OfType<ReturnStatementSyntax>()
+            .FirstOrDefault();
+
+        if (returnStatement?.Expression == null)
+        {
+            return null;
+        }
+
+        return semanticModel.GetTypeInfo(returnStatement.Expression).Type;
+    }
+}

diff --git a/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
new file mode 100644
--- /dev/null
+++ b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
@@ -1,0 +1,85 @@
+using System.Composition;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+
+namespace Moq.CodeFixes;
+
+/// <summary>
+/// Fixes for <see cref="DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod"/> (Moq1208).
+/// Replaces <c>.Returns(delegate)</c> with <c>.ReturnsAsync(delegate)</c> when the
+/// mocked method is async and the delegate returns the unwrapped type.
+/// </summary>
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ReturnsDelegateShouldReturnTaskFixer))]
+[Shared]
+public sealed class ReturnsDelegateShouldReturnTaskFixer : CodeFixProvider
+{
+    private static readonly string Title = "Use ReturnsAsync instead of Returns";
+
+    /// <inheritdoc />
+    public override ImmutableArray<string> FixableDiagnosticIds { get; } =
+        ImmutableArray.Create(DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod);
+
+    /// <inheritdoc />
+    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+    /// <inheritdoc />
+    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+    {
+        SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+        if (root == null)
+        {
+            return;
+        }
+
+        Diagnostic diagnostic = context.Diagnostics[0];
+
+        // The diagnostic span starts at the "Returns" identifier. Use FindToken to land
+        // on that token, then walk up to the enclosing MemberAccessExpressionSyntax.
+        SyntaxToken token = root.FindToken(diagnostic.Location.SourceSpan.Start);
+        MemberAccessExpressionSyntax? memberAccess = token.Parent?.FirstAncestorOrSelf<MemberAccessExpressionSyntax>();
+        if (memberAccess == null)
+        {
+            return;
+        }
+
+        if (!string.Equals(memberAccess.Name.Identifier.ValueText, "Returns", StringComparison.Ordinal))
+        {
+            return;
+        }
+
+        context.RegisterCodeFix(
+            CodeAction.Create(
+                title: Title,
+                createChangedDocument: _ => ReplaceReturnsWithReturnsAsync(context.Document, root, memberAccess),
+                equivalenceKey: Title),
+            diagnostic);
+    }
+
+    private static Task<Document> ReplaceReturnsWithReturnsAsync(
+        Document document,
+        SyntaxNode root,
+        MemberAccessExpressionSyntax memberAccess)
+    {
+        SimpleNameSyntax oldName = memberAccess.Name;
+        SimpleNameSyntax newName;
+        if (oldName is GenericNameSyntax genericName)
+        {
+            newName = SyntaxFactory.GenericName(
+                SyntaxFactory.Identifier("ReturnsAsync"),
+                genericName.TypeArgumentList);
+        }
+        else
+        {
+            newName = SyntaxFactory.IdentifierName("ReturnsAsync");
+        }
+
+        newName = newName
+            .WithLeadingTrivia(oldName.GetLeadingTrivia())
+            .WithTrailingTrivia(oldName.GetTrailingTrivia());
+
+        MemberAccessExpressionSyntax newMemberAccess = memberAccess.WithName(newName);
+        SyntaxNode newRoot = root.ReplaceNode(memberAccess, newMemberAccess);
+
+        return Task.FromResult(document.WithSyntaxRoot(newRoot));
+    }
+}

diff --git a/src/Common/DiagnosticIds.cs b/src/Common/DiagnosticIds.cs
--- a/src/Common/DiagnosticIds.cs
+++ b/src/Common/DiagnosticIds.cs
@@ -17,6 +17,7 @@
     internal const string EventSetupHandlerShouldMatchEventType = "Moq1205";
     internal const string ReturnsAsyncShouldBeUsedForAsyncMethods = "Moq1206";
     internal const string SetupSequenceOnlyUsedForOverridableMembers = "Moq1207";
+    internal const string ReturnsDelegateMismatchOnAsyncMethod = "Moq1208";
     internal const string VerifyOnlyUsedForOverridableMembers = "Moq1210";
     internal const string AsShouldOnlyBeUsedForInterfacesRuleId = "Moq1300";
     internal const string MockGetShouldNotTakeLiterals = "Moq1301";

diff --git a/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs b/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
--- a/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
+++ b/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
@@ -7,15 +7,22 @@
     where TAnalyzer : DiagnosticAnalyzer, new()
     where TCodeFixProvider : CodeFixProvider, new()
 {
-    public static async Task VerifyCodeFixAsync(string originalSource, string fixedSource, string referenceAssemblyGroup)
+    public static async Task VerifyCodeFixAsync(string originalSource, string fixedSource, string referenceAssemblyGroup, CompilerDiagnostics? compilerDiagnostics = null)
     {
         ReferenceAssemblies referenceAssemblies = ReferenceAssemblyCatalog.Catalog[referenceAssemblyGroup];
 
-        await new Test<TAnalyzer, TCodeFixProvider>
+        Test<TAnalyzer, TCodeFixProvider> test = new Test<TAnalyzer, TCodeFixProvider>
         {
             TestCode = originalSource,
             FixedCode = fixedSource,
             ReferenceAssemblies = referenceAssemblies,
-        }.RunAsync().ConfigureAwait(false);
+        };
+
+        if (compilerDiagnostics.HasValue)
+        {
+            test.CompilerDiagnostics = compilerDiagnostics.Value;
+        }
+
+        await test.RunAsync().ConfigureAwait(false);
     }
 }

diff --git a/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
@@ -1,0 +1,243 @@
+using Microsoft.CodeAnalysis.Testing;
+using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier<Moq.Analyzers.ReturnsDelegateShouldReturnTaskAnalyzer>;
+
+namespace Moq.Analyzers.Test;
+
+public class ReturnsDelegateShouldReturnTaskAnalyzerTests(ITestOutputHelper output)
+{
+    public static IEnumerable<object[]> ValidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Delegate returns Task<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => Task.FromResult(42));"""],
+
+            // Uses ReturnsAsync (correct, different overload)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).ReturnsAsync(42);"""],
+
+            // Direct value, not a delegate
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(42);"""],
+
+            // Non-async method with sync delegate (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(() => 42);"""],
+
+            // Async lambda (Moq1206's domain, not ours)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(async () => 42);"""],
+
+            // Setup without Returns call
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync());"""],
+
+            // Delegate returns ValueTask<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).Returns(() => ValueTask.FromResult(42));"""],
+
+            // Chained Callback with correct Task return
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Callback(() => { }).Returns(() => Task.FromResult(42));"""],
+
+            // Anonymous method returning Task.FromResult (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(delegate { return Task.FromResult(42); });"""],
+
+            // Async anonymous method (Moq1206's domain, not ours)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(async delegate { return 42; });"""],
+
+            // Method group returning Task<int> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(GetIntAsync);"""],
+
+            // Anonymous method on sync method (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(delegate { return 42; });"""],
+
+            // Method group on sync method (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(GetInt);"""],
+
+            // Direct value on async method (not a delegate, different Returns overload)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(Task.FromResult(42));"""],
+
+            // Non-generic Task method with sync delegate (no mismatch, Task has no inner type)
+            ["""new Mock<AsyncService>().Setup(c => c.DoAsync()).Returns(() => Task.CompletedTask);"""],
+
+            // Parenthesized Setup with ReturnsAsync (valid)
+            ["""(new Mock<AsyncService>().Setup(c => c.GetValueAsync())).ReturnsAsync(42);"""],
+
+            // Block-bodied lambda returning Task (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => { return Task.FromResult(42); });"""],
+
+            // Generic non-Task return type (IList<int> is generic but not Task/ValueTask)
+            ["""new Mock<AsyncService>().Setup(c => c.GetItems()).Returns(() => new List<int>());"""],
+
+            // Property setup (resolves to IPropertySymbol, not IMethodSymbol)
+            ["""new Mock<AsyncService>().Setup(c => c.Value).Returns(() => Task.FromResult(42));"""],
+
+            // Split setup/returns (FindSetupInvocation can't walk past variable reference)
+            [
+                """
+                var setup = new Mock<AsyncService>().Setup(c => c.GetValueAsync());
+                setup.Returns(() => Task.FromResult(42));
+                """,
+            ],
+
+            // Expression variable setup (Setup argument is not a lambda, so mocked member can't be extracted)
+            [
+                """
+                System.Linq.Expressions.Expression<Func<AsyncService, Task<int>>> expr = c => c.GetValueAsync();
+                new Mock<AsyncService>().Setup(expr).Returns(() => Task.FromResult(42));
+                """,
+            ],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    public static IEnumerable<object[]> InvalidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Sync delegate returning int on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Sync delegate returning string on Task<string> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetNameAsync()).{|Moq1208:Returns(() => "hello")|};"""],
+
+            // Sync delegate returning int on ValueTask<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Parenthesized Setup with sync delegate mismatch
+            ["""(new Mock<AsyncService>().Setup(c => c.GetValueAsync())).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Chained Callback then Returns with sync delegate mismatch
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Callback(() => { }).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Block-bodied lambda returning wrong type on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(() => { return 42; })|};"""],
+
+            // Sync delegate with parameter returning wrong type on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.ProcessAsync(It.IsAny<string>())).{|Moq1208:Returns((string x) => x.Length)|};"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    /// <summary>
+    /// Anonymous methods and method groups with type mismatches produce compiler errors
+    /// (CS0029/CS1662), unlike lambdas. We suppress compiler diagnostics to isolate the analyzer.
+    /// </summary>
+    /// <returns>Test data with compiler diagnostic suppression for anonymous delegate and method group cases.</returns>
+    public static IEnumerable<object[]> InvalidAnonymousDelegateAndMethodGroupTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Anonymous method returning int on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(delegate { return 42; })|};"""],
+
+            // Anonymous method returning int on ValueTask<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).{|Moq1208:Returns(delegate { return 42; })|};"""],
+
+            // Anonymous method with parameter returning wrong type on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.ProcessAsync(It.IsAny<string>())).{|Moq1208:Returns(delegate (string x) { return x.Length; })|};"""],
+
+            // Method group returning int on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(GetInt)|};"""],
+
+            // Method group returning string on Task<string> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetNameAsync()).{|Moq1208:Returns(GetString)|};"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    /// <summary>
+    /// Valid patterns that produce compiler errors (CS0029/CS1662) but should not trigger the analyzer.
+    /// We suppress compiler diagnostics to isolate the analyzer.
+    /// </summary>
+    /// <returns>Test data with compiler diagnostic suppression.</returns>
+    public static IEnumerable<object[]> ValidWithCompilerSuppression()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Void anonymous delegate on async method (delegate return type is null, no mismatch to report)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(delegate { });"""],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    [Theory]
+    [MemberData(nameof(ValidTestData))]
+    public async Task ShouldNotTriggerOnValidPatterns(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock);
+    }
+
+    [Theory]
+    [MemberData(nameof(ValidWithCompilerSuppression))]
+    public async Task ShouldNotTriggerOnValidPatternsWithCompilerSuppression(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock, CompilerDiagnostics.None);
+    }
+
+    [Theory]
+    [MemberData(nameof(InvalidTestData))]
+    public async Task ShouldTriggerOnSyncDelegateMismatch(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock);
+    }
+
+    [Theory]
+    [MemberData(nameof(InvalidAnonymousDelegateAndMethodGroupTestData))]
+    public async Task ShouldFlagAnonymousDelegateAndMethodGroupMismatch(string referenceAssemblyGroup, string @namespace, string mock)
+    {
+        await VerifyAsync(referenceAssemblyGroup, @namespace, mock, CompilerDiagnostics.None);
+    }
+
+    [Theory]
+    [MemberData(nameof(DoppelgangerTestHelper.GetAllCustomMockData), MemberType = typeof(DoppelgangerTestHelper))]
+    public async Task ShouldPassIfCustomMockClassIsUsed(string mockCode)
+    {
+        await Verifier.VerifyAnalyzerAsync(
+            DoppelgangerTestHelper.CreateTestCode(mockCode),
+            ReferenceAssemblyCatalog.Net80WithNewMoq);
+    }
+
... diff truncated: showing 800 of 980 lines

…type arguments

- Add argument count guard to HasSyncDelegateArgument to prevent IndexOutOfRangeException

- Add argument count guard to GetDelegateReturnType for consistency

- Preserve GenericNameSyntax type arguments when replacing Returns with ReturnsAsync in code fix
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs (1)

271-273: ⚠️ Potential issue | 🟠 Major

Exclude local-function scopes when inferring anonymous delegate return type.

Line 271-273 prunes nested anonymous functions but still descends into local functions. This can pick return from a local function instead of the outer anonymous delegate, causing false positives.

Proposed fix
 ReturnStatementSyntax? returnStatement = block
-    .DescendantNodes(node => node is not AnonymousFunctionExpressionSyntax)
+    .DescendantNodes(node =>
+        node is not AnonymousFunctionExpressionSyntax &&
+        node is not LocalFunctionStatementSyntax)
     .OfType<ReturnStatementSyntax>()
     .FirstOrDefault();

Please also add a regression test for:
Returns(delegate { int Local() { return 1; } return Task.FromResult(Local()); }).

Based on learnings: anonymous-method return analysis must use the outer body return expression and avoid nested-function returns to prevent Roslyn target-type inference from masking the true body type.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs` around lines 271 -
273, The analyzer currently prunes nested anonymous functions but still descends
into local functions, so update the DescendantNodes predicate in
ReturnsDelegateShouldReturnTaskAnalyzer (the block traversal that builds
returnStatement via .DescendantNodes(...).OfType<ReturnStatementSyntax>()) to
also exclude LocalFunctionStatementSyntax (i.e., change the lambda to return
false for LocalFunctionStatementSyntax as well as
AnonymousFunctionExpressionSyntax) so nested local functions' return statements
are not considered; then add a regression unit test asserting that the
anonymous-method case `Returns(delegate { int Local() { return 1; } return
Task.FromResult(Local()); })` is treated as returning a Task (no diagnostic),
placed alongside the existing analyzer tests for anonymous delegates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs`:
- Around line 129-139: IsMethodGroupExpression is treating invoked calls as
method groups; change it to first return false if expression is an
InvocationExpressionSyntax, and then only proceed to semanticModel.GetSymbolInfo
for expressions that are SimpleNameSyntax or MemberAccessExpressionSyntax (i.e.,
the real method-group syntaxes). Update the logic in IsMethodGroupExpression to
short-circuit on InvocationExpressionSyntax and restrict the symbol-based checks
(SymbolInfo.Symbol is IMethodSymbol and
CandidateSymbols.OfType<IMethodSymbol>()) to those allowed syntax kinds so
invocation results like GetInt() are not misclassified as delegates.

---

Duplicate comments:
In `@src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs`:
- Around line 271-273: The analyzer currently prunes nested anonymous functions
but still descends into local functions, so update the DescendantNodes predicate
in ReturnsDelegateShouldReturnTaskAnalyzer (the block traversal that builds
returnStatement via .DescendantNodes(...).OfType<ReturnStatementSyntax>()) to
also exclude LocalFunctionStatementSyntax (i.e., change the lambda to return
false for LocalFunctionStatementSyntax as well as
AnonymousFunctionExpressionSyntax) so nested local functions' return statements
are not considered; then add a regression unit test asserting that the
anonymous-method case `Returns(delegate { int Local() { return 1; } return
Task.FromResult(Local()); })` is treated as returning a Task (no diagnostic),
placed alongside the existing analyzer tests for anonymous delegates.

ℹ️ Review info

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4c5e83a and e4cf645.

📒 Files selected for processing (3)
  • src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
  • src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
  • tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs

rjmurillo and others added 2 commits March 1, 2026 12:11
Fix 1: GetReturnTypeFromBlock now prunes LocalFunctionStatementSyntax
in addition to AnonymousFunctionExpressionSyntax when searching for
return statements. Previously, a local function inside an anonymous
delegate (e.g., delegate { int Local() { return 1; } return
Task.FromResult(Local()); }) would cause the analyzer to find the
local function's "return 1" instead of the outer "return
Task.FromResult(...)".

Fix 2: IsMethodGroupExpression now filters out InvocationExpressionSyntax
before checking symbol info. Previously, Returns(GetInt()) was
misclassified as a delegate because GetSymbolInfo resolves invocations
to IMethodSymbol (the called method). Invocations are values, not
method groups.

Both bugs confirmed by regression tests that fail without the fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
coderabbitai[bot]
coderabbitai bot previously approved these changes Mar 1, 2026
…delegate-mismatch

# Conflicts:
#	docs/rules/README.md
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Duplicated FindSetupInvocation logic across sibling analyzers
    • Extracted the robust FindSetupInvocation implementation from Moq1208 into a shared extension method in InvocationExpressionSyntaxExtensions, enabling both Moq1206 and Moq1208 analyzers to use semantic symbol-based detection that walks up to 10 levels through fluent chains.
Preview (c5f829ab76)
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
 | [Moq1205](docs/rules/Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               |
 | [Moq1206](docs/rules/Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        |
 | [Moq1207](docs/rules/Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               |
+| [Moq1208](docs/rules/Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  |
 | [Moq1210](docs/rules/Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      |
 | [Moq1300](docs/rules/Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 |
 | [Moq1301](docs/rules/Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     |

diff --git a/docs/rules/Moq1208.md b/docs/rules/Moq1208.md
new file mode 100644
--- /dev/null
+++ b/docs/rules/Moq1208.md
@@ -1,0 +1,133 @@
+# Moq1208: Returns() delegate type mismatch on async method setup
+
+| Item     | Value   |
+| -------- | ------- |
+| Enabled  | True    |
+| Severity | Warning |
+| CodeFix  | True    |
+
+---
+
+## What this rule checks
+
+In Moq, `.Setup()` defines what a mocked method should do when called.
+`.Returns()` specifies the value the method gives back. For example:
+
+```csharp
+mock.Setup(x => x.GetName()).Returns(() => "Alice");
+//   ^^^^^ "when GetName is called"     ^^^^^^^^^ "return Alice"
+```
+
+This rule fires when a delegate passed to `.Returns()` gives back a plain
+value like `int` or `string`, but the mocked method is async and returns
+`Task<int>` or `ValueTask<string>`. Moq requires the types to match exactly.
+
+The rule detects three forms of delegates:
+
+- **Lambdas**: `Returns(() => 42)`
+- **Anonymous methods**: `Returns(delegate { return 42; })`
+- **Method groups**: `Returns(GetInt)` where `GetInt` returns `int`
+
+### Why this matters
+
+The code compiles without errors, but the test fails at runtime with this
+exception:
+
+```text
+MockException: Invalid callback. Setup on method with return type 'Task<int>'
+cannot invoke callback with return type 'int'.
+```
+
+This analyzer catches the mismatch at compile time so you don't have to debug
+a failing test to find it.
+
+### How this differs from Moq1206
+
+[Moq1206](./Moq1206.md) flags `async` delegates in `.Returns()`, such as
+`Returns(async () => 42)`. Moq1208 flags regular (non-async) delegates that
+return the wrong type, such as `Returns(() => 42)` on a `Task<int>` method.
+
+## Examples of patterns that are flagged by this analyzer
+
+```csharp
+public interface IService
+{
+    Task<int> GetValueAsync();       // Returns Task<int>
+    Task<string> GetNameAsync();     // Returns Task<string>
+    ValueTask<int> GetValueTaskAsync(); // Returns ValueTask<int>
+}
+
+var mock = new Mock<IService>();
+
+// Lambda returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42); // Moq1208
+
+// Lambda returning string on a Task<string> method.
+mock.Setup(x => x.GetNameAsync()).Returns(() => "hello"); // Moq1208
+
+// Anonymous method returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(delegate { return 42; }); // Moq1208
+
+// Method group returning int on a Task<int> method.
+mock.Setup(x => x.GetValueAsync()).Returns(GetInt); // Moq1208
+// where: static int GetInt() => 42;
+
+// ValueTask<int> with wrong return type.
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => 42); // Moq1208
+```
+
+## Solution
+
+### Option 1: Use ReturnsAsync (recommended)
+
+`.ReturnsAsync()` wraps the value in `Task.FromResult()` for you. This is the
+simplest fix and what the built-in code fix applies automatically.
+
+```csharp
+var mock = new Mock<IService>();
+
+// Pass a plain value. Moq wraps it in Task.FromResult() internally.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(42);
+
+// Or pass a lambda. Moq wraps the lambda's return value the same way.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(() => 42);
+
+// Anonymous methods and method groups work the same way.
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(delegate { return 42; });
+mock.Setup(x => x.GetValueAsync()).ReturnsAsync(GetInt);
+```
+
+### Option 2: Wrap the value yourself
+
+If you need more control, keep `.Returns()` and wrap the value explicitly.
+
+```csharp
+var mock = new Mock<IService>();
+
+mock.Setup(x => x.GetValueAsync()).Returns(() => Task.FromResult(42));
+mock.Setup(x => x.GetNameAsync()).Returns(() => Task.FromResult("hello"));
+mock.Setup(x => x.GetValueTaskAsync()).Returns(() => new ValueTask<int>(42));
+```
+
+## Suppress a warning
+
+If you just want to suppress a single violation, add preprocessor directives to
+your source file to disable and then re-enable the rule.
+
+```csharp
+#pragma warning disable Moq1208
+mock.Setup(x => x.GetValueAsync()).Returns(() => 42);
+#pragma warning restore Moq1208
+```
+
+To disable the rule for a file, folder, or project, set its severity to `none`
+in the
+[configuration file](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files).
+
+```ini
+[*.{cs,vb}]
+dotnet_diagnostic.Moq1208.severity = none
+```
+
+For more information, see
+[How to suppress code analysis warnings](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/suppress-warnings).

diff --git a/docs/rules/README.md b/docs/rules/README.md
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -15,6 +15,7 @@
 | [Moq1205](./Moq1205.md) | Correctness   | Event setup handler type should match event delegate type                               | [EventSetupHandlerShouldMatchEventTypeAnalyzer.cs](../../src/Analyzers/EventSetupHandlerShouldMatchEventTypeAnalyzer.cs)                           |
 | [Moq1206](./Moq1206.md) | Correctness   | Async method setups should use ReturnsAsync instead of Returns with async lambda        | [ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs](../../src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs)                       |
 | [Moq1207](./Moq1207.md) | Correctness   | SetupSequence should be used only for overridable members                               | [SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer.cs) |
+| [Moq1208](./Moq1208.md) | Correctness   | Returns() delegate type mismatch on async method setup                                  | [ReturnsDelegateShouldReturnTaskAnalyzer.cs](../../src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs)                                       |
 | [Moq1210](./Moq1210.md) | Correctness   | Verify should be used only for overridable members                                      | [VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs](../../src/Analyzers/VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer.cs)               |
 | [Moq1300](./Moq1300.md) | Usage         | `Mock.As()` should take interfaces only                                                 | [AsShouldBeUsedOnlyForInterfaceAnalyzer.cs](../../src/Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs)                                         |
 | [Moq1301](./Moq1301.md) | Usage         | Mock.Get() should not take literals                                                     | [MockGetShouldNotTakeLiteralsAnalyzer.cs](../../src/Analyzers/MockGetShouldNotTakeLiteralsAnalyzer.cs)                                             |

diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md
--- a/src/Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/Analyzers/AnalyzerReleases.Unshipped.md
@@ -17,6 +17,7 @@
 Moq1205 | Usage | Warning | EventSetupHandlerShouldMatchEventTypeAnalyzer (updated category from Moq to Usage)
 Moq1206 | Usage | Warning | ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer (updated category from Moq to Usage)
 Moq1207 | Usage | Error | SetupSequenceShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
+Moq1208 | Usage | Warning | ReturnsDelegateShouldReturnTaskAnalyzer
 Moq1210 | Usage | Error | VerifyShouldBeUsedOnlyForOverridableMembersAnalyzer (updated category from Moq to Usage)
 Moq1300 | Usage | Error | AsShouldBeUsedOnlyForInterfaceAnalyzer (updated category from Moq to Usage)
 Moq1301 | Usage | Warning | Mock.Get() should not take literals (updated category from Moq to Usage)

diff --git a/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs b/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs
--- a/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs
+++ b/src/Analyzers/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer.cs
@@ -44,7 +44,8 @@
         }
 
         // Find the Setup call that this Returns is chained from
-        InvocationExpressionSyntax? setupInvocation = FindSetupInvocation(invocation);
+        MemberAccessExpressionSyntax memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
+        InvocationExpressionSyntax? setupInvocation = memberAccess.Expression.FindSetupInvocation(context.SemanticModel, knownSymbols);
         if (setupInvocation == null)
         {
             return;
@@ -58,9 +59,6 @@
         }
 
         // Report diagnostic on just the Returns(...) method call
-        // We can safely cast here because IsReturnsMethodCallWithAsyncLambda already verified this is a MemberAccessExpressionSyntax
-        MemberAccessExpressionSyntax memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
-
         // Create a span from the Returns identifier through the end of the invocation
         int startPos = memberAccess.Name.SpanStart;
         int endPos = invocation.Span.End;
@@ -96,21 +94,6 @@
         return HasAsyncLambdaArgument(invocation);
     }
 
-    private static InvocationExpressionSyntax? FindSetupInvocation(InvocationExpressionSyntax returnsInvocation)
-    {
-        // The pattern is: mock.Setup(...).Returns(...)
-        // The returnsInvocation is the entire chain, so we need to examine its structure
-        if (returnsInvocation.Expression is MemberAccessExpressionSyntax memberAccess &&
-            memberAccess.Expression.WalkDownParentheses() is InvocationExpressionSyntax setupInvocation &&
-            setupInvocation.Expression is MemberAccessExpressionSyntax setupMemberAccess &&
-            string.Equals(setupMemberAccess.Name.Identifier.ValueText, "Setup", StringComparison.Ordinal))
-        {
-            return setupInvocation;
-        }
-
-        return null;
-    }
-
     private static bool HasAsyncLambdaArgument(InvocationExpressionSyntax invocation)
     {
         if (invocation.ArgumentList.Arguments.Count == 0)

diff --git a/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
new file mode 100644
--- /dev/null
+++ b/src/Analyzers/ReturnsDelegateShouldReturnTaskAnalyzer.cs
@@ -1,0 +1,260 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Moq.Analyzers;
+
+/// <summary>
+/// Returns() delegate on async method setup should return Task/ValueTask to match the mocked method's return type.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ReturnsDelegateShouldReturnTaskAnalyzer : DiagnosticAnalyzer
+{
+    private static readonly LocalizableString Title = "Moq: Returns() delegate type mismatch on async method";
+    private static readonly LocalizableString Message = "Returns() delegate for async method '{0}' should return '{2}', not '{1}'. Use ReturnsAsync() or wrap with Task.FromResult().";
+    private static readonly LocalizableString Description = "Returns() delegate on async method setup should return Task/ValueTask. Use ReturnsAsync() or wrap with Task.FromResult().";
+
+    private static readonly DiagnosticDescriptor Rule = new(
+        DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod,
+        Title,
+        Message,
+        DiagnosticCategory.Usage,
+        DiagnosticSeverity.Warning,
+        isEnabledByDefault: true,
+        description: Description,
+        helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod}.md");
+
+    /// <inheritdoc />
+    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(Rule);
+
+    /// <inheritdoc />
+    public override void Initialize(AnalysisContext context)
+    {
+        context.EnableConcurrentExecution();
+        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+        context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression);
+    }
+
+    private static void Analyze(SyntaxNodeAnalysisContext context)
+    {
+        MoqKnownSymbols knownSymbols = new(context.SemanticModel.Compilation);
+
+        InvocationExpressionSyntax invocation = (InvocationExpressionSyntax)context.Node;
+
+        if (!IsReturnsMethodCallWithSyncDelegate(invocation, context.SemanticModel, knownSymbols, out MemberAccessExpressionSyntax? memberAccess, out InvocationExpressionSyntax? setupInvocation))
+        {
+            return;
+        }
+
+        if (!TryGetMismatchInfo(setupInvocation, invocation, context.SemanticModel, knownSymbols, out string? methodName, out ITypeSymbol? expectedReturnType, out ITypeSymbol? delegateReturnType))
+        {
+            return;
+        }
+
+        // Report diagnostic spanning from "Returns" identifier through the closing paren
+        int startPos = memberAccess.Name.SpanStart;
+        int endPos = invocation.Span.End;
+        Microsoft.CodeAnalysis.Text.TextSpan span = Microsoft.CodeAnalysis.Text.TextSpan.FromBounds(startPos, endPos);
+        Location location = Location.Create(invocation.SyntaxTree, span);
+
+        string actualDisplay = delegateReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        string expectedDisplay = expectedReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
+        Diagnostic diagnostic = location.CreateDiagnostic(Rule, methodName, actualDisplay, expectedDisplay);
+        context.ReportDiagnostic(diagnostic);
+    }
+
+    private static bool IsReturnsMethodCallWithSyncDelegate(
+        InvocationExpressionSyntax invocation,
+        SemanticModel semanticModel,
+        MoqKnownSymbols knownSymbols,
+        [NotNullWhen(true)] out MemberAccessExpressionSyntax? memberAccess,
+        [NotNullWhen(true)] out InvocationExpressionSyntax? setupInvocation)
+    {
+        memberAccess = null;
+        setupInvocation = null;
+
+        if (invocation.Expression is not MemberAccessExpressionSyntax access)
+        {
+            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.CandidateReason is CandidateReason.OverloadResolutionFailure
+              && symbolInfo.CandidateSymbols
+                  .OfType<IMethodSymbol>()
+                  .Any(m => m.IsMoqReturnsMethod(knownSymbols));
+
+        if (!isReturnsMethod)
+        {
+            return false;
+        }
+
+        if (!HasSyncDelegateArgument(invocation, semanticModel))
+        {
+            return false;
+        }
+
+        setupInvocation = access.Expression.FindSetupInvocation(semanticModel, knownSymbols);
+        if (setupInvocation == null)
+        {
+            return false;
+        }
+
+        memberAccess = access;
+        return true;
+    }
+
+    private static bool HasSyncDelegateArgument(InvocationExpressionSyntax invocation, SemanticModel semanticModel)
+    {
+        if (invocation.ArgumentList.Arguments.Count == 0)
+        {
+            return false;
+        }
+
+        ExpressionSyntax firstArgument = invocation.ArgumentList.Arguments[0].Expression;
+
+        // Lambdas and anonymous methods share AnonymousFunctionExpressionSyntax,
+        // which exposes AsyncKeyword for sync/async detection.
+        if (firstArgument is AnonymousFunctionExpressionSyntax anonymousFunction)
+        {
+            return !anonymousFunction.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword);
+        }
+
+        // Method groups require semantic resolution to distinguish from raw values.
+        return IsMethodGroupExpression(firstArgument, semanticModel);
+    }
+
+    private static bool IsMethodGroupExpression(ExpressionSyntax expression, SemanticModel semanticModel)
+    {
+        // Invocations (e.g., GetInt()) resolve to IMethodSymbol but are values, not method groups.
+        if (expression is InvocationExpressionSyntax)
+        {
+            return false;
+        }
+
+        SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(expression);
+        if (symbolInfo.Symbol is IMethodSymbol)
+        {
+            return true;
+        }
+
+        // Method groups with overloads fail resolution when no single overload matches the expected delegate type
+        return symbolInfo.CandidateReason is CandidateReason.OverloadResolutionFailure or CandidateReason.MemberGroup
+            && symbolInfo.CandidateSymbols.OfType<IMethodSymbol>().Any();
+    }
+
+    private static bool TryGetMismatchInfo(
+        InvocationExpressionSyntax setupInvocation,
+        InvocationExpressionSyntax returnsInvocation,
+        SemanticModel semanticModel,
+        MoqKnownSymbols knownSymbols,
+        [NotNullWhen(true)] out string? methodName,
+        [NotNullWhen(true)] out ITypeSymbol? expectedReturnType,
+        [NotNullWhen(true)] out ITypeSymbol? delegateReturnType)
+    {
+        methodName = null;
+        expectedReturnType = null;
+        delegateReturnType = null;
+
+        // Get the mocked method from the Setup lambda
+        ExpressionSyntax? mockedMemberExpression = setupInvocation.FindMockedMemberExpressionFromSetupMethod();
+        if (mockedMemberExpression == null)
+        {
+            return false;
+        }
+
+        SymbolInfo mockedSymbolInfo = semanticModel.GetSymbolInfo(mockedMemberExpression);
+        if (mockedSymbolInfo.Symbol is not IMethodSymbol mockedMethod)
+        {
+            return false;
+        }
+
+        // The mocked method must return Task<T> or ValueTask<T> (generic variants).
+        // Non-generic Task/ValueTask have no inner type to mismatch against.
+        ITypeSymbol returnType = mockedMethod.ReturnType;
+        if (returnType is not INamedTypeSymbol { IsGenericType: true })
+        {
+            return false;
+        }
+
+        if (!returnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        // Get the delegate's return type from the Returns() argument
+        delegateReturnType = GetDelegateReturnType(returnsInvocation, semanticModel);
+        if (delegateReturnType == null)
+        {
+            return false;
+        }
+
+        // If the delegate already returns a Task/ValueTask type, no mismatch
+        if (delegateReturnType.IsTaskOrValueTaskType(knownSymbols))
+        {
+            return false;
+        }
+
+        methodName = mockedMethod.Name;
+        expectedReturnType = returnType;
+        return true;
+    }
+
+    private static ITypeSymbol? GetDelegateReturnType(InvocationExpressionSyntax returnsInvocation, SemanticModel semanticModel)
+    {
+        if (returnsInvocation.ArgumentList.Arguments.Count == 0)
+        {
+            return null;
+        }
+
+        ExpressionSyntax firstArgument = returnsInvocation.ArgumentList.Arguments[0].Expression;
+
+        // For anonymous methods, prefer body analysis. Roslyn may infer the return type
+        // from the target delegate type (e.g., Task<int>) for parameterless anonymous methods,
+        // masking the actual body return type (e.g., int).
+        if (firstArgument is AnonymousMethodExpressionSyntax { Body: BlockSyntax block })
+        {
+            return GetReturnTypeFromBlock(block, semanticModel);
+        }
+
+        // GetSymbolInfo resolves lambdas to IMethodSymbol even when type conversion fails.
+        // Raw values resolve to ILocalSymbol/IFieldSymbol/etc., filtered by the type check.
+        SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(firstArgument);
+        if (symbolInfo.Symbol is IMethodSymbol methodSymbol)
+        {
+            return methodSymbol.ReturnType;
+        }
+
+        // Method groups with type conversion errors may not resolve via Symbol.
+        // Fall back to CandidateSymbols only when all candidates agree on the return type.
+        IMethodSymbol[] candidates = symbolInfo.CandidateSymbols.OfType<IMethodSymbol>().ToArray();
+        if (candidates.Length > 0
+            && candidates.All(c => SymbolEqualityComparer.Default.Equals(c.ReturnType, candidates[0].ReturnType)))
+        {
+            return candidates[0].ReturnType;
+        }
+
+        return null;
+    }
+
+    private static ITypeSymbol? GetReturnTypeFromBlock(BlockSyntax block, SemanticModel semanticModel)
+    {
+        // Find the first return statement in this block,
+        // pruning nested functions so we don't pick up their returns.
+        ReturnStatementSyntax? returnStatement = block
+            .DescendantNodes(node =>
+                node is not AnonymousFunctionExpressionSyntax
+                && node is not LocalFunctionStatementSyntax)
+            .OfType<ReturnStatementSyntax>()
+            .FirstOrDefault();
+
+        if (returnStatement?.Expression == null)
+        {
+            return null;
+        }
+
+        return semanticModel.GetTypeInfo(returnStatement.Expression).Type;
+    }
+}

diff --git a/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
new file mode 100644
--- /dev/null
+++ b/src/CodeFixes/ReturnsDelegateShouldReturnTaskFixer.cs
@@ -1,0 +1,85 @@
+using System.Composition;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+
+namespace Moq.CodeFixes;
+
+/// <summary>
+/// Fixes for <see cref="DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod"/> (Moq1208).
+/// Replaces <c>.Returns(delegate)</c> with <c>.ReturnsAsync(delegate)</c> when the
+/// mocked method is async and the delegate returns the unwrapped type.
+/// </summary>
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ReturnsDelegateShouldReturnTaskFixer))]
+[Shared]
+public sealed class ReturnsDelegateShouldReturnTaskFixer : CodeFixProvider
+{
+    private static readonly string Title = "Use ReturnsAsync instead of Returns";
+
+    /// <inheritdoc />
+    public override ImmutableArray<string> FixableDiagnosticIds { get; } =
+        ImmutableArray.Create(DiagnosticIds.ReturnsDelegateMismatchOnAsyncMethod);
+
+    /// <inheritdoc />
+    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+    /// <inheritdoc />
+    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
+    {
+        SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+        if (root == null)
+        {
+            return;
+        }
+
+        Diagnostic diagnostic = context.Diagnostics[0];
+
+        // The diagnostic span starts at the "Returns" identifier. Use FindToken to land
+        // on that token, then walk up to the enclosing MemberAccessExpressionSyntax.
+        SyntaxToken token = root.FindToken(diagnostic.Location.SourceSpan.Start);
+        MemberAccessExpressionSyntax? memberAccess = token.Parent?.FirstAncestorOrSelf<MemberAccessExpressionSyntax>();
+        if (memberAccess == null)
+        {
+            return;
+        }
+
+        if (!string.Equals(memberAccess.Name.Identifier.ValueText, "Returns", StringComparison.Ordinal))
+        {
+            return;
+        }
+
+        context.RegisterCodeFix(
+            CodeAction.Create(
+                title: Title,
+                createChangedDocument: _ => ReplaceReturnsWithReturnsAsync(context.Document, root, memberAccess),
+                equivalenceKey: Title),
+            diagnostic);
+    }
+
+    private static Task<Document> ReplaceReturnsWithReturnsAsync(
+        Document document,
+        SyntaxNode root,
+        MemberAccessExpressionSyntax memberAccess)
+    {
+        SimpleNameSyntax oldName = memberAccess.Name;
+        SimpleNameSyntax newName;
+        if (oldName is GenericNameSyntax genericName)
+        {
+            newName = SyntaxFactory.GenericName(
+                SyntaxFactory.Identifier("ReturnsAsync"),
+                genericName.TypeArgumentList);
+        }
+        else
+        {
+            newName = SyntaxFactory.IdentifierName("ReturnsAsync");
+        }
+
+        newName = newName
+            .WithLeadingTrivia(oldName.GetLeadingTrivia())
+            .WithTrailingTrivia(oldName.GetTrailingTrivia());
+
+        MemberAccessExpressionSyntax newMemberAccess = memberAccess.WithName(newName);
+        SyntaxNode newRoot = root.ReplaceNode(memberAccess, newMemberAccess);
+
+        return Task.FromResult(document.WithSyntaxRoot(newRoot));
+    }
+}

diff --git a/src/Common/DiagnosticIds.cs b/src/Common/DiagnosticIds.cs
--- a/src/Common/DiagnosticIds.cs
+++ b/src/Common/DiagnosticIds.cs
@@ -17,6 +17,7 @@
     internal const string EventSetupHandlerShouldMatchEventType = "Moq1205";
     internal const string ReturnsAsyncShouldBeUsedForAsyncMethods = "Moq1206";
     internal const string SetupSequenceOnlyUsedForOverridableMembers = "Moq1207";
+    internal const string ReturnsDelegateMismatchOnAsyncMethod = "Moq1208";
     internal const string VerifyOnlyUsedForOverridableMembers = "Moq1210";
     internal const string AsShouldOnlyBeUsedForInterfacesRuleId = "Moq1300";
     internal const string MockGetShouldNotTakeLiterals = "Moq1301";

diff --git a/src/Common/InvocationExpressionSyntaxExtensions.cs b/src/Common/InvocationExpressionSyntaxExtensions.cs
--- a/src/Common/InvocationExpressionSyntaxExtensions.cs
+++ b/src/Common/InvocationExpressionSyntaxExtensions.cs
@@ -18,6 +18,43 @@
     }
 
     /// <summary>
+    /// Walks up the Moq fluent chain to find the Setup invocation.
+    /// Handles patterns like <c>mock.Setup(...).Returns(...)</c> and
+    /// <c>mock.Setup(...).Callback(...).Returns(...)</c>.
+    /// </summary>
+    /// <param name="receiver">The receiver expression to start walking from (typically the expression before the Returns call).</param>
+    /// <param name="semanticModel">The semantic model for symbol resolution.</param>
+    /// <param name="knownSymbols">The known Moq symbols for type checking.</param>
+    /// <returns>The Setup invocation if found; otherwise, <see langword="null"/>.</returns>
+    internal static InvocationExpressionSyntax? FindSetupInvocation(this ExpressionSyntax receiver, SemanticModel semanticModel, MoqKnownSymbols knownSymbols)
+    {
+        ExpressionSyntax current = receiver;
+
+        // Moq fluent chains are short (Setup.Callback.Returns at most 3-4 deep).
+        // Guard against pathological syntax trees.
+        for (int depth = 0; depth < 10; depth++)
+        {
+            ExpressionSyntax unwrapped = current.WalkDownParentheses();
+
+            if (unwrapped is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax candidateMemberAccess } candidateInvocation)
+            {
+                return null;
+            }
+
+            SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(candidateInvocation);
+            if (symbolInfo.Symbol != null && symbolInfo.Symbol.IsMoqSetupMethod(knownSymbols))
+            {
+                return candidateInvocation;
+            }
+
+            // Continue walking up the chain (past Callback, etc.)
+            current = candidateMemberAccess.Expression;
+        }
+
+        return null;
+    }
+
+    /// <summary>
     /// Determines if an invocation is a Raises method call using symbol-based detection.
     /// This method verifies the method belongs to IRaiseable or IRaiseableAsync.
     /// </summary>

diff --git a/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs b/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
--- a/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
+++ b/tests/Moq.Analyzers.Test/Helpers/CodeFixVerifier.cs
@@ -7,15 +7,22 @@
     where TAnalyzer : DiagnosticAnalyzer, new()
     where TCodeFixProvider : CodeFixProvider, new()
 {
-    public static async Task VerifyCodeFixAsync(string originalSource, string fixedSource, string referenceAssemblyGroup)
+    public static async Task VerifyCodeFixAsync(string originalSource, string fixedSource, string referenceAssemblyGroup, CompilerDiagnostics? compilerDiagnostics = null)
     {
         ReferenceAssemblies referenceAssemblies = ReferenceAssemblyCatalog.Catalog[referenceAssemblyGroup];
 
-        await new Test<TAnalyzer, TCodeFixProvider>
+        Test<TAnalyzer, TCodeFixProvider> test = new Test<TAnalyzer, TCodeFixProvider>
         {
             TestCode = originalSource,
             FixedCode = fixedSource,
             ReferenceAssemblies = referenceAssemblies,
-        }.RunAsync().ConfigureAwait(false);
+        };
+
+        if (compilerDiagnostics.HasValue)
+        {
+            test.CompilerDiagnostics = compilerDiagnostics.Value;
+        }
+
+        await test.RunAsync().ConfigureAwait(false);
     }
 }

diff --git a/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs b/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs
--- a/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs
+++ b/tests/Moq.Analyzers.Test/ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzerTests.cs
@@ -26,10 +26,6 @@
             // Setup without Returns call should not be affected
             ["""new Mock<AsyncClient>().Setup(c => c.GetAsync());"""],
 
-            // Callback chained before Returns: FindSetupInvocation returns null because
-            // the expression before .Returns is Callback, not Setup
-            ["""new Mock<AsyncClient>().Setup(c => c.GetAsync()).Callback(() => { }).Returns(async () => "value");"""],
-
             // Parenthesized Setup with ReturnsAsync (valid)
             ["""(new Mock<AsyncClient>().Setup(c => c.GetAsync())).ReturnsAsync("value");"""],
 
@@ -52,6 +48,9 @@
             // Async lambda in Returns for ValueTask method
             ["""new Mock<AsyncClient>().Setup(c => c.DoValueTaskAsync()).{|Moq1206:Returns(async () => { })|};"""],
 
+            // Callback chained before Returns should still detect the Setup call
+            ["""new Mock<AsyncClient>().Setup(c => c.GetAsync()).Callback(() => { }).{|Moq1206:Returns(async () => "value")|};"""],
+
             // Parenthesized Setup with async Returns (invalid)
             ["""(new Mock<AsyncClient>().Setup(c => c.GetAsync())).{|Moq1206:Returns(async () => "value")|};"""],
 

diff --git a/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
new file mode 100644
--- /dev/null
+++ b/tests/Moq.Analyzers.Test/ReturnsDelegateShouldReturnTaskAnalyzerTests.cs
@@ -1,0 +1,249 @@
+using Microsoft.CodeAnalysis.Testing;
+using Verifier = Moq.Analyzers.Test.Helpers.AnalyzerVerifier<Moq.Analyzers.ReturnsDelegateShouldReturnTaskAnalyzer>;
+
+namespace Moq.Analyzers.Test;
+
+public class ReturnsDelegateShouldReturnTaskAnalyzerTests(ITestOutputHelper output)
+{
+    public static IEnumerable<object[]> ValidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Delegate returns Task<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => Task.FromResult(42));"""],
+
+            // Uses ReturnsAsync (correct, different overload)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).ReturnsAsync(42);"""],
+
+            // Direct value, not a delegate
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(42);"""],
+
+            // Non-async method with sync delegate (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(() => 42);"""],
+
+            // Async lambda (Moq1206's domain, not ours)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(async () => 42);"""],
+
+            // Setup without Returns call
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync());"""],
+
+            // Delegate returns ValueTask<T> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).Returns(() => ValueTask.FromResult(42));"""],
+
+            // Chained Callback with correct Task return
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Callback(() => { }).Returns(() => Task.FromResult(42));"""],
+
+            // Anonymous method returning Task.FromResult (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(delegate { return Task.FromResult(42); });"""],
+
+            // Async anonymous method (Moq1206's domain, not ours)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(async delegate { return 42; });"""],
+
+            // Method group returning Task<int> (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(GetIntAsync);"""],
+
+            // Anonymous method on sync method (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(delegate { return 42; });"""],
+
+            // Method group on sync method (no mismatch)
+            ["""new Mock<AsyncService>().Setup(c => c.GetSync()).Returns(GetInt);"""],
+
+            // Direct value on async method (not a delegate, different Returns overload)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(Task.FromResult(42));"""],
+
+            // Non-generic Task method with sync delegate (no mismatch, Task has no inner type)
+            ["""new Mock<AsyncService>().Setup(c => c.DoAsync()).Returns(() => Task.CompletedTask);"""],
+
+            // Parenthesized Setup with ReturnsAsync (valid)
+            ["""(new Mock<AsyncService>().Setup(c => c.GetValueAsync())).ReturnsAsync(42);"""],
+
+            // Block-bodied lambda returning Task (correct)
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).Returns(() => { return Task.FromResult(42); });"""],
+
+            // Generic non-Task return type (IList<int> is generic but not Task/ValueTask)
+            ["""new Mock<AsyncService>().Setup(c => c.GetItems()).Returns(() => new List<int>());"""],
+
+            // Property setup (resolves to IPropertySymbol, not IMethodSymbol)
+            ["""new Mock<AsyncService>().Setup(c => c.Value).Returns(() => Task.FromResult(42));"""],
+
+            // Split setup/returns (FindSetupInvocation can't walk past variable reference)
+            [
+                """
+                var setup = new Mock<AsyncService>().Setup(c => c.GetValueAsync());
+                setup.Returns(() => Task.FromResult(42));
+                """,
+            ],
+
+            // Expression variable setup (Setup argument is not a lambda, so mocked member can't be extracted)
+            [
+                """
+                System.Linq.Expressions.Expression<Func<AsyncService, Task<int>>> expr = c => c.GetValueAsync();
+                new Mock<AsyncService>().Setup(expr).Returns(() => Task.FromResult(42));
+                """,
+            ],
+        };
+
+        return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
+    }
+
+    public static IEnumerable<object[]> InvalidTestData()
+    {
+        IEnumerable<object[]> data = new object[][]
+        {
+            // Sync delegate returning int on Task<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Sync delegate returning string on Task<string> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetNameAsync()).{|Moq1208:Returns(() => "hello")|};"""],
+
+            // Sync delegate returning int on ValueTask<int> method
+            ["""new Mock<AsyncService>().Setup(c => c.GetValueTaskAsync()).{|Moq1208:Returns(() => 42)|};"""],
+
+            // Parenthesized Setup with sync delegate mismatch
... diff truncated: showing 800 of 1082 lines

}

return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicated FindSetupInvocation logic across sibling analyzers

Low Severity

The new FindSetupInvocation in ReturnsDelegateShouldReturnTaskAnalyzer and the existing one in ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer solve the same problem — locating the Setup call in a Moq fluent chain — with divergent approaches. The Moq1208 version is significantly more robust (semantic IsMoqSetupMethod checks, walks up to 10 fluent-chain levels, handles Callback chains), while the Moq1206 version is syntax-only, uses string comparison for "Setup", and only handles one level. This divergence means the Moq1206 analyzer silently misses Setup(...).Callback(...).Returns(async () => ...) patterns. Extracting the Moq1208 version into a shared utility would fix this gap and eliminate duplicated logic.

Fix in Cursor Fix in Web

- Move robust FindSetupInvocation implementation from Moq1208 analyzer to InvocationExpressionSyntaxExtensions

- Update Moq1206 (ReturnsAsyncShouldBeUsedForAsyncMethodsAnalyzer) to use the shared method

- Update Moq1208 (ReturnsDelegateShouldReturnTaskAnalyzer) to use the shared method

- Moq1206 now correctly detects Setup(...).Callback(...).Returns(async () => ...) patterns

- Update tests to expect diagnostic for Callback-chained async Returns patterns
Copy link
Copy Markdown
Collaborator

@rjmurillo-bot rjmurillo-bot left a comment

Choose a reason for hiding this comment

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

All required CI checks pass. Moq1208 analyzer for Returns() delegate mismatch is ready.

@rjmurillo rjmurillo merged commit c7485f4 into main Mar 1, 2026
39 of 42 checks passed
@rjmurillo rjmurillo deleted the feat/moq1208-returns-delegate-mismatch branch March 1, 2026 21:11
@rjmurillo rjmurillo added this to the vNext milestone Mar 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

analyzers Change that impacts an analyzer behavior documentation feature releasable

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explainer: Detect Returns() with Non-Task Delegate on Async Methods

4 participants