[PLAN-0054] Compono.TestDoubles: Overload-Safe Argument Matching and Sequential Responses¶
Status: Done. Phase 1 (sequential/call-count-based responses) and Phase 2 (overload-safe argument matching) are both fully implemented and validated. Full solution sweep: 820/820 on net10.0 (Compono.Generators.Tests 274/274 on both net10.0 and net11.0). A real Native AOT publish-and-run covering both phases' scenarios passed. Not yet done: the dynamodb-distributed-lock dogfood gate via scripts/dogfood-validate.sh - a separate, later gate per explicit instruction, not part of this plan's own scope.
Implements: ADR-0044 Amendment 21, ADR-0054
Context¶
dynamodb-distributed-lock dogfooding surfaced two real Compono.TestDoubles capability gaps against Amazon.DynamoDBv2.IAmazonDynamoDB — an overloaded member needing argument-content matching, and three retry-loop tests needing sequential (fail, fail, succeed) responses the SUT consumes internally, with no chance for the test to reconfigure between calls. Both are recorded as Accepted pre-1.0 requirements (API still Proposed) in the two ADRs above, per explicit product direction — this is not a "should we build this" plan, only a "how."
This plan spans both ADRs in one document (per docs/plans/README.md's multi-ADR rule) because they were evidenced together, reviewed together, and their generated-code paths intersect at exactly one place (an overloaded member with a sequenced entry), which is easier to reason about as one plan than two that have to cross-reference each other's assumptions.
Out of scope for this plan, per explicit instruction: - ADR-0053 (invocation-aware callbacks) — a different, unrelated capability. - The final consumer dogfooding pass against dynamodb-distributed-lock — a separate, later gate using scripts/dogfood-validate.sh, run only once both phases below are merged and released to the local dogfood feed.
Spike findings¶
Real investigation/spike work already happened across this session (uncommitted, not on a branch) before and after this plan was first written, per the instruction to preserve it as evidence. Distinguishing what it proved from what it changed. Two rounds of API-safety spikes have now happened; this section reflects the second (corrected) round — see each ADR's own "Links"/revision history for the first round's now-superseded findings, kept there rather than deleted.
Confirms ADR assumptions¶
- ADR-0054's "entry owns one response representation, no new parallel type" question is answered: no new type needed.
ReturnConfig<T>(src/Compono/ReturnConfig.cs) already IS the entry-owned response representation for both the plain single-field dispatch shape and each ADR-0050Entry.Configfield — extending it in place (two new fields:SequenceOutcome<T>[]? Sequence,int SequenceOrdinal; one new method:NextSequenceOutcome()) required zero changes toEntry's own shape,TestDoubleEmitter.cs, or the ADR-0050 entry-append/lock machinery. A parallelSequenceReturnConfig<T>was never actually necessary. - Ordinal claiming is thread-safe with no lock, using the exact same
Interlocked.IncrementprimitiveRecordCall()/CallCountalready establishes as this codebase's chosen concurrency approach for this class of state — no new pattern introduced. Verified with a 500-iterationParallel.Forunit test and, separately, a real end-to-end generator test. - Call recording is independent of response consumption by construction, not by new design —
RecordCall()already runs unconditionally before anyHasConfigured*/dispatch branching in every dispatch shape, sequence-aware or not. - Reconfiguration-resets-ordinal composes with the existing
Returns/Throwslast-configuration-wins contract — extendedReturns/Throwsto also clearSequence/SequenceOrdinal, andReturnsSequenceto clearValue/Exception/reset the ordinal, so exactly one of the three states is ever live, matchingReturnConfigBuilder<T>'s pre-existing documented contract. - The declared-return-type contract is preserved, not specially unwrapped for sequences — per explicit product direction,
ReturnsSequence(params SequenceOutcome<T>[])uses the sameTReturns(T value)already uses (aTask<PutItemResponse>value for aTask<PutItemResponse>-returning member, not a barePutItemResponse). No second mental model. - AOT/trimming: no reflection introduced anywhere, in either phase's design — every mechanism spiked (implicit conversions,
Interlockedordinal claiming, ordinary generic method overloading) is plain compile-time-resolved code. A realdotnet publish -p:PublishAot=true+ run againstCompono.TestDoubles.AotSmokeTest(extended with a sequenced scenario) succeeded for Phase 1's mechanism (modulo theSequenceOutcome<T>correction below, not yet re-run against the corrected shape).
Requires an ADR amendment / another design decision — resolved this round, recorded in both ADRs¶
Capability 1 (overload-safe matching) — the API shape changed twice, now settled on real compiler evidence:
- (First round, superseded) A nested
.For<T1, ...>()call under a nullary member-name property — rejected,CS0102(property/method name collision with the existing discriminator-only method). - (Second round, this session, rejected) A flattened, purely-generic overload of the member name (
Configure().PutItemAsync<T1, T2>(), zero real parameters). Compiles and avoids (1)'s collision, but is a real type-safety hole, confirmed by compile-and-run:self.PutItemAsync<int, System.DateTime>()compiles cleanly and silently selects the real(PutItemRequest, CancellationToken)overload — the type parameters are pure, completely unenforced arity witnesses. Rejected as misleading regardless of whether a careful caller would ever actually pass the wrong types. - (Recommended, this session, confirmed safe by compile-and-run against every family that made Amendment 18's original shape ambiguous): a separate, matching-specific member name taking real
Match<T>parameters directly — e.g.Configure().DeleteItemAsyncMatching(Match<DeleteItemRequest> request, Match<CancellationToken> ct). Verified against numeric widening, base/derived, array-vs-IEnumerable<T>, a 3-member same-arity overload set with an overlapping type hierarchy, and the realIAmazonDynamoDB-shaped ⅔/4-arity family — every case resolves correctly when the caller supplies an already-Match<T>-typed argument (Match.Any<T>()/Match.Is<T>(predicate)). Same-arity overloads are fully supported by this shape — a materially better outcome than shape (2) or the original.For<T1,...>()sketch, both of which would have needed a same-arity exclusion/diagnostic. Literal shorthand does not carry over when it's genuinely ambiguous — confirmed by a realCS0121, and corrected during Phase 2 implementation (this claim was originally stated too broadly): a bare literal only reproduces the original ambiguity when two sibling overloads share the same<Member>Matchingalias name AND the literal implicitly converts to both of theirMatch<T>parameter types (numeric widening -Get(int)/Get(long)sharingGetMatching, called asGetMatching(5)). When the alias group's sibling overloads have unrelated parameter types (no shared implicit conversion target - e.g.DeleteItemAsync(DeleteItemRequest, CancellationToken)vsDeleteItemAsync(string, CancellationToken), whose aliases takeMatch<DeleteItemRequest>vsMatch<string>), a bare literal compiles fine and becomes an ordinary equality matcher viaMatch<T>'s own implicit conversion (Amendment 18), exactly like the pre-existing non-overloaded matching-eligible surface - both shapes proven by realGeneratorTestHelpers.CompileAndExecuteevidence inTestDoubleOverloadMatchingExecutionTests.cs. - No
CMP0038diagnostic is needed — the same-arity exclusion it would have documented doesn't exist under this shape. This removes an entire diagnostic + its test coverage from Phase 2's scope relative to the first-round plan. - Action taken: ADR-0044 Amendment 21 has been corrected in place to this shape (superseded text preserved via the Amendment mechanic, not deleted) — see that Amendment's own body for the full reasoning and compiler-evidence table.
-
Overloaded members currently bypass ADR-0050's entries/matching machinery entirely (confirmed by reading
TestDoubleAnalyzer.cs'sisEligibleForMatchingcomputation directly:hasConfigurationSurface && !isOverloaded && ...— a single unconditional exclusion) and the template (TestDouble.scriban'sis_overloadedbranch: one plainReturnConfig<T>field, noEntry/Entriesclass, no lock, no call log). Implementing the new matching-specific member name requires adding an entries-list dispatch path attached to that real overload — traced to the exact two call sites (TestDoubleAnalyzer.cs's eligibility computation,TestDoubleEmitter.cs/TestDouble.scriban's emission) that need to change; see Phase 2 below. -
Architectural correction (this round, found in review, not by compiler spike): the "independent surface, no fallthrough needed" framing above was wrong.
DeleteItemAsyncMatching(...)is a name that exists only on the generatedConfigure()/Verify()API — the SUT never calls it. Production code still calls the real interface member,IAmazonDynamoDB.DeleteItemAsync(DeleteItemRequest, CancellationToken). IfDeleteItemAsyncMatchingbacked its own, separately-dispatched generated method, aConfigure().DeleteItemAsyncMatching(...)call would never be consulted by the real dispatch at all — dead configuration. The matching-specific member name must be a pureConfigure()/Verify()-side alias that attaches its state to the same real overload's existing entries/call-recording state, not a second runtime-dispatched member. - Corrected shape: unify, don't parallel. An overload that meets the (structural, arity-independent) eligibility conditions gets promoted, for that overload specifically, to the exact
Entry/Entries/call-log/lock shape ADR-0050 already emits for a non-overloaded matching-eligible member — reusingEntryClassName/EntriesFieldNamekeyed off that overload's ownFieldName, no new naming scheme. The existing discriminator-onlyConfigure(realArgs)method's implementation changes (not its signature, not its observable behavior) from "write directly to a singleReturnConfig<T>field" to "append an always-matchingEntry" — this is not a new idea, it is the exact migration ADR-0050 already performed for a non-overloaded member's own "compatibility" zero-argumentConfigure()(seeTestDouble.scriban's own comment on that overload: "under multi-entry, this no longer needs to null out prior matchers to reproduce 'last wins' — it just appends its own new, all-null-matcher (always-matching) entry"). The newDeleteItemAsyncMatching(Match<T1>, ...)method appends a real-matcherEntryto the same list. Dispatch for the realDeleteItemAsync(DeleteItemRequest, CancellationToken)explicit interface implementation becomes the same reverse-scan-under-lock ADR-0050 already uses — the discriminator-registered (always-matching) entry and any.Matching()-registered (real-matcher) entries live in one ordered list, so "a more specific match wins if registered after the broad default, otherwise fall through to the broad default" is the existing last-registered-wins scan, not new fallback logic. Discriminator-onlyVerify()also migrates the same way ADR-0050's own compatibility-Verify()already did: fromfield.ConfiguredCallCountto the call log'sCount(an unfiltered read over the same log the new filteredVerify().DeleteItemAsyncMatching(...)scans) — both surfaces read from one shared call log, so a call is counted by both consistently, automatically. - Consequence for the "byte-identical snapshot" acceptance criterion from the first-round plan: wrong, corrected below. Every overload meeting the (now arity-independent) eligibility conditions gets regenerated to the entries/call-log shape unconditionally — structurally, the same way a non-overloaded member already does, whether or not any test in the compilation ever actually calls
.Matching()against it. Generated code for such an overload will change shape. What stays true, and is the real invariant to test: observable behavior for a consumer who only ever calls the existing discriminator-onlyConfigure(realArgs)/Verify(realArgs)is unchanged — same inputs, same outputs, same call counts — proven by execution tests, not by snapshot diffing. - The invariant to hold onto, stated explicitly (per review): the matching-specific member name is only a
Configure()/Verify()-side API disambiguator (needed because C# can't otherwise name "the real-argument overload-selecting call" and "the real-Match<T>-parameter call" the same thing without recreating Amendment 18's own ambiguity) — the matching state (entries, call log, lock) belongs to, and is keyed by, the real interface overload, never the API name itself. Overload identity (today's existingFieldName/discriminator-suffix mechanism, unchanged) remains part of that key, so sibling overloads of the same member name never share entries/call-log state.
Capability 2 (sequential responses) — SequenceOutcome<T>'s conversion design is corrected:
- The dual-implicit-conversion shape (
SequenceOutcome<T>converting implicitly from bothTandException) is rejected, confirmed unsafe by real compile-and-run, not by inspection. ForT = InvalidOperationException,new InvalidOperationException(...)compiles unambiguously but resolves to the value conversion (IsException = false) — silently the opposite of what a reader would likely expect. ForT = object, it resolves to the exception conversion, and there is no way to express "return this exception as boxed data" for such aTat all. Both are real, legitimate return shapes a real interface member could have; an API whose meaning depends on which of two equally-plausible readings C#'s betterness rules silently prefer is unacceptable, independent of whether it happens to compile. - Fix, confirmed safe by real compile-and-run across every problematic
T(Exception,InvalidOperationException,object,Exception?) plusnull: drop the implicitException → SequenceOutcome<T>conversion. Keep the single implicitT → SequenceOutcome<T>conversion (safe by construction, nothing competes with it). Add an explicit factory,Compono.SequenceOutcome.Throw(Exception exception), returning a small non-generic marker type (SequenceOutcome.ThrownOutcome) with its own implicit conversion toSequenceOutcome<T>for everyT— a distinct, non-generic source type can never collide with theT-conversion regardless of whatTis. No explicit type argument is needed anywhere;Tis still inferred from the surroundingReturnConfigBuilder<T>/params-array context exactly as before. - Action taken: ADR-0054 has been corrected in place with the full compiler-evidence table and the corrected type sketch.
- Action needed in code (Phase 1, not yet done):
src/Compono/SequenceOutcome.cs/ReturnConfigBuilder.cscurrently implement the rejected dual-conversion shape (shipped as spike code before this round of investigation) — must be corrected before Phase 1 is considered done. Every existing example (in this plan, in the AOT smoke test, in the generator/unit tests) that passes a bareExceptiondirectly toReturnsSequence(...)must be updated toSequenceOutcome.Throw(...). - A default-struct hazard in
SequenceOutcome.ThrownOutcome, flagged in review, not yet spiked — must be closed before the marker type ships.ThrownOutcomeis a publicstruct;default(SequenceOutcome.ThrownOutcome)(ordefault(SequenceOutcome<T>.SomeField)wherever one is stored) is always constructible regardless of constructor accessibility, and bypassesSequenceOutcome.Throw(...)'s ownArgumentNullException.ThrowIfNull— producing aThrownOutcomewhose internalExceptionfield isnull. If that default value is ever implicitly converted toSequenceOutcome<T>, the result is a "configured to thrownull" outcome that only fails, confusingly, wheneverNextSequenceOutcome()/dispatch eventually reaches that array index — a deferred, hard-to-trace failure, not a clear one at the point the mistake was actually made. Required fix:SequenceOutcome<T>'s implicit conversion fromThrownOutcomemust itself null-checkthrown.Exceptionand throw immediately (anArgumentException, matching this codebase's existingReturnsSequence's own empty-array guard) at the conversion call site — the smallest guard that turns a deferred, confusing failure into an immediate, clear one. Needs a unit test (SequenceOutcome<T> x = default(SequenceOutcome.ThrownOutcome);throws immediately) before Phase 1 is done.
Implementation details that don't affect the public design¶
- The AOT smoke-test scenario I added initially had a test-authoring bug (reused a
gatewaydouble instance whoseSend(string)overload had already recorded an unrelated call earlier in the same file —Verify().Send("sequenced").Exactly(3)failed with4, since overloaded-memberVerify()is an unfiltered per-overload count, not filtered by the literal argument's value). Not a generator/runtime bug — a freshcomposer.Create<IGateway>()instance per independent scenario fixes it. Worth remembering as a real trap for Phase 2's own AOT scenario too (multiple independent test scenarios must not share one double instance if they assert onVerify()counts). ReturnConfigSequenceTests.cs's empty-sequence andReturnConfigBuilder<T>tests needed to avoid capturing aref struct/ref-parameter local inside a lambda (CS8175/CS1628) — a plain try/catch, notShould().Throwon a captured builder, is the working pattern for aref structAPI under this test style.
Public API delta¶
Representative C# after both phases ship, corrected per this round's spikes:
// 1. Overload-safe argument matching (Phase 2) - the real evidenced IAmazonDynamoDB case. A
// separate, matching-specific member name - NOT the existing discriminator-only DeleteItemAsync.
dynamoDb.Configure().DeleteItemAsyncMatching(
Match.Is<DeleteItemRequest>(r => r.ConditionExpression == expected),
Match.Any<CancellationToken>())
.Returns(Task.FromResult(response));
dynamoDb.Verify().DeleteItemAsyncMatching(
Match.Is<DeleteItemRequest>(r => r.ConditionExpression == expected),
Match.Any<CancellationToken>())
.Once();
// The existing discriminator-only surface is untouched and still works, unfiltered:
dynamoDb.Configure().DeleteItemAsync(new DeleteItemRequest(), CancellationToken.None)
.Returns(Task.FromResult(response));
// 2. A normal (value-only) sequence (Phase 1) - unaffected by the SequenceOutcome<T> correction.
someDouble.Configure().TrySomething().ReturnsSequence(false, false, true);
// 3. Exception -> value.
dynamo.Configure().PutItemAsync(new PutItemRequest(), CancellationToken.None)
.ReturnsSequence(
SequenceOutcome.Throw(new ConditionalCheckFailedException("lock exists")),
Task.FromResult(new PutItemResponse()));
// 4. Exception -> exception -> value (the real evidenced retry shape).
dynamo.Configure().PutItemAsync(new PutItemRequest(), CancellationToken.None)
.ReturnsSequence(
SequenceOutcome.Throw(new ConditionalCheckFailedException("lock exists")),
SequenceOutcome.Throw(new ConditionalCheckFailedException("lock exists")),
Task.FromResult(new PutItemResponse()));
// 5. Sequencing combined with ADR-0050 argument-distinguished entries - each entry owns its
// own independent sequence/ordinal.
repository.Configure().Withdraw(Match.Is<string>(id => id == "acct-1"), Match.Any<decimal>(), Match.Any<bool>())
.ReturnsSequence(false, true);
repository.Configure().Withdraw(Match.Is<string>(id => id == "acct-2"), Match.Any<decimal>(), Match.Any<bool>())
.ReturnsSequence(true, false);
New public surface introduced across both phases (corrected from the first-round sketch): - Compono.SequenceOutcome<T> — corrected: a single implicit conversion, from T only. No public constructor. - Compono.SequenceOutcome (new, non-generic static class) — Throw(Exception exception), returning Compono.SequenceOutcome.ThrownOutcome (a small marker type with its own implicit conversion to SequenceOutcome<T> for any T). - Compono.ReturnConfigBuilder<T>.ReturnsSequence(params SequenceOutcome<T>[]) (shipped in spike form, unaffected by the SequenceOutcome<T> correction). - Compono.ReturnConfig<T>.HasConfiguredSequence / NextSequenceOutcome() (shipped in spike form — public because generated dispatch code in the consumer's own assembly reads it, same reasoning as every other ReturnConfig<T> accessor). - A new generated (not core-library) Configure()/Verify() member name per overloaded member needing matching (illustrative convention: a Matching suffix, e.g. DeleteItemAsyncMatching — exact convention TBD at implementation time), taking real Match<T> parameters directly. This name is a configuration/verification-side alias only — it attaches its entries/call-log state to the same real overload the existing discriminator-only Configure()/Verify() methods already dispatch through; it is never itself an independently-invoked generated method. Phase 2, generated code only, no new core Compono/Compono.TestDoubles public type. - No new diagnostic code — CMP0038 is no longer needed (see "Spike findings" above); this is a reduction from the first-round plan, not an addition.
Scope¶
In scope: - Phase 1: sequential/call-count-based responses (ReturnConfig<T> extension, corrected SequenceOutcome<T>/SequenceOutcome.Throw(...) shape, template dispatch wiring, generator/runtime tests, AOT proof). - Phase 2: overload-safe argument matching via a new matching-specific member name, for every overloaded member needing it (no arity restriction — same-arity overloads are fully supported, see "Spike findings") — discovery/eligibility, emitter/template, generator/runtime tests, AOT proof.
Explicitly deferred (per ADR-0054's own scope note and this session's instructions): - Void/non-generic-Task/ValueTask sequence shapes beyond what Phase 1's implementation naturally covers (see Phase 1 Tasks — the AOT smoke test already exercises exception-only sequencing on a void member, which fell out for free; no further void-specific work is planned unless Phase 1 testing finds a real gap). - A member that is both generic and overloaded, needing the new matching-specific surface (real boundary, not yet spiked — see Phase 2 Tasks). - ref/out parameters interacting with the new matching-specific surface (real boundary, not yet spiked — see Phase 2 Tasks; in is already known-fine, no new behavior needed). - ADR-0053 invocation-aware callbacks. - The dynamodb-distributed-lock consumer dogfooding pass — separate gate, scripts/dogfood-validate.sh, after both phases are merged.
Phase 1: Sequential/call-count-based responses¶
Goal: ReturnConfigBuilder<T>.ReturnsSequence(...) works end-to-end through the real generator, for both dispatch shapes it can reach (plain single-field member, ADR-0050 entries-list member), using the corrected SequenceOutcome<T>/SequenceOutcome.Throw(...) shape, with real generator, unit, and Native-AOT proof.
Status: Done. SequenceOutcome<T> rewritten to the corrected shape (single implicit conversion from T; SequenceOutcome.Throw(Exception) → ThrownOutcome, with a null-guard on the ThrownOutcome → SequenceOutcome<T> conversion against default(ThrownOutcome)). All acceptance criteria below met.
Production changes¶
src/Compono/SequenceOutcome.cs— rewrite, not just extend:SequenceOutcome<T>keeps only the implicit conversion fromT; add the non-genericSequenceOutcomestatic class withThrow(Exception)→ThrownOutcome, andSequenceOutcome<T>'s second implicit conversion is fromThrownOutcome, notException.src/Compono/ReturnConfig.cs— unaffected by theSequenceOutcome<T>correction (already spiked:Sequence/SequenceOrdinalfields,HasConfiguredSequence,NextSequenceOutcome()).src/Compono/ReturnConfigBuilder.cs— unaffected by the correction (ReturnsSequence(params SequenceOutcome<T>[]);Returns/Throwsalso clear sequence state — already spiked).src/Compono.Generators/Templates/TestDouble.scriban— unaffected by the correction (already spiked: aHasConfiguredSequencecheck ahead of the existingHasConfiguredException/HasConfiguredValuechecks at everyReturnConfig<T>-consuming dispatch site — plain void member, plain non-void member, property getter, ADR-0050 entries-list void loop, ADR-0050 entries-list non-void loop, closed-instantiation-eligible entries loop, closed-instantiation-eligible no-params ternary — 7 sites total).
Tasks¶
- Rewrite
src/Compono/SequenceOutcome.csto the corrected shape (SequenceOutcome<T>single implicit conversion fromT; newSequenceOutcome.Throw(Exception)/ThrownOutcome). - Confirm the 7 template dispatch sites are exactly and only the sites touched (
grep -n "HasConfiguredException" TestDouble.scribanbefore/after should show one newHasConfiguredSequenceline per existing one) — confirmed unchanged (7 pairs, no new/missing sites); theSequenceOutcome<T>rewrite touched onlySequenceOutcome.cs, invisible to the template. -
test/Compono.Tests/ReturnConfigSequenceTests.cs— updated every existing case that passed a bareExceptiontoReturnsSequence(...)toSequenceOutcome.Throw(...); added new unit coverage for the corrected shape:T = Exception(value-conversion vs.Throwboth resolve unambiguously),T = InvalidOperationException(value-conversion resolves as value, not throw),T = object(Throwstill resolves as throw; value-conversion still works),T = Exception?andT = string?(nullvia theT-conversion resolves as a value, not a throw), anddefault(SequenceOutcome.ThrownOutcome)converted toSequenceOutcome<int>throwsArgumentExceptionimmediately at the conversion site. All prior coverage retained. 17/17 passing. -
test/Compono.Generators.Tests/TestDoubleSequentialResponseExecutionTests.cs— updated toSequenceOutcome.Throw(...); all 6 real end-to-end generator-execution tests passing (zero-parameter value sequence, zero-parameter mixed exception/value sequence, calls-still-count-toward-verification, matching-eligible member value sequence, two independent argument-matched entries with independent ordinals, reconfiguring an entry resets its ordinal). - Snapshot review: reran
test/Compono.Generators.Tests(262/262 passing) — zero.received.csfiles produced, confirming theSequenceOutcome<T>rewrite produced zero snapshot diffs as expected. -
test/Compono.TestDoubles.AotSmokeTest/Program.cs— updated toSequenceOutcome.Throw(...); realpack-compono.sh+dotnet publish -c Release -f net10.0 -p:PublishAot=true+ direct run of the published binary printedPASSand exited 0 — confirms the mixed exception/valueTask<T>sequence, exhaustion-repeats-final,Verify().Exactly(n)correctness across throwing calls, two independent ADR-0050 entries with independent ordinals, and the fresh-double-instancevoid-member exception-only sequence (the earlier interrupted re-verification is now complete) all survive Native AOT. - Full solution test sweep:
dotnet test -f net10.0at the repo root — 808/808 passing across everytest/*.Testsproject (Compono.Tests274,Compono.Generators.Tests262,Compono.TestDoubles.Tests6,Compono.DependencyInjection.Tests17,Compono.TUnit.Tests52,Compono.Http.Tests29,Compono.Bogus.Tests63,Compono.NSubstitute.Tests23,Compono.XunitV3.Tests70, plus the two sample test projects), zero failures. -
skills/compono/references/testdoubles.md— replaced the "no sequential/call-count-based responses" language with a new "Sequential/call-count-based responses" section documenting.ReturnsSequence(...)/SequenceOutcome.Throw(...), and removed the item from the unsupported-capabilities list.
Test plan¶
Covered by the Tasks checklist above — unit (Compono.Tests), real generated-code execution (Compono.Generators.Tests), and real Native AOT publish-and-run (Compono.TestDoubles.AotSmokeTest), matching this repo's three-tier verification convention for a Compono.TestDoubles capability (see TestDoubleVerificationExecutionTests.cs's own doc comment for why the generator-execution tier exists separately from unit tests of the runtime type alone).
Acceptance criteria¶
- Every Tasks checkbox above is checked.
dotnet testgreen across everytest/*.Testsproject on at least one TFM.- The AOT smoke test's real
dotnet publish -p:PublishAot=truebinary runs and printsPASS. - No
.received.csfiles left intest/Compono.Generators.Tests/Snapshots/after snapshot review. - No production or test code anywhere passes a bare
Exceptiondirectly toReturnsSequence(...)— every exception outcome goes throughSequenceOutcome.Throw(...).
Phase 2: Overload-safe argument matching¶
Goal: every overloaded member needing content-based argument matching (no arity restriction — same-arity overloads are fully supported, per "Spike findings") gets a new, matching-specific Configure()/Verify() member name taking real Match<T> parameters directly — but that name is purely a configuration-side alias: its state (entries, call log, lock) attaches to the same real overload the existing discriminator-only surface already dispatches through, so a call the SUT actually makes is visible to both surfaces consistently. The existing discriminator-only surface's signature and observable behavior are unaffected; its generated implementation is not, for any overload that becomes matching-eligible (see "Spike findings" — this replaces the first-round "byte-identical snapshot" claim, which was wrong).
Status: Done. Implemented exactly per the "Architecture (revised)" design below: IsOverloadMatchingEligible/MatchingMemberName in TestDoubleAnalyzer.cs, unified entries/call-log/lock state reused from ADR-0050 in TestDouble.scriban, real generator-execution and snapshot coverage, a real Native AOT publish-and-run, and the skill reference doc updated. All acceptance criteria below met; two corrections to the plan's own text found and recorded during implementation (the generic+overloaded extension needing generic_suffix, and the literal-shorthand claim being narrower than originally stated) - see "Spike findings" and the Tasks checklist above.
Architecture (revised)¶
State ownership — per real overload, not per API name:
// One Entry/Entries/call-log/lock set per matching-eligible OVERLOAD (keyed off that overload's
// own existing FieldName/discriminator suffix - unchanged identity mechanism).
internal sealed class __DeleteItemAsync_<hash>_Entry
{
internal Match<DeleteItemRequest>? Matcher_request; // set only by DeleteItemAsyncMatching(...)
internal Match<CancellationToken>? Matcher_cancellationToken;
internal ReturnConfig<Task<DeleteItemResponse>> Config; // ADR-0054-capable: value, exception, or sequence
}
internal readonly List<__DeleteItemAsync_<hash>_Entry> __DeleteItemAsync_<hash>_entries = [];
internal readonly List<(DeleteItemRequest, CancellationToken)> __DeleteItemAsync_<hash>_calls = [];
internal readonly object __DeleteItemAsync_<hash>_lock = new();
Configure().DeleteItemAsync(realRequest, realToken)(existing, signature unchanged) now appends an always-matching entry to this list — the exact migration ADR-0050 already performed for a non-overloaded member's own zero-argument "compatibility"Configure().Configure().DeleteItemAsyncMatching(Match<DeleteItemRequest>, Match<CancellationToken>)(new) appends a real-matcher entry to the same list.Verify().DeleteItemAsync(realRequest, realToken)(existing, signature unchanged) reads the call log's unfilteredCount— the same migration ADR-0050's own compatibility-Verify()already performed.Verify().DeleteItemAsyncMatching(Match<DeleteItemRequest>, Match<CancellationToken>)(new) reads the call log filtered by the supplied matchers — the same filtered-scan ADR-0050 already implements for a non-overloaded matching-eligible member.
Dispatch (the real IAmazonDynamoDB.DeleteItemAsync(...) explicit interface implementation) — the existing ADR-0050 reverse-scan, unchanged in shape:
- Under the shared lock: append the actual
(request, cancellationToken)to the call log (so bothVerify()surfaces see every real call, regardless of which entry — or none — ends up answering it). - Reverse-scan
entriesnewest-to-oldest. For the first entry whose matchers all match the real arguments (an always-matching discriminator-entry matches unconditionally, exactly like today): HasConfiguredSequence→NextSequenceOutcome()(ADR-0054, already wired intoReturnConfig<T>/the template — no new interaction code needed here, it's the sameConfigtype every other entry already uses).- else
HasConfiguredException→ throw. - else
HasConfiguredValue→ return. - else (matched but not yet configured) continue the scan, exactly like ADR-0050's own "no
break" rule. - If nothing configured is found anywhere in the list: existing default/configuration-required fallback, unchanged.
Why no separate "fall back to the old field" step is needed: there is no separate old field once an overload is promoted — the discriminator-only Configure() call is an entry in the same list. Registration order gives the correct precedence for free: register the broad discriminator response first, a specific .Matching() override second, and the reverse-scan finds the specific one first, falling through to the broad one for anything it doesn't match — the same idiom the AOT smoke test's own Withdraw scenario already demonstrates for a non-overloaded member.
Overload identity stays part of the key: each overload keeps its own independent Entry/Entries/call-log/lock set (already-existing per-overload FieldName-derived naming) — a call to a sibling overload of the same member name can never be recorded into, or matched against, another overload's state.
Naming/collision policy for the matching-specific member name¶
The convention <MemberName>Matching was illustrative through this ADR/plan's earlier revisions, with the exact convention left "TBD at implementation time." Before committing to it, real compiler spikes checked what happens when the interface's own closure already contains a real member whose literal name equals the candidate alias name — the case the review raised:
interface IFoo
{
Result Get(Request request);
Result Get(string id); // overloaded Get -> wants an alias named "GetMatching"
Result GetMatching(SomeOtherType value); // a REAL, unrelated member with that exact name
}
Findings (real dotnet build/dotnet run, not predicted):
- No real collision (the common case): when the real
GetMatchingmember's own generatedConfigure()extension has a genuinely different parameter-type signature than either of the alias's own generated overloads (GetMatching(Match<SomeOtherType>)vs.GetMatching(Match<Request>)/GetMatching(Match<string>)), all three coexist as ordinary C# overloads, resolved correctly by argument type — confirmed by compile-and-run. - Real collision: when the real member's own generated extension signature is identical to one of the alias's own generated overloads (e.g. the real member happens to be
GetMatching(string value), whose ownConfigure()extension would beGetMatching(Match<string> value)— exactly the same signature the alias already generates for theGet(string)overload) — confirmedCS0111("already defines a member with the same parameter types"). A real, if narrow, risk that must be detected and handled deterministically, not left to produce broken generated code. - Inheritance-hierarchy variant of (1)/(2): doesn't introduce a new case. Every member (real or alias) a generated double implements is flattened onto one
_DoubleConfigurationstatic class regardless of which interface in the closure originally declared it (already true architecturally — confirmed by every existing generated-code example touching a multi-interface closure, e.g.IRepository : IClockin the AOT smoke test) — so the collision check is naturally interface-closure-wide, not per-declaring-interface, with no separate mechanism needed for the cross-interface case. - A real generic member sharing the candidate name (
GetMatching<T>(Match<T> value), alongside the non-generic alias overloads) — confirmed to compile cleanly, noCS0111(generic arity is part of C# overload identity, and the alias's own overloads are never artificially generic — see the rejected flattened-selector shape). A softer, non-blocking finding worth documenting, not solving in this phase: an ordinary (non-explicit-type-argument) call always prefers the non-generic overload when both are applicable — confirmed by compile-and-run — so if the real generic member's own closed instantiation for someThappens to share a signature with one of the alias's concrete overloads, that specific instantiation becomes reachable only via an explicit type argument on the real member's own name, never implicitly. Since generic-and-overloaded matching is already out of this phase's scope, this is recorded as a documented caveat forreferences/testdoubles.md, not new template logic.
Policy, in the priority order requested:
- Default: generate the natural
<MemberName>Matchingname. This is safe and predictable for every case that doesn't hit finding (2) above — the overwhelming majority. - On a detected real collision (finding 2): fall back to a deterministic alternate name, not a diagnostic and not silently dropping matching support. This repo already has a proven, shipped mechanism for exactly this shape of problem —
TestDoubleAnalyzer's existing derived-name-collision detection (derivedNameCollisionMembers) and discriminator-suffix pre-pass, covered by the existingOverloadSuffixCollidesWithDifferentlyNamedRealMember_GeneratesDoubleWithDistinctFieldNamestest: when a generated name collides with a real member's own literal name, lengthen/rehash the generated name until it's globally unique. Extend that same mechanism's reserved-name pool to include the candidate<MemberName>Matchingname, with the same fallback shape (e.g.<MemberName>Matching_<hash>, using the existingTestDoubleOverloadIdentity.StableHash/DiscriminatorSuffixconvention) — not a new mechanism, an extension of an existing one. Per the instruction to avoid hash-looking names "in the normal case": this fallback is exactly that — a rare, deterministic escape hatch for a proven real collision, not the default shape. - No new diagnostic is needed. Because the deterministic fallback in (2) always succeeds (the existing discriminator-suffix mechanism already has its own collision-handling for the astronomically rarer case of the hash itself colliding), there is no case where "no clean generated surface" actually occurs — so priority 3 ("an actionable diagnostic only if there is no clean generated surface") is never reached for this specific problem, consistent with Phase 2 needing no new
CMP0xxxcode anywhere.
Documentation: references/testdoubles.md can describe the rule in one sentence — "the matching-specific member name is <Member>Matching; in the rare case that collides with a real member of that exact name, Compono disambiguates automatically, the same way it already does for other generated names" — without needing to explain the mechanism's internals to a consumer, since the fallback is deterministic and (per finding 2) provably rare.
Production changes¶
src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs:- New
TestDoubleMemberInfoflag (name TBD, e.g.IsOverloadMatchingEligible) =hasConfigurationSurface && isOverloaded && <every other ADR-0048 eligibility conditionisEligibleForMatchingalready checks: parameters.Count > 0, no ref-like parameter, no derived-name collision, not theEquals-collision shape, no open-type-parameter reference for a generic method>— same condition list asisEligibleForMatching, minus the!isOverloadedguard. No arity-uniqueness check (same-arity is fully supported). - Matching-specific member name derivation:
<MemberName>Matchingby default; on a detected real-member-name collision (see "Naming/collision policy" above), fall back to the existing discriminator-suffix mechanism's hash-suffixed convention — extendderivedNameCollisionMembers's existing reserved-name pool, don't invent a parallel check. src/Compono.Generators/Emitters/TestDoubleEmitter.cs/Templates/TestDouble.scriban:- For each
IsOverloadMatchingEligibleoverload: replace its existing plain-ReturnConfig<T>-field emission with theEntry/Entries/call-log/lock shape above (reusingEntryClassName/EntriesFieldNamekeyed off that overload's ownFieldName). - The real interface member's explicit-implementation dispatch body for that overload changes to the reverse-scan shape above (structurally identical to today's non-overloaded matching-eligible dispatch, not new logic).
Configure()/Verify()extensions for that overload: the existing discriminator-only method's body changes to "append an always-matching entry" / "read the call log'sCount"; a newMatching-suffixed method is added, takingMatch<T1>, Match<T2>, ...directly, appending a real-matcher entry / performing a filtered scan — mirroring the existing non-overloaded matching-eligible member's own two-method shape (zero-arg compatibility + real-matcher) as closely as possible, just under two different names instead of one overloaded name (forced by C#, since the real-argument andMatch<T>-argument versions can't share a name without reopening Amendment 18's ambiguity).- An overloaded member that does not meet
IsOverloadMatchingEligible(fails an eligibility condition — e.g. has aref/outparameter, per Amendment 18's existing carve-out) keeps today's plain single-field shape, completely unaffected. skills/compono/references/testdoubles.md— replace the "overloaded members are discriminator-only, never matchers" language with the corrected boundary, and document that the discriminator-only surface's underlying storage changes for a matching-eligible overload even though its own call signature/behavior doesn't.
Tasks¶
- Before any template work: proven not via a separate standalone spike but by the fact that
GeneratorTestHelpers.Verify's ownoutputCompilation.GetDiagnostics()assertion (zero errors) passes for every one of the 265+ fixtures below — the discriminator-only andMatching-named methods coexist cleanly on the same{{safe_identifier}}_DoubleConfiguration/_DoubleVerificationstatic classes in every case, including the dedicatedOverloadedMemberWithRealParameters_GeneratesMatchingSpecificMemberNameSharingSameOverloadStatesnapshot test. -
TestDoubleAnalyzer.cs: newIsOverloadMatchingEligibleflag (condition list mirrorsIsEligibleForMatchingminus the!IsOverloadedguard, plus!IsClosedInstantiationEligible; no arity-uniqueness computation) +MatchingMemberNamederivation with the hash-suffixed collision fallback (signature-based, per "Naming/collision policy"). -
TestDoubleEmitter.cs/TestDouble.scriban: eachIsOverloadMatchingEligibleoverload promoted to the unifiedEntry/Entries/call-log/lock shape (reusing the existingIsEligibleForMatchingstate-declaration/dispatch blocks, gated onis_eligible_for_matching || is_overload_matching_eligible); the discriminator-onlyConfigure()/Verify()bodies now append/scan the shared entries list instead of a removed single field; the newMatching-namedConfigure()/Verify()methods added; the real overload's dispatch reuses the existing reverse-scan shape unchanged. - Spike (real, kept as regression coverage -
GenericAndOverloadedMatchingEligibleMember_GeneratesGenericConfigureAndMatchingExtensions): yes - a member that is both generic and overloaded (with a real parameter not referencing its own type parameter, so still matching-eligible-shaped) needs the SAME "extension becomes generic" treatment Amendment 1 already gives the discriminator-only surface, applied identically to the newMatching-named method ({{ member.generic_suffix }}/{{ member.constraint_clauses_text }}added to both). Found by a real snapshot regression sweep after the initial (ungeneric) template draft: a same-parameter-types generic/non-generic overload pair (e.g.Process<T>(int, string)/Process(int, string)) collided (CS0111) with a fixed, non-genericProcessMatchingsignature until fixed - confirmed compiling cleanly (zero diagnostics) after the fix. - Spike (real, kept as regression coverage -
RefOutOverloadSibling_StaysFallbackOnly_MatchingEligibleSiblingsUnaffected): aref/outoverload needs no new rule -IsOverloadMatchingEligiblealready requiresWouldGetConfigurationSurface, which already excludes aref/out/inparameter unconditionally (Amendment 5). Confirmed with a real three-way overload set (oneref/outsibling with no surface at all, two real-parameter siblings each independently promoted) - theref/outsibling still reportsCMP0030and is otherwise untouched; its two siblings each get their ownMatching-named surface. -
test/Compono.Generators.Tests/TestDoubleVerifyTests.cs-style snapshot coverage:OverloadedMemberWithRealParameters_GeneratesMatchingSpecificMemberNameSharingSameOverloadState(a matching-eligible overload generating both the unchanged-signature discriminator method and the newMatching-named method, both attached to one shared entries/call-log state);OverloadMatchingAliasCollidesWithRealMemberOfThatName_FallsBackToHashSuffixedNameandOverloadMatchingAliasNameMatchesButSignatureDoesNotCollide_KeepsNaturalName(the naming-collision pair below);RefOutOverloadSibling_StaysFallbackOnly_MatchingEligibleSiblingsUnaffected(a non-matching-eligible overloaded member unaffected). Every one of the 77 Phase-1-touched snapshots was also reviewed and confirmed to change ONLY where the member is actuallyIsOverloadMatchingEligible- see "Meaningful generated-code changes" in the completion report. - Real end-to-end generator execution tests (
test/Compono.Generators.Tests/TestDoubleOverloadMatchingExecutionTests.cs, mirroring Phase 1'sTestDoubleSequentialResponseExecutionTests.cspattern) — the invariant proved throughout is that the matching-specific name only configures/observes; the SUT-visible dispatch is always through the real overload: - Coexistence/precedence (user-specified example): proved by
CoexistencePrecedence_MatchingEntryOverridesDiscriminatorFallback_ForMatchingCallsOnly. - Sibling-overload independence: proved by
SiblingOverloadIndependence_ConfiguringOneOverloadNeverAffectsTheOther. - Filtered verification: proved by
FilteredVerification_CountsOnlyRealCallsMatchingThePredicate. - Discriminator verification unchanged: proved by
DiscriminatorVerification_StillReportsTotalRealCallCount_BackedByTheCallLogNow. - Sequencing on a matching-eligible entry: proved by
SequencingOnAMatchingEligibleEntry_EveryRealCallStillRecordedInTheSharedCallLog. - Literal shorthand — corrected finding, see "Spike findings":
LiteralShorthandOnTheMatchingNamedSurface_CompilesAndMatchesByEqualityproves a literal compiles and matches by equality when the sibling overloads'Match<T>types are unrelated (no shared implicit-conversion target);LiteralShorthandAmbiguousAcrossSiblingOverloads_FailsToCompileproves the realCS0121only when two siblings'Match<T>types are ambiguously literal-convertible (numeric widening). The plan's original text ("literal shorthand does not carry over") overstated this as a blanket rule; both shapes are now real, evidenced tests. - Naming collision: proved at the snapshot layer (
TestDoubleVerifyTests.cs, above) rather than execution, since the fallback name itself is opaque/hash-derived and not meant to be called directly from a consumer's test. -
Compono.TestDoubles.AotSmokeTest: extendedIGateway's existing overloaded-member coverage with coexistence/precedence (SendMatchingnarrower override vs. the broadSend(...)discriminator entry) and sibling-independence (Send(string)vsSend(int, string)) - proven via a realpack-compono.sh+dotnet publish -c Release -f net10.0 -p:PublishAot=true+ run of the published binary, printedPASS, exit 0. -
skills/compono/references/testdoubles.mdupdate — new "Overload-safe argument matching (ADR-0044 Amendment 21)" section added under "Overloaded members (v2)": the<Member>Matchingnaming rule, the corrected (non-blanket) literal-shorthand behavior, the rare-collision fallback, and the documented generic-member soft-shadowing caveat. - Full solution test sweep green (see validation results in the completion report).
Test plan¶
Same three-tier shape as Phase 1 (snapshot/compile-only coverage in TestDoubleVerifyTests.cs, real generated-code execution in TestDoubleOverloadMatchingExecutionTests.cs, real Native AOT proof) plus the coexistence/precedence, sibling-independence, filtered-verification, and sequence-interaction scenarios listed above — these are the tests that actually prove the corrected architecture (state attaches to the real overload) rather than the rejected one (an independently-dispatched alias member).
Acceptance criteria¶
- Every Tasks checkbox above is checked.
- The real
IAmazonDynamoDB.DeleteItemAsync(Match.Is<DeleteItemRequest>(x => x.ConditionExpression == expected), Match.Any<CancellationToken>())shape from the original dogfood investigation is provably expressible (a generator test using that exact real AWS SDK shape, or as close a stand-in asCompono.Generators.Tests' existing fixtures allow without taking a new external package dependency), via the new matching-specific member name, and dispatches through the real overload (proven by the coexistence test, not assumed). dotnet testgreen across everytest/*.Testsproject on at least one TFM.- The AOT smoke test's real published binary runs and prints
PASS. - Corrected criterion (the first-round plan's "byte-identical snapshot" claim was wrong — see "Spike findings"): every matching-eligible overload's generated code changes shape (new entries/call-log emission); every non-matching-eligible overloaded member, and every non-overloaded member, is unaffected — verified by diffing the full snapshot set against Phase 1's already-clean baseline and confirming only matching-eligible-overload fixtures changed. Discriminator-only
Configure()/Verify()observable behavior (not generated code shape) for a matching-eligible overload is unchanged — proven by execution tests, not snapshot diffing.
Critical files¶
src/Compono/SequenceOutcome.cs— rewritten to the corrected shape, including theThrownOutcome-from-defaultguard (Phase 1).src/Compono/ReturnConfig.cs,src/Compono/ReturnConfigBuilder.cs— extended (Phase 1, unaffected by theSequenceOutcome<T>correction).src/Compono.Generators/Templates/TestDouble.scriban— extended in both phases (7 sites in Phase 1; Phase 2 replaces, not adds alongside, each matching-eligible overload's dispatch/Configure()/Verify()emission with the unified entries/call-log shape).src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs— extended (Phase 2 only: new eligibility flag + matching-specific member name derivation).test/Compono.Tests/ReturnConfigSequenceTests.cs,test/Compono.Generators.Tests/TestDoubleSequentialResponseExecutionTests.cs— new/updated (Phase 1) — the former gains theThrownOutcome-default-guard test.test/Compono.Generators.Tests/TestDoubleOverloadMatchingExecutionTests.cs— new (Phase 2) — coexistence/precedence, sibling-independence, filtered-verification, and sequence-interaction tests (see Phase 2 Tasks).test/Compono.TestDoubles.AotSmokeTest/Program.cs— extended/updated in both phases.test/Compono.Generators.Tests/Snapshots/*.verified.cs— ~77 files touched by Phase 1 alone (mechanical, template-wide change; zero further diffs expected from theSequenceOutcome<T>correction, since it's invisible to generated code). Phase 2 touches every matching-eligible-overload fixture's snapshot (a real, not byte-identical, change — see Phase 2's corrected acceptance criterion) and leaves every non-matching-eligible overloaded member and every non-overloaded member untouched.docs/adr/0044-...md,docs/adr/0054-...md,skills/compono/references/testdoubles.md— documentation/ADR touches per phase (both ADRs already corrected in place for the API-shape questions; this round's dispatch-architecture correction lives in this plan only, pertasks/design.md's ADR-records-what/why-plan-records-how split — no further ADR edit needed for it).
Notes¶
(Empty at plan-writing time — this section fills in as implementation reveals divergence from what's scoped here, per this repo's own plan-maintenance convention.)