diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e3082..5f57525 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.181] - 2026-07-21 + +### Fixed + +- **CC020 / CC021** false negatives: mentioning `ServerCallContext.CancellationToken` or + `HttpContext.RequestAborted` only inside `nameof(...)` no longer counts as observing request + cancellation. These compile-time-only references now leave the runtime token-use diagnostic active. + ## [1.27.180] - 2026-07-21 ### Fixed diff --git a/docs/ANALYZER_HEALTH.md b/docs/ANALYZER_HEALTH.md index 7ba71c0..980d633 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.180 hardening loop) +Reviewed: 2026-07-21 (refreshed through the v1.27.181 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 @@ -47,8 +47,8 @@ Calibration notes: | 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). | | CC023 | `async void` (non-event-handler) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.19.0 (new):** flags an `async void` method that is not an event handler (`(object, EventArgs)` shape, EventArgs subclasses included) and not externally-controlled; fixer changes `void`→`Task` + adds the Tasks import. Classic async anti-pattern (cf. VSTHRD100) — `async void` can't be awaited or cancelled and crashes on unhandled exceptions. | | CC022 | Prefer `CancelAsync()` over `Cancel()` in async | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.18.0 (new):** flags a parameterless `CancellationTokenSource.Cancel()` inside async code; fixer rewrites to `await cts.CancelAsync()`. Info (`Cancel()` is still valid). The `Cancel(bool)` overload and sync contexts are excluded. Modern .NET 8 guidance — `Cancel()` runs callbacks synchronously on the caller. | -| CC021 | `HttpContext.RequestAborted` not observed | Usage | Info | 4 | 3 | n/a | 4 | 3 | 3 | Low | **v1.16.0 (new):** the HttpContext parallel of CC020. Flags a method with a `Microsoft.AspNetCore.Http.HttpContext` parameter that does async work but never reads `context.RequestAborted` and never passes the context on. Info because HttpContext is often taken for non-cancellation reasons (hence FP score 3). Shares `AccessesMember`/`ParameterEscapesAsArgument` with CC020. | -| CC020 | gRPC method ignores `ServerCallContext.CancellationToken` | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.15.0 (new):** flags a method with a `Grpc.Core.ServerCallContext` parameter that does async work but never reads `context.CancellationToken` and never passes the context on. Fills a genuine gap — the token is a property, not a parameter, so CC002 can't see it (cf. CC017 for BackgroundService). Analyzer-only; gated by parameter type name+namespace (tests use a stub). | +| CC021 | `HttpContext.RequestAborted` not observed | Usage | Info | 4 | 3 | n/a | 4 | 3 | 3 | Low | **v1.16.0 (new):** the HttpContext parallel of CC020. Flags a method with a `Microsoft.AspNetCore.Http.HttpContext` parameter that does async work but never reads `context.RequestAborted` and never passes the context on. **v1.27.181:** a compile-time-only `nameof(context.RequestAborted)` no longer counts as observation. Info because HttpContext is often taken for non-cancellation reasons (hence FP score 3). Shares `AccessesMember`/`ParameterEscapesAsArgument` with CC020. | +| CC020 | gRPC method ignores `ServerCallContext.CancellationToken` | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.15.0 (new):** flags a method with a `Grpc.Core.ServerCallContext` parameter that does async work but never reads `context.CancellationToken` and never passes the context on. **v1.27.181:** a compile-time-only `nameof(context.CancellationToken)` no longer counts as observation. Fills a genuine gap — the token is a property, not a parameter, so CC002 can't see it (cf. CC017 for BackgroundService). Analyzer-only; gated by parameter type name+namespace (tests use a stub). | | CC019 | Broad catch swallows `OperationCanceledException` | Usage | Info | 4 | 3 | 4 | 4 | 3 | 3 | Low | **v1.14.0 (new); fixes v1.17.0/v1.27.174/v1.27.175:** flags a catch-all/`catch (Exception)` with no `when` filter, over a `try` containing awaited work in the current function scope, whose body never rethrows. Covers explicit `await`, `await foreach`, and both `await using` forms; awaits inside nested local functions/lambdas are ignored because that deferred work does not execute in the `try` itself. Info because boundary handlers are sometimes intended. Conservative (filter/rethrow/specific-type/no-await all suppress). The fix inserts `if (ex is OperationCanceledException) throw;` (typed catches only). | | CC018 | SignalR hub method missing `CancellationToken` | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.13.0 (new):** SignalR analogue of CC005B. Flags a public non-static async method on a `Microsoft.AspNetCore.SignalR.Hub`/`Hub` subclass without a token; excludes lifecycle overrides + externally-controlled signatures. Reuses the shared add-token-parameter fixer. Base-type gated by name+namespace (tests use a faithful Hub stub, no package). | | CC017 | `BackgroundService.ExecuteAsync` ignores stopping token | Usage | Warning | 4 | 4 | n/a | 4 | 3 | 4 | Low | **v1.12.0 (new); fix v1.27.177:** flags an `override` of `ExecuteAsync(CancellationToken)` on a `Microsoft.Extensions.Hosting.BackgroundService` subclass whose body never references the stopping token at runtime — the override case CC016 skips. A compile-time-only `nameof(stoppingToken)` does not count as observation. Analyzer-only; token passed to a helper or observed in a loop counts as used. Framework-gated to BackgroundService by base-type walk. | @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor ## Verification Baseline +- v1.27.181: 638 tests, green locally. **CC020/CC021 FN fix:** compile-time-only + `nameof(context.CancellationToken)` and `nameof(context.RequestAborted)` references no longer + suppress the runtime token-observation diagnostics. - v1.27.180: 636 tests, green locally. **CC009 FN fix:** a loop that mentions `token.IsCancellationRequested` only inside compile-time `nameof(...)` is now diagnosed because it still performs no runtime cancellation check. diff --git a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj index 7efe3fe..1614d5f 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.180 + 1.27.181 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/CancellationTokenHelpers.cs b/src/CancelCop.Analyzer/CancellationTokenHelpers.cs index d52fa1a..22372d1 100644 --- a/src/CancelCop.Analyzer/CancellationTokenHelpers.cs +++ b/src/CancelCop.Analyzer/CancellationTokenHelpers.cs @@ -349,8 +349,9 @@ public static bool IsInsideNameof( /// /// Returns true when reads a member named - /// off (e.g. context.CancellationToken). Used by the - /// context-token rules (CC020/CC021) to tell an observed token from an ignored one. + /// off at runtime (e.g. context.CancellationToken). + /// Compile-time-only nameof references are ignored. Used by the context-token rules + /// (CC020/CC021) to tell an observed token from an ignored one. /// public static bool AccessesMember( SyntaxNode body, @@ -365,8 +366,11 @@ public static bool AccessesMember( continue; if (SymbolEqualityComparer.Default.Equals( - semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol, parameter)) + semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol, parameter) && + !IsInsideNameof(memberAccess, semanticModel, cancellationToken)) + { return true; + } } return false; diff --git a/tests/CancelCop.Analyzer.Tests/GrpcServerCallContextAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/GrpcServerCallContextAnalyzerTests.cs index a59330a..a48d7cd 100644 --- a/tests/CancelCop.Analyzer.Tests/GrpcServerCallContextAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/GrpcServerCallContextAnalyzerTests.cs @@ -110,6 +110,27 @@ public async Task SayHello(string request, ServerCallContext context) await VerifyCS.VerifyAnalyzerAsync(test); } + [Fact] + public async Task GrpcMethod_NameofTokenOnly_ShouldReportDiagnostic() + { + var test = @" +using System.Threading.Tasks; +using Grpc.Core; + +public class GreeterService +{ + public async Task SayHello(string request, ServerCallContext {|#0:context|}) + { + _ = nameof(context.CancellationToken); + await Task.Yield(); + return ""hi""; + } +}" + ContextStub; + + var expected = VerifyCS.Diagnostic("CC020").WithLocation(0).WithArguments("context"); + await VerifyCS.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task GrpcMethod_PassesContextOn_ShouldNotReportDiagnostic() { diff --git a/tests/CancelCop.Analyzer.Tests/RequestAbortedAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/RequestAbortedAnalyzerTests.cs index 80b822d..7add30b 100644 --- a/tests/CancelCop.Analyzer.Tests/RequestAbortedAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/RequestAbortedAnalyzerTests.cs @@ -84,6 +84,26 @@ public async Task InvokeAsync(HttpContext context) await VerifyCS.VerifyAnalyzerAsync(test); } + [Fact] + public async Task Method_NameofRequestAbortedOnly_ShouldReportDiagnostic() + { + var test = @" +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +public class Middleware +{ + public async Task InvokeAsync(HttpContext {|#0:context|}) + { + _ = nameof(context.RequestAborted); + await Task.Yield(); + } +}" + ContextStub; + + var expected = VerifyCS.Diagnostic("CC021").WithLocation(0).WithArguments("context"); + await VerifyCS.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task Method_PassesContextOn_ShouldNotReportDiagnostic() {