diff --git a/CHANGELOG.md b/CHANGELOG.md index 8360031..5625062 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.177] - 2026-07-21 + +### Fixed + +- **CC016 / CC017** false negatives: mentioning a token only inside `nameof(...)` no longer counts + as observing cancellation. `nameof` is evaluated at compile time, so these methods still ignore + their runtime cancellation signal and are now diagnosed. + ## [1.27.176] - 2026-07-21 ### Fixed diff --git a/docs/ANALYZER_HEALTH.md b/docs/ANALYZER_HEALTH.md index aed6686..10a9032 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.176 hardening loop) +Reviewed: 2026-07-21 (refreshed through the v1.27.177 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 @@ -51,8 +51,8 @@ Calibration notes: | 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). | | 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):** flags an `override` of `ExecuteAsync(CancellationToken)` on a `Microsoft.Extensions.Hosting.BackgroundService` subclass whose body never references the stopping token — the override case CC016 skips. Analyzer-only; token passed to a helper or observed in a loop counts as used. Framework-gated to BackgroundService by base-type walk. | -| CC016 | Unused `CancellationToken` parameter | Usage | Info | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.11.0 (new):** flags a method/local function that does async work (has `await`) but never references its `CancellationToken` parameter; excludes externally-controlled signatures and sync bodies. Analyzer-only by design. A token used in a nested lambda/local function counts as used. **v1.27.11 (FP fix):** a token marked `[EnumeratorCancellation]` is excluded — the async-iterator infrastructure delivers a consumer's `WithCancellation` token to it, so it is observed even without a body reference (cf. CC011). | +| 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. | +| CC016 | Unused `CancellationToken` parameter | Usage | Info | 4 | 4 | n/a | 4 | 3 | 3 | Low | **v1.11.0 (new); fixes v1.27.11/v1.27.177:** flags a method/local function that does async work (has `await`) but never references its `CancellationToken` parameter at runtime; excludes externally-controlled signatures and sync bodies. Analyzer-only by design. A token used in a nested lambda/local function counts as used, while a compile-time-only `nameof(token)` does not. A token marked `[EnumeratorCancellation]` is excluded — the async-iterator infrastructure delivers a consumer's `WithCancellation` token to it, so it is observed even without a body reference (cf. CC011). | | CC015 | Blocking on async code (sync-over-async) | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.10.0 (new):** flags `.Result`/`.Wait()`/`.GetAwaiter().GetResult()` on a `Task`/`Task`/`ValueTask` inside an `async` function; fixer rewrites to `await`. Symbol-resolved (a look-alike `.Result` on a non-task is ignored). Shares `IsInAsyncFunction` with CC013. | | CC014 | `CancellationTokenSource` never disposed | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.9.0 (new):** flags a local `new CancellationTokenSource(...)`/`CreateLinkedTokenSource(...)` that is not a `using` decl, never disposed, and never escapes (return/out-assign/argument/nested-capture); fixer converts to a `using` declaration. Conservative escape analysis — any disposal-elsewhere path suppresses it (like a scoped CA2000 for CTS). | | CC013 | `Thread.Sleep` in async code | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.8.0 (new):** flags `System.Threading.Thread.Sleep` lexically inside an `async` method/local function/lambda/anonymous method; fixer rewrites to `await Task.Delay(delay, token)` (token flowed when in scope). Async-context check stops at the first function boundary, so a synchronous lambda inside an async method is quiet. Symbol-resolved (no name-only match). | @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor ## Verification Baseline +- v1.27.177: 632 tests, green locally. **CC016/CC017 FN fix:** token references contained only + within `nameof(...)` no longer suppress unused-token diagnostics because they have no runtime + cancellation effect; real references in nested lambdas and local functions remain observed. - v1.27.176: 630 tests, green locally. **CC012 FP fix:** a custom token-valued property named `None` is no longer treated as `CancellationToken.None`; the framework property must resolve by symbol identity before the analyzer offers the in-scope token replacement. diff --git a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj index 6419d92..6f68eb9 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.176 + 1.27.177 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 9141dd8..0bda021 100644 --- a/src/CancelCop.Analyzer/CancellationTokenHelpers.cs +++ b/src/CancelCop.Analyzer/CancellationTokenHelpers.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; namespace CancelCop.Analyzer; @@ -297,9 +298,10 @@ public static bool IsInAsyncFunction(SyntaxNode node) } /// - /// Returns true when is referenced anywhere within + /// Returns true when is referenced at runtime within /// — including inside nested lambdas and local functions, where the - /// parameter is captured. Used to tell an observed token from a dead one. + /// parameter is captured. Compile-time-only nameof references are ignored. Used to tell + /// an observed token from a dead one. /// public static bool IsParameterReferenced( SyntaxNode body, @@ -312,8 +314,29 @@ public static bool IsParameterReferenced( if (identifier.Identifier.Text != parameter.Name) continue; - if (SymbolEqualityComparer.Default.Equals( + if (!SymbolEqualityComparer.Default.Equals( semanticModel.GetSymbolInfo(identifier, cancellationToken).Symbol, parameter)) + { + continue; + } + + if (!IsInsideNameof(identifier, semanticModel, cancellationToken)) + return true; + } + + return false; + } + + private static bool IsInsideNameof( + IdentifierNameSyntax identifier, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken) + { + for (var operation = semanticModel.GetOperation(identifier, cancellationToken); + operation != null; + operation = operation.Parent) + { + if (operation is INameOfOperation) return true; } diff --git a/tests/CancelCop.Analyzer.Tests/BackgroundServiceTokenAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/BackgroundServiceTokenAnalyzerTests.cs index 41800f3..dee61e4 100644 --- a/tests/CancelCop.Analyzer.Tests/BackgroundServiceTokenAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/BackgroundServiceTokenAnalyzerTests.cs @@ -44,6 +44,30 @@ protected override async Task ExecuteAsync(CancellationToken {|#0:stoppingToken| await CreateTest(test, expected).RunAsync(); } + [Fact] + public async Task ExecuteAsync_OnlyNamesStoppingToken_ShouldReportDiagnostic() + { + var test = @" +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +public class Worker : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken {|#0:stoppingToken|}) + { + _ = nameof(stoppingToken); + await Task.Delay(1000); + } +}"; + + var expected = new DiagnosticResult("CC017", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("stoppingToken"); + + await CreateTest(test, expected).RunAsync(); + } + [Fact] public async Task ExpressionBodiedExecuteAsync_IgnoresStoppingToken_ShouldReportDiagnostic() { diff --git a/tests/CancelCop.Analyzer.Tests/UnusedTokenParameterAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/UnusedTokenParameterAnalyzerTests.cs index c143968..7b70162 100644 --- a/tests/CancelCop.Analyzer.Tests/UnusedTokenParameterAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/UnusedTokenParameterAnalyzerTests.cs @@ -28,6 +28,26 @@ public async Task RunAsync(CancellationToken {|#0:cancellationToken|}) await VerifyCS.VerifyAnalyzerAsync(test, expected); } + [Fact] + public async Task AsyncMethod_TokenOnlyInNameof_ShouldReportDiagnostic() + { + var test = @" +using System.Threading; +using System.Threading.Tasks; + +public class TestClass +{ + public async Task RunAsync(CancellationToken {|#0:cancellationToken|}) + { + _ = nameof(cancellationToken); + await Task.Delay(1000); + } +}"; + + var expected = VerifyCS.Diagnostic("CC016").WithLocation(0).WithArguments("cancellationToken"); + await VerifyCS.VerifyAnalyzerAsync(test, expected); + } + [Fact] public async Task AsyncIterator_EnumeratorCancellationToken_NotReferencedInBody_ShouldNotReportDiagnostic() {