feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D)#5621
feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D)#5621
Conversation
…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.
There was a problem hiding this comment.
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 normalOption 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 orderingProjects 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:
- Already has
using System.Threading;— verify no duplicate is added. - Method already has parameters — verify
CancellationTokenis appended after existing params, not prepended. - 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.
GetFixAllProviderreturningBatchFixeris correct for this fix shape.Formatter.Annotation+Formatter.FormatAsyncis the right pattern to let the workspace normalise indentation.EnsureSystemThreadingUsingis clean and avoids adding duplicates for the common case.- Tests use the correct
CSharpCodeFixVerifierpattern withequivalenceKeyto 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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 33 |
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
There was a problem hiding this comment.
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 (ParseTypeName → IdentifierName) — 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 nullbefore registering body-modifying actions is clean and prevents confusing UX. Formatter.Annotation+Formatter.FormatAsyncround-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.
There was a problem hiding this comment.
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
VirtualHookOverrideCodeFixProvidercleanly. - 7 test cases with good coverage: bare, throw, discard, no-dup-using, existing params, sorted-insertion, expression-bodied guard.
method.Body is not nullguard for expression-bodied methods is correct and well-explained.GetFixAllProvider()returningBatchFixeris 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.
…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.
There was a problem hiding this comment.
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) |
ParseTypeName → IdentifierName |
✅ 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 ParseTypeName → IdentifierName 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 nullguard 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;GetFixAllProviderreturnsBatchFixer.
LGTM. 🟢
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> [](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>
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:CancellationTokenparameter — bare parameter, user wires it up.CancellationTokenparameter withThrowIfCancellationRequested— prepends the call so the test actually observes cancellation.CancellationTokenparameter as discard — prepends_ = cancellationToken;to silence IDE0060 / CA1801 underTreatWarningsAsErrors.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 parameterAdds_CancellationToken_Parameter_With_ThrowIfCancellationRequested— prepends the callAdds_CancellationToken_Parameter_As_Discard— prepends_ = cancellationToken;