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.179] - 2026-07-21

### Fixed

- **CC014** false negative: undisposed `CancellationTokenSource` locals in top-level programs are
now analyzed using the compilation unit as their synthesized function boundary. The existing fix
safely rewrites these declarations to `using var` at top level.

## [1.27.178] - 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.178 hardening loop)
Reviewed: 2026-07-21 (refreshed through the v1.27.179 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 @@ -54,7 +54,7 @@ Calibration notes:
| 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/v1.27.178:** flags a method/local function that does async work (has `await` in its current function scope) but never references its `CancellationToken` parameter at runtime; excludes externally-controlled signatures and sync bodies. Await eligibility stops at nested lambdas/local functions, while token-reference analysis still descends into them because captures are real usage. A compile-time-only `nameof(token)` does not count. A token marked `[EnumeratorCancellation]` is excluded because the async-iterator infrastructure observes it (cf. CC011). Analyzer-only by design. |
| 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). |
| CC014 | `CancellationTokenSource` never disposed | Usage | Warning | 4 | 4 | 4 | 4 | 3 | 4 | Low | **v1.9.0 (new); fix v1.27.179:** 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. Top-level-program locals use the compilation unit as their synthesized function boundary and receive the same safe `using var` fix. 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); 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. |
Expand Down Expand Up @@ -168,6 +168,9 @@ Grading: **P0** = release-blocking; **P1** = next hardening loop; **P2** = oppor

## Verification Baseline

- v1.27.179: 635 tests, green locally. **CC014 FN fix:** undisposed CTS locals in top-level
programs now use the compilation unit as their synthesized function scope; analyzer and code-fix
regressions pin the warning and the valid top-level `using var` rewrite.
- v1.27.178: 633 tests, green locally. **CC016 FP fix:** an `await` owned only by a nested lambda
or local function no longer makes a synchronous containing method eligible for an unused-token
diagnostic; token references in nested functions still count as captures.
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.178</Version>
<Version>1.27.179</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
7 changes: 4 additions & 3 deletions src/CancelCop.Analyzer/UndisposedTokenSourceAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ namespace CancelCop.Analyzer;
/// resources leak until finalization. The cleanest fix is a <c>using</c> declaration.
/// </para>
/// <para>
/// <b>What it detects:</b> a local variable initialized with <c>new CancellationTokenSource(...)</c>
/// or <c>CancellationTokenSource.CreateLinkedTokenSource(...)</c> that is not already a <c>using</c>
/// <b>What it detects:</b> a local variable (including one in a top-level program) initialized with
/// <c>new CancellationTokenSource(...)</c> or
/// <c>CancellationTokenSource.CreateLinkedTokenSource(...)</c> that is not already a <c>using</c>
/// declaration, is never disposed (<c>Dispose</c>/<c>DisposeAsync</c>), and never escapes (it is not
/// returned, assigned out, passed as an argument, or captured by a nested function).
/// </para>
Expand Down Expand Up @@ -115,7 +116,7 @@ private static bool IsCancellationTokenSource(ITypeSymbol? type)
private static bool IsFunctionScope(SyntaxNode node) =>
node is MethodDeclarationSyntax or LocalFunctionStatementSyntax or
AnonymousFunctionExpressionSyntax or AccessorDeclarationSyntax or
ConstructorDeclarationSyntax;
ConstructorDeclarationSyntax or CompilationUnitSyntax;

/// <summary>
/// Returns true when the source is disposed within <paramref name="scope"/>, or when it escapes
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;
Expand All @@ -9,6 +10,25 @@ namespace CancelCop.Analyzer.Tests;

public class UndisposedTokenSourceAnalyzerTests
{
[Fact]
public async Task TopLevelSource_NeverDisposed_ShouldReportDiagnostic()
{
var test = new CSharpAnalyzerTest<UndisposedTokenSourceAnalyzer, DefaultVerifier>
{
TestCode = @"
using System.Threading;

var {|#0:cts|} = new CancellationTokenSource();
_ = cts.Token;
",
};
test.TestState.OutputKind = OutputKind.ConsoleApplication;
test.ExpectedDiagnostics.Add(
VerifyCS.Diagnostic("CC014").WithLocation(0).WithArguments("cts"));

await test.RunAsync();
}

[Fact]
public async Task LocalSource_NeverDisposed_ShouldReportDiagnostic()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;
Expand All @@ -10,6 +11,35 @@ namespace CancelCop.Analyzer.Tests;

public class UndisposedTokenSourceCodeFixTests
{
[Fact]
public async Task AddsUsingDeclaration_InTopLevelProgram()
{
var test = new CSharpCodeFixTest<
UndisposedTokenSourceAnalyzer,
UndisposedTokenSourceCodeFixProvider,
DefaultVerifier>
{
TestCode = @"
using System.Threading;

var {|#0:cts|} = new CancellationTokenSource();
_ = cts.Token;
",
FixedCode = @"
using System.Threading;

using var cts = new CancellationTokenSource();
_ = cts.Token;
",
};
test.TestState.OutputKind = OutputKind.ConsoleApplication;
test.FixedState.OutputKind = OutputKind.ConsoleApplication;
test.ExpectedDiagnostics.Add(
VerifyCS.Diagnostic("CC014").WithLocation(0).WithArguments("cts"));

await test.RunAsync();
}

[Fact]
public async Task FixAll_TwoSources_BothBecomeUsing()
{
Expand Down
Loading