[PLAN-0045] Compono.TestDoubles: Configuration-Required Members¶
Status: Done
Implements: ADR-0045
Goal¶
A Compono.TestDoubles member (property or method, including through Task<T>/ValueTask<T>) that returns a non-nullable reference type with no deterministic default no longer rejects its whole interface at generation time, provided the member would otherwise have a real Configure()/Verify() surface — the interface generates, and that specific member throws a clear TestDoubleNotConfiguredException if invoked before Configure().Member(...).Returns(...)/.Throws(...) is called, via the new CMP0032 diagnostic (one per interface, not one per member). A member with no deterministic default that also has no configuration surface for an unrelated reason (a diamond collision, a zero-argument-extension collision, an overloaded ref/out/in parameter — or, for a method specifically, an object-member collision; a colliding property already rejects via CMP0024 before its return type is even checked, unaffected either way, Amendment 7) is unaffected — it keeps its unchanged CMP0025 whole-interface rejection, same as today, so no member ever ends up throwing unconditionally with no way to configure it. A real dotnet publish -p:PublishAot=true run proves the new dispatch shape stays AOT-safe, and a third lightsaber-skill dogfooding pass measures whether real tests can now actually drop Compono.NSubstitute — not just how many interfaces generate.
Scope¶
Builds exactly what ADR-0045 decided: a new member-scoped dispatch fallback (throw instead of a computed default) for the one specific CMP0025 sub-case ADR-0045 identifies, reusing the existing ReturnConfig<T>/ReturnConfigBuilder<T> state machinery unchanged, plus one new exception type (TestDoubleNotConfiguredException) and one new diagnostic (CMP0032). Explicitly deferred/out of scope, per ADR-0045's own boundaries: relaxing whole-interface rejection for any other unsupported shape (pointer parameters, ref/out/in parameters without a sibling overload, unconstrained T? type parameters, a generic method whose return type depends on its own type parameter); special-casing fluent self-return (rejected in ADR-0045); manufacturing or composing return values (rejected in ADR-0045); reopening ADR-0044/PLAN-0044 (both stay Accepted/Done, untouched).
Tasks¶
Phase 0 — Configuration-required return semantics (Done)¶
-
src/Compono/TestDoubleNotConfiguredException.cs: newsealedexception type, message-only constructor, matchingTestDoubleVerificationException's exact shape. -
docs/reference/api/Compono/: regenerate the API reference pages for the new publicTestDoubleNotConfiguredExceptiontype and its constructor (per Codex review — ADR-0032's toolchain/CI gate checks the committed generated reference for drift; the analogousTestDoubleVerificationExceptionchange committed both a type page and a constructor page, matching shape expected here). -
src/Compono.Generators/AnalyzerReleases.Unshipped.md: add theCMP0032entry (per Codex review —Compono.Generators.csproj'sEnforceExtendedAnalyzerRulesmakes this a required Roslyn analyzer-release-tracking file; every existingCMP00xxdescriptor already has a matching row here, and Phase 0 doesn't build clean without adding this one alongside the descriptor task below). -
src/Compono.Generators/Diagnostics/DiagnosticDescriptors.cs: addCMP0032("Test-double member(s) require explicit configuration"),DiagnosticSeverity.Info, interface-scoped, count-only message text (per ADR-0045 Amendment 1: one diagnostic per interface, fired once with a count of how many members require configuration, not one per member — avoids diagnostic-noise blowup on a large real-world interface likeIAmazonS3; the exact member identity is supplied precisely byTestDoubleNotConfiguredExceptionat the point a configuration-required member is actually invoked unconfigured, so the diagnostic doesn't need to enumerate members by name to stay useful).CMP0025's message text/descriptor is unchanged (ADR-0045 Amendment 4 — it still describes all four of its original shape sub-cases, including "a non-nullable reference type with no deterministic default"): only the condition for reaching that fourth branch changes, per the analyzer task below — it now fires only when the member wouldn't have had a configuration surface anyway (Amendment 3's combined-shape case); every other no-default member takes the newCMP0032path instead. -
src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs: at the method-return-type check (TryGetDefaultExpressionfailure for a method's return type) and the property-type check (same failure for a property's type), stop returning whole-interfaceFailure(...)for the "non-nullable reference, no deterministic default" case — but only when the member would otherwise have a realHasConfigurationSurface(Amendment 3, corrected by Amendment 6): genuinely-unimplementable shapes (by-ref, pointer, ref-like, checked separately just above these two call sites) keep failing exactly as today, and so does a member that combines "no deterministic default" with "no configuration surface for an unrelated reason" — a diamond-colliding identity, a zero-argument-extension collision, an overloadedref/out/inparameter, or, for the method branch specifically, an object-member-collision shape (ToString/GetHashCode/GetType/Equals, Amendment 6 — hoist this predicate's evaluation to before this check, reusing the same logic the existing object-collision check at lines ~524-555 already computes, rather than relying on that later check's ownFailure(...)to catch it retroactively; that would change the diagnostic this combined shape produces fromCMP0025today toCMP0024, which Amendment 6 rules out). The property branch needs no corresponding change (Amendment 7) — its own object-collision check (lines ~732-737) already runs before its return-type check (lines ~766-769), the reverse of the method branch's order, so a colliding property already rejects viaCMP0024unconditionally today, independent of its return type; do not reorder or touch the property branch's existing check sequence. Reuse whichever ofisDiamondCollision/isZeroArgCollision/hasRefOutInParameter/the hoisted object-collision predicate is already computed at that point (method branch only) rather than adding new detection logic beyond that one hoist. Only when a real surface would exist: mark the member as configuration-required (member-scoped for generation purposes, following the same shapeCMP0030's out-parameter exclusion already uses to keep an interface generating while excluding just one member's full surface) and collect it into a per-interface count, the same "collect across the member-walk" shapeCMP0028already uses. After the full member walk, if that count is nonzero, emit exactly oneCMP0032for the interface, naming the interface and the count — not one per member (Amendment 1). -
src/Compono.Generators/Emitters/TestDoubleEmitter.cs/src/Compono.Generators/Templates/TestDouble.scriban: new dispatch- body branch for a configuration-required member — identicalRecordCall()/HasConfiguredException/HasConfiguredValueshape every member already has, with the final fallback emittingthrow new global::Compono.TestDoubleNotConfiguredException(...)(a fully literal, generation-time-computed message: interface name, member name and signature, the fix hint) instead of a computed default expression.Configure()/Verify()extension generation for this member is unchanged — it already works for anyTregardless of whetherThas a default, sinceReturnConfigBuilder<T>.Returns/.Throwsnever depended on one. -
test/Compono.Generators.Tests/: generator snapshot/behavior tests — a configuration-required method member, a configuration-required property member, confirming (a) the interface still generates, (b) exactly oneCMP0032fires for the interface with the correct count in its text (not one per member), © an interface with multiple configuration-required members (anIAmazonS3-shaped regression case) still emits exactly oneCMP0032, with the count matching, (d) every other member on the same interface is unaffected, (e)CMP0025still fires unchanged for a genuinely unimplementable shape on a different interface (regression coverage — this ADR narrowsCMP0025's scope, doesn't remove its remaining trigger), (f)CMP0025also still fires unchanged (neverCMP0024) for every combined shape Amendments 3 and 6 identify — all four of them, not a subset: an overloadedref/out/inmember with a no-default return type, a diamond-colliding member with a no-default return type, a same-named zero-argument-extension-colliding member with a no-default return type (Amendment 3's third named condition — a naive gate checking only the other three would mark this one configuration-required despite itsConfigure()surface being withheld, generating a member that throws unconditionally forever), and an object-member-collision-shaped method (ToString/GetHashCode/GetType/Equals) with a no-default return type (Amendment 6 — this one specifically assertsCMP0025, notCMP0024, since Amendment 5's original "it'll just fall through to the object-collision check and get CMP0024, which is fine" reasoning was itself wrong and withdrawn) — each on an interface that also has an unrelated, genuinely configuration- required member, confirming the combined-shape gate doesn't accidentally suppressCMP0025/CMP0024or leak a surfaceless member intoCMP0032's count. - Do not enable generation in
test/Compono.TestDoubles.Tests(superseding the previous round's plan, per Codex review — a real correctness bug in that earlier fix, not just a missing property). That project'sGeneratedTestDoubleProviderTests.cs,PrecedenceTests.cs, andCompositionBuilderExtensionsTests.cseach declare their own trivially-generatable fixture interfaces (IRepository,IUnregisteredRepository,IGateway,IService) and hand-callGeneratedTestDoubleRegistry.RegisterFactory<T>(...)after composition starts, specifically to test hand-managed registry/precedence behavior —RegisterFactoryis first- registration-wins (ADR-0043 Amendment 3), and a[ModuleInitializer]always runs before any test method, so enabling generation project-wide would let the generator discover these same fixture interfaces (each is referenced via a realComposer.Create<T>()call, a real discovery root) and silently register generated factories for them before the hand calls ever run — making the hand registration a no-op and flippingTryProvide_DoesNotHandle_AnInterfaceWithNoRegisteredFactoryfrom "throws" to "succeeds". At least three existing test files would break, silently, with no compile-time signal. -
test/Compono.TestDoubles.SampleTests/: newConfigurationRequiredMemberTests.cs(matching the one-file-per- shape pattern this project's sibling files already establish —GeneratedDoubleTests.cs,OverloadedMemberTests.cs,GenericMemberTests.cs,VerificationTests.cs) — a configuration- required member throwsTestDoubleNotConfiguredExceptionwhen unconfigured, returns the configured value afterReturns(...), throws the configured exception afterThrows(...)— same three- state coverage every other member type already has. This project is the right home: alreadyComponoGeneratedTestDoubles=true, a real packagedPackageReferenceconsumer (noProjectReferencetoCompono/Compono.TestDoublesat all, per its own.csprojcomment), and none of its existing files hand-manage the registry — no risk of the interaction above. - Async coverage, moved here from a later phase per Codex review: same
test/Compono.TestDoubles.SampleTests/file, aTask<TReference>-returning configuration-required member and aValueTask<TReference>-returning one, both states (unconfigured throws, configured returns/throws). Phase 0's own analyzer change applies to every no-default return shape including these two — this plan's "no separate implementation needed for async" claim (ADR-0045's "Async returns" section) is explicitly a hypothesis, not a certainty, so Phase 0 must not ship as its own mergeable PR without confirming it empirically first; deferring this to a later phase risked shipping a broken generated async member in between. If this surfaces a real gap (contrary to the ADR's expectation), record it as an ADR-0045 Amendment before proceeding, per this repo's Amendment convention — don't silently patch around it. - Fluent self-return, moved here from a later phase per Codex review: same
test/Compono.TestDoubles.SampleTests/file, anIResponseBuilder-shaped fluent self-returning member (a method returning the interface itself, RESEARCH-0004's own motivating example and this ADR's central "no special-casing" decision) — confirm it's configuration-required like any other non-nullable reference return, and that configuring it (Returns(self)) works for a chained-call test. Phase 0 changes the analyzer for exactly this shape and ships as its own mergeable PR; deferring its first proof to Phase 1 would ship the capability's own central motivating case unvalidated. - Packaged-consumer smoke test, this phase's own shape only (added per Codex review — matching PLAN-0044's own established pattern, added there for the identical reason:
dotnet packcoreCompono/Compono.Generatorsinto a local feed, a throwaway consumer project referencing the packed.nupkg(never aProjectReference) withComponoGeneratedTestDoubles=true, exercising a configuration-required method, property, an async (Task<TReference>) member, a fluent self-returning member, and the combined-shape regression case end to end with a realdotnet build/dotnet run. PLAN-0044's own Notes record that every defect its review round found (CS0122,CS0460,CS0111,CS0214,CS0177) was exactly the class of cross-assembly compile failure an in-process snapshot test cannot catch — this phase does not ship (its own PR does not merge) until this smoke test is green, rather than deferring all packaged proof to Phase 2. - Docs, this phase's own shape (moved here from a later docs-only phase per Codex review — matching PLAN-0044's own precedent for the identical reason:
references/documentation.md's "update the relevant doc in the same PR" rule means Phase 0 shipping the public exception, the runtime behavior, andCMP0032as its own PR can't leavedocs/packages/compono-testdoubles.md/docs/reference/diagnostics.mdstill describingCMP0025as unconditional whole-interface rejection until some later PR): docs/packages/compono-testdoubles.md: new "Configuration-required members" section (parallel to the existing "Overloaded members"/ "Generic methods"/"Call verification" sections) documenting the dispatch rule and a real example using one of RESEARCH-0004's acceptance interfaces. Update "Deterministic defaults for unconfigured members" to cross-reference the new section rather than imply every non-nullable-reference return is still a hard rejection.docs/reference/diagnostics.md:CMP0025's entry is not narrowed (ADR-0045 Amendment 4) — update its Cause text to note the fourth sub-case now only fires when the member also has no configuration surface for an unrelated reason, cross-referencing the new "Configuration-required members" doc section for the ordinary case. Add aCMP0032entry (Cause/Fix, matching the existing entries' shape) explaining it's one diagnostic per interface (a count), not whole-interface rejection.skills/compono/references/diagnostics.md: same two updates, keeping the skill-local summary table consistent with the canonical file (per the pattern PLAN-0044 Phase 4 already established for keeping these two files in sync).skills/compono/references/testdoubles.md: document the new configuration-required-member behavior for agent-facing migration guidance — in particular, that an agent migrating a test offCompono.NSubstituteshould now expect some generated members to require explicitReturns(...)/Throws(...)before use, rather than assuming "it generated, therefore every call is safe unconfigured."docs/troubleshooting/common-errors.md,docs/getting-started/ai-agent-skill.md, andskills/compono/SKILL.md(three separateCMP0020-CMP0031range caps found there via a repo-wide search — its trigger-metadata description, workflow step 6, and reference-routing table entry, the same three spots PLAN-0044 Phase 4 updated forCMP0031): bump every already-identifiedCMP0020-CMP0031range cap toCMP0020-CMP0032. Moved here from a later consistency-pass phase per Codex review — these are already-known stale locations, not something a later sweep needs to discover; deferring already-identified staleness left consumer- and agent-facing entry points describing a smaller diagnostic range than what Phase 0 actually ships.
Phase 1 — Existing-behavior regression coverage (Done)¶
- Confirm zero behavior change for every already-shipped deterministic-default member shape (
bool,int, nullable reference,Task, known collection shapes) — existing v1/v2 tests continue passing unmodified; add one small regression test mixing a configuration-required member and a deterministic-default member on the same interface if no existing test already covers this combination.
Phase 2 — Packaged/AOT verification (Done)¶
Phase 0's own lightweight packaged smoke test already proves basic cross-assembly compilation; this phase is the AOT-specific proof and the full supported-TFM matrix, not the first point this feature gets packaged at all.
- Extend
test/Compono.TestDoubles.AotSmokeTest/Program.csto exercise a configuration-required synchronous method, a configuration-required property, and a configuration-requiredTask<T>-returning method — both the configured-success path and the throws-when-unconfigured path — under a realdotnet publish -p:PublishAot=truerun. Manually verify zero IL2xxx/IL3xxx warnings and a correct exit code, per this repo's "prove it, don't assume it" standard (PLAN-0044 Phase 3's same discipline). -
test/Compono.TestDoubles.SampleTests/: a real packaged-.nupkgtest proving the same shapes across all supported TFMs, matching PLAN-0044 Phase 3's existing pattern (no workflow change expected — runs automatically in CI viapackage-validation.yaml). - Performance: no new benchmark class added preemptively (ADR-0045's "Performance" section, per ADR-0034's benchmark-only-if-real-risk policy). If implementation surfaces an actual measured concern during this phase, record it as an ADR-0045 Amendment and add a targeted benchmark then — not before.
Phase 3 — Documentation consistency pass (Done)¶
Every doc touch introducing this feature's own behavior, and every already-identified stale reference, already happened in Phase 0 (moved there per Codex review — see Phase 0's "Docs, this phase's own shape" task, which now includes every location a repo-wide search found before this phase was even reached). Unlike PLAN-0044 (which phased overloads/ generics/verification across three separate PRs and needed a real cross-cutting consistency pass), this plan's only behavior-introducing phase is Phase 0 — so this phase is narrower still: a final proactive sweep for anything genuinely not yet discovered, run once Phases 0-2 have actually shipped and there's real code to check docs against.
- Grep the repo for any stale
CMP0020-CMP0031-style range cap or "returning a non-nullable reference always rejects" claim that wasn't already caught by Phase 0's own search, matching the proactive sweep PLAN-0044 Phase 4 ran before its own final push.
Phase 4 — Third lightsaber-skill dogfood (Done)¶
- Re-run the exact
lightsaber-skillmigration analysis (same method as RESEARCH-0004) against the shipped implementation of this ADR. Quantify against the acceptance cases:IResponseBuilder,IAmazonS3,ISkillMediator,IOptions<LightsaberOptions>,ILambdaContext,IHandlerInput— which now generate; which of their members are configuration-required vs. deterministic-default; whetherILogger<T>(already working under v2) still works unchanged (regression check, not a redesign target). - The acceptance criterion is "can real tests remove
Compono.NSubstitute," not "do more interfaces generate." Quantify against the same ~40 original NSubstitute call sites: how many can now migrate; how many tests, if any, can dropCompono.NSubstituteentirely; whether any test still needs both providers side by side and why. - Record the result as a new
docs/research/*.mdfinding (next sequential number after RESEARCH-0004), following the same evidence-record convention. Updatedocs/roadmap/post-mvp.md's entry for this candidate accordingly — move it from "outstanding" to "shipped" only if the real-test-removal bar is actually met; if it's a partial improvement short of that bar, record the honest result the same way RESEARCH-0004 did, and open a further roadmap candidate for any residual gap rather than overstating this one.
Phase 4 result: RESEARCH-0005. Six of seven interfaces now generate and resolve cleanly (CMP0025 never fired); 4 of 5 real test files fully migrated off Compono.NSubstitute (~44 call sites down to ~9, all in one file). Compono.NSubstitute still required project-wide: IAmazonS3 declares a static abstract member (CreateDefaultClientConfig), a shape Compono.TestDoubles explicitly doesn't support (ADR-0042 Non-Goals) — not a gap ADR-0045 was scoped to close. Per this task's own instruction, a residual gap normally opens a further roadmap candidate; this one doesn't, because RESEARCH-0005 classifies it not a bug and not a new roadmap candidate under ADR-0029's rubric — a narrow, rare, already-documented non-goal, not evidence Compono needs a new capability. A controlled before/after hyperfine benchmark on the same suite found no meaningful performance difference (-1.05% on median, inside run-to-run noise) — recorded as an honest real-world data point, not a general Compono performance claim. docs/roadmap/post-mvp.md updated accordingly: the candidate is removed from "outstanding" (the capability that blocked real migration is confirmed fixed) with the residual IAmazonS3 limit recorded in prose, not as a new candidate entry.
Update (2026-08-18, same day): the paragraph above is superseded by a same-day reclassification — kept here unedited, per this plan's own historical-record convention, rather than rewritten to look like it was always this way. Measured against a stronger, explicit product-owner acceptance criterion (full Compono.NSubstitute removal from lightsaber-skill, not just "material improvement"), the residual IAmazonS3 gap is a roadmap candidate after all — see RESEARCH-0005's own "Reclassification" section, ADR-0042 Amendment 2, and ADR-0046/ PLAN-0046, which now track it. docs/roadmap/post-mvp.md reflects the reclassified, current state, not this paragraph's original conclusion.
Critical Files¶
src/Compono/TestDoubleNotConfiguredException.cs— new exception type.docs/reference/api/Compono/— regenerated pages for the new exception type/constructor (ADR-0032's toolchain, drift-checked in CI).src/Compono.Generators/AnalyzerReleases.Unshipped.md— newCMP0032row (required byEnforceExtendedAnalyzerRules).src/Compono/ReturnConfig.cs,ReturnConfigBuilder.cs— unchanged, reused as-is; listed for reviewer visibility that nothing here changes.src/Compono.Generators/Diagnostics/DiagnosticDescriptors.cs— newCMP0032descriptor;CMP0025's own message text is unchanged (Amendment 4) — only the analyzer condition for reaching it narrows.src/Compono.Generators/Discovery/TestDoubleAnalyzer.cs— the method- return-type and property-type default-lookup failure branches change from whole-interfaceFailure(...)to member-scoped configuration- required marking, for the one sub-case ADR-0045 scopes.src/Compono.Generators/Emitters/TestDoubleEmitter.cs,src/Compono.Generators/Templates/TestDouble.scriban— new configuration-required dispatch-body branch.test/Compono.TestDoubles.Tests/— not modified (generation stays disabled there — see Phase 0's own task for why enabling it would silently break three existing test files).test/Compono.Generators.Tests/,test/Compono.TestDoubles.SampleTests/,test/Compono.TestDoubles.AotSmokeTest/Program.cs— new coverage per phase above.docs/packages/compono-testdoubles.md,docs/reference/diagnostics.md,skills/compono/references/diagnostics.md,skills/compono/references/testdoubles.md,docs/troubleshooting/common-errors.md,docs/getting-started/ai-agent-skill.md,skills/compono/SKILL.md— doc/skill alignment for this feature's own behavior, including every already-identified staleCMP0020-CMP0031range cap (Phase 0, per Codex review — moved out of a later phase since these were already known, not left for a later sweep to discover).docs/roadmap/post-mvp.md, a newdocs/research/000N-*.md— Phase 4's dogfood result.
Test Plan¶
Matches references/testing.md's existing pattern for this feature area (established by PLAN-0043/PLAN-0044): generator-level snapshot/behavior tests for the analysis and diagnostic changes, plus packaged-consumer behavior tests for the three dispatch states (unconfigured throws, configured-return, configured-throws) across the sync/property/async/ fluent-self-return shapes — all in Phase 0, since that's the phase that actually ships the behavior and Phase 0 doesn't merge without this coverage, including its own central IResponseBuilder-shaped motivating case (Codex review); Phase 1 adds only the existing-behavior regression check on top, confirming zero change for already-shipped deterministic- default shapes. A real PublishAot=true execution proof rather than static AOT-safety analysis (Phase 2, "prove it, don't assume it"), and a real external-project dogfooding pass as the final acceptance test (Phase 4) rather than relying on in-repo tests alone to validate the real-world claim this ADR is motivated by.
Notes¶
Phase 0's "no separate implementation needed for async" expectation (ADR-0045's own reasoning, based on ReturnConfig<T> already being generic over the member's real declared return type) is a hypothesis carried into the plan, not a certainty — moved from a later phase into Phase 0 itself per Codex review, since Phase 0 ships as its own mergeable PR and touches every no-default return shape including async ones. Phase 0's task list explicitly calls for recording an ADR-0045 Amendment if implementation proves the hypothesis wrong, rather than silently reshaping the plan around a surprise.
Phase 0 result: the hypothesis held — no separate implementation was needed for Task<T>/ValueTask<T> async members, confirmed empirically by test/Compono.TestDoubles.SampleTests/ConfigurationRequiredMemberTests.cs against a real packaged consumer (ComponoGeneratedTestDoubles=true, a locally-packed .nupkg restore, not a ProjectReference), across every supported TFM (net8.0-net11.0). The same file also confirms the fluent self-return case (Returns(self), a chained-call test) works with no special-casing, exactly as ADR-0045 decided. No ADR-0045 Amendment was needed. The generator-level regression coverage for all four combined shapes (Amendments 3 and 6) lives in test/Compono.Generators.Tests/TestDoubleVerifyTests.cs.
Phase 1 result: zero behavior change confirmed — the full solution test suite (2147 tests, every already-shipped deterministic-default member shape included) passes unmodified. The generator-level MultipleConfigurationRequiredMembers_ReportsSingleCmp0032WithCorrectCount test (Phase 0) already mixed a deterministic-default member (int GetCount()) with configuration-required members on one interface for diagnostic-count purposes, but no existing test exercised the runtime dispatch behavior of both member kinds together on the same double. Added Deterministic_default_members_are_unaffected_by_sibling_configuration_required_members to test/Compono.TestDoubles.SampleTests/ConfigurationRequiredMemberTests.cs, extending IProfileRepository with one member per deterministic-default shape the plan names — int ViewCount (0), bool IsActive (false), string? Nickname (null), IReadOnlyList<string> Tags() ([], the known-collection-shape default), and Task<int> GetScoreAsync() (Task.FromResult(0)) — confirming each returns its own computed default unconfigured, on the same instance and same interface as the existing configuration-required members, with no interaction between the two dispatch paths.
Phase 2 result: extended test/Compono.TestDoubles.AotSmokeTest/Program.cs with a new IProfileRepository-shaped interface (a synchronous method, a property, and a Task<T>-returning method, all configuration-required) and exercised both states — unconfigured throws TestDoubleNotConfiguredException (caught via a plain try/catch helper, since this project deliberately carries no xUnit/AwesomeAssertions usings), then Configure().Member().Returns(...) dispatches the real value — under a real dotnet publish -c Release -f net10.0 -p:PublishAot=true run against the packaged Compono/Compono.TestDoubles (a PackageReference, not a ProjectReference, per this project's own established reasoning). Manually confirmed: zero IL2xxx/IL3xxx trim/AOT warnings anywhere in the publish output, and running the published native binary printed the expected PASS line and exited 0. Bullet two (a real packaged-.nupkg test proving the same shapes across all supported TFMs) was already satisfied by Phase 0/1's own test/Compono.TestDoubles.SampleTests/ConfigurationRequiredMemberTests.cs — that project already restores Compono.TestDoubles from a locally-packed .nupkg (never a ProjectReference) and already builds/runs across all four supported TFMs (net8.0-net11.0, test/Directory.Build.props), and already covers a configuration-required sync method, property, and Task<T> method (Phase 0) alongside every deterministic-default shape (Phase 1); package-validation.yaml's existing "Local-feed packed-consumer smoke test (Compono.TestDoubles)" step already runs it in CI with no workflow change needed — no new bullet-two-specific test file was required. No new benchmark was added (bullet three): nothing in this phase surfaced a measured performance concern, so no ADR-0045 Amendment was needed either.
Phase 3 result: repo-wide grep for stale CMP0020-CMP0031-style range caps and "non-nullable reference always rejects" claims, beyond what Phase 0's own search already fixed. The two literal patterns the task names turned up nothing: skills/compono/references/testdoubles.md:211's CMP0020-CMP0031 range is not stale — it specifically scopes the whole-interface-rejection subset of diagnostics (the line immediately after it already correctly notes the no-default-return case no longer rejects the whole interface, per v2). The one other pre-existing "still unsupported" table row (docs/adr/0044-compono-testdoubles-v2-overloads-generics-verification.md line 435) is Accepted and immutable, correctly describing ADR-0044's own scope at the time it was written — not a living doc required to track ADR-0045's later, separate decision. Every consumer- and agent-facing diagnostics/skill reference (docs/reference/diagnostics.md, skills/compono/references/diagnostics.md, skills/compono/references/testdoubles.md, skills/compono/SKILL.md, docs/troubleshooting/common-errors.md, docs/getting-started/ai-agent-skill.md) already correctly reads CMP0020-CMP0032 from Phase 0.
Widening the sweep past the task's two named patterns (per Codex-style review — the first pass only grepped the literal wording, not the broader "is any doc's status/progress claim about this feature now stale") found two real, previously-missed staleness cases, both fixed in this phase: docs/roadmap/post-mvp.md's fourth-dogfooding-pass bullet still said "[PLAN-0045] tracks the implementation, not yet started" — stale now that Phases 0-3 are Done; corrected to name which phases shipped and that only Phase 4 remains. docs/research/0004-lightsaber-skill-testdoubles-v2-dogfood.md's Feeds: line and closing "Decisions" paragraph both still said PLAN-0045 (Not Started); corrected the same way in both spots. docs/plans/README.md's own status column (In Progress) and ADR-0045's own cross-references were checked and are already accurate — no change needed there.