Skip to content

feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D)#5621

Merged
thomhurst merged 4 commits intomainfrom
fix/5613-tunit0015-code-fix
Apr 18, 2026
Merged

feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D)#5621
thomhurst merged 4 commits intomainfrom
fix/5613-tunit0015-code-fix

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Part D of #5613 (final). Adds a Roslyn code-fix for TUnit0015 (MissingTimeoutCancellationTokenAttributes) with three user-selectable actions, mirroring the shape suggested in the issue:

  1. Add CancellationToken parameter — bare parameter, user wires it up.
  2. Add CancellationToken parameter with ThrowIfCancellationRequested — prepends the call so the test actually observes cancellation.
  3. Add CancellationToken parameter as discard — prepends _ = cancellationToken; to silence IDE0060 / CA1801 under TreatWarningsAsErrors.

All three add using System.Threading; if it's missing and re-format the affected method so indentation matches surrounding code.

Test plan

  • Adds_CancellationToken_Parameter — bare parameter
  • Adds_CancellationToken_Parameter_With_ThrowIfCancellationRequested — prepends the call
  • Adds_CancellationToken_Parameter_As_Discard — prepends _ = cancellationToken;
  • Manual: trigger TUnit0015 in an IDE and apply each of the three code-fixes

…en parameter (#5613 D)

Registers three code-fix actions on TUnit0015
(MissingTimeoutCancellationTokenAttributes):

- Add CancellationToken parameter
- Add CancellationToken parameter with ThrowIfCancellationRequested
- Add CancellationToken parameter as discard (_ = cancellationToken;)

Automatically adds using System.Threading; when missing. Ensures the
new parameter is formatted consistently with the rest of the method.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: feat(analyzers): code-fix for TUnit0015

Overall this is a clean, well-structured code fix provider. The three-action design (bare param / ThrowIfCancellationRequested / discard) directly maps to the real use cases users will encounter. A few issues worth addressing before merging.


Issue 1: Expression-bodied methods silently ignored (bug)

// AddCancellationTokenAsync — only handles block bodies:
if (bodyMode != BodyMode.None && updated.Body is { } body)
{
    // ... only runs for block-bodied methods
}

If the test method uses an expression body (e.g. public Task MyTest() => SomethingAsync();), the ThrowIfCancellationRequested and Discard modes silently skip the statement insertion. The user picks one of those actions expecting a statement to be prepended, and it isn't. The bare-param fix still works, but only that one.

Better approach — either convert the expression body to a block body first, or skip registering the body-modifying actions entirely when method.ExpressionBody != null. Silently doing nothing is worse than not offering the action:

// Option A: skip body-mode actions for expression-bodied methods
if (method.Body != null)
{
    context.RegisterCodeFix(/* ThrowIfCancellationRequested action */, diagnostic);
    context.RegisterCodeFix(/* Discard action */, diagnostic);
}

// Option B: rewrite expression body → block body then prepend statement
var blockBody = SyntaxFactory.Block(
    SyntaxFactory.ReturnStatement(method.ExpressionBody!.Expression));
updated = updated.WithBody(blockBody).WithExpressionBody(null).WithSemicolonToken(default);
// then prepend statement as normal

Option A is simpler and safer; Option B gives the user the richer fix.


Issue 2: Using directive appended at end, not sorted

private static CompilationUnitSyntax EnsureSystemThreadingUsing(CompilationUnitSyntax compilationUnit)
{
    // ...
    return compilationUnit.AddUsings(newUsing); // always appends at the end
}

AddUsings appends to the end of the using list, producing:

using TUnit.Core;
using System.Threading.Tasks;
using System.Threading;   // ← wrong position under alphabetical or System-first ordering

Projects using StyleCop, Roslyn's own IDE0210, or .editorconfig dotnet_separate_import_directive_groups will immediately re-flag this. The fix should insert the using in sorted position (or at minimum within the System.* group). The Roslyn workspace Formatter won't reorder usings.

Better approach — find the insertion index by comparing directive names:

var newUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(SystemThreadingNamespace))
    .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
var insertAt = compilationUnit.Usings
    .TakeWhile(u => string.Compare(u.Name?.ToString(), SystemThreadingNamespace, StringComparison.Ordinal) < 0)
    .Count();
return compilationUnit.WithUsings(compilationUnit.Usings.Insert(insertAt, newUsing));

Issue 3: Missing test coverage for edge cases

The three existing tests are good, but they all share the same method shape. Worth adding:

  1. Already has using System.Threading; — verify no duplicate is added.
  2. Method already has parameters — verify CancellationToken is appended after existing params, not prepended.
  3. Expression-bodied method — either verifies the action is not offered, or verifies the expression body is converted (depending on the fix chosen for Issue 1 above).

Minor: ParseTypeName for a known simple identifier

// Current:
.WithType(SyntaxFactory.ParseTypeName(CancellationTokenTypeName).WithTrailingTrivia(SyntaxFactory.Space))

// Slightly cleaner:
.WithType(SyntaxFactory.IdentifierName(CancellationTokenTypeName).WithTrailingTrivia(SyntaxFactory.Space))

ParseTypeName invokes the full parser; IdentifierName is direct. Minor, but since the type name is a known simple identifier this avoids unnecessary work.


What's good

  • Three-action design maps cleanly to real user intent — this is exactly what the IDE should offer.
  • GetFixAllProvider returning BatchFixer is correct for this fix shape.
  • Formatter.Annotation + Formatter.FormatAsync is the right pattern to let the workspace normalise indentation.
  • EnsureSystemThreadingUsing is clean and avoids adding duplicates for the common case.
  • Tests use the correct CSharpCodeFixVerifier pattern with equivalenceKey to target specific actions.

The expression-body silent-skip (Issue 1) and the using sort order (Issue 2) are both user-visible bugs worth fixing before this lands.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 18, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 33 complexity

Metric Results
Complexity 33

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

…n code-fix

Addresses review on #5621:

- Do not register body-modifying actions (ThrowIfCancellationRequested
  and Discard) on expression-bodied methods — they would silently
  no-op, which is worse than not offering them.
- Insert `using System.Threading;` in sorted position within the
  existing System.* using group (instead of appending at the very
  end), so StyleCop / IDE0210 / dotnet_separate_import_directive_groups
  don't re-flag the output. Appends only when no System.* group
  exists.
- Replace ParseTypeName("CancellationToken") with IdentifierName —
  the former invokes the full parser unnecessarily for a known simple
  identifier.

New tests:
- Does_Not_Duplicate_Existing_System_Threading_Using
- Appends_CancellationToken_After_Existing_Parameters
- Expression_Bodied_Method_Only_Offers_Bare_Parameter_Action
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review: All Previous Issues Addressed ✅

All four concerns from the first review have been resolved — this is ready to merge.

Previous Issues — Status

Issue 1 (Expression-bodied methods silently ignored) — Fixed ✅
Body-modifying code fixes are now only registered when method.Body is not null, so expression-bodied methods only see the bare-parameter action. Correct choice (Option A) and now covered by Expression_Bodied_Method_Only_Offers_Bare_Parameter_Action.

Issue 2 (Using directive appended unsorted) — Fixed ✅
EnsureSystemThreadingUsing now performs a sorted insertion within the System.* group. The logic is correct for the common cases.

Issue 3 (Missing test coverage) — Fixed ✅
Three new tests added: Does_Not_Duplicate_Existing_System_Threading_Using, Appends_CancellationToken_After_Existing_Parameters, and Expression_Bodied_Method_Only_Offers_Bare_Parameter_Action. All edge cases from the prior review are now exercised.

Minor (ParseTypeNameIdentifierName) — Fixed ✅
Uses SyntaxFactory.IdentifierName as suggested.


One Minor Edge Case to Be Aware Of

EnsureSystemThreadingUsing has an uncommon edge case: if non-System usings are interspersed between System.* usings (e.g. using System.Text;, using Z;, using System.Threading.Tasks;), the inserted System.Threading can land adjacent to the non-System using rather than grouped with the other System ones. Example result:

using System.Text;
using Z;
using System.Threading; // ← inserted between Z and System.Threading.Tasks
using System.Threading.Tasks;

This is a very contrived scenario (no real project organises usings this way), so it does not need to block the PR — just noting it for awareness if a future report comes in.


What's Well Done

  • Three-action design (bare / ThrowIfCancellationRequested / discard) maps exactly to real user intent.
  • Guard on method.Body is not null before registering body-modifying actions is clean and prevents confusing UX.
  • Formatter.Annotation + Formatter.FormatAsync round-trip is idiomatic Roslyn.
  • Test matrix is now comprehensive for a code-fix provider of this scope.

LGTM. 🟢

…on-System usings

Reviewer noted that when non-System usings sit between System.* usings,
the new System.Threading lands adjacent to a non-System using on one
side. Documented behaviour: it sorts into the System group such that
its nearer neighbour is still System.* (System.Threading.Tasks here).
Regression test prevents future drift.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: TUnit0015 Code-Fix Provider

This is a well-structured addition that follows the established patterns in the codebase. The three-action UX design is the right call — users have genuinely different intents (use the token, discard-silence it, or wire it themselves).

Good

  • Mirrors the shape of VirtualHookOverrideCodeFixProvider cleanly.
  • 7 test cases with good coverage: bare, throw, discard, no-dup-using, existing params, sorted-insertion, expression-bodied guard.
  • method.Body is not null guard for expression-bodied methods is correct and well-explained.
  • GetFixAllProvider() returning BatchFixer is the right default.
  • ConfigureAwait(false) throughout.

Issue: EnsureSystemThreadingUsing is blind to global using

// Only checks file-level usings:
foreach (var usingDirective in compilationUnit.Usings)

If the consuming project has global using System.Threading; (e.g., in a GlobalUsings.cs), the fix will redundantly add using System.Threading; to the file. The semantic model is the right tool here:

private static async Task<bool> IsSystemThreadingAlreadyImported(Document document, CancellationToken ct)
{
    var semanticModel = await document.GetSemanticModelAsync(ct).ConfigureAwait(false);
    if (semanticModel is null) return false;
    var ns = semanticModel.Compilation.GetTypeByMetadataName("System.Threading.CancellationToken");
    // Check if any using alias or global using makes CancellationToken accessible without qualification
    // Simpler: check SemanticModel's available symbols or rely on compilation's GetUsedNamespaces
    return false; // fall back to syntactic check if semantic unavailable
}

A lighter-weight workaround that avoids the perf cost of a full semantic model: scan for global using System.Threading in compilationUnit.Usings (they have GlobalKeyword set) in addition to the normal usings. This at least covers the same-file global using, though cross-file global usings would still require the semantic model.

Minor: hardcoded "\n" line ending

.WithTrailingTrivia(SyntaxFactory.ElasticEndOfLine("\n"))

On CRLF files the Formatter will likely normalise this anyway, but SyntaxFactory.ElasticLineFeed (or detecting the document's line ending via document.Project.Solution.Workspace.Options) would be more correct. Low risk in practice since Formatter.FormatAsync follows through.

Suggestion: consider offering the fix for the CancellationTokenMustBeLastParameter diagnostic too

The analyzer reports two separate diagnostics:

  • MissingTimeoutCancellationTokenAttributes — token absent (handled here)
  • CancellationTokenMustBeLastParameter — token present but not last (no fix exists)

A companion fix that reorders the existing parameter to last would complete the pair and use almost identical infrastructure. Not a blocker for this PR, but worth a follow-up issue.

Verdict

Approved. The global-using gap is a real correctness issue but narrow in practice (requires a project-wide global using System.Threading while also lacking a file-level one). Worth a quick follow-up, but it should not block merging.

@thomhurst thomhurst enabled auto-merge (squash) April 18, 2026 10:44
…cope

Cross-file `global using System.Threading;` (explicit or via SDK
ImplicitUsings) already makes CancellationToken resolvable. The code fix
previously added a redundant `using System.Threading;` in that case.
Check via SemanticModel.LookupSymbols at the method position so all
scopes (file-level, same-file global, cross-file global, implicit) are
covered uniformly.

Also replaced hardcoded ElasticEndOfLine("\n") with ElasticLineFeed
for a slightly cleaner trivia construction; Formatter normalises either
way.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Final Pass — All Previous Issues Addressed ✅

This is now in excellent shape. The fourth commit addresses the global-using gap raised in the third review, and the fix is well-chosen.

Previous Issues — Final Status

Issue Status
Expression-bodied method silent no-op ✅ Fixed (review 1)
Using directive appended unsorted ✅ Fixed (review 1)
Missing test coverage (dup-using, existing params, expression-bodied) ✅ Fixed (review 2)
ParseTypeNameIdentifierName ✅ Fixed (review 2)
EnsureSystemThreadingUsing blind to global using ✅ Fixed (this commit)
Hardcoded ElasticEndOfLine("\n") ✅ Fixed (this commit) — ElasticLineFeed used

What the Latest Commit Gets Right

Semantic scope check is the correct design. Using semanticModel.LookupSymbols(method.SpanStart, name: CancellationTokenTypeName) uniformly covers file-level usings, same-file global usings, cross-file global usings, and SDK ImplicitUsings — all via one call. The syntactic loop that remains inside EnsureSystemThreadingUsing now serves as a defence-in-depth fallback for when the semantic model is unavailable (null path returns false, so that branch still runs and the syntactic check prevents duplicates). That layering is correct.

New test is appropriately scoped. Does_Not_Add_Using_When_System_Threading_Is_Global_Using_In_Other_File exercises the cross-file case explicitly; the interspersed-namespace regression test from the previous commit locks in the sorted-insertion edge case.


One Observation (not a blocker)

EnsureSystemThreadingUsing uses SyntaxFactory.ParseName(SystemThreadingNamespace) for the using directive's name. For a dotted namespace this works correctly (produces a QualifiedNameSyntax), but it does invoke the full parser for a string that could be constructed directly:

// Current — invokes full parser:
SyntaxFactory.ParseName(SystemThreadingNamespace)

// Direct equivalent:
SyntaxFactory.QualifiedName(
    SyntaxFactory.IdentifierName(System),
    SyntaxFactory.IdentifierName(Threading))

This is purely a micro-consistency note (you already swapped ParseTypeNameIdentifierName for the parameter type) — the parser result is identical and FormatAsync normalises either way. Not worth a follow-up commit.


What's Well Done

  • Three-action UX (bare / throw / discard) maps exactly to real user intent.
  • method.Body is not null guard prevents confusing no-ops on expression-bodied methods.
  • Sorted insertion within System.* group is correct for the common cases and regression-tested.
  • Semantic scope check covers all import mechanisms with a single call.
  • 8 test cases across a well-chosen matrix; each exercises a distinct behaviour.
  • ConfigureAwait(false) throughout; GetFixAllProvider returns BatchFixer.

LGTM. 🟢

intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Apr 21, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to
1.37.10.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.37.10

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.10 -->

## What's Changed
### Other Changes
* docs(test-filters): add migration callout for --filter →
--treenode-filter by @​johnkattenhorn in
thomhurst/TUnit#5628
* fix: re-enable RPC tests and modernize harness (#​5540) by @​thomhurst
in thomhurst/TUnit#5632
* fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch
(#​5626) by @​JohnVerheij in
thomhurst/TUnit#5631
* ci: use setup-dotnet built-in NuGet cache by @​thomhurst in
thomhurst/TUnit#5635
* feat(playwright): propagate W3C trace context into browser contexts by
@​thomhurst in thomhurst/TUnit#5636
### Dependencies
* chore(deps): update tunit to 1.37.0 by @​thomhurst in
thomhurst/TUnit#5625

## New Contributors
* @​johnkattenhorn made their first contribution in
thomhurst/TUnit#5628
* @​JohnVerheij made their first contribution in
thomhurst/TUnit#5631

**Full Changelog**:
thomhurst/TUnit@v1.37.0...v1.37.10

## 1.37.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.37.0 -->

## What's Changed
### Other Changes
* fix: stabilize flaky tests across analyzer, OTel, and engine suites by
@​thomhurst in thomhurst/TUnit#5609
* perf: engine hot-path allocation wins (#​5528 B) by @​thomhurst in
thomhurst/TUnit#5610
* feat(analyzers): detect collection IsEqualTo reference equality
(TUnitAssertions0016) by @​thomhurst in
thomhurst/TUnit#5615
* perf: consolidate test dedup + hook register guards (#​5528 A) by
@​thomhurst in thomhurst/TUnit#5612
* perf: engine discovery/init path cleanup (#​5528 C) by @​thomhurst in
thomhurst/TUnit#5611
* fix(assertions): render collection contents in IsEqualTo failure
messages (#​5613 B) by @​thomhurst in
thomhurst/TUnit#5619
* feat(analyzers): code-fix for TUnit0015 to insert CancellationToken
(#​5613 D) by @​thomhurst in
thomhurst/TUnit#5621
* fix(assertions): add Task reference forwarders on
AsyncDelegateAssertion by @​thomhurst in
thomhurst/TUnit#5618
* test(asp-net): fix race in FactoryMethodOrderTests by @​thomhurst in
thomhurst/TUnit#5623
* feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource]
(#​5613 C) by @​thomhurst in
thomhurst/TUnit#5620
* fix(pipeline): isolate AOT publish outputs to stop clobbering pack
DLLs (#​5622) by @​thomhurst in
thomhurst/TUnit#5624
### Dependencies
* chore(deps): update tunit to 1.36.0 by @​thomhurst in
thomhurst/TUnit#5608
* chore(deps): update modularpipelines to 3.2.8 by @​thomhurst in
thomhurst/TUnit#5614


**Full Changelog**:
thomhurst/TUnit@v1.36.0...v1.37.0

## 1.36.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.36.0 -->

## What's Changed
### Other Changes
* fix: don't render test's own trace as Linked Trace in HTML report by
@​thomhurst in thomhurst/TUnit#5580
* fix(docs): benchmark index links 404 by @​thomhurst in
thomhurst/TUnit#5587
* docs: replace repeated benchmark link suffix with per-test
descriptions by @​thomhurst in
thomhurst/TUnit#5588
* docs: clearer distributed tracing setup and troubleshooting by
@​thomhurst in thomhurst/TUnit#5597
* fix: auto-suppress ExecutionContext flow for hosted services (#​5589)
by @​thomhurst in thomhurst/TUnit#5598
* feat: auto-align DistributedContextPropagator to W3C by @​thomhurst in
thomhurst/TUnit#5599
* feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory
inheritance by @​thomhurst in
thomhurst/TUnit#5601
* feat: auto-propagate test trace context through IHttpClientFactory by
@​thomhurst in thomhurst/TUnit#5603
* feat: TUnit.OpenTelemetry zero-config tracing package by @​thomhurst
in thomhurst/TUnit#5602
* fix: restore [Obsolete] members removed in v1.27 (#​5539) by
@​thomhurst in thomhurst/TUnit#5605
* feat: generalize OTLP receiver for use outside TUnit.Aspire by
@​thomhurst in thomhurst/TUnit#5606
* feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by
@​thomhurst in thomhurst/TUnit#5607
### Dependencies
* chore(deps): update tunit to 1.35.2 by @​thomhurst in
thomhurst/TUnit#5581
* chore(deps): update dependency typescript to ~6.0.3 by @​thomhurst in
thomhurst/TUnit#5582
* chore(deps): update dependency coverlet.collector to v10 by
@​thomhurst in thomhurst/TUnit#5600


**Full Changelog**:
thomhurst/TUnit@v1.35.2...v1.36.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.35.2...v1.37.10).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.35.2&new-version=1.37.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant