From bd2f72b53feae09ad887ff20a6312fe1682fcd3a Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 31 Jul 2026 14:50:13 -0400 Subject: [PATCH 1/3] feat(nsubstitute): milestone 5 phase 1 - Compono.NSubstitute package NSubstituteProvider/NSubstituteOptions/UseNSubstitute per ADR-0025 (Amendment 1's corrected snapshot/delegate-matching shapes). Tests (Phase 2) and docs (Phase 3) tracked separately in PLAN-0005. Co-Authored-By: Claude Sonnet 5 --- Compono.slnx | 1 + ...005-milestone-5-nsubstitute-integration.md | 31 +++++++++++-- .../Compono.NSubstitute.csproj | 23 ++++++++++ .../CompositionBuilderExtensions.cs | 28 +++++++++++ src/Compono.NSubstitute/NSubstituteOptions.cs | 15 ++++++ .../NSubstituteProvider.cs | 46 +++++++++++++++++++ 6 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 src/Compono.NSubstitute/Compono.NSubstitute.csproj create mode 100644 src/Compono.NSubstitute/CompositionBuilderExtensions.cs create mode 100644 src/Compono.NSubstitute/NSubstituteOptions.cs create mode 100644 src/Compono.NSubstitute/NSubstituteProvider.cs diff --git a/Compono.slnx b/Compono.slnx index e023dd0..5ec5559 100644 --- a/Compono.slnx +++ b/Compono.slnx @@ -3,6 +3,7 @@ + diff --git a/docs/plans/0005-milestone-5-nsubstitute-integration.md b/docs/plans/0005-milestone-5-nsubstitute-integration.md index 3a12435..791d6f8 100644 --- a/docs/plans/0005-milestone-5-nsubstitute-integration.md +++ b/docs/plans/0005-milestone-5-nsubstitute-integration.md @@ -123,15 +123,15 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. ### Phase 1: `Compono.NSubstitute` package (ADR-0025) -**Status:** Not Started +**Status:** Done -- [ ] New `src/Compono.NSubstitute/Compono.NSubstitute.csproj` (matching +- [x] New `src/Compono.NSubstitute/Compono.NSubstitute.csproj` (matching `Compono.XunitV3.csproj`'s TFM/packaging shape — `PackageReference` to `Compono` + `NSubstitute`, `PrivateAssets="none"` on the `Compono` reference per PLAN-0004 Phase 3's real packaging-bug lesson). -- [ ] `NSubstituteOptions`: `SubstituteAbstractClasses` (`bool`, default +- [x] `NSubstituteOptions`: `SubstituteAbstractClasses` (`bool`, default `true`). -- [ ] `NSubstituteProvider : ICompositionValueProvider`: the +- [x] `NSubstituteProvider : ICompositionValueProvider`: the `IsSubstitutable` static check (interface / delegate / conditionally unsealed-abstract-class), `Substitute.For(Type[], object[])` call for a match, `NotHandled` otherwise, no `context.Resolve()` call @@ -146,7 +146,7 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule. **not** `typeof(Delegate).IsAssignableFrom(requestedType)` — the latter wrongly matches the non-substitutable `Delegate`/ `MulticastDelegate` base types themselves. -- [ ] `CompositionBuilderExtensions.UseNSubstitute()`/ +- [x] `CompositionBuilderExtensions.UseNSubstitute()`/ `UseNSubstitute(Action)` extension methods (C# 14 extension-block syntax, per `coding-standards.md`). @@ -317,6 +317,27 @@ alpha must: ## Notes +**Phase 1 (Done):** + +- Implemented exactly per ADR-0025's Amendment 1 corrected shapes — no + deviation from the ADR's own code sketches for `NSubstituteOptions`, + `NSubstituteProvider`, or `CompositionBuilderExtensions`. +- `Compono.NSubstitute.csproj` mirrors `Compono.XunitV3.csproj`'s shape: + `net10.0;net11.0` TFMs, `ProjectReference` to `Compono` with + `PrivateAssets="none"` (PLAN-0004 Phase 3's packaging lesson, applied + proactively rather than rediscovered), `PackageReference` to + `NSubstitute` (version centrally managed, already present in + `Directory.Packages.props` from the test-project usage), and + `InternalsVisibleTo` for the not-yet-created `Compono.NSubstitute.Tests` + (Phase 2) so `IsSubstitutable` can stay `internal`. + `docs/architecture.md`'s package-dependency diagram already lists + `Compono.NSubstitute → Compono, NSubstitute` — matched exactly, no + reference to any other package. +- Added to `Compono.slnx`. Whole-solution `dotnet build` green, no warnings. +- No test project yet (Phase 2) — `IsSubstitutable`/`NSubstituteProvider`/ + `UseNSubstitute` are implemented but only build-verified in this phase, + not test-verified; that's Phase 2's job per the plan's own phase split. + **Phase 0 (Done):** - Implemented in the same branch/PR as the design docs, per explicit user diff --git a/src/Compono.NSubstitute/Compono.NSubstitute.csproj b/src/Compono.NSubstitute/Compono.NSubstitute.csproj new file mode 100644 index 0000000..f11047d --- /dev/null +++ b/src/Compono.NSubstitute/Compono.NSubstitute.csproj @@ -0,0 +1,23 @@ + + + + latest + enable + enable + net10.0;net11.0 + NSubstitute integration for Compono - a test-double provider composing interface, delegate, and (optionally) abstract-class parameters as real NSubstitute substitutes. See docs/adr/0024-public-provider-extensibility-model.md and docs/adr/0025-compono-nsubstitute-package-design.md. + + + + + + + + + + + + + diff --git a/src/Compono.NSubstitute/CompositionBuilderExtensions.cs b/src/Compono.NSubstitute/CompositionBuilderExtensions.cs new file mode 100644 index 0000000..2b7c566 --- /dev/null +++ b/src/Compono.NSubstitute/CompositionBuilderExtensions.cs @@ -0,0 +1,28 @@ +namespace Compono; + +/// +/// Activates on a . See +/// docs/adr/0025-compono-nsubstitute-package-design.md. +/// +public static class CompositionBuilderExtensions +{ + extension(CompositionBuilder builder) + { + /// + /// Registers an with default . + /// + public CompositionBuilder UseNSubstitute() => builder.UseNSubstitute(static _ => { }); + + /// + /// Registers an , configured by . + /// + /// Sets the provider's . + public CompositionBuilder UseNSubstitute(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var options = new NSubstituteOptions(); + configure(options); + return builder.AddTestDoubleProvider(new NSubstituteProvider(options)); + } + } +} diff --git a/src/Compono.NSubstitute/NSubstituteOptions.cs b/src/Compono.NSubstitute/NSubstituteOptions.cs new file mode 100644 index 0000000..11cde0d --- /dev/null +++ b/src/Compono.NSubstitute/NSubstituteOptions.cs @@ -0,0 +1,15 @@ +namespace Compono; + +/// +/// Configuration for , set via +/// CompositionBuilderExtensions.UseNSubstitute(Action{NSubstituteOptions}). See +/// docs/adr/0025-compono-nsubstitute-package-design.md. +/// +public sealed class NSubstituteOptions +{ + /// + /// Whether an unsealed abstract class is substitutable, in addition to every interface and + /// delegate type. Defaults to . + /// + public bool SubstituteAbstractClasses { get; set; } = true; +} diff --git a/src/Compono.NSubstitute/NSubstituteProvider.cs b/src/Compono.NSubstitute/NSubstituteProvider.cs new file mode 100644 index 0000000..eaf4c63 --- /dev/null +++ b/src/Compono.NSubstitute/NSubstituteProvider.cs @@ -0,0 +1,46 @@ +using NSubstitute; + +namespace Compono; + +/// +/// A stage-6 test-double provider that composes an interface, delegate, or (when +/// allows it) unsealed abstract-class +/// request as a real NSubstitute substitute, via . +/// Registered via CompositionBuilderExtensions.UseNSubstitute(). See +/// docs/adr/0025-compono-nsubstitute-package-design.md (Amendment 1 for the corrected shape +/// below). +/// +public sealed class NSubstituteProvider : ICompositionValueProvider +{ + private readonly bool _substituteAbstractClasses; + + /// + /// Creates an , snapshotting 's + /// current values - the provider never retains the caller-owned + /// instance itself, so a caller mutating it after construction can't change this + /// already-registered provider's behavior (ADR-0024's immutable-provider-registration guarantee). + /// + /// The options to snapshot. + public NSubstituteProvider(NSubstituteOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _substituteAbstractClasses = options.SubstituteAbstractClasses; + } + + /// + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) + { + if (!IsSubstitutable(request.RequestedType, _substituteAbstractClasses)) + return CompositionProviderResult.NotHandled; + + return CompositionProviderResult.Handled(Substitute.For([request.RequestedType], [])); + } + + // ADR-0025 Amendment 1: IsSubclassOf(typeof(MulticastDelegate)), not + // typeof(Delegate).IsAssignableFrom(requestedType) - the latter wrongly matches the + // non-substitutable Delegate/MulticastDelegate framework base types themselves. + internal static bool IsSubstitutable(Type requestedType, bool substituteAbstractClasses) => + requestedType.IsInterface + || requestedType.IsSubclassOf(typeof(MulticastDelegate)) + || (substituteAbstractClasses && requestedType.IsAbstract && !requestedType.IsSealed && !requestedType.IsInterface); +} From bd47a9011811a0c04ff32979ed002f80f2173cac Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 31 Jul 2026 14:58:30 -0400 Subject: [PATCH 2/3] docs(xunitv3): shorten PrivateAssets comment on Compono reference Trim wording that implied all transitive dependencies would otherwise disappear - it's specifically the analyzer asset that needs to flow through. Co-Authored-By: Claude Sonnet 5 --- src/Compono.XunitV3/Compono.XunitV3.csproj | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Compono.XunitV3/Compono.XunitV3.csproj b/src/Compono.XunitV3/Compono.XunitV3.csproj index 699e03b..3fe7b91 100644 --- a/src/Compono.XunitV3/Compono.XunitV3.csproj +++ b/src/Compono.XunitV3/Compono.XunitV3.csproj @@ -9,17 +9,10 @@ - +