Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Compono.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<Project Path="test/Compono.Tests/Compono.Tests.csproj" />
<Project Path="test/Compono.Generators.Tests/Compono.Generators.Tests.csproj" />
<Project Path="test/Compono.XunitV3.Tests/Compono.XunitV3.Tests.csproj" />
<Project Path="test/Compono.NSubstitute.Tests/Compono.NSubstitute.Tests.csproj" />
</Folder>
<Folder Name="/benchmarks/">
<Project Path="benchmarks/Compono.Benchmarks/Compono.Benchmarks.csproj" />
Expand Down
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
feed populated by dotnet pack (PLAN-0004 Phase 3), never a ProjectReference. The version here
must match the -p:Version passed to that project's PackToLocalFeed pre-restore target. -->
<PackageVersion Include="Compono.XunitV3" Version="1.0.0" />
<!-- Compono.XunitV3.SampleTests' own dependency, same reasoning as Compono.XunitV3 above
(PLAN-0005 Phase 2's real-runner proof) - never a ProjectReference, and this version must
match the -p:Version passed to PackToLocalFeed's pack of Compono.NSubstitute. -->
<PackageVersion Include="Compono.NSubstitute" Version="1.0.0" />
<!-- Mocking. No AutoFixture here, deliberately - Compono is a from-scratch
AutoFixture replacement (docs/manifesto.md), so dogfooding AutoFixture
inside Compono's own test suite would be a direct contradiction of the
Expand Down
61 changes: 61 additions & 0 deletions docs/adr/0024-public-provider-extensibility-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,67 @@ different-type request through the same instance) and
unchanged, confirming the fix didn't reverse the Provider Failure Semantics
decision.

## Amendment 2 (2026-07-31): `Compono.Generators`' root-level CMP0003 check loosened for interface/abstract-class/delegate roots

PLAN-0005 Phase 2's own real end-to-end verification (a packaged
`Compono.XunitV3.SampleTests` run of this plan's Goal-section scenario β€” a
bare `[Shared] IOrderRepository` xUnit theory parameter, substituted via
`UseNSubstitute()`) failed to compile with `CMP0003` ("`IOrderRepository` is
abstract and cannot be constructed directly"), exposing a real gap this ADR's
Decision Outcome didn't anticipate: `Compono.Generators`'
`LeafTypeClassifier.IsRuntimeProviderResolved` β€” the check that decides
whether the *root* of a `Composer.Create<T>()` call (or, equivalently, one of
a `CompositionRow`'s own composed theory parameters) needs a generated
constructor-selection plan at all β€” was narrower than its member-level
counterpart (`IsProviderResolved`) specifically to **exclude** interface,
abstract-class, and delegate root types, routing them into constructor
selection so they'd get a real compile-time diagnostic instead of silently
compiling into a call that could only ever fail at runtime. That exclusion
predates this ADR (introduced in PLAN-0001's Milestone 1 work, hardened by a
PR #11 regression fix) and was correct at the time: before this ADR, nothing
could ever satisfy such a root at runtime, so treating it as a genuine
compile error was strictly more helpful than a generic runtime failure.

This ADR's own Decision Outcome invalidates that assumption. A registered
`ICompositionValueProvider` (stage 5/6) can now genuinely satisfy an
interface/abstract-class/delegate root at runtime, exactly like it already
could for a *member* of the same shape β€” `IsProviderResolved` never excluded
members this way, only the root-only classifier did. Continuing to route such
a root into constructor selection reports a false `CMP0003` for code that
compiles and runs correctly once a suitable provider (e.g.
`Compono.NSubstitute`'s `NSubstituteProvider`) is registered β€” exactly this
ADR's own worked example.

**Fix:** `LeafTypeClassifier.IsRuntimeProviderResolved` now delegates directly
to `IsProviderResolved` β€” an interface, abstract class, or delegate root is
classified identically at root and member position, left as a bare
`context.Resolve<T>()` call for a provider to satisfy. This is an *additive*
compile-time behavior change during alpha, not a breaking one: it makes
previously non-compiling code compile; it does not change the meaning or
diagnostics of any code that already compiled. Per this ADR's own Alpha
Compatibility Policy, the `breaking` label/major-version-bump mechanism is
reserved for changes to an already-shipped contract's *shape* β€” loosening an
overly-strict compile-time rejection doesn't qualify, so this shipped as an
ordinary `fix`, no `breaking` label applied. A concrete, non-abstract,
non-delegate root's classification is entirely unchanged β€” it still reaches
constructor selection and still gets a real `CMP0001`/`CMP0002`/`CMP0003`
diagnostic for an ambiguous, inaccessible, or otherwise unconstructible
shape, per `ConstructorSelector`'s own unchanged logic.

Regression coverage in `Compono.Generators.Tests/CompositionPlanVerifyTests.cs`:
`AbstractRootType_GeneratesNoPlan_ProviderResolvedAtRuntime`,
`InterfaceRootType_GeneratesNoPlan_ProviderResolvedAtRuntime`,
`DelegateRootType_GeneratesNoPlan_ProviderResolvedAtRuntime`, and
`ComposableAttributeOnInterface_GeneratesNoPlan_ProviderResolvedAtRuntime`
(the `[Composable]` eager-warmup path shares the same root classification) all
now assert clean compilation with no generated plan, replacing four tests that
previously asserted `CMP0003`.
`ConcreteRootTypeWithNoAccessibleConstructor_StillReportsDiagnostic_AfterRootProviderCheck`
is the inverse regression guard, proving the loosening is scoped to
interface/abstract-class/delegate roots only. PLAN-0005 Phase 2's packaged
`Compono.XunitV3.SampleTests.NSubstituteTests.Saves_order` test is the real
end-to-end proof this fix exists for.

## Links

- [docs/mvp.md](../mvp.md) β€” Milestone 5 scope, and Milestone 3's explicit
Expand Down
16 changes: 11 additions & 5 deletions docs/mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,12 +270,18 @@ package: substitutable-shape rules including delegate types, `NSubstituteOptions
diagnostics). **ADR-0024's core engine extension point is implemented
(PLAN-0005 Phase 0)** β€” `builder.AddSemanticProvider(...)`/
`builder.AddTestDoubleProvider(...)` are real, tested public API today.
**`Compono.NSubstitute` itself (ADR-0025) is implemented (PLAN-0005 Phase 1)**
β€” `NSubstituteProvider`/`NSubstituteOptions`/`UseNSubstitute()` are real code,
but not yet test-covered or end-to-end verified (PLAN-0005 Phase 2) β€” see
**`Compono.NSubstitute` itself (ADR-0025) is implemented and test-covered/
end-to-end verified (PLAN-0005 Phase 2)** β€” `NSubstituteProvider`/
`NSubstituteOptions`/`UseNSubstitute()` are real, tested code, verified both by
`Compono.NSubstitute.Tests` and by a real packaged `Compono.XunitV3.SampleTests`
run of this milestone's own Goal scenario. See
[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for the
phase-by-phase tracker; this milestone's own exit criteria aren't met until
that verification lands.
phase-by-phase tracker (Phase 3, docs/cleanup, still open) and
[ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md) for
a `Compono.Generators` compile-time check (`CMP0003`) Phase 2's real
verification found and fixed along the way β€” an interface/abstract-class/
delegate root is now correctly left for a provider to satisfy at runtime
instead of being rejected at compile time.

### Scope

Expand Down
82 changes: 76 additions & 6 deletions docs/plans/0005-milestone-5-nsubstitute-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,9 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.

### Phase 2: Test suites and verification

**Status:** Not Started
**Status:** Done

- [ ] `test/Compono.NSubstitute.Tests`: `IsSubstitutable` unit coverage
- [x] `test/Compono.NSubstitute.Tests`: `IsSubstitutable` unit coverage
(interface / delegate / unsealed abstract class with the option on /
unsealed abstract class with the option off / sealed class / struct /
`string`); `NSubstituteProvider.TryProvide` returns a real NSubstitute
Expand All @@ -163,7 +163,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
not a hand-rolled heuristic); `UseNSubstitute()`/
`UseNSubstitute(configure)` wiring a working `NSubstituteProvider`
into a real `Composer`.
- [ ] **ADR-0025 Amendment 1 regression coverage:**
- [x] **ADR-0025 Amendment 1 regression coverage:**
- Negative `IsSubstitutable` cases for `typeof(Delegate)` and
`typeof(MulticastDelegate)` themselves (the framework base types),
alongside a positive case for a real custom `delegate` type β€” the
Expand All @@ -174,7 +174,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
then mutate `SubstituteAbstractClasses` on that same instance
afterward, and assert the already-registered provider's behavior is
unaffected β€” proving the snapshot, not just that it compiles.
- [ ] End-to-end composition tests against a real `Composer`: an interface
- [x] End-to-end composition tests against a real `Composer`: an interface
parameter composes as a substitute; an abstract class composes as a
substitute only when the option allows it (and produces the engine's
ordinary "could not satisfy" diagnostic, naming the type, when it
Expand All @@ -184,11 +184,11 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
shape from this plan's own Goal section, run for real, not just
asserted against a hand-written fake provider (Phase 0 already covers
that in isolation).
- [ ] An API-surface/approval test locking `Compono.NSubstitute`'s public
- [x] An API-surface/approval test locking `Compono.NSubstitute`'s public
shape (`NSubstituteProvider`, `NSubstituteOptions`,
`CompositionBuilderExtensions`, and nothing else), matching
`Compono.XunitV3.Tests`' existing pattern (PLAN-0004 Phase 3).
- [ ] A real end-to-end run through `test/Compono.XunitV3.SampleTests` (or a
- [x] A real end-to-end run through `test/Compono.XunitV3.SampleTests` (or a
new sibling sample project) proving `UseNSubstitute()` composes
correctly under a real xUnit v3 theory, not just `Compono.NSubstitute.Tests`'
own direct `Composer` calls β€” matching PLAN-0004 Phase 3's real-
Expand Down Expand Up @@ -327,6 +327,76 @@ alpha must:

## Notes

**Phase 2 (Done):**

- `test/Compono.NSubstitute.Tests` created (23 tests Γ— 2 TFMs = 46), covering
`IsSubstitutable` directly (`IsSubstitutableTests.cs`), `NSubstituteProvider.TryProvide`
through a real `Composer` (`NSubstituteProviderTests.cs` β€” `CompositionProviderResult`'s
`Value`/`IsHandled` are internal to `Compono`, so a provider's outcome is only
observable from outside through the pipeline it feeds, not by calling `TryProvide`
directly against a hand-rolled `ICompositionContext`), `UseNSubstitute()`/
`UseNSubstitute(configure)` wiring (`CompositionBuilderExtensionsTests.cs`), the
`[Shared]`-via-`CompositionRow` reuse shape against the real `NSubstituteProvider`
(`EndToEndCompositionTests.cs`), and a `PublicApiSurfaceTests` approval test.
"Is this a substitute" is asserted via `NSubstitute.Core.SubstitutionContext.Current
.GetCallRouterFor(value)` rather than `is ICallRouterProvider` β€” a real substitute for
an **interface**/abstract-class request is a Castle-proxy instance that does implement
`ICallRouterProvider`, but a **delegate** substitute is a plain compiled delegate whose
target isn't the proxy itself, so `is ICallRouterProvider` is false for exactly the
shape this plan's own scope added (delegate substitutes) β€” confirmed against a small
scratch program before writing the assertion helper, not assumed.
- **Writing this phase's own `IsSubstitutable` regression coverage caught a real bug in
Phase 1's implementation**, before any PR/review round: the negative test for
`typeof(Delegate)`/`typeof(MulticastDelegate)` themselves failed, because
`System.Delegate`/`System.MulticastDelegate` are *themselves* abstract, unsealed,
non-interface classes β€” `IsSubstitutable`'s third OR-clause (the abstract-class branch)
matched them even though the delegate-specific second clause correctly declined them.
ADR-0025 Amendment 1's own stated intent ("the latter wrongly matches the
non-substitutable Delegate/MulticastDelegate framework base types themselves") was only
half-enforced. Fixed in `NSubstituteProvider.IsSubstitutable` by excluding
`typeof(Delegate).IsAssignableFrom(requestedType)` from the abstract-class branch β€”
real delegate types are unaffected (already matched by the earlier
`IsSubclassOf(MulticastDelegate)` clause, which returns before the abstract branch is
ever reached for them).
- **The real end-to-end run (this plan's own last Phase 2 task) surfaced a second, larger
gap**: this plan's own Goal-section snippet (`[Shared] IOrderRepository repository` as a
bare xUnit theory parameter) failed to compile with `CMP0003` when actually built against
a real packaged `Compono.XunitV3.SampleTests` consumer. `Compono.Generators`'
`LeafTypeClassifier.IsRuntimeProviderResolved` β€” the root-only classifier deciding
whether `Composer.Create<T>()`'s (or a `CompositionRow`'s own) requested type needs a
generated plan at all β€” predates ADR-0024 and still hard-excluded interface/
abstract-class/delegate roots, on the (once-true, now-stale) assumption that nothing
could ever satisfy such a root at runtime. Fixed by making `IsRuntimeProviderResolved`
delegate to the member-level `IsProviderResolved` check, so a root is classified
identically to a member β€” see [ADR-0024's Amendment 2](../adr/0024-public-provider-extensibility-model.md)
for the full account, including why this ships as an ordinary `fix` (additive
compile-time behavior, not a breaking change to an already-shipped contract) rather than
under the `breaking` label. Confirmed via user decision before implementing (the
alternative considered was leaving `CMP0003` as-is and working around it in the sample
test, matching `Domain.cs`'s existing `GatewayConsumer` pattern for the same limitation β€”
rejected in favor of fixing the actual stale assumption, since real dogfooding evidence
now contradicts it). Four existing `Compono.Generators.Tests` snapshot tests that
asserted the old (now-incorrect) `CMP0003` behavior were rewritten to assert clean
compilation instead; one new regression test
(`ConcreteRootTypeWithNoAccessibleConstructor_StillReportsDiagnostic_AfterRootProviderCheck`)
proves the loosening is scoped to interface/abstract-class/delegate roots only β€” a
concrete type's root classification is unchanged.
- `test/Compono.XunitV3.SampleTests` extended with a `Compono.NSubstitute` package
reference (added to `Directory.Packages.props`' central version management, matching
`Compono.XunitV3`'s existing `Version="1.0.0"` local-feed convention), a third
`dotnet pack` call threaded through `pack-to-local-feed.sh` and its `PackToLocalFeed`
MSBuild target, and a new `NSubstituteTests.cs` running this plan's own Goal scenario
verbatim (`CreateOrderHandler`/`IOrderRepository`/`[Shared]`/`UseNSubstitute()` via an
`ICompositionProfile`) against the real packaged dependency chain, per PLAN-0004 Phase
3's real-packaged-consumer strategy β€” the same strategy that caught PLAN-0004's own
`PrivateAssets` packaging bug, and caught this phase's `CMP0003` gap here.
- Full suite re-verified green after both fixes: whole-solution `dotnet build`/`dotnet test`
720/720 (`Compono.Tests` 412/412, `Compono.Generators.Tests` 168/168,
`Compono.XunitV3.Tests` 94/94, `Compono.NSubstitute.Tests` 46/46 β€” all Γ—2 TFMs), plus a
separate real `dotnet test` run of `test/Compono.XunitV3.SampleTests` against packages
packed from current source (not `ProjectReference`), confirming
`NSubstituteTests.Saves_order` passes end-to-end.

**Phase 1 (Done):**

- Implemented exactly per ADR-0025's Amendment 1 corrected shapes β€” no
Expand Down
9 changes: 6 additions & 3 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,11 +422,14 @@ same interface, registered via `AddSemanticProvider` instead of

## NSubstitute Integration

Implemented (PLAN-0005 Phase 1) per
Implemented and test-covered/end-to-end verified (PLAN-0005 Phase 2) per
[ADR-0025](adr/0025-compono-nsubstitute-package-design.md), built on the Provider
Extensibility contract above β€” `Compono.NSubstitute`'s `NSubstituteProvider`/
`NSubstituteOptions`/`UseNSubstitute()` are real code today. Test coverage and
end-to-end verification are still pending (PLAN-0005 Phase 2).
`NSubstituteOptions`/`UseNSubstitute()` are real, tested code, verified end-to-end
against a real packaged xUnit v3 consumer (`Compono.XunitV3.SampleTests`) running
this milestone's own Goal scenario: a `[Shared]` interface theory parameter
composed as a real substitute and reused by a nested constructor parameter of the
same type.

Activation:

Expand Down
Loading