[PLAN-0043] Compono-Generated Test Doubles¶
Status: Done
Implements: ADR-0043 (design), ADR-0042 (admitted problem)
Goal¶
ComponoGeneratedTestDoubles=true plus builder.UseGeneratedTestDoubles() lets composer.Create<T>() automatically satisfy an otherwise-unresolvable interface dependency with a generated, AOT-safe double — configurable via interfaceValue.Configure().Member().Returns(...)/.Throws(...) (a generator-emitted extension per discovered interface, per ADR-0043 Amendment 1) — with zero behavior change for any consumer who doesn't opt into both gates, and a real dotnet publish -p:PublishAot=true execution test proving the whole path is Native-AOT-safe.
Scope¶
Builds exactly what ADR-0043 (as corrected by Amendment 2) decided — the generated code shape, the compile-time opt-in on LeafTypeClassifier, the core registry/builder primitives, GeneratedTestDoubleProvider, and the new Compono.TestDoubles package. Explicitly deferred, per ADR-0042's Non-Goals (unchanged by ADR-0043): verification, call recording, strict mode, argument matchers (struck entirely by Amendment 2 — configuration is member-level and argument-independent, not "a minimal closed shape" as the pre-Amendment text once said), callbacks, sequential returns, class/protected-member/static-abstract-member support, indexers/events/generic methods/ref/out/in parameters. Standalone (non-Compono) usability is included only if it falls out at the cost ADR-0043's "Standalone usability" section already found (near zero) — not worth its own phase if it turns out to need more than that.
Phases¶
Phase 0 — Core primitives and generator foundation¶
- Core
Compono(notCompono.TestDoubles— Amendment 2 moved these to fix a cross-assembly reference the original design got backwards):ReturnConfig<T>(internalbacking fields,publicreadonly accessors —HasConfiguredValue/HasConfiguredException/ConfiguredValue/ConfiguredException— for cross-assembly generated dispatch code to read; Amendment 3 Finding A),ReturnConfigBuilder<T>(apublic readonly ref structholding aref ReturnConfig<T>, public constructor — Amendment 3 Finding A — whoseReturnssets bothValueandHasValue(Amendment 3 Finding B) and clears any previously-setException, and whoseThrowsclearsHasValue(last-configuration-wins — Amendment 7 Finding R), andGeneratedTestDoubleRegistry(RegisterFactory<T>(Func<T> factory)/TryCreate(Type requestedType, out object? value),Type-keyed, first-registration-wins — Amendment 3 Finding C documents this as a known v1 limitation for multi-assembly same-interface scenarios, not something this phase needs to solve) — always present in core, inert unless a factory is ever registered. - Extend
LeafTypeClassifierwith the compile-time-gated third classification outcome (ADR-0043's "Generator architecture"). - Ship a
CompilerVisiblePropertydeclaration forComponoGeneratedTestDoublesvia coreCompono's own packaged build assets (Amendment 4 Finding F — a custom MSBuild property is not automatically visible toAnalyzerConfigOptionsProviderthe way a built-in one likeInterceptorsNamespacesis; without this declaration the opt-in can never activate, regardless of what a consumer sets). - Read
ComponoGeneratedTestDoublesviaAnalyzerConfigOptionsProvider; confirm zero generated-output diff when unset/false(a compile-diff regression test, not just a manual check). - Emit, per discovered interface, one single generated file, no namespace declaration (global namespace) — Amendment 11 Finding AA:
internalaccessibility alone doesn't makeConfigure()/the per-member extensions reachable from an arbitrary consumer namespace without an import, and Amendment 4 Finding G already retired the global-using injection on the assumption no import would ever be needed — true only once every generated type is unconditionally visible, i.e. in the global namespace. Containing (Amendment 2's verified-by-spike shape — do not file-scope any of the first three;CS9051blocks a file-local type from appearing in any non-file-local member's signature, even co-located):- Discovery walks the interface's full transitive base-interface closure (
ITypeSymbol.AllInterfaces), not just its own declared members (Amendment 11 Finding Z —IChild.GetMembers()doesn't return a memberIChild : IBaseonly inherits fromIBase; a double emitted fromIChild's own members alone would fail to implement it,CS0535). Every unsupported-shape/collision diagnostic below applies across the full closure, not just the leaf interface. An inherited member's explicit-implementation accessor is qualified against the interface that actually declares it (ReturnType IBase.Get()), not the leaf interface requested (ReturnType IChild.Get()would not compile). internal sealed class <Hash>_Double : IRepository— explicit interface implementation, oneReturnConfig<T>field per member. Avoidmember's field isReturnConfig<Compono.Unit>, whereUnit(if core doesn't already have one) is introduced aspublic readonly struct Unitfrom the start — notinternal, per Amendment 4 Finding H, applying Amendment 3's own cross-assembly-accessibility lesson up front rather than missing it for this one type too. A read/write property gets real auto-property semantics (Amendment 7 Finding Q, confirmed with the requester over two alternatives — properties-unsupported, and getter-only-with-no-op-setter): the getter and setter share oneReturnConfig<T>field, the getter returns whatever was last set or the deterministic default,Configure().<PropertyName>().Returns(...)/.Throws(...)still work as an explicit override. The write accessor's exact kind (setvs.init) must match what the interface actually declares (Amendment 9 Finding U —initandsetare non-interchangeable; emitting the wrong one fails to implement the interface) — aget-only property gets no write accessor at all, configurable only viaConfigure(), same as a method. Neither write accessor touchesReturnConfig<T>'s internal fields directly (Amendment 7's own sketch did, and reintroduced Amendment 3's cross-assemblyCS0122defect for writes — corrected by Amendment 8 Finding S) — both construct aReturnConfigBuilder<T>(public constructor) and call its publicReturnsmethod, the same public surface the externalConfigure()path already uses. A set-only property (a setter with no getter at all) is diagnosed as unsupported, not emitted (Amendment 10 Finding W, confirmed with the requester) — v1's already-decided lack of call recording/verification means nothing could ever observe a value written through a set-only property, so there's no meaningful behavior to give it, not just a limited one.internal static class <Hash>_DoubleConfiguration— per-member configuration extensions (FindAsync()/Save()/property names, no parameters — argument-independent per Amendment 2 Finding 4; a property's configuration extension is method-shaped exactly like an ordinary member's, no special-casing). Member names reuseRequiredMemberCollector.EscapeIdentifier's existingSyntaxFacts.GetKeywordKind-based@-escaping convention (src/Compono.Generators/Discovery/RequiredMemberCollector.cs), generalized rather than duplicated (Amendment 6 Finding O — an interface member like@newmust round-trip through its generated extension's name too, not just through generated type names). The same escaping applies to the member name in the explicit interface implementation too (Amendment 9 Finding V — Amendment 6 only covered the configuration extension;int IFoo.new()is still invalid without it), not just the type-name half Amendment 5 Finding J covers. And to every emitted method parameter name (Amendment 10 Finding X —void Save(int @class)'s parameter symbol name is the bareclass; escaping only the member name left the parameter list itself invalid).internal static class <Hash>_ConfigureExtension— theConfigure(this IRepository)bridge (Amendment 1, corrected target type by Amendment 2), whose cast-failure exception message names the multi-assembly-collision scenario explicitly (Amendment 3 Finding C).file static class <Hash>_DoubleRegistration—[ModuleInitializer]registering the double's factory intoGeneratedTestDoubleRegistry(this one can stayfile-scoped — never called by name).<Hash>uses a new, identifier-specific sanitizer (Amendment 5 Finding J —HintNameForitself is reused only for its FNV-1a hash over the original, unsanitized fully-qualified name; its own sanitized-name output deliberately preserves dots, which are illegal in a C# identifier, so a separate sanitizer replacing.with_alongside every other characterHintNameForalready replaces is needed for the type-name half).GeneratedFileNaming.csitself is unchanged — this is a sibling helper, not a modification.- Deduplicated per distinct interface symbol across the compilation (same
.Collect()+SymbolEqualityComparerpattern used elsewhere in the generator).
- Discovery walks the interface's full transitive base-interface closure (
- Compile-time diagnostic for an interface inaccessible to a top-level generated type (Amendment 8 Finding T — a
private/protectednested interface is a legal call-site request but a top-level double can never implement it) — reuse the existingcompilation.IsSymbolAccessibleWithin(...)check already used for generated collection plans and row-invoker registrations (TransitiveClosureWalker.ToDiscoveredCollectionInfo), not a new mechanism; leaf still defers to the unchanged runtime-provider path. - Deterministic-default logic per ADR-0043's "Deterministic defaults" (primitives, nullable refs,
Task/Task<T>,ValueTask/ValueTask<T>, empty collections nevernull). Non-nullable reference returns (string, a non-nullableCustomer,Task<Customer>) have no deterministic default at all (Amendment 5 Finding K) — diagnose and reject, per the decision below; do not emitnull(violates the interface's own nullable annotation) or attempt real composition (out of scope, confirmed with the requester). - Compile-time diagnostics for unsupported member shapes (indexers, events, generic methods,
ref/out/in, static abstract members, overloaded members — Amendment 3 Finding D: a zero-argument configuration extension can't disambiguateGet(int)fromGet(string), diagnose and reject rather than emit a duplicate- signature compile error) — leaf still defers to the unchanged runtime-provider path. - Compile-time diagnostic for an interface that declares its own member named
Configurewith a colliding signature (Amendment 3 Finding E — an instance member always wins over the generated extension in overload resolution, silently making the bridge unreachable) — leaf still defers to the unchanged runtime-provider path. - Compile-time diagnostics for unsupported return shapes — ref-like (
Span<byte> Read(), can't close the unconstrained genericReturnConfig<T>at all), by-ref-returning members, pointer, and function-pointer returns (Amendment 4 Finding I — the original list only covered parameter modifiers, not returns), and non-nullable reference returns (Amendment 5 Finding K — no deterministic default exists for these; diagnose and reject rather than emitnullor attempt real composition) — leaf still defers to the unchanged runtime-provider path. - Compile-time diagnostics for unsupported parameter shapes — pointer and function-pointer parameters (Amendment 10 Finding Y — the direct parameter-side counterpart to Amendment 4 Finding I's return-side check; an unhandled pointer/function-pointer parameter would need the generated method wrapped in
unsafe, which nothing in this design emits, producingCS0214instead of a clean diagnostic) — leaf still defers to the unchanged runtime-provider path. - Compile-time diagnostic for an interface member whose generated, zero-argument extension collides with an inherited
objectmember (GetHashCode(),ToString(),Equals(object),GetType()— Amendment 5 Finding L, corrected by Amendment 6 Finding N) — same "instance member always wins over extension" shadowing as theConfigure()collision above, just againstobjectinstead of the interface's own declared members. Compare the generated (always zero-argument, per Amendment 2 Finding 4) extension's name againstobject's members — not the interface member's own declared signature — Amendment 6 Finding N: e.g.int ToString(int format)is notobject.ToString()as declared, but its zero-argument generated extension collides with it;Equals(object obj)'s zero-argument generated extension does not collide withobject.Equals(object)(one parameter), so checking the original interface signature both under- and over-diagnoses. Leaf still defers to the unchanged runtime-provider path.
Phase 1 — Runtime package (Compono.TestDoubles)¶
- New
src/Compono.TestDoublesproject — onlyGeneratedTestDoubleProvider : ICompositionValueProvider(reads the coreGeneratedTestDoubleRegistry) andUseGeneratedTestDoubles()builder extension (ADR-0024'sAddTestDoubleProvider,NSubstituteProvider-sized). NoReturnConfigBuilder<T>, no registry, noConfigure(...)here — all three live in coreComponoor are generator-emitted per interface (Amendment 2). - Precedence documentation:
UseGeneratedTestDoubles()beforeUseNSubstitute()when both are installed (ADR-0043's "Runtime activation and precedence") — a real sample/test proving registration order produces the documented result, not just prose. - No global-using declaration. Amendment 1's original
global using Compono.TestDoubles.Generated;idea is retired by Amendment 4 Finding G, and validated (not just assumed) by Amendment 11 Finding AA's global-namespace-placement fix: every type this feature generates lives in the global namespace (Phase 0), which is exactly what makes "no import needed at all" true rather than merely hoped-for. A real cross-namespace consumer test (Phase 2) is what actually proves this, not just the design sketch. Do not add a global-using back during implementation.
Phase 2 — End-to-end verification¶
- A real packaged-consumer sample (matching
Compono.XunitV3.SampleTests/Compono.TUnit.SampleTests' existing pattern) exercisingcomposer.Create<T>()with a generated double satisfying an interface dependency,[Shared] IRepositoryreuse into the SUT, andrepository.Configure().Member().Returns(...)/.Throws(...)called from the test file — the real cross-file case Amendment 2's spike verified in isolation, now proven against the actual generator. The sample's test type lives in a real, non-global namespace (Amendment 11 Finding AA) — this is what actually provesConfigure()is reachable with no import, not just the design intent. The composed interface dependency extends a base interface (Amendment 11 Finding Z) — proves the full-closure walk, not just a single flat interface. -
dotnet publish -p:PublishAot=true+ real execution proving the same generated-double path the sample exercises — via a dedicated AOT-only sibling project (test/Compono.TestDoubles.AotSmokeTest), not a literal publish of the sample project itself. PLAN-0040's own identical checklist item already hedges this exact choice ("test/Compono.TUnit.SampleTests(or a dedicated AOT-only sibling project)") and picked the sibling for the same reason: verified directly (PR #85 review) thatdotnet publish -p:PublishAot=trueagainst anIsTestProject=truexUnit v3/MTP project fails withMSB3030("Could not copy the file '.../apphost' because it was not found") regardless of TFM/RID/--self-contained— isolated the cause toIsTestProject=trueitself (not the MTP-specific properties, which made no difference once isolated):Microsoft.NET.Test.Sdk's own targets skip apphost generation for any test project, a real SDK-level incompatibility between "is a test project" andPublishAot, not a local misconfiguration. The "prove it, don't assume it" standardCompono.TUnit(PLAN-0040) already set for this repo is still satisfied — the sibling harness drives the samecomposer.Create<T>()+UseGeneratedTestDoubles()+Configure()path the sample proves under ordinary JIT execution. - Public-API-surface approval test for
Compono.TestDoubles(now a much smaller surface post-Amendment-2: just the provider type andUseGeneratedTestDoubles()), matchingCompono.TUnit.Tests.PublicApiSurfaceTests' pattern — added in Phase 1 astest/Compono.TestDoubles.Tests/PublicApiSurfaceTests.cs, alongside the rest of that phase's own test project rather than deferred here. CoreComponohas no public-API-surface test of its own yet (no such file exists intest/Compono.Tests) — nothing to extend forReturnConfig<T>/ReturnConfigBuilder<T>/GeneratedTestDoubleRegistry.
Phase 3 — Docs and skill alignment¶
-
docs/packages/compono-testdoubles.md(new Package Guide). -
docs/packages/index.mdrow. -
skills/compono/SKILL.mddetection table +references/testdoubles.md(new reference file, followingreferences/nsubstitute.md's shape). -
docs/roadmap/future-packages.md— move this entry to shipped once the package exists, matchingCompono.TUnit's own graduation edit. -
docs/plans/README.mdstatus flip toDone(ADR-0043 itself staysAcceptedindocs/adr/README.md— ADRs don't transition to the plan-onlyDonestatus, so there is no corresponding ADR-index change to make here). -
docs/architecture/current/generated-plans-and-discovery.md's "Open questions" section gains a fourth item forGeneratedTestDoubleRegistry, matchingRowInvokerRegistry's existing collectible-AssemblyLoadContext-rooting entry (Amendment 5 Finding M — identical shape, identical consequence: a plainType-keyed dictionary entry has no closed-generic-instantiation home-context tie, so it roots its generated factory delegate, and the assembly that defined it, for the process's lifetime). Same disposition as the existing three items on that page — deferred, revisit together if collectible-ALC hosting becomes an actual target.
Critical Files¶
src/Compono/— new core primitives:ReturnConfig<T>,ReturnConfigBuilder<T>,GeneratedTestDoubleRegistry,Unitif not already present (ADR-0043 Amendment 2 — moved here from the originally-plannedCompono.TestDoublesto fix a cross-assembly reference the generator couldn't otherwise make; all public from the start per Amendments 3 and 4).- Core
Compono's packaged build assets (.props/.targetsor equivalent) — theCompilerVisiblePropertydeclaration forComponoGeneratedTestDoubles(Amendment 4 Finding F) — without this, the opt-in silently never activates. src/Compono.Generators/Discovery/LeafTypeClassifier.cs— the compile-time-gated third classification outcome.src/Compono.Generators/Emitters/GeneratedFileNaming.cs— reused (not modified) for the new hash-suffixed collision-safe type names.src/Compono.Generators/— new generated-code-emission logic: one file per discovered interface containing the double, its configuration extensions, itsConfigure(...)bridge, and its module-initializer registration (ADR-0043 Amendments 1 and 2).src/Compono.TestDoubles/— new project, deliberately small:GeneratedTestDoubleProvider,UseGeneratedTestDoubles().test/Compono.Generators.Tests/— generator-outputVerify()tests, including the "gate off → zero diff" regression test, and a real cross-file compile test (generated code in one file, a hand-written consumer file callingConfigure()in another) proving Amendment 2's verified shape actually works end-to-end through the real generator, not just the standalone spike.test/Compono.TestDoubles.Tests/,test/Compono.TestDoubles.SampleTests/— new test projects.
Test Plan¶
Matches references/testing.md's existing pattern: Verify()-based generator-output snapshot tests (including the opt-in-off no-op case), unit tests for GeneratedTestDoubleProvider/ReturnConfigBuilder<T>/ GeneratedTestDoubleRegistry in isolation, a real packaged sample exercising the full composer.Create<T>() path (including cross-file Configure() usage), and a real PublishAot=true execution test — not a claim, a run, per this repo's established AOT-verification standard.
Notes¶
Historical note: before implementation began, this plan stayed Not Started pending ADR-0043 review closure and an explicit implementation request. See "Phase 0 implementation notes" below for where that changed; the plan's current status is recorded at the top of this document, not here.
Pre-implementation review (Codex, PR #82) caught three P1 defects and one P2 defect in ADR-0043's original design, all corrected via ADR-0043 Amendment 2 before any implementation code was written:
- The runtime provider couldn't reach a lookup generated into the consumer's own compilation (same class of cross-assembly defect Amendment 1 already fixed once, this time in the opposite direction) — fixed by moving the registry into core
Compono, populated via[ModuleInitializer], the same pattern this repo's own TUnit.Mocks investigation already proved. - The core generator would have needed to hardcode an optional package's type shape (
Compono.TestDoubles.ReturnConfigBuilder<T>) — fixed by movingReturnConfigBuilder<T>into core alongside the registry. - The original file-scoped-types fix was drafted, then experimentally disproven twice before landing on the correct shape (
internal+ hash-suffixed collision-safe names, reusingGeneratedFileNaming) — see Amendment 2's own account of both failed attempts (CS0246, thenCS9051) so neither gets rediscovered during implementation. - An
Arg.Any<Guid>()sample contradicted the requester's own already- decided v1 scope (no argument matchers) — struck; configuration is member-level and argument-independent.
A second review pass on Amendment 2's own corrected sketches caught four more P1s and one P2, corrected via ADR-0043 Amendment 3, still before any implementation code was written:
ReturnConfig<T>'s fields andReturnConfigBuilder<T>'s constructor wereinternal, unreachable from the consumer assembly the generated code actually lives in (CS0122) — fixed with public readonly accessors for reads, a public constructor, mutable state still confined to core.Returnsnever setHasValue, so every configured return would have silently fallen through to the default — fixed.- A registry keyed only by
System.Typebreaks if two consumer assemblies both generate a double for the same shared interface — confirmed with the requester as a documented v1 limitation (first-registration-wins, a named diagnostic message on cast failure), not a core-engine redesign. - The zero-argument configuration-extension shape can't disambiguate overloaded interface members — fixed by diagnosing and rejecting overloaded members, matching the existing unsupported-shape pattern.
- An interface declaring its own
Configuremember silently shadows the generated bridge (instance members always win over extensions) — fixed by diagnosing the collision.
A third review pass caught four more P1s, corrected via ADR-0043 Amendment 4, still before any implementation code was written:
- The compile-time opt-in was never declared
CompilerVisibleProperty— without it,AnalyzerConfigOptionsProvidernever sees a custom MSBuild property at all, so the feature could never activate regardless of what a consumer sets — fixed by shipping the declaration in coreCompono's own packaged build assets. - Amendment 1's
global using Compono.TestDoubles.Generated;promise went stale the moment Amendment 2 moved every generated type into per-interfaceinternaltypes — nothing was left in that namespace to import, gate on or off, and the unconditionalglobal usingwas itself a compile error — retired entirely, not repaired; nousingwas ever actually needed under Amendment 2's design. - The
void-member marker (Compono.Unit) was missed by Amendment 3's own cross-assembly-accessibility fix — introducedpublicfrom the start instead. - The unsupported-shape diagnostic list covered parameter modifiers but not return shapes (
Span<T>-like ref-like returns, by-ref-returning members, pointers, function pointers) — added.
A fourth review pass caught two more P1s and two P2s, corrected via ADR-0043 Amendment 5, still before any implementation code was written:
- The generated type names weren't valid C# identifiers —
HintNameFordeliberately preserves dots (correct for file names, wrong for type names) — fixed with a distinct identifier-specific sanitizer, still hashing the original fully-qualified name for the collision-safe suffix. - Non-nullable reference returns had no deterministic default at all — confirmed with the requester as diagnose-and-reject, not an attempt at real composition.
- A configuration extension can be shadowed by an inherited
objectmember (GetHashCode/ToString/Equals/GetType), the same class of bug as the earlierConfigure()collision — fixed by diagnosing it too. GeneratedTestDoubleRegistryroots a collectibleAssemblyLoadContext, the same documented consequence this repo already accepts forRowInvokerRegistry— added as a Phase 3 doc task (not made now, since the registry doesn't exist yet anddocs/architecture/current/*.mddescribes only shipped behavior).
A fifth review pass caught two more real gaps in Amendment 5's own fixes plus a stale cross-reference, corrected via ADR-0043 Amendment 6, still before any implementation code was written:
- Amendment 5's
object-collision check compared the interface member's declared signature — but every configuration extension is always zero-argument (Amendment 2 Finding 4), so the check needs to compare the generated signature instead:int ToString(int format)wasn't flagged but should have been (its generated extension collides);Equals(object)was flagged but shouldn't have been (its generated extension doesn't). - Amendment 5 added identifier escaping for generated type names but not member names — an interface member like
@newneeds the same treatment, reusing this repo's existingRequiredMemberCollector.EscapeIdentifierconvention.
future-packages.md's "two Amendments" reference was also corrected to avoid hardcoding a count that will keep going stale.
A sixth review pass caught one genuine undecided design question and one clean bug, corrected via ADR-0043 Amendment 7, still before any implementation code was written:
- Read/write properties were never actually specified — every generated- code sketch only covered methods, and properties were neither diagnosed as unsupported nor given a decided accessor contract. Confirmed with the requester over two alternatives (unsupported-for-v1, getter-only with a no-op setter): real auto-property semantics — the setter stores, the getter returns what was last set (or the default),
Returns/Throwsstill work as an explicit override. - Repeated configuration left stale state —
Returnsafter an earlierThrowson the same member was silently ignored, sinceReturnsnever cleared the exception and dispatch checks it first — fixed with last-configuration-wins semantics (each setter now clears the other's state).
A seventh review pass caught one repeat of an already-fixed defect class and one real gap this repo already has precedent for closing, corrected via ADR-0043 Amendment 8, still before any implementation code was written:
- Amendment 7's property setter directly mutated
ReturnConfig<T>'sinternalfields — the exact cross-assemblyCS0122defect Amendment 3 fixed for reads, reintroduced for writes because Amendment 7 conflated "the struct instance is stored in the consumer's generated file" with "the struct's type is defined in that same assembly" (it isn't —ReturnConfig<T>is core). Fixed by routing the setter through the existing publicReturnConfigBuilder<T>constructor +Returnsmethod instead — no new core API. - No diagnostic existed for an interface inaccessible to a top-level generated type (a
private/protectednested interface) — fixed by reusing the exactCompilation.IsSymbolAccessibleWithincheck this repo already applies identically to generated collection plans and row-invoker registrations.
An eighth review pass caught two more real gaps, both extensions of already-decided mechanisms rather than new forks, corrected via ADR-0043 Amendment 9, still before any implementation code was written:
initaccessors weren't preserved — Amendment 7's property design assumed{ get; set; }uniformly, butinitandsetare non-interchangeable; a{ get; init; }interface property would have failed to be implemented. Fixed by preserving whichever accessor kind the interface actually declares, routinginitthrough the sameReturnConfigBuilder<T>.Returnscall the setter already uses.- Keyword escaping (Amendment 6) covered the configuration extension's member name but not the explicit interface implementation's —
int IFoo.new()was still invalid. Fixed by applying the same escaping at every site a member name is emitted, not just the one Amendment 6 happened to fix first.
A ninth review pass caught one shape never considered and two more instances of "escape/diagnose every emission site," corrected via ADR-0043 Amendment 10, still before any implementation code was written:
- Set-only properties (
int Value { set; }) were never specified — confirmed with the requester as diagnose-and-reject, since v1's already- decided lack of call recording/verification means nothing could ever observe a value written through one; there's no meaningful behavior to give it. - Explicit-implementation method parameter names were never escaped —
void Save(int @class)would still emit an invalid bareclassparameter. Fixed by extending the same escaping convention to parameters. - Unsafe pointer/function-pointer parameter shapes were never diagnosed, only the return-side equivalent was (Amendment 4) — fixed by adding the parameter-side counterpart to the same diagnostic list.
A tenth review pass caught two structural gaps — more fundamental than the escaping/diagnostic refinements the previous several rounds had converged on — corrected via ADR-0043 Amendment 11, still before any implementation code was written:
- Interface inheritance was never addressed — every sketch discovered only a leaf interface's own declared members, never its base interfaces (
IChild.GetMembers()doesn't return whatIChild : IBaseinherits fromIBase). Fixed by walking the full transitive base-interface closure (AllInterfaces), with every diagnostic already decided applying across that closure too, and inherited-member explicit implementations qualified against the interface that actually declares them. - No namespace was ever decided for the generated types, and Amendment 4's retirement of the global-using injection only holds if they're universally visible without one — fixed by placing every generated type in the global namespace, which is what actually validates (not just assumes) Amendment 4's "no import needed" reasoning.
This closes the pure pre-implementation design-review loop. Ten review rounds surfaced real, load-bearing defects across ADR-0043 and its Amendments — confirmed directly with the requester after this round that severity had shifted from structural (Amendments 2-3, this round) toward narrower edge-case escaping/diagnostic coverage (Amendments 5-10), and that further refinement continues during actual implementation instead, where tasks/implement.md's build/test/PR-review cycle surfaces and resolves remaining gaps empirically against real generated code rather than through further prediction against a design that doesn't compile anything yet.
This plan's task list above already reflects the fully-corrected shape.
Phase 0 implementation notes (2026-08-13)¶
Phase 0 is implemented and every task above is checked off: core primitives (ReturnConfig<T>/ReturnConfigBuilder<T>/GeneratedTestDoubleRegistry/Unit), the packaged CompilerVisibleProperty opt-in, LeafTypeClassifier's third outcome threaded through TransitiveClosureWalker via a new WalkContext (introduced to keep EnqueueRoot/EnqueueMember's own parameter lists from growing further — a fourth discovery kind alongside types/collections needed somewhere to live), TestDoubleAnalyzer (fail-fast, one diagnostic per interface leaf, matching RequiredMemberCollector/ConstructorSelector's existing convention), TestDoubleDefaults, TestDoubleIdentifierNaming, and TestDoubleEmitter + TestDouble.scriban. Real end-to-end Verify() tests (TestDoubleVerifyTests) prove: the opt-in-off zero-diff regression, a real generated double actually compiling, Configure() reachable from a different namespace with no using (Amendment 11's global-namespace claim), and five of the diagnostics (event, Configure collision, set-only property, overload, non-nullable-reference return). Full solution build + 1719-test run (every project, both this feature's own tests and every pre-existing test) is green.
Two things this pass deliberately left for empirical follow-up rather than designing further ahead of real feedback, per this plan's own closing decision to move pre-implementation prediction into real build/test/PR-review:
- Diagnostic test coverage is representative, not exhaustive — one or two tests per diagnostic category (member-kind, collision, return-shape), not one per every shape Amendment 3–10 individually named (e.g.
ref/out/inparameters, pointer/function-pointer returns, andinit-accessor preservation each have analyzer logic but no dedicatedVerifyFailuretest yet — static abstract properties/operators do now, added during PR #83 review round 1 below). The analyzer logic itself directly mirrors each Amendment's decided shape. - Same interface discovered from two call sites with two different, disagreeing diagnostics: the merge step in
ComponoIncrementalGenerator(discoveredTestDoubles) takesgroup.Distinct().First()rather than preserving every distinct failure at its own location, unlikeDiscoveredCollectionInfo's/DiscoveredTypeInfo's own conflict-preserving merge. Low-impact (only matters if the same interface is independently reached from two request sites where the interface's own shape differs in accessibility between them, which it structurally can't), but worth tightening to match the existing pattern if Phase 2's real sample ever exercises it.
PR #83 review round 1 (2026-08-13)¶
Codex caught five real gaps, all fixed before merge:
- (P1) The Phase 0 notes above claimed verification but every check ran through the in-process generator test harness only, never the packaged
.nupkgitself — an incorrectly packagedbuild/Compono.props(wrongPackagePath, missing from the.nuspec, whatever) could ship invisible and every existing test would still pass, since none of them go through real NuGet restore. Fixed by actually doing it:dotnet packon coreCompono, a throwaway consumer project referencing the packed.nupkgfrom a local feed with<ComponoGeneratedTestDoubles>true</ComponoGeneratedTestDoubles>, realdotnet restore+dotnet build. This caught a real, if environment-local, failure mode along the way: a stale global NuGet cache entry for a previously-restoredCompono 1.0.0silently shadowed the newly packed content (NuGet trusts a cached id+version pair without re-inspecting bytes) — clearing~/.nuget/packages/compono/1.0.0and re-restoring produced the real<Import Project="...buildTransitive/Compono.props">and the property became visible.IRepository_<hash>.TestDouble.g.cswas generated for real, through the real packaged analyzer. (The subsequentCompositionExceptionat runtime is expected and correct — Phase 1'sGeneratedTestDoubleProviderdoesn't exist yet.).github/scripts/inspect-packed-nupkgs.shalso needed its own fix here (unrelated to Codex, caught by this PR's own CI): its hardcoded expected-file-listing allowlist didn't yet know aboutbuild/Compono.props/buildTransitive/Compono.props. - (P2) The overload/collision pre-pass only considered
IMethodSymbol, so two same-named properties inherited from different base interfaces (a diamond shape) both passed through un-diagnosed and would have emitted the same backing field and configuration extension twice — a duplicate- member compile error instead of the intendedCMP0022. Fixed by folding properties into the same duplicate-name pre-pass methods already used. - (P2) A static abstract property was silently skipped (
if (property.IsStatic) continue;with noIsAbstractcheck), leaving the double failing to implement it (CS0535) instead of getting theCMP0021diagnostic every other unsupported shape gets. Fixed — mirrors the method-side static-abstract check that already existed. - (P2) A static abstract operator has
MethodKind.UserDefinedOperator, notOrdinary— the existingMethodKind: not Ordinary → continuefilter ran before the static-abstract check ever saw it, silently dropping it the same way. Fixed by moving the static-abstract check ahead of theMethodKindfilter (excluding property/event accessorMethodKinds, so a static abstract property's own diagnostic still names the property, not its accessor method). - (P2) The
object-member collision check (ToString/GetHashCode/GetType) was only applied to methods — a property with one of those names silently lost itsConfigure()surface to the inheritedobjectmember instead of gettingCMP0024. Fixed by applying the same check to properties.
Four new VerifyFailure regression tests cover findings 2–5 directly (the diamond-property collision, both static-abstract shapes, and the property- side object collision) — TestDoubleVerifyTests is now 11 tests, all green on both target frameworks.
Also fixed in this round, required by this PR's own CI rather than by Codex: AnalyzerReleases.Unshipped.md needed entries for CMP0020-CMP0027 (Roslyn's release-tracking analyzer requires every declared diagnostic ID to be listed), a handful of missing <param> XML doc tags on the new model records, and docs/reference/api/ needed regenerating for the new public Compono.ReturnConfig<T>/ReturnConfigBuilder<T>/GeneratedTestDoubleRegistry/Unit surface.
PR #83 review round 2 (2026-08-13)¶
Codex caught five more real gaps in TestDoubleDefaults/TestDoubleAnalyzer, all fixed:
ValueTask<T>/ValueTaskare themselves structs, so the generictype.IsValueType → defaultfallback fired before theValueTask-specific branch further down the method ever ran -ValueTask<string>silently returned aValueTaskwrappingnullinstead of either the deterministic default forstring's own shape or the non-nullable-reference diagnostic. Fixed by moving theTask/ValueTaskchecks ahead of the generic value-type fallback.- A nullable-annotated collection (
List<int>?,int[]?) hit the nullable-reference fallback first and returnednull, contradicting "empty collections never null." Fixed by moving the collection-shape checks ahead of the nullable-reference fallback - a nullable-annotated collection now gets[]same as a non-nullable one. - A multi-dimensional array (
int[,]) matched the sameIArrayTypeSymbol → []branch as an ordinary array, but C# collection expressions only target rank-1 arrays - the generated double would have failed to compile. Fixed by restricting the[]default toRank: 1and falling through to the unsupported-return-shape diagnostic otherwise. - A private default-implemented interface method (
private int Helper() => 1;, a C# 8+ default interface member) was only excluded by the static check, not by accessibility - a private (or otherwise non-public) instance default member isn't part of any implementing type's contract and can't be explicitly implemented at all, so the double failed to compile. Fixed by skipping any non-abstract, non-public member (both methods and properties, for the same reason). - The
Configure-name collision check was name-only, not arity-aware. Verified directly with a real compile spike before fixing (not taken on faith): an interface's ownConfigure(int mode), explicitly implemented on a concrete type, alongside a zero-argumentConfigure(this IFoo)extension -foo.Configure()on anIFoo-typed receiver resolves to the extension without ambiguity or error. C# only falls back to extension-method resolution when ordinary member lookup finds no applicable candidate, not merely "no candidate with this name," so a differently-shapedConfiguremember never actually shadows the bridge. The blanket name-only check over-rejected valid interfaces. Fixed to flag a collision only when the interface's ownConfiguremember is non-method (property/ field/event - always collides, since member lookup never falls back to extensions for a non-method name at all) or a zero-parameter method.
Six new tests added (one Verify() golden-path test for finding 5's fix doubled as proof both Configure() extensions - the bridge and the member's own config extension - coexist without ambiguity, since they have different receiver types). TestDoubleVerifyTests is now 16 tests, all green on both target frameworks. Full solution: 1945/1945 tests pass.
PR #83 review round 3 (2026-08-13)¶
A real dotnet publish -p:PublishAot=true verification (against the packed Compono .nupkg, driving a generated double directly through GeneratedTestDoubleRegistry/Configure() since Phase 1's runtime provider doesn't exist yet) confirmed the AOT-safety claim empirically, not just by inspection: zero IL2xxx/IL3xxx trim/AOT-analyzer warnings during native code generation, and the published native binary ran standalone and passed. Not a substitute for Phase 2's own real end-to-end PublishAot test against the full sample (still unchecked in the Phase 2 task list above) - this was scoped narrowly to "does the generated-code-and-core-primitives path itself survive AOT," which is exactly the risk surface Phase 0 introduced.
Codex caught four more real gaps:
- (P1, docs)
AGENTS.md/coding-standards.md's "every generator- emitted type isfile-scoped" rule was never updated to record ADR-0043's own exception (test-double types reference each other across signatures, whichfile-scoping breaks withCS9051- already proven twice during design review). Left unchanged, a future change following that stale blanket rule would "fix" this back into a compile error. Documented the exception in bothAGENTS.mdandreferences/coding-standards.md's "Generated code" section. - (P2)
TransitiveClosureWalker'sVisitedTestDoubleInterfacesusedSymbolEqualityComparer.Default, notIncludeNullabilitylike the adjacentVisitedTypesfield -IProvider<string>andIProvider<string?>collapsed to whichever was discovered first, silently deciding (by traversal order) whether the double was rejected or emitted with a possibly-wrong default. Fixed toIncludeNullability, matchingVisitedTypes. This alone would have turned the bug into a worse one - a duplicateAddSourcehint-name crash - sinceToDisplayString(FullyQualifiedFormat)doesn't include nullable annotations either (verified directly with a real compile spike before touching anything:IProvider<string>andIProvider<string?>both display asglobal::IProvider<string>). Fixed properly by mirroringDiscoveredTypeInfo's ownCMP0010conflict-merge pattern exactly:ComponoIncrementalGenerator'sdiscoveredTestDoublesmerge now groups by emission identity, passes through real per-location diagnostics when any exist (so two discoveries that disagree - one fails, one would succeed - now deterministically report the real failure, instead of an order-dependent silent pick), and only synthesizes the newCMP0028when every surviving entry succeeded but still disagrees structurally. - (P2)
HashSet<T>was missing fromTestDoubleDefaults's known- collection-shapes whitelist - a member returningHashSet<int>was wrongly rejected (CMP0025) instead of getting[]. Added. - (P2) The overload/duplicate-name pre-pass in
TestDoubleAnalyzercounted members the main emission loop already silently skips (a private or non-abstract-static default-interface member) - a publicGet()sharing a name with an unrelated private defaultGet()helper falsely trippedCMP0022even though only one of them would ever generate anything. Fixed by filtering the pre-pass to the same instance-contract eligibility the emission loop already applies.
Four new tests (one for each of findings 2-4; finding 1 is docs-only). Full solution: 1951/1951 tests pass.
PR #83 review round 4 (2026-08-13)¶
Codex caught three more real gaps:
- (P1)
TestDouble.scribanneverglobal::-qualified references to the generated double type itself (only toCompono/BCL types) - theConfigure()bridge's cast, the module initializer'snew, and every configuration extension'sthisparameter all referenced{safe_identifier}_Doubleunqualified, violating this repo's own "every type reference emitted into generated code isglobal::-qualified" rule (coding-standards.md's "Generated code" section - the same rule already flagged for the interface/BCL references, just missed for the double's own type). A consumer with a global alias matching the hash-suffixed identifier could bind incorrectly. Fixed by qualifying all four reference sites; the type's own declaration doesn't need qualification, only references to it. Every existingTestDouble.g.verified.cssnapshot (6 of them) changed as a result and was re-accepted after confirming the diff was exactly this and nothing else. - (P2) The
Configure()-collision check (already corrected once in round 2 to compare arity, not just name) still checked rawParameters.Length, not real zero-argument applicability -Configure(int mode = 0)andConfigure(params int[] modes)both haveParameters.Length > 0but are genuinely callable with zero arguments, so both actually do collide with the generated bridge exactly like a zero-parameter method does. Fixed with a proper applicability check (every parameter optional, or trailingparams) - the same rule the C# compiler itself uses. - (P2)
Dictionary<TKey, TValue>(andIDictionary/IReadOnlyDictionary) were missing fromTestDoubleDefaults's known-collection-shapes whitelist - wrongly rejected instead of getting[](a valid, already-established pattern elsewhere in this repo's own code).
Five new tests. Full solution: 1957/1957 tests pass.
PR #83 review round 5 (2026-08-13)¶
Codex caught three more real gaps:
- (P2)
SymbolDisplayFormat.FullyQualifiedFormatomits the?nullable-reference-type modifier - every emitted member return type, parameter type, and slot type lost its nullable annotation (Task<string?>emitted asTask<string>), which explains aCS8603/nullable warning already visible (unremarked-on) in earlier build output. The default-value decision was never wrong (it reads the real symbol'sNullableAnnotation, not the display string), only the emitted text was. Fixed with a sharedNullableAwareFullyQualifiedFormat(IncludeNullableReferenceTypeModifieradded) used everywhere a type reference is emitted into generated code - deliberately not used forInterfaceFullyQualifiedName/hint-name/emission-identity purposes, which must stay nullability-blind per round 3's conflict-merge design. - (P2)
IDictionary<TKey, TValue>/IReadOnlyDictionary<TKey, TValue>(added in round 4) aren't "constructible collection types" under C#'s collection-expression rules, unlike concreteDictionary<TKey, TValue>-[]targeting either producesCS9174. Verified directly with a real compile spike before fixing. Fixed by constructing a concrete emptyDictionary<TKey, TValue>(assignable to both interfaces) instead of reusing the shared[]literal for this specific shape. - (P2) A property's
SetMethodcan exist but not be part of the implementable contract - a default-implementedprivate setalongside a default-implementedget(int Value { get => 0; private set { ... } }, confirmed compilable via a real spike after two invalid variants) - the accessor-kind selection only checked whetherSetMethodwas non-null, not its accessibility, so it selectedGetSetand tried to explicitly implement an inaccessible private setter. Fixed to requireDeclaredAccessibility: Public, same principle as round 3's private default-method fix, just for property accessors.
Three of these five review rounds now (2, 3, 4 partially, 5) have been "narrow, real, fixable" rather than structural - a good signal the generator core is converging, though not yet exhausted (this round alone found a nullable-annotation-loss bug affecting every emitted member, missed by all four prior rounds).
Six new tests. Full solution: 1963/1963 tests pass.
Phase 1 implementation notes (2026-08-13)¶
Phase 1 is implemented and every task above is checked off. src/Compono.TestDoubles is deliberately small, matching Compono.NSubstitute's own shape: GeneratedTestDoubleProvider (reads core GeneratedTestDoubleRegistry via TryCreate, no state of its own) and CompositionBuilderExtensions.UseGeneratedTestDoubles() (registers it via the existing AddTestDoubleProvider). No new core API was needed - the provider is a thin adapter over Phase 0's already-public registry.
Precedence (ADR-0043's "Runtime activation and precedence") is proven, not just documented: PrecedenceTests registers both UseGeneratedTestDoubles() and UseNSubstitute() in both orders and asserts the first-registered provider's value wins each time - a direct, testable consequence of AddTestDoubleProvider's existing "tried in registration order" contract (unchanged by this phase), not special-cased logic in either provider.
Real generated-code coverage (a real [ModuleInitializer] populating the registry, driven through an actual ComponoGeneratedTestDoubles=true build) is explicitly deferred to Phase 2's packaged sample, per this plan's own phase split - Phase 1's tests populate the registry by hand (GeneratedTestDoubleRegistry.RegisterFactory<T>), exactly the shape a generated module initializer produces, which is enough to prove the provider and precedence logic in isolation. test/Compono.TestDoubles.Tests (6 tests, PublicApiSurfaceTests included) references both Compono.TestDoubles and Compono.NSubstitute project-to-project - the only test project in the repo that does, needed for the precedence proof.
New package wired into the same CI surface every prior package addition needed: Compono.slnx, docs.yml (path triggers + net10.0 build loop for the API-reference-drift check), package-validation.yaml (baseline lookup, pack, CS1591 enforcement), inspect-packed-nupkgs.sh (file-listing allowlist + manifest-field/exact-pin assertions - no third-party dependency to range- check, just the Compono exact pin), generate-api-reference.sh (integration_pkgs), and mkdocs.yml's API Reference nav (the Package Guide page itself stays Phase 3, per this plan's own split - the nav entry here only points at the already-generated docs/reference/api/ content, kept in step with every other publishable package so the mkdocs build --strict gate in docs.yml doesn't need a separate follow-up). No local-feed packed-consumer smoke test yet - that needs a sample project, which is Phase 2's job. Full solution (562 tests across every project) is green.
Phase 2 (end-to-end verification: a real packaged-consumer sample, cross-namespace Configure() reachability, and a real PublishAot=true run — Compono.TestDoubles' own public-API-surface approval test already landed in this phase, above) is next.
Phase 2 implementation notes (2026-08-13)¶
Phase 2 is implemented and every task above is checked off.
test/Compono.TestDoubles.SampleTests mirrors Compono.XunitV3.SampleTests'/Compono.TUnit.SampleTests' own local-feed packed-consumer pattern exactly (PackageReference, never ProjectReference, to Compono.XunitV3 and Compono.TestDoubles, packed into the shared .local-nuget-feed by its own pack-to-local-feed.sh) - the real "does the packaged dependency chain actually work" proof this phase exists to give, not another Compono.Generators.Tests snapshot. Domain.cs declares IRepository : IClock (a base-interface leaf, proving Amendment 11 Finding Z's full-closure walk - UtcNow is only ever declared on IClock, never on IRepository itself) and OrderService, the SUT. GeneratedDoubleTests.cs (namespace Compono.TestDoubles.SampleTests, a real non-global namespace, proving Amendment 11 Finding AA's "no import needed" claim for real rather than by inspection) has two tests: one configures CountAsync()/UtcNow() via Returns(...) and asserts the SUT observes exactly those values through its own [Shared] IRepository constructor parameter, the other configures CountAsync() via Throws(...) and asserts the SUT's own async/await call surfaces it. CreateOrderServiceTests.cs (added in PR #85 review) adds a third test that calls composer.Create<OrderService>() directly, never composing IRepository as its own call-site parameter at all - proving the composition engine discovers and satisfies OrderService's nested IRepository dependency purely through the generated-double provider, not only that a sibling [Shared] IRepository theory parameter happens to compose correctly alongside it. OrderService gained a public Repository property (mirroring Compono.XunitV3.SampleTests.OrderService's own identical property) so both this test and GeneratedDoubleTests can reach the exact double instance actually wired into the service. Confirmed via strings on the built assembly that a real generator-emitted ..._IRepository_<hash>_Double type exists, not just that the tests happened to pass. The compile-time opt-in (<ComponoGeneratedTestDoubles>true</ComponoGeneratedTestDoubles>) is set directly in the sample's own .csproj, exercising the real packaged CompilerVisibleProperty declaration (Amendment 4 Finding F) rather than an in-memory AdditionalFiles shortcut a ProjectReference would allow. Directory.Packages.props gained a Compono.TestDoubles PackageVersion pin (1.0.0, matching every other local-feed sample dependency's own pin) - this project is Central-Package-Managed like every other real test project, unlike the throwaway AOT harness below. .github/workflows/package-validation.yaml gained a "Local-feed packed-consumer smoke test (Compono.TestDoubles)" step, matching the existing Compono.TUnit.SampleTests step's shape (no deliberately-failing proof tests in this project, so no --filter-not-class needed). Not added to Compono.slnx - deliberate, matching every other *.SampleTests/*.AotSmokeTest project's own omission (package-validation.yaml's own comment on why).
test/Compono.TestDoubles.AotSmokeTest mirrors test/Compono.AotSmokeTest/test/Compono.TUnit.AotSmokeTest's own shape almost verbatim: a single throwaway net10.0-only console app, PackageReference (never ProjectReference, for the same NETSDK1207 reason those two projects' own comments already document) to Compono.TestDoubles packed by its own pack-compono.sh into a dedicated .local-nuget-feed-testdoubles-aot-smoke, opted out of Central Package Management (the throwaway Version="1.0.0" pack-compono.sh stamps has no business in the shared Directory.Packages.props). Program.cs composes the same IRepository : IClock shape as the sample project (independently declared - this harness has no reference to Compono.TestDoubles.SampleTests), configures both members via the generated Configure() bridge, and asserts the composed values are real. A real dotnet publish -c Release -f net10.0 -p:PublishAot=true produced zero IL2xxx/IL3xxx trim/AOT-analyzer warnings, and the published native binary ran standalone and printed PASS: generated double (composer.Create<T>() + UseGeneratedTestDoubles(), full base-interface closure) survived Native AOT - CountAsync()=7, UtcNow=08/13/2026 00:00:00 +00:00. - not a claim, a run, matching PR #83 round 3's own narrower proof of the same standard (that round scoped to the generated-code-and-core-primitives path alone, since Phase 1's runtime provider didn't exist yet; this one runs the real Compono.TestDoubles package end to end). Like the two existing AOT harnesses, not wired into CI (package-validation.yaml's own local-feed smoke test above already covers ordinary JIT execution through the packaged chain for CI purposes) - a manual, one-shot proof, matching Compono.AotSmokeTest's/Compono.TUnit.AotSmokeTest's own disposition.
A dedicated sibling project, not a literal dotnet publish -p:PublishAot=true of Compono.TestDoubles.SampleTests itself - confirmed this isn't just a matter of choice (PR #85 review): publishing the sample directly fails with MSB3030 ("Could not copy the file '.../apphost' because it was not found"), reproduced with every combination of explicit -r osx-arm64 --self-contained true and with the MTP-specific properties (TestingPlatformDotnetTestSupport/UseMicrosoftTestingPlatformRunner) stripped out entirely - neither changed the failure. Isolating IsTestProject itself (rather than the MTP properties) as the variable flips the failure mode from MSB3030 to a plain CS0246 (the xUnit package references IsTestProject=true pulls in disappear), confirming Microsoft.NET.Test.Sdk's own targets are what skip apphost generation for any test project - a real SDK-level incompatibility between "is a test project" and PublishAot, not a fixable local misconfiguration. PLAN-0040's own identical checklist item already anticipated exactly this choice ("test/Compono.TUnit.SampleTests (or a dedicated AOT-only sibling project)") and picked the sibling for the same reason.
A real, pre-existing bug was found and fixed along the way, unrelated to this feature but blocking this phase's own AOT proof: test/Directory.Build.targets' xUnit-v3-only global-Using ItemGroup (Xunit/NSubstitute/AwesomeAssertions) was never scoped to IsTestProject, only to ComponoTestFramework != 'TUnit' - since this .targets file imports after a project's own body (documented in its own comment, for exactly the opposite reason: so a project's own ComponoTestFramework override is visible in time), a throwaway console app's own <Using Remove="Xunit"/> etc. (set in its own PropertyGroup, evaluated before this file's Include) could never actually cancel it. This silently broke dotnet build/dotnet publish for the pre-existing Compono.AotSmokeTest project too (CS0246 for Xunit/NSubstitute/AwesomeAssertions, none of which it references) - confirmed by reproducing the failure on that project directly before touching anything, and confirming the fix (scoping the ItemGroup to IsTestProject == true, matching the PackageReference ItemGroup immediately below it in the same file) resolves it with a clean full- solution rebuild (1987/1987 tests still green) afterward. Compono.TUnit.AotSmokeTest was never affected (its own ComponoTestFramework=TUnit already skipped the Include outright, for unrelated reasons), which is presumably why this had gone unnoticed until a third AOT harness needed the same Using Remove pattern to actually work.
Phase 3 implementation notes (2026-08-14)¶
Phase 3 is implemented and every task above is checked off: the Package Guide (docs/packages/compono-testdoubles.md), docs/packages/index.md and mkdocs.yml nav rows, skills/compono/'s detection table/default-workflow routing/guardrails/references table and its new references/testdoubles.md, future-packages.md's graduation out of the roadmap section (mirroring Compono.TUnit's own PLAN-0040 closeout), the docs/plans/README.md/this plan's own Status: Done flip, and docs/architecture/current/generated-plans-and-discovery.md's fourth Open Questions item for GeneratedTestDoubleRegistry's ALC-rooting. PLAN-0043 is now fully Done - all four phases (0-3) complete.