[PLAN-0049] Compono.TestDoubles: Per-Closed-Instantiation Configuration for Generic Methods Whose Return Type Depends on Their Own Type Parameter¶
Status: Done
Implements: ADR-0049
Goal¶
A Compono.TestDoubles-generated double supports independent Configure()/Verify() state per closed T for a generic method whose return type is exactly T, or the sole type argument of Task<T>/ Task<T?>/ValueTask<T>/ValueTask<T?>, for a single method-type-parameter — closing the real ncipollina/trivia-platform gap (IConversationalContextManager.GetContextDataAsync<T> : Task<T?>) ADR-0049 designs. Done when: the eligibility check no longer routes this shape to whole-interface Failure(); Configure<T>()/Verify<T>() for two different closed Ts on the same double instance are provably independent; both of ADR-0045's existing dispatch branches (deterministic-default, configuration-required) compose correctly through the new bucket mechanism; the full existing Compono.TestDoubles test suite passes unmodified; a generator-driven (not hand-written) AOT smoke test proves the real generated shape survives Native AOT; and docs/packages/compono-testdoubles.md documents the new capability and its scope boundary.
Scope¶
Exactly ADR-0049's Decision Outcome: a Dictionary<Type, object> bucket keyed by typeof(T), valued by a generator-emitted nested class generic in T (a ReturnConfig<TSlot> field, one Match<TParam>? field per real non-T parameter, a lock-guarded call log), reusing Match<T>/ CallVerifier unchanged. Scoped to a single method-type-parameter, referenced only as the method's direct return type or the sole type argument of Task/ValueTask.
Explicitly out, per ADR-0049 and this repo's evidence discipline:
Tnested deeper in the return type (Task<List<T>>,Task<Dictionary<string, T>>, etc.) — stays whole-interface-rejecting, unchanged.- More than one method-type-parameter on a self-referencing-return method — stays whole-interface-rejecting, unchanged.
SetContextDataAsync<T>-shaped members (Tin a parameter, not the return type) — not touched by this plan at all, including the argument-aware-matching gap ADR-0049's design pass separately surfaced and explicitly declined to fold in. That gap is its own future ADR, not a task here.- Any change to
ReturnConfig<T>,ReturnConfigBuilder<T>,Match<T>, orCallVerifier's own public shape — this plan reuses all four completely unchanged.
One implementation PR¶
Per the user's explicit direction: this capability is one coherent generated-code change (eligibility analysis, model, template, dispatch, Configure<T>()/Verify<T>() extensions all only make sense together) and should not be artificially split across phases/PRs. There is no genuine technical reason to stage it — unlike PLAN-0044's real multi-milestone scope, everything here lands in Compono.Generators plus the runtime already exists (Match<T>, ReturnConfig<T>, CallVerifier are all unchanged, pre-existing types).
Tasks¶
Grouped by concern, checked off as work proceeds.
1. Eligibility analysis (TestDoubleAnalyzer.cs)¶
- At the existing
method.IsGenericMethod && TypeReferencesOwnTypeParameter(method.ReturnType, method)check (TestDoubleAnalyzer.cs:410, today's unconditional whole-interfaceFailure(...)), add a narrower classification before that check fires: does the return type match exactly one ofT,Task<T>,Task<T?>,ValueTask<T>,ValueTask<T?>(Tthe method's sole type parameter, appearing nowhere else in the return type's syntax tree beyond that one direct position)? If so, mark the member "closed-instantiation eligible" and continue instead of returningFailure. Any other shape referencing the method's own type parameter in the return type (deeper nesting, multiple type parameters) keeps today's exactFailure(...)behavior, unchanged. - Overloaded members are eligible, not excluded — verified with a real compiler/Native AOT spike, not assumed. An earlier draft of this plan excluded overloaded members here, reasoning by false analogy to ADR-0048's Match
exclusion. That reasoning was wrong: ADR-0048 excludes overloaded members specifically from argument matching ( Match<T>-wrapped parameters caused realCS0121ambiguity for 3 of 5 realistic overload-parameter-type families — ADR-0048's own Decision Outcome), not from generics or from having independently-configurable state at all. The bucket-by-closed-T mechanism this ADR adds is an orthogonal axis to the overload- discriminator mechanism ADR-0044 Requirement 1 already ships (real, un-wrapped parameter types as pure discriminators,generic_suffixalready supported per-overload — proven in production today byIWidget.Process<T>(T)/Process<T>(IEnumerable<T>)inCompono.TestDoubles.SampleTests/GenericMemberTests.cs). A spike (two overloads of the same generic-return-depends-on-own-type- parameter method, discriminated by real parameter arity, each with its own discriminator-suffixed bucket dictionary and nested state class) compiled, ran correctly under JIT, and survived a realdotnet publish -c Release -f net10.0 -p:PublishAot=true+ native run with zero warnings — proving per-overload buckets stay fully independent of each other (including when the same closedTis used on both overloads) and that ordinary overload resolution + generic type inference route eachConfigure<T>()/Verify<T>()call to the right overload's own bucket. The generatedConfigure<T>()/Verify<T>()signature for an overloaded closed- instantiation-eligible member therefore mirrors ADR-0044's existing discriminator shape exactly (real parameter types,generic_suffix, noMatch<T>wrapping) — it simply doesn't get ADR-0048's argument- matching capability, identical to any other overloaded member today, not a new restriction this plan introduces. - Gate the new eligibility on: no ref-like real parameter (mirrors ADR-0048 Amendment 1 — a ref-like type can never be a generic type argument, so it can't appear in the bucket's own generic-in-
Tstate class either way); no real parameter itself referencing the method's own type parameter (that combination is unevidenced and out of scope, same discipline as the deeper-nesting exclusion above). Not gated on overload status — see above. - Test:
Task<List<T>> Get<T>()still produces whole-interfaceFailurewithUnsupportedTestDoubleGenericReturnShape, byte-for- byte the same diagnostic as before this plan. - Test:
Task<TResult> Get<TKey, TResult>(TKey key)(return depends on one of two type parameters) still produces whole-interfaceFailure, unchanged. - Test:
void Log<TState>(int, TState)(ADR-0044's existing supported generic shape, return independent of own type parameter) is completely unaffected — same generated output as before this plan, a real diff against a pre-plan snapshot, not just "still compiles" (mirrors PLAN-0048 task 2's own regression-proof pattern). - Test:
SetContextDataAsync<T>-shaped member (Tin a parameter, not the return type) is completely unaffected — same generated output as before this plan.
2. Model (TestDoubleMemberInfo.cs and/or a new closed-instantiation-specific model type)¶
- Carry whatever the template needs for a closed-instantiation-eligible member: the return-shape template (
T/Task<T>/Task<T?>/ValueTask<T>/ValueTask<T?>, expressed so the template can splice the state class's ownTin), the real (non-T) parameters (same shapeTestDoubleParameterInfoalready carries for an ordinary ADR-0048-eligible member), and whether the return shape is nullable (drives the ADR-0045 default-vs-configuration-required dispatch branch — reuse the exact same nullability/default-expression check ADR-0045's existing logic already runs, just applied to the substituted-Tshape instead of a fully concrete type). - Keep
HasConfigurationSurface/IsEligibleForMatching's existing meaning unchanged for every other member shape — a closed- instantiation-eligible member is a third, new classification, not a repurposing of either existing flag (avoids the kind of ambiguous- meaning defect ADR-0044's own Amendments repeatedly had to correct).
3. Generated storage and dispatch (TestDouble.scriban, TestDoubleEmitter.cs)¶
- Per closed-instantiation-eligible member, emit the nested generic-in-
Tstate class (ReturnConfig<TSlot>field, oneMatch<TParam>?field per real parameter,lock-guarded call log — exactly ADR-0049's Decision Outcome code block), theDictionary<Type, object>bucket field, and thelock-guarded bucket-lookup-or-create method — allinternal, matching this repo's existing generated-field visibility convention. - Emit the real interface member's dispatch body routed through the bucket: record the call, evaluate matchers, then reuse ADR-0045's existing configured-value/configured-exception/default-or- configuration-required branch verbatim (reading from the bucket's
ReturnConfig<TSlot>field instead of a direct member field) — no new dispatch shape, no new exception type. - For a non-overloaded closed-instantiation-eligible member, emit
Configure<T>()/Verify<T>()extensions generic inT, withMatch<TParam>parameters for each real parameter (same signature shape ADR-0048-eligible members already get), looking up the bucket for the caller's closedTand returning the existingReturnConfigBuilder<TSlot>/CallVerifier— both already-existing, unmodified types. - For an overloaded closed-instantiation-eligible member, reuse ADR-0044 Requirement 1's existing overload-discriminator machinery unchanged: each overload gets its own
generic_suffix-discriminated bucket field and its ownConfigure<T>()/Verify<T>()extension, resolved by ordinary overload resolution against the real (non-T) parameter types — notMatch<TParam>-wrapped (task 1's finding: overloaded members keep the plain-parameter discriminator shape they already have today, they just don't gain ADR-0048 argument matching). No new discriminator logic — the existing per-overload naming the generator already emits for non-generic overloads applies here verbatim. - Test: generated-output review for each of the five evidenced return shapes (
T,Task<T>,Task<T?>,ValueTask<T>,ValueTask<T?>) against ADR-0049's Decision Outcome code block. - Test: generated-output review for an overloaded closed-instantiation- eligible member (two overloads, e.g. differing real-parameter arity) — each overload's
Configure<T>()/Verify<T>()carries its own discriminator suffix and its own bucket field, byte-for-byte matching ADR-0044's existing overload-discriminator output shape.
4. Runtime behavior tests (Compono.TestDoubles.Tests / Compono.TestDoubles.SampleTests)¶
- Two closed
Ts configured/verified independently on the same double instance —Configure<TypeA>()provably doesn't affectTypeB's state (mirrors the AOT spike's own proof, now against real generator output). - ADR-0045 deterministic-default branch: an unconfigured closed
Ton a nullable-return member (Task<T?>) returns the real default (null), not a throw. - ADR-0045 configuration-required branch: an unconfigured closed
Ton a non-nullable-return member (Task<T>) throwsTestDoubleNotConfiguredException. - Argument mismatch against a correctly-configured
Tfalls through to that sameT's own default/configuration-required behavior — not the configured value, and not anotherT's state. -
Match.Any<TParam>()/Match.Is<TParam>(predicate)/literal-equality all work against the real (non-T) parameters, scoped per bucket. -
Once()/Never()/Exactly(n)all work per closedT, independent of anotherT's call count. - Regression/composition: an overloaded closed-instantiation-eligible member (mirrors the AOT spike's
IReproOverloaded.GetDataAsync<T>shape) —Configure<T>()/Verify<T>()on each overload only affects that overload's own bucket, proven with the same closedTused on both overloads (e.g.GetDataAsync<UpsellPayload>(id)vs.GetDataAsync<UpsellPayload>(id, version)) to rule out any cross-overload bucket-key collision, not just different-Tisolation (that's the prior item). Proves ADR-0044's overload-discriminator mechanism and this ADR's bucket-by-closed-T mechanism compose correctly, per the user's explicit request for this regression test. - Regression: an interface containing both a closed-instantiation- eligible member and ordinary members (mirrors
IConversationalContextManager's real shape — a closed-instantiation member alongsideTransitionContextAsync/GetCurrentContextAsync- shaped members) generates a real double for every member, not just the new shape — the whole-interface-Failure()consequence ADR-0049's Context section named is actually gone, not merely theorized. - Full existing
Compono.TestDoublestest suite passes completely unmodified — zero expected diffs, same standard PLAN-0048 held itself to.
5. AOT smoke test (test/Compono.TestDoubles.AotSmokeTest)¶
- Add a real interface (through the actual generator this time, not hand-written) mirroring the ADR-0049 spike's shape — a nullable closed-instantiation member and a non-nullable one, proving both ADR-0045 branches survive Native AOT through real generated code, not just the hand-written proof-of-concept.
- Real
dotnet publish -c Release -f net10.0 -p:PublishAot=true+ running the published binary — zeroIL2xxx/IL3xxx/AOT warnings, exit 0,PASSwith every assertion holding.
6. Documentation¶
-
docs/packages/compono-testdoubles.md: new section for this capability — the supported return shapes, the single-type-parameter scope boundary, aConfigure<T>()/Verify<T>()example, and an explicit note thatSetContextDataAsync<T>-shaped members (Tin a parameter) are unaffected and remain a separate, undecided question. - ADR-0044 Amendment 19 — already written during ADR-0049's design pass (recording that Requirement 2's exclusion is reopened, scoped to this evidenced shape, pointing to ADR-0049). No further edit needed unless implementation surfaces a real correction to record.
Critical Files¶
src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs— the eligibility classification (task 1) and dispatch-branch reuse (task 3).src/Compono.Generators/Models/TestDoubleMemberInfo.cs(and/or a new model type) — carries the closed-instantiation shape info to the template (task 2).src/Compono.Generators/Templates/TestDouble.scriban— the new generated-code branch: nested state class, bucket, dispatch,Configure<T>()/Verify<T>()(task 3).src/Compono.Generators/Emitters/TestDoubleEmitter.cs— wiring the new model data into the template, if the existing wiring doesn't already generalize.src/Compono.Generators/Diagnostics/DiagnosticDescriptors.cs— confirmUnsupportedTestDoubleGenericReturnShapestill fires correctly for the still-unsupported shapes (no new descriptor expected — this is a narrowing of when the existing one fires, not a new diagnostic).test/Compono.Generators.Tests/*— eligibility + generated-output tests (task 1, task 3).test/Compono.TestDoubles.Tests/*,test/Compono.TestDoubles.SampleTests/*— behavior tests (task 4).test/Compono.TestDoubles.AotSmokeTest/Program.cs— task 5.docs/packages/compono-testdoubles.md— task 6.docs/adr/0044-compono-testdoubles-v2-overloads-generics-verification.md— Amendment 19, already written (task 6, checked).
Test Plan¶
Per references/testing.md: generator-output classification/snapshot tests for the narrowed eligibility check and the three shapes that must stay unaffected (deeper-nested T, multi-type-parameter, T-in-parameter) (task 1); generated-output review against ADR-0049's Decision Outcome code block for all five evidenced return shapes (task 3); real behavior tests proving independent per-closed-T state, both ADR-0045 branches, and ADR-0048 matcher/verification reuse (task 4); a full unmodified existing- suite run (task 4); and a generator-driven AOT smoke test (task 5) — the hand-written spike proved the mechanism is AOT-safe in principle, this is the proof that the real generated code is too. No new benchmark unless implementation surfaces real evidence one is needed, matching PLAN-0048's own disposition.
Notes¶
Implementation (2026-08-23) surfaced two real defects a compiler-driven build/test loop caught, neither anticipated by ADR-0049's own spike (which never exercised them):
- A constrained (
where T : class) closed-instantiation-eligible member's explicit interface implementation cannot spell its return type with the nullable annotation intact.Task<T?> IFoo.GetAsync<T>(...)fails to compile (CS9334/CS0453/CS0452cascade) — an explicit interface implementation can never restatewhere T : class(CS0460), and without it the compiler can't tell whetherT?means a nullable-annotated reference orSystem.Nullable<T>. Reproduced with a minimal hand-written repro before touching the generator. Fixed by declaring the explicit implementation's return type with the type parameter's own?stripped (Task<T>, notTask<T?>— the same CLR type either way) and suppressing the resultingCS8616/CS8619nullability-mismatch warnings with#pragma warning disable/restorearound just that member. Deliberately pragma-based, not#nullable disable/restore—#nullable restorereverts to the project's default annotation context, not back to this generated file's own leading#nullable enable, which silently left every later member in the same file oblivious (a realCS8669regression this task's ownCompono.TestDoubles.SampleTestsbuild caught on the first attempt). - An overloaded closed-instantiation-eligible member's generated state class must not declare
Matcher_*/Calls/Lockfields at all — those only exist for the non-overloaded,Match<TParam>-wrapped shape; an overloaded member'sConfigure<T>()/Verify<T>()never writes them (CS0649unused-field warning), and itsVerify<T>()already readsConfig.ConfiguredCallCountdirectly instead of walking a call log. Split the emitter's single "has real parameters" flag into two: one for the Configure/Verify zero-vs-real-parameter signature split (unaffected by overload status) and a narrower one (`!IsOverloaded && Parameters.Count0`) gating the state class's matcher machinery and the dispatch body's matcher-evaluation loop.
Both were caught by this task's own "build and run the full test suite after each major step" discipline (references/testing.md), not by assumption — every dotnet build/dotnet test run after each fix was warning-clean before moving on. Full solution: 2334/2334 tests passing, zero build warnings. Compono.Generators.Tests: 370/370 (14 new tests: 2 still-unsupported-shape regressions, 2 new-capability generator-output tests, 1 real Native AOT-mirroring solo test). Compono.TestDoubles.SampleTests: 208/208 (12 new closed-instantiation runtime-behavior tests). Compono.TestDoubles.AotSmokeTest: real dotnet publish -c Release -f net10.0 -p:PublishAot=true + native run, zero AOT/trim warnings, exit 0, PASS with every assertion holding (including two independently-configured closed Ts and both ADR-0045 dispatch branches through the real generator, not the hand-written spike).
PR #107 Codex review (2026-08-23) caught one real gap the spike and implementation both missed: a type parameter declared where T : allows ref struct (C# 13's ref-like-capable anti-constraint) matched the closed-instantiation-eligible return shape (T itself is neither ref-like as a symbol nor caught by any existing ref-like-parameter guard, since this is a constraint on the type parameter, not a parameter type) — but the generated state class's ReturnConfig<T>/ReturnConfigBuilder<T> fields (Compono's existing, unmodified runtime types) declare no allows ref struct on their own T, so a real caller closing this method's T over an actual ref struct would fail to compile with CS9244 inside generated code instead of the clean CMP0031 whole-interface-fallback diagnostic every other unsupported shape gets. Fixed at the single eligibility choke point (IsClosedInstantiationEligibleReturnShape, excluding AllowsRefLikeType type parameters, falling back to whole-interface rejection like every other no-constructible-body shape), with a new regression test proving T Create<T>() where T : allows ref struct still produces CMP0031. The same review also caught two unawaited ThrowAsync assertions in ClosedInstantiationTests.cs (a false-pass risk — the test could finish before observing whether the assertion held) — fixed by making both tests async Task and awaiting the assertion. Full solution after both fixes: 2336/2336 tests passing, zero build warnings.
PR #107 Codex review, round 2 (2026-08-23) caught a follow-on gap in the round-1 fix: the nullable-annotation-stripping fix for a where T : class-constrained explicit interface implementation was keyed off IsClosedInstantiationEligible, which requires HasConfigurationSurface. But a member can match ADR-0049's closed-instantiation return shape and still end up with no configuration surface for an unrelated reason — concretely, ADR-0044 Amendment 5's ref/out/in overload-set-internal fallback (Task<T?> Get<T>(ref int x) where T : class alongside a sibling overload). That member still gets a real explicit interface implementation (a deterministic-default-only fallback body), and it hit the identical CS9334/CS0453 cascade the round-1 fix didn't cover, since the fallback branch of the template used the member's plain, unmodified return-type text. Fixed by adding a new model field, IsClosedInstantiationEligibleShape — the same return-shape test, deliberately independent of HasConfigurationSurface — and keying the emitter's nullable-stripping computation and the template's fallback-body return-type spelling off that instead. A new regression test (ClosedInstantiationShapedRefParameterOverloadFallback_CompilesWithoutConfigurationSurface) uses VerifyWithInfoDiagnostic, which re-compiles the real generated output and asserts zero compiler errors — exactly the check that would have caught this cascade. Full solution after this fix: 2338/2338 tests passing, zero build warnings.
PR #107 Codex review, round 3 (2026-08-23) caught two real gaps, both the same root-cause class as rounds 1–2: a pre-pass computed before the main per-member loop (needed for collision detection, which necessarily runs ahead of knowing each member's final classification) had its own inline copy of "is this member closed-instantiation-eligible" logic that fell out of sync with the real rule.
- Finding A: the pre-existing ADR-0048
derivedAuxiliaryNameOwnerspre-pass (reserving_calls/_lock/_m_{param}names) didn't exclude closed-instantiation-eligible candidates, so it wrongly reserved__Get_callson behalf of a member likeTask<T?> Get<T>(string key)— which never actually emits that name (itsCallsfield lives inside its own__Get_State<T>class instead). An unrelated sibling literally namedGet_callsthen collided with that phantom reservation, which fed intoisClosedInstantiationEligibleShape's own!derivedNameCollisionMembers.Contains(method)gate and incorrectly rejected the whole interface. - Finding B: the
zeroArgExtensionSharerscollision-detection pre-pass (guarding against a realCS0111risk between a method and a differently-shaped same-named sibling) still assumed the pre-ADR-0049 rule that a solo (non-overloaded) generic method's extension is always non-generic — so a solo closed-instantiation-eligible member's real generic arity was computed as0, indistinguishable from an unrelated zero-arg non-generic sibling (e.g. aGetproperty inherited from a different base interface). That false collision stripped the closed-instantiation member's configuration surface, and for an unconstrainedT Get<T>()with no deterministic default, that meant whole-interface rejection. - A third instance of the same formula gap, found while fixing B and not separately flagged by Codex: the same pre-pass forced
effectiveArityto0for every non-overloaded method, assuming it always gets an ADR-0048-style zero-argument "compatibility" overload alongside any value-parameter one — untrue for a closed-instantiation-eligible member with real parameters, which gets only its real-parameterConfigure<T>()(no compatibility overload). Fixed with the same conditional as finding B, so a with-parameters closed-instantiation member is now correctly excluded from zero-arg collision detection entirely, not just given the right arity.
Fixed by extracting the duplicated shape test into one shared IsClosedInstantiationEligibleCandidate helper and rekeying all four call sites (the two collision pre-passes, the ADR-0049-specific name-reservation pre-pass, and the main loop's own eligibility check) off it, so the definition can no longer drift between them the way it just had four times. Two new regression tests reproduce Codex's own repros directly and assert clean generation (GeneratorTestHelpers.Verify/VerifyWithInfoDiagnostic, which both re-compile the real generated output). Full solution after this fix: 2342/2342 tests passing, zero build warnings.
PR #107 Codex review, round 4 (2026-08-23) caught one more real gap in IsClosedInstantiationEligibleReturnShape's own BCL Task<T>/ValueTask<T> identification: the check compared only ContainingNamespace and simple Name, never ContainingType — a consumer's own nested type also named Task<T>, declared inside some other type living in the System.Threading.Tasks namespace (Codex's repro: System.Threading.Tasks.Container.Task<T>), shares both of those with the real BCL Task<T> (a namespace is the same regardless of nesting depth), so it was misclassified as the supported shape. Downstream, TestDoubleDefaults would then emit a real global::System.Threading.Tasks.Task.FromResult<T>(...) default-value expression for a member whose actual declared return type is the unrelated nested type — a genuine type-mismatch compile error in generated code. Fixed by additionally requiring ContainingType is null (the real BCL Task<T>/ValueTask<T> are always top-level) — the minimum fix Codex's own finding sanctioned, without needing to thread a Compilation down to this static helper for a full GetTypeByMetadataName comparison. A new regression test (GenericMethodReturningNestedTypeNamedLikeBclTask_ReportsUnsupportedGenericReturnShapeDiagnostic) reproduces the exact nested-type repro with VerifyFailure, proving it now correctly falls back to whole-interface CMP0031. Note: TestDoubleDefaults.cs has an identically-shaped, pre-existing check with the same underlying imprecision (ContainingNamespace+Name, no ContainingType) — not touched here, since it predates ADR-0049 and isn't part of the shape this PR's own reachable code paths exercise; left as a separate, unrelated latent issue outside this PR's scope, per this repo's own deferral discipline for pre-existing issues merely surfaced by new work. Full solution after this fix: 2344/2344 tests passing, zero build warnings.
PR #107 Codex review, round 5 (2026-08-23) caught two more real gaps:
- Finding 1 (P1):
TestDoubleDefaults'sValueTask<T>default-value expression usednew ValueTask<TResult>(inner)— butValueTask<TResult>has two constructors,(TResult result)and(Task<TResult> task), andinneris frequently the baredefaultliteral (any nullable-annotated reference or defaultable value type), which converts to both parameter types with no better-conversion tie-breaker — a realCS0121ambiguous-call compiler error in generated code. This was a latent, pre-existing bug inTestDoubleDefaults.csitself, reachable by any defaultableValueTask<T>member (not just a closed-instantiation one) — it had simply never been exercised by an existing test until ADR-0049 madeValueTask<T>/ValueTask<T?>the return type of a self-referencing generic member for the first time, unlike the otherwise-identicalTask<T>branch (already unambiguous viaTask.FromResult<T>(...), a static method). Fixed by switching to the equally-unambiguous staticValueTask.FromResult<TResult>(TResult)factory — this one was fixed at the source for everyValueTask<T>default-generation call site, not scoped to closed-instantiation members only, since the bug itself was never scoped to them either. - Finding 2 (P2): round 4's
ContainingType is nullfix only ruled out a nested impostor sharing the BCLTask<T>'s namespace and simple name — it didn't cover a genuinely top-level consumer type reopening the sameSystem.Threading.Tasksnamespace with their ownTask<T>(legal C# — a source-declared type is even permitted to shadow an imported one of the identical fully-qualified name,CS0436, a warning not an error). An interim fix comparing identity via the simpler, singularCompilation.GetTypeByMetadataNamelooked plausible but was proven wrong by the regression test itself:GetTypeByMetadataNamefollows the same "source wins" rule as ordinary C# name resolution rather than returningnullfor the ambiguity, so it silently returned the consumer's own shadow type — the fix appeared to do nothing, and running the test asVerify()(forcing a real recompile, notVerifyFailure()) caught a genuineCS0029in the generated code before the real fix was found. Fixed with a newTaskWellKnownTypeshelper (mirroring the existingCollectionWellKnownTypesprecedent) that resolves the real, externally-referenced BCL type viaCompilation.GetTypesByMetadataName(plural — every candidate across every assembly) filtered to exclude any candidate declared in the current compilation's own assembly — the interface's own declared return type still resolves to the shadow (per the same source-wins rule), so the identity comparison now correctly fails and the member falls back to whole-interfaceCMP0031.
Both fixes verified with real regression tests that reproduce Codex's own repros (one Verify(), proving the generated ValueTask.FromResult<T?> expression actually compiles and dispatches; one VerifyFailure(), proving the shadow-namespace shape correctly falls back). Full solution after this fix: 2348/2348 tests passing, zero build warnings.
PR #107 Codex review, round 6 (2026-08-23) caught two more gaps, one code, one docs:
- Finding 1 (code): the generated state class's own type parameter can't be renamed away from the real method's type parameter identifier — it's baked verbatim into every pre-rendered type-string this candidate's slot/parameter types already use (Roslyn's
ToDisplayString, not something this code chooses or can substitute). So when a consumer's own type parameter happens to be named identically to this file's derived state-class name (Codex's repro:T Get<__Get_State>()— the consumer's type parameter literally named__Get_State, coincidentally matching the derived name this PR's own convention produces for a member namedGet), the generatedclass __Get_State<__Get_State>isCS0694("type parameter has the same name as the type"), a real compiler error. Fixed by reserving the candidate's own type parameter name as an already-taken literal field name before the derived-name collision check runs — routes through the exact same collision-detection mechanism every other derived name in this file already uses, falling back to whole-interfaceCMP0031like any other derived-name collision, not a new exclusion mechanism. - Finding 2 (docs):
docs/packages/compono-testdoubles.md's "What it deliberately doesn't do" section still said, unchanged, that there was no support for "a generic method whose return type depends on its own type parameter" — directly contradicting the new "Per-closed- instantiation configuration" section just added earlier in the same file. Corrected to describe only the shapes still genuinely unsupported (deeper nesting, multiple type parameters), cross-referencing the new section and ADR-0049 for the now-supported narrower shape.
Regression test for finding 1 (VerifyFailure, reproducing Codex's exact __Get_State repro) confirms the correct CMP0031 fallback. Full solution after this fix: 2350/2350 tests passing, zero build warnings.
PR #107 Codex review, round 7 (2026-08-23) caught three more gaps — two of them direct follow-ons to round 6's own fix and round 5's own declined-scope decision, both real:
- Finding 1 (code, follow-on to round 6): round 6's fix for the
CS0694self-collision reserved the candidate's own type parameter name into the shared, interface-wideusedFieldNamesset — so an entirely unrelated method's own, differently-named type parameter merely happening to share that same string (not because anything actually collides, pure coincidence) wrongly poisoned the first method too (Codex's repro:T Get<T>()deriving__Get_State, alongside an unrelated__Get_State Other<__Get_State>()whose own type parameter literally has that name —Other's own derived state-class name is__Other_State, never anywhere nearGet's declaration). Fixed by making the check strictly self-scoped — a candidate's own type parameter name is compared only against its own derived state-class name, never reserved into the shared pool at all. This is the third time this exact class of bug (a plausible-looking name-collision fix that's itself too broad or too narrow) has needed a follow-up round — worth naming as a pattern: any fix in this area needs a second regression test proving the fix doesn't reject something that should legitimately generate, not just one proving the original repro is caught. - Finding 2 (code, reopens round 4's declined scope):
TestDoubleDefaults.TryGetDefaultExpression's ownTask/ValueTaskidentification had the identical namespace/simple-name-only imprecision the eligibility check inTestDoubleAnalyzeralready had fixed (rounds 4–5) — and this function is reached by any member whose declared return type isTask/Task<T>/ValueTask<T>, not just a closed-instantiation-eligible one, so it was never actually gated behind ADR-0049's own new eligibility check at all. Round 4's notes explicitly declined to fix this exact function, reasoning it was "pre-existing... unrelated to the shape this PR's own reachable code paths exercise" — Codex's round-7 finding directly refutes that framing with real evidence the function is reachable independent of ADR-0049 entirely, so it was fixed properly this time:TaskWellKnownTypesextended withIsTask/IsTaskOfT/IsValueTaskOfTgranular checks,TryGetDefaultExpressionnow takes aCompilationand verifies real BCL identity for theTask(arity 0) andTask<T>/ValueTask<T>(arity 1) branches — the non-genericValueTaskbranch needs no equivalent check, since its own default expression is the baredefaultliteral, which references no type by name and target-types correctly against whatever the explicit implementation's own declared return type is, real or shadowed alike. A regression test proves this directly: an ordinary, non-generic member returning a shadowedValueTask<T>now correctly falls through to the generic value-typedefaultfallback (identity-agnostic, always safe) instead of the broken BCL-specific expression —Verify()(full recompile) passes clean. - Finding 3 (docs):
docs/reference/diagnostics.md'sCMP0031section still described the diagnostic's cause using the old, pre-ADR-0049 wording (any self-referencing generic return, including the now-supportedT Get<T>()/Task<T> GetAsync<T>()shapes) — directly contradicting both the new package-doc section and the actual implemented diagnostic boundary. Corrected to enumerate exactly the shapes still genuinely unsupported (deeper nesting, multiple type parameters,allows ref struct, ref-like/self-referencing real parameters, derived-name collisions), cross-referencing the now-supported narrower shape.
Full solution after this fix: 2354/2354 tests passing, zero build warnings.
PR #107 Codex review, round 8 (2026-08-23) caught two more gaps, both building directly on the last two rounds:
- Finding 1 (code, second follow-on to round 6): fresh evidence beyond the state-class-name self-collision fixed in round 6/7 — a generic method's own name colliding with its own type parameter is
CS0694too, not only a type's. Codex's repro:__Get_Bucket Get<__Get_Bucket>(), where the consumer's type parameter matches this file's derived bucket method name (not the state class name), producinginternal __Get_State<__Get_Bucket> __Get_Bucket<__Get_Bucket>()— a method sharing its own name with its own type parameter. Fixed by extending the same self-scoped collision check (round 7's fix) to also cover the bucket method's derived name. The bucket dictionary field's own name still needs no equivalent check — a field has no type parameter of its own to collide with. - Finding 2 (scope, not a bug):
Task<T?> Get<T>() where T : struct(likewiseValueTask<T?>) reportsCMP0031, not the closed-instantiation surface — for a value-typeT, C# representsT?as the distinct generic typeSystem.Nullable<T>, not asTwith a nullable annotation (annotations apply only to reference types), so the eligibility check's direct symbol comparison against the method's ownTnever matches. Unlike every other round-6–8 finding, this is safe fallback behavior, not broken generated code — it correctly falls through to whole-interfaceCMP0031. Every real trivia-platform call site this ADR's evidence table cites iswhere T : class; per ADR-0029's evidence discipline, recognizingNullable<T>would be new, unevidenced capability, not a bug fix to the shape actually designed and spiked. Resolved by clarifying scope rather than expanding capability: added ADR-0049 Amendment 1 naming this explicitly as an out-of-scope shape (same disposition as deeper nesting/multi-type- parameter returns), and correcteddocs/packages/compono-testdoubles.md(both mentions) anddocs/reference/diagnostics.md'sCMP0031section to state thewhere T : classprecondition explicitly rather than implyingT?works unconditionally.
Regression tests for both: finding 1 (VerifyFailure, reproducing Codex's exact __Get_Bucket repro) confirms the correct CMP0031 fallback; finding 2 (VerifyFailure, Task<T?> Get<T>() where T : struct) documents and locks in the current, correct, already-safe behavior. Full solution after this fix: 2358/2358 tests passing, zero build warnings.
PR #107 Codex review, round 9 (2026-08-23) caught three more real gaps, all distinct from anything found before:
- Finding 1 (code): the bucket lookup method's own local variable was hardcoded as
boxeddirectly in the scriban template, never reserved collision-safely (unlike every other synthetic local, which is computed in the emitter viaSafeLocalName). ForT Create<boxed>(), the generated bucket method's own type parameter is also literallyboxed—out var boxedinside a method whose own type parameter isboxedisCS0412. Fixed by computing this local's name in the emitter (SafeLocalName("__boxed", ...), reserved against the method's own type parameter), matching every other synthetic local's precedent. This changed the generated identifier for every closed- instantiation member (not just the collision case — the default candidate name changed fromboxedto__boxed, matching this repo's__-prefixed internal-identifier convention), so all 8 existing closed-instantiation snapshots needed re-accepting alongside the new regression test. - Finding 2 (code):
Task<T?> Get<T>()with an unconstrainedT(nowhereclause at all) reported the full closed-instantiation surface instead ofCMP0031— the eligibility check's own newly-added Amendment 1 rule (T?requireswhere T : class, added in round 8) was never actually enforced in code, only documented. Unlike a value-type-constrainedT?(Roslyn represents that as the distinct typeSystem.Nullable<T>, already correctly rejected by the equality check failing outright), an unconstrainedT?stays the same symbolTwith a nullable annotation, whichSymbolEqualityComparer.Defaultignores — so the equality check silently passed, shipping the same unevidenced capability round 8 explicitly declined. Fixed by requiringHasReferenceTypeConstraintwhenever the matched return position is nullable-annotated, for both the bare-TandTask<T>/ValueTask<T>- wrapped cases. - Finding 3 (code): the old, pre-ADR-0049
derivedAuxiliaryNameOwnerspre-pass (over-approximate by design) didn't exclude a generic method whose own real parameter references its own type parameter (void M<T>(T x)) — categorically ineligible for ADR-0048 matching, so it will never emit any_m_{param}field for any parameter, not just the self-referencing one. Codex's repro:void M<T>(T x_State)'s phantom reservation__M_m_x_State(derived from a parameter merely namedx_State, a name that member will never actually emit as a field) exactly matched an unrelated closed-instantiation-eligible memberU M_m_x<U>()'s own real, actually-emitted derived state-class name — a false collision that rejected the whole interface even though the two members' generated names never conflict. Fixed by excluding a self-referencing-parameter generic method from this pre-pass entirely.
Regression tests for all three (VerifyWithInfoDiagnostic/VerifyFailure/ VerifyWithInfoDiagnostic, reproducing each of Codex's exact repros) — the two VerifyWithInfoDiagnostic tests force a full recompile, proving the generated code actually compiles, not just that the expected diagnostic is present. Full solution after this fix: 2364/2364 tests passing, zero build warnings.
PR #107 Codex review, round 10 (2026-08-23) caught two more real gaps, both the same "hoisted/parallel check never updated for ADR-0049" pattern as several earlier rounds:
- Finding 1: the self-scoped
CS0694collision check (rounds 6–9) only compared the consumer's type parameter against the derived state-class/bucket-method names — not the state class's own member names.Config Create<Config>()'s type parameter matches the state class's ownConfigfield literally (internal ReturnConfig<Config> Config;insideclass __Create_State<Config>), a real declaration collision. Fixed by extending the same self-scoped check to also coverConfig(always emitted) and, when the candidate has real parameters and isn't overloaded,Calls/Lock/Matcher_{param}(only emitted in that shape — checking against them for an overloaded/zero-parameter candidate would be a phantom check with nothing to actually collide with). - Finding 2:
T ToString<T>() where T : classreportedCMP0025(object-member collision), not the closed-instantiation surface. A separate, hoisted copy of the object-collision check — evaluated earlier, specifically so a member with no deterministic default is still diagnosed correctly beforehasConfigurationSurface/defaultExpressionare computed — only exempted an overloaded generic member from the "solo member's extension is zero-arity, non-generic" object-collision assumption, mirroring an exemption this PR's very first commit already added to a later, separate copy of the same check. It was never updated to also exempt a solo closed- instantiation-eligible member, even though that member's ownConfigure<T>()/Verify<T>()extension is genuinely generic and distinguishable fromobject.ToString()— the same reasoning the later exemption already used. Fixed by mirroring that later exemption's exact shape in this hoisted copy too.
Regression tests for both (VerifyFailure/VerifyWithInfoDiagnostic, reproducing Codex's exact repros) — the latter forces a full recompile, proving the generated code actually compiles when calling the interface's own generic ToString<T>() via an explicit type argument. Full solution after this fix: 2368/2368 tests passing, zero build warnings.
PR #107 Codex review, round 11 (2026-08-23) caught one more real gap, and exposed a real hole in the test harness itself while fixing it:
- Finding: the direct (non-
Task/ValueTask-wrapped)T? Get<T>() where T : classshape strips the?for the explicit implementation's declared return type (T, notT?, per the round-1 fix for theCS9334/CS0453cascade), but its fallback dispatch expression is the baredefaultliteral — i.e.null— returned from a method now declared to return non-nullableT. That's a realCS8603("possible null reference return") the existingCS8616/CS8619pragma never covered — those two only suppress signature/generic-argument nullability mismatches, not a direct return-statement analysis. A consumer building with warnings-as-errors couldn't compile this advertised-supported shape. Fixed by addingCS8603to both pragma disable/restore pairs in the template.
Confirmed by removing the fix and re-running the new test first — it failed with the exact CS8603 warning, then passed once the fix was restored, proving the test (not just the fix) is real.
A real hole in the test infrastructure, found while writing this regression test: Verify/VerifyWithInfoDiagnostic's own "generated code should compile" check filters DiagnosticSeverity.Error only — it has never once failed a test over a warning, so a real nullable-mismatch warning escaping the suppression pragma would have silently passed every existing test in this suite. Added a new helper, GeneratorTestHelpers.VerifyWithNoWarnings, asserting a given set of diagnostic IDs is absent from the recompiled output at any severity — used for this regression test, and worth reaching for on any future nullable-suppression-adjacent change.
This round's fix changed the pragma text for every closed-instantiation member needing nullable suppression, so the 5 existing affected snapshots needed re-accepting alongside the new test. Full solution after this fix: 2370/2370 tests passing, zero build warnings.
PR #107 Codex review, round 12 (2026-08-23) caught two more real gaps, both in name/identity resolution paths that predate — or sit adjacent to — this PR's own changes:
- Finding 1: the early, blanket
usedFieldNamesreservation pass (__{candidate.Name}for every non-overloaded eligible candidate) never checkedWouldGetConfigurationSurface— unlike every other reservation pass in this file. A diamond-colliding member (two base interfaces both declaringstring? Get_State(), no configuration surface, no field ever emitted under any name) still reserved__Get_State. An unrelated closed-instantiation-eligible memberT Get<T>()deriving that exact same suffixed name for its own real, actually-emitted nested state class then got falsely flagged as colliding with a name nothing ever actually emits, rejecting the whole interface. This reservation pass predates ADR-0049 entirely — it was harmless before this PR (its only consumer was the ADR-0048 collision loop, comparing against genuinely- derived names), and only became a real bug once the closed- instantiation reservation pass started deriving suffix-based names that could coincidentally match another member's literal name. Fixed by gating that reservation onWouldGetConfigurationSurface, matching every other pass. - Finding 2:
TaskWellKnownTypes' round-5 fix (filter out candidates declared in the consumer's own compilation) didn't rule out a genuinely third-party referenced assembly that also happens to declare a type named identically to the real BCLTask<T>/ValueTask<T>— with two or more non-consumer candidates,FirstOrDefaultcould pick either one depending on arbitrary reference-list ordering. Fixed by anchoring to the compilation's own resolvedSpecialType.System_Object— every real .NET compilation has exactly one true core/runtime assembly, andSystem.Objectcan only ever resolve to a type declared in it, the same assembly the genuineTask<T>/ValueTask<T>live in for every target this repo builds against. This is a strictly more precise replacement for round 5's original filter (it also correctly excludes the consumer's own shadow type, so the "not the consumer's own assembly" check was removed rather than kept alongside it). All three existing shadow-type regression tests (rounds 4, 5, 7) still pass unmodified, confirming the new anchor correctly identifies the real BCL type in every case already covered; a genuine three-assembly (consumer + real BCL + third-party impostor) repro was not added as a dedicated test — constructing one has no precedent in this test suite (ExtraReferencesexists onCodeGenerationOptionsbut is unused anywhere in it) and the fix itself is a standard, well-understood Roslyn idiom for locating the true core assembly, not a novel mechanism needing its own proof the way rounds 1–11's spikes did.
Regression test for finding 1 (VerifyWithInfoDiagnostic, reproducing Codex's exact Get_State/Get<T> repro) forces a full recompile, proving the generated code actually compiles with both the diamond- colliding fallback members and the closed-instantiation member coexisting correctly. Full solution after this fix: 2372/2372 tests passing, zero build warnings.
PR #107 Codex review, round 13 (2026-08-23) caught two more gaps — one code, the mirror image of round 12's own finding; one docs:
- Finding 1 (code): the exact mirror image of round 12's finding — that one was a non-emitting member (a diamond collision) polluting a real closed-instantiation member's derived name via the early blanket
usedFieldNamesreservation; this one is a different real emitter doing the same.U B_State<U>()is itself closed-instantiation- eligible with a configuration surface, so round 12'sWouldGetConfigurationSurfacegate alone didn't exclude it — but a closed-instantiation-eligible candidate never emits the ordinary__{Name}field regardless of its own configuration-surface outcome (it emits__{Name}_State/_buckets/_Bucketinstead), so reserving__B_Stateon its own behalf was still a phantom reservation — one that then falsely collided with an unrelated closed-instantiation memberT B<T>()'s own real, actually-emitted derived state-class name (also__B_State, fromB+_State). Fixed by also excluding closed-instantiation candidates from this early reservation loop, alongside round 12's existingWouldGetConfigurationSurfaceexclusion. - Finding 2 (docs):
src/Compono.Generators/AnalyzerReleases.Unshipped.md'sCMP0031ledger entry still described the old, pre-ADR-0049 cause (any self-referencing generic return, unconditionally) — the same staleness round 6 already found and fixed indocs/reference/diagnostics.md, just in this separate, machine-read analyzer-release-tracking file. Corrected to match the narrower, now-accurate boundary.
Regression test for finding 1 (VerifyWithInfoDiagnostic, reproducing Codex's exact B/B_State repro) forces a full recompile, proving both members generate real, independently-working surfaces. Full solution after this fix: 2374/2374 tests passing, zero build warnings.