Repository navigation
fix: CMTK0003 reports assertions lost in nested combinators and through catches that cannot keep them - #172
Merged
Conversation
…gh catches that cannot keep them A Task.WhenAny inside a Task.WhenAll or a Task.Run completes with the faulted assertion as its value, so awaiting the outer task saw no failure and the rule said nothing (#165). HandsOn now returns how deep the root holds the failure instead of a bool: 0 where its task faults with it (WhenAll, Run), one more for each task that completes with it as a value (WhenAny, WhenEach, an async lambda that returns it), one fewer for each await that unwraps one. DroppedAssertion treats an awaited, waited-for or converted combinator as observing only a failure at depth 0, so the same path reports await Task.WhenAll(Task.WhenAny(...)), await Task.Run(() => Task.WhenAny(...)), their Wait() and plain-Task forms, at any depth, and keeps await await Task.WhenAny(...) silent. Task.WaitAll of a WhenAny was already reported once, at the WhenAny's conversion to a plain Task. A catch around an awaited assertion in an async lambda counted as observing the failure whatever it did (#166). EnclosingAsyncFunction now stops only at a catch that keeps it: the await is in its try block, its type is or is a base of TypeKitAssertionException (resolved in the Testing assembly by its metadata name), and its handler can complete (control flow: endpoint reachable, or a return/break leaves it). A constant false filter leaves the handler unreachable, so control flow already reads it as never completing: an explicit filter check, seeded, was caught by nothing, and is not kept. catch { throw; }, a log and throw;, throw new ..., catch (OperationCanceledException) and when (false) now report. A filter that is no constant counts as keeping the failure: an Error reports only a failure lost whatever the filter answers. Both forms are written only with 2.3.0's Testing API, so decision 0118 covers them; no 2.2.0 build reports more. Input class: AIS, an assertion's task lost by a statement. Variants/DroppedTaskShapes gains 42 forms, all assertion-only: every combinator inside each other, three and four deep, arrays, Selects and async lambdas of WhenAny that return it, await it once, twice and three times; every catch kind (try/finally, rethrow, log and rethrow, throw another, unrelated type, false filter, nested tries, await in a handler or a finally) against silent controls (catch all, catch the assertion's exception, true filter, opaque filter, conditional rethrow, return, a later clause, nested tries that keep it); and Task.WaitAll/WaitAny of nested combinators. 1837 cases, from 830. IgnoredOutcomeAnalyzerTests holds the issues' snippets and a look-alike TypeKitAssertionException of another type. Seeded 11 ways, each caught by DroppedTaskVariantTests (failing cases): the producer's depth (192), WhenAny's depth (168), an async lambda's return (48), await unwrapping (48), a catch's try block only (48), the exception type (48), the rethrow check (144), its exit points (24), the exception's metadata name (48), the base chain (240), and an unreachable handler read as completing, which turns the when (false) rows red (24). README (CMTK0003) says so; claims T03-a-nested-when-any-is-reported, T03-a-catch-that-cannot-keep-the-failure-is-reported and T03-an-unknown-filter-counts-as-catching quote it. Closes #165 Closes #166 Defect-Class: AIS Found-By: OR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #165
Closes #166
Problem
CMTK0003 missed two ways an assertion of
CodoMetis.TypeKit.Testingcan fail without failing the test. Both were reproduced on9878a6c.Task.WhenAnyinside aTask.WhenAllor aTask.Runfinishes with the failed assertion's task as its value. Soawait Task.WhenAll(Task.WhenAny(a.ShouldBeNone(), b.ShouldBeNone()))andawait Task.Run(() => Task.WhenAny(...))pass while both assertions fail. The same loss through.Wait()and through an implicit plain-Taskreturn was silent too. The rule saw that the outer combinator faults with the tasks it awaits, and stopped there.catcharound an awaited assertion in anasynclambda counted as observing the failure. That includedcatch { throw; },catch (OperationCanceledException) { }andcatch (Exception) when (false) { }, none of which keep it.Change
IgnoredOutcomeAnalyzer.HandsOnnow returns a depth, not a bool. The depth is 0 where the root's task faults with the failure (WhenAll,Run). It goes up by one for each task that completes with the failure as a value instead (WhenAny,WhenEach, anasynclambda that returns the task). It goes down by one for eachawaitthat unwraps such a task.DroppedAssertioncounts an awaited, waited-for or converted combinator as observing a failure only at depth 0. This is one shared path, with no special case, so it holds at any depth.await await Task.WhenAny(...)stays silent.Task.WaitAll(Task.WhenAny(...))was already reported once, where theWhenAnyconverts to a plainTask, soAnalyzeWaitis unchanged.EnclosingAsyncFunctionstops only at a catch that keeps the failure (Keeps), which takes all of:awaitis in the catch'stryblock, not in a handler or afinally;TypeKitAssertionExceptionor one of its bases. The type is resolved in the Testing assembly by its metadata name (TypeKitSymbols.AssertionException), so a look-alike of the same name elsewhere doesn't count;returnorbreakleaves it.falsefilter leaves the handler unreachable, so control flow already reads it as never completing. A filter that isn't a constant, such aswhen (Expected(e)), counts as keeping the failure, because an Error reports only a failure that is lost whatever the filter answers. This is written down in theKeepsdoc comment, indesign/analyzers.mdand in the analyzer README.Resulttasks go throughDroppedOutcome, which this PR does not touch.T03-a-nested-when-any-is-reported,T03-a-catch-that-cannot-keep-the-failure-is-reportedandT03-an-unknown-filter-counts-as-catching;design/analyzers.mdis updated, with the case count going from 830 to 1837;CHANGELOG.mdis widened.Tests
Catalogue:
Variants/DroppedTaskShapeshas 42 new forms, all assertion-only:WhenAll,WhenAnyandRun, each inside each other, three and four deep. Arrays and Selects ofWhenAny. Async lambdas that return aWhenAnyor await one once, twice or three times.falsefilter, nested rethrowing tries, and an await in a handler or in afinally.TypeKitAssertionException, atruefilter, a filter that isn't a constant, a conditional rethrow, areturnfrom the catch, a later clause that keeps it, and nested tries that keep it.Task.WaitAllandTask.WaitAnyof nested combinators.The existing silent controls (
_ =, held, returned, awaited forms that observe, a catch that swallows) are unchanged and still silent.IgnoredOutcomeAnalyzerTestsholds both issues' snippets,await await Task.WhenAny(...), and a look-alikeTypeKitAssertionExceptionof another type.11 guards seeded, each caught by
DroppedTaskVariantTests(number of failing cases):WhenAny's depthawaitunwrappingwhen (false)rows red)One more seed was not caught: an explicit constant-
false-filter check passed the suite with the check removed. It was redundant with the control-flow check, so it is removed, and thewhen (false)rows stay as what the unreachable-handler seed shows held. Every seed was reverted by restoring the file, checked identical by diff, and touched.Local runs: Analyzers 3689 passed, Conventions 2002 passed, Oracles 51 passed,
docs-query checkclean.weaving-review (§3, §8):
catch (TypeKitAssertionException)silent rows and the look-alike test, and shown by the metadata-name seed.Environment.FailFastandAssert.Failin a handler (end point reachable, so silent),throw new Wrapped(e)(reported, since the task still faults) and an opaque filter (silent).awaitat the statement's root (await await Task.WhenAny(Task.WhenAny(...))), a local function between the await and the lambda,Task.Factory.StartNewandContinueWith, and a type-pattern filter, which counts as catching.🤖 Generated with Claude Code