Repository navigation
CMTK0003 misses dropped async assertions through Task wrappers and branches #152
Description
Problem
The 2.3.0 CMTK0003 guard for unawaited asynchronous assertions misses ordinary statement shapes. A failing assertion can therefore leave a faulted task unobserved and a test can pass without the promised diagnostic.
Reproduced against the real built analyzer and Testing assemblies at release tip 99c6032d9364a5f94b71ed8f7dce1cdada200870, by running Compilation.WithAnalyzers on compilable consumer snippets. No compiler errors occur in these examples.
public static void Check(Task<Option<int>> pending)
{
pending.ShouldBeNone(); // CMTK0003, as expected
pending.ShouldBeNone().ConfigureAwait(false); // no diagnostic
pending.ShouldBeNone().WaitAsync(TimeSpan.FromSeconds(1)); // no diagnostic
}ShouldBeNone returns a non-generic Task. The same gap affects command ShouldBeSuccess, expected-value ShouldBeError, ShouldBeAbsent and ShouldBeNull, and both Task and ValueTask input overloads. Both ConfigureAwait overloads and both timeout/token WaitAsync forms were reproduced. Generic-result assertions such as ShouldBeSome().ConfigureAwait(false) are correctly diagnosed when called directly.
There is also a branch shape gap even when the assertion returns Task:
(choose ? first.ShouldBeSome() : second.ShouldBeSome()).ConfigureAwait(false);
(otherIntTask ?? first.ShouldBeSome()).ConfigureAwait(false);
(choose switch
{
true => first.ShouldBeSome(),
false => second.ShouldBeSome()
}).ConfigureAwait(false);Each compiles with no CMTK0003. The corresponding awaited expressions and explicit _ = discards correctly remain silent. A runtime check with Some(42) confirmed ShouldBeNone returns a faulted Task without throwing synchronously; observing that task throws TypeKitAssertionException.
Cause
Tasks.PassesOn in IgnoredOutcomeAnalyzer.cs unwraps ConfigureAwait/WaitAsync on Task and ValueTask only, leaving non-generic Task wrappers opaque. Separately, the new IsPendingAssertion check in Analyze only examines the one outer passedOn invocation, outside the Producers loop that already enumerates conditional/coalesce/switch branches.
Expected
Apply the pending-assertion check to every dropped producer, and follow non-generic Task pass-throughs for these assertions without changing the existing outcome and held-task rules. Cover the input class across assertion return types, Task/ValueTask inputs, direct/conditional/coalesce/switch forms and ConfigureAwait/WaitAsync wrappers, with awaited/held/returned/discarded controls.
This guard is new to 2.3.0. Fixing its reach before shipping avoids leaving its newly promised behavior incomplete when the minor-version Error-severity compatibility boundary freezes again.
Found by the maintainer-requested pre-release review.
Activity
CaffeinatedCoder commentedon Oct 29, 2026
Fix on
fix/cmtk0003-wrapped-assertions(stacked on #156; its PR opens once #156 merges). Cause as stated:Tasks.PassesOnnow hands on a plainTask'sConfigureAwaitandWaitAsync, and the pending-assertion check runs inside theProducersloop. New catalogueVariants/DroppedTaskShapes(25 calls x 9 pass-throughs x 6 forms x 6 uses, 6,831 statements) inDroppedTaskVariantTests; no sibling beyond the two forms here. A neighbour outside this issue,Task.WhenAllof dropped assertions, is #157.The rule that lets this reach ship in 2.3.0 is decision 118 (
design/adr/0118-an-error-may-learn-a-form-only-new-api-can-write.md); the fix commitcc1980bcalls it 117, its number before #151 took 0117.