Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/ANALYZER_HEALTH.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<T>`/`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<T>` 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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackageId>CancelCop.Analyzer</PackageId>
<!-- Fallback for local packs; the publish workflow overrides this with -p:Version from
the release tag so the published package always matches the tag. -->
<Version>1.27.175</Version>
<Version>1.27.176</Version>
<Authors>George Wall</Authors>
<Description>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.</Description>
<PackageTags>roslyn;analyzer;cancellationtoken;async;code-fix;loops;efcore;httpclient</PackageTags>
Expand Down
34 changes: 31 additions & 3 deletions src/CancelCop.Analyzer/ExplicitNoneTokenAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -108,7 +112,11 @@ private void AnalyzeArgument(SyntaxNodeAnalysisContext context)
/// Recognises the three "no cancellation" spellings: <c>default</c>,
/// <c>default(CancellationToken)</c>, and <c>CancellationToken.None</c>.
/// </summary>
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)
{
Expand All @@ -118,12 +126,32 @@ 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:
displayText = string.Empty;
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);
}
}
18 changes: 18 additions & 0 deletions tests/CancelCop.Analyzer.Tests/ExplicitNoneTokenAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading