diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f92118..e815bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.27.184] - 2026-07-21 + +### Fixed + +- **CC027** false negative: returning an async call through an interface or base-type cast of a + `using`-scoped resource is now diagnosed. The cast does not extend the resource lifetime, so it + is still disposed before the returned task completes. + ## [1.27.183] - 2026-07-21 ### Fixed diff --git a/docs/ANALYZER_HEALTH.md b/docs/ANALYZER_HEALTH.md index 4806f57..6606345 100644 --- a/docs/ANALYZER_HEALTH.md +++ b/docs/ANALYZER_HEALTH.md @@ -1,6 +1,6 @@ # Analyzer Health -Reviewed: 2026-07-21 (refreshed through the v1.27.183 hardening loop) +Reviewed: 2026-07-21 (refreshed through the v1.27.184 hardening loop) A deliberately harsh health audit for the twenty-eight implemented CancelCop rule IDs (CC001–CC006, CC009–CC028). Scores are 1–5, where `5` means reference-quality and hard to improve, `3` means usable but @@ -41,7 +41,7 @@ Calibration notes: | CC009 | Loop missing cancellation check | Usage | Warning | 4 | 4 | 4 | 4 | 4 | 4 | Low | v1.4.0: semantic receiver resolution (no name matching), walks methods/local functions/lambdas, all four loop kinds, fixer inserts `ThrowIfCancellationRequested()`. **v1.27.180:** a compile-time-only `nameof(token.IsCancellationRequested)` reference no longer counts as a runtime cancellation check. The strongest rule in the set. | | CC010 | `await foreach` missing CancellationToken flow | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.5.0 (new):** flags `await foreach` over an `IAsyncEnumerable` (or implementer) when a token is in scope, the source does not already pass a token argument, and it is not already a configured cancelable enumerable; fixer rewrites the source to `.WithCancellation(token)`. **v1.27.183:** `WithCancellation` wrapper recognition is semantic and framework-gated, so a custom zero-argument look-alike no longer suppresses the rule. Uses the shared `FindEnclosingCancellationTokenParameter` scope walk. Conservative: synchronous `foreach`, no-token scopes, and producer calls already receiving a token are quiet. No analyzer XML `` example variety yet (P3). | | CC028 | Blocking `System.IO` read/write/append in async code | Usage | Warning | 4 | 4 | 4 | 4 | 4 | 4 | Low | **v1.24.0 (new); fix v1.25.0:** flags a blocking synchronous `System.IO` helper inside async code (method/local function/lambda/anonymous method) when a signature-compatible `Async` counterpart exists on the type — `File` read/write/append (`ReadAllText`/`ReadAllBytes`/`ReadAllLines`, `WriteAll*`, `AppendAll*`), `StreamReader.ReadToEnd`/`ReadLine`, and (v1.27.0) `StreamWriter.Write`/`WriteLine`/`Flush` (generalised from `System.IO.File` to `System.IO` in v1.26.0). **v1.27.0** replaced the name-only counterpart lookup with a parameter-signature match (overload equals the call's params, optionally + a trailing token), so the rewrite always compiles (`StreamWriter.Write(bool)` has no async form → quiet) and the token is only flowed when the matched overload accepts one (`Write(string)`→`await WriteAsync(text)` tokenless; `Flush()`→`await FlushAsync(token)`). Fixer rewrites to `await …Async(…[, token])`, flowing the in-scope token via `FindEnclosingCancellationTokenParameter` (named arg when the call uses named args, await parenthesized when the result is a receiver/element/conditional access). Symbol-resolved + namespace-gated to `System.IO` (look-alike `File`/`StreamReader` ignored); only in async context via the shared `IsInAsyncFunction`. Fix-All batches across the type→method map. Rounds out the blocking-in-async family (CC013/CC015/CC026). | -| CC027 | Returned task uses a disposed `using` resource | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.23.0 (new):** flags a non-async `Task`-returning method/local function whose `return` is a call on a `using`-declared local — the resource is disposed before the returned task completes (premature disposal). High confidence: only the receiver case is flagged (a synchronous read into a completed task like `Task.FromResult(resource.Value)` is not). Analyzer-only (fix = make async + await). | +| CC027 | Returned task uses a disposed `using` resource | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.23.0 (new):** flags a non-async `Task`-returning method/local function whose `return` is a call on a `using`-declared local — the resource is disposed before the returned task completes (premature disposal). **v1.27.184:** receiver walking unwraps interface/base casts, which do not change the using local's lifetime. High confidence: only the receiver case is flagged (a synchronous read into a completed task like `Task.FromResult(resource.Value)` is not). Analyzer-only (fix = make async + await). | | CC026 | `SemaphoreSlim.Wait()` in async code | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.22.0 (new):** flags every `SemaphoreSlim.Wait()` overload (parameterless, timeout, token) inside async code — a classic deadlock source; fixer → `await gate.WaitAsync(…)`, carrying the original arguments through and injecting the in-scope token when `Wait()` was parameterless (v1.22.2). Symbol-resolved to `System.Threading.SemaphoreSlim`. | | CC025 | Prefer `await using` for `IAsyncDisposable` | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.21.0 (new):** flags a `using` statement/declaration (no `await`) over an `IAsyncDisposable` resource in async code; fixer prepends `await`. Both the declaration (`using var x = …`) and statement (`using (…)`) forms, expression and variable receivers. Info. | | CC024 | `async` lambda converted to `Action` | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.20.0 (new):** the lambda counterpart of CC023. Flags an `async` lambda whose converted delegate type is `System.Action`/`Action<…>` (binds as async void). Catches the `Parallel.ForEach(..., async x => …)` trap. `Func` and event-handler delegates are not `Action`, so excluded. Analyzer-only (the right delegate depends on the consuming API). | @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor ## Verification Baseline +- v1.27.184: 641 tests, green locally. **CC027 FN fix:** a returned async call through an + interface/base cast of the using-scoped receiver is now diagnosed because the same local is still + disposed before task completion. - v1.27.183: 640 tests, green locally. **CC010 FN fix:** a custom method merely named `WithCancellation` no longer counts as configured token flow; only the framework API is unwrapped as a cancellation-aware enumerable. diff --git a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj index 341003c..5c08ec0 100644 --- a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj +++ b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj @@ -13,7 +13,7 @@ CancelCop.Analyzer - 1.27.183 + 1.27.184 George Wall A surgical Roslyn analyzer focused on CancellationToken best practices: propagation, parameter positioning, loop cancellation checks, and more. Includes automatic code fixes for public APIs, handlers, EF Core, HTTP calls, and Minimal APIs. roslyn;analyzer;cancellationtoken;async;code-fix;loops;efcore;httpclient diff --git a/src/CancelCop.Analyzer/ReturnedTaskUsingDisposedAnalyzer.cs b/src/CancelCop.Analyzer/ReturnedTaskUsingDisposedAnalyzer.cs index 06cc58e..1f54ba7 100644 --- a/src/CancelCop.Analyzer/ReturnedTaskUsingDisposedAnalyzer.cs +++ b/src/CancelCop.Analyzer/ReturnedTaskUsingDisposedAnalyzer.cs @@ -27,8 +27,9 @@ namespace CancelCop.Analyzer; /// /// What it detects: a non-async method or local function returning /// Task/Task<T>/ValueTask where a return expression is a call whose -/// left-most receiver is a using-declared local. Only the receiver case is flagged (high -/// confidence); a resource read synchronously into a completed task — e.g. +/// left-most receiver is a using-declared local, including when the receiver is cast to a +/// base type or interface. Only the receiver case is flagged (high confidence); a resource read +/// synchronously into a completed task — e.g. /// Task.FromResult(resource.Value) — is not. /// /// @@ -147,8 +148,8 @@ private static IEnumerable DescendantsInOwnScope(SyntaxNode body) => child is not LocalFunctionStatementSyntax && child is not AnonymousFunctionExpressionSyntax); /// - /// Walks an invocation/member-access (and conditional-access/parenthesis) chain down to its - /// left-most receiver expression. + /// Walks an invocation/member-access (and conditional-access/parenthesis/cast) chain down to + /// its left-most receiver expression. /// private static ExpressionSyntax GetLeftmostReceiver(ExpressionSyntax expression) { @@ -168,6 +169,9 @@ private static ExpressionSyntax GetLeftmostReceiver(ExpressionSyntax expression) case ParenthesizedExpressionSyntax parenthesized: expression = parenthesized.Expression; break; + case CastExpressionSyntax cast: + expression = cast.Expression; + break; default: return expression; } diff --git a/tests/CancelCop.Analyzer.Tests/ReturnedTaskUsingDisposedAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/ReturnedTaskUsingDisposedAnalyzerTests.cs index a54b6dc..a5bdeee 100644 --- a/tests/CancelCop.Analyzer.Tests/ReturnedTaskUsingDisposedAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/ReturnedTaskUsingDisposedAnalyzerTests.cs @@ -57,6 +57,37 @@ public Task ReadAsync() await VerifyCS.VerifyAnalyzerAsync(test, expected); } + [Fact] + public async Task ReturnTaskThroughCastUsingResource_ShouldReportDiagnostic() + { + var test = @" +using System; +using System.Threading.Tasks; + +public interface IResource : IDisposable +{ + Task DoAsync(); +} + +public sealed class Resource : IResource +{ + public void Dispose() { } + public Task DoAsync() => Task.FromResult(0); +} + +public class TestClass +{ + public Task ReadAsync() + { + using var resource = new Resource(); + return {|#0:((IResource)resource).DoAsync()|}; + } +}"; + + var expected = VerifyCS.Diagnostic("CC027").WithLocation(0).WithArguments("resource"); + await VerifyCS.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task ReturnCompletedTaskReadingResource_ShouldNotReportDiagnostic() {