diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f594f0..8360031 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.176] - 2026-07-21 + +### Fixed + +- **CC012** false positive: a custom `CancellationToken`-valued property named `None` is no longer + mistaken for `System.Threading.CancellationToken.None`. The analyzer now verifies the framework + property symbol before suggesting replacement with an in-scope token. + ## [1.27.175] - 2026-07-21 ### Fixed diff --git a/docs/ANALYZER_HEALTH.md b/docs/ANALYZER_HEALTH.md index ee0cba9..aed6686 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.175 hardening loop) +Reviewed: 2026-07-21 (refreshed through the v1.27.176 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 @@ -56,7 +56,7 @@ Calibration notes: | 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). | -| CC012 | Explicit `CancellationToken.None`/`default` when a token is in scope | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.7.0 (new):** flags `CancellationToken.None`/`default`/`default(CancellationToken)` bound to a `CancellationToken` parameter when an in-scope token exists; fixer swaps in the token. Info severity because best-effort cleanup legitimately opts out. Uses the shared scope walk + converted-type gate (a bare `default` only counts in token context). | +| CC012 | Explicit `CancellationToken.None`/`default` when a token is in scope | Usage | Info | 4 | 4 | 4 | 4 | 3 | 3 | Low | **v1.7.0 (new); fix v1.27.176:** flags the actual `System.Threading.CancellationToken.None` property or `default`/`default(CancellationToken)` bound to a `CancellationToken` parameter when an in-scope token exists; fixer swaps in the token. The framework property is symbol-resolved, so a custom token-valued property merely named `None` stays quiet. Info severity because best-effort cleanup legitimately opts out. Uses the shared scope walk + converted-type gate (a bare `default` only counts in token context). | | CC011 | Async-iterator token missing `[EnumeratorCancellation]` | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.6.0 (new):** producer-side complement to CC010. Flags an `async IAsyncEnumerable` iterator (method or local function with `yield`) whose `CancellationToken` parameter lacks `[EnumeratorCancellation]`, so a token passed via `.WithCancellation` would be silently dropped. Fixer adds the attribute + `System.Runtime.CompilerServices` import. Conservative: non-iterators returning the type, tokenless iterators, and already-marked params are quiet. Yield detection stops at nested local functions/lambdas. | ## Planning Shortlist @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor ## Verification Baseline +- 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. - v1.27.175: 629 tests, green locally. **CC019 FN fix:** `await foreach`, `await using var`, and `await using (...)` now count as awaited work in the current `try` scope, closing the syntax gap where their await keywords were invisible to the prior `AwaitExpressionSyntax`-only check. diff --git a/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj b/src/CancelCop.Analyzer.Package/CancelCop.Analyzer.Package.csproj index a214640..6419d92 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.175 + 1.27.176 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/ExplicitNoneTokenAnalyzer.cs b/src/CancelCop.Analyzer/ExplicitNoneTokenAnalyzer.cs index cb4443d..ba30fff 100644 --- a/src/CancelCop.Analyzer/ExplicitNoneTokenAnalyzer.cs +++ b/src/CancelCop.Analyzer/ExplicitNoneTokenAnalyzer.cs @@ -83,7 +83,11 @@ private void AnalyzeArgument(SyntaxNodeAnalysisContext context) if (argument.Parent is not ArgumentListSyntax { Parent: InvocationExpressionSyntax or BaseObjectCreationExpressionSyntax }) return; - if (!IsNoneishToken(argument.Expression, out var displayText)) + if (!IsNoneishToken( + argument.Expression, + context.SemanticModel, + context.CancellationToken, + out var displayText)) return; // The argument must actually bind to a CancellationToken (a bare `default` only counts in a @@ -108,7 +112,11 @@ private void AnalyzeArgument(SyntaxNodeAnalysisContext context) /// Recognises the three "no cancellation" spellings: default, /// default(CancellationToken), and CancellationToken.None. /// - private static bool IsNoneishToken(ExpressionSyntax expression, out string displayText) + private static bool IsNoneishToken( + ExpressionSyntax expression, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken, + out string displayText) { switch (expression) { @@ -118,7 +126,8 @@ private static bool IsNoneishToken(ExpressionSyntax expression, out string displ case DefaultExpressionSyntax: displayText = "default"; return true; - case MemberAccessExpressionSyntax memberAccess when memberAccess.Name.Identifier.Text == "None": + case MemberAccessExpressionSyntax memberAccess + when IsCancellationTokenNone(memberAccess, semanticModel, cancellationToken): displayText = "CancellationToken.None"; return true; default: @@ -126,4 +135,23 @@ private static bool IsNoneishToken(ExpressionSyntax expression, out string displ return false; } } + + private static bool IsCancellationTokenNone( + MemberAccessExpressionSyntax memberAccess, + SemanticModel semanticModel, + System.Threading.CancellationToken cancellationToken) + { + if (semanticModel.GetSymbolInfo(memberAccess, cancellationToken).Symbol is not IPropertySymbol + { + Name: "None", + IsStatic: true, + } property) + { + return false; + } + + var cancellationTokenType = semanticModel.Compilation.GetTypeByMetadataName( + "System.Threading.CancellationToken"); + return SymbolEqualityComparer.Default.Equals(property.ContainingType, cancellationTokenType); + } } diff --git a/tests/CancelCop.Analyzer.Tests/ExplicitNoneTokenAnalyzerTests.cs b/tests/CancelCop.Analyzer.Tests/ExplicitNoneTokenAnalyzerTests.cs index 4c8126c..f3350c1 100644 --- a/tests/CancelCop.Analyzer.Tests/ExplicitNoneTokenAnalyzerTests.cs +++ b/tests/CancelCop.Analyzer.Tests/ExplicitNoneTokenAnalyzerTests.cs @@ -192,6 +192,24 @@ public async Task RunAsync(CancellationToken cancellationToken) await VerifyCS.VerifyAnalyzerAsync(test); } + [Fact] + public async Task CustomTokenPropertyNamedNone_ShouldNotReportDiagnostic() + { + var test = Harness + @" + private sealed class TokenProvider + { + public CancellationToken None => new(canceled: true); + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + await DoAsync(new TokenProvider().None); + } +}"; + + await VerifyCS.VerifyAnalyzerAsync(test); + } + [Fact] public async Task DefaultToNonTokenParameter_ShouldNotReportDiagnostic() {