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 @@ -3,6 +3,7 @@
<Project Path="src/Compono/Compono.csproj" />
<Project Path="src/Compono.Generators/Compono.Generators.csproj" />
<Project Path="src/Compono.XunitV3/Compono.XunitV3.csproj" />
<Project Path="src/Compono.NSubstitute/Compono.NSubstitute.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/Compono.Tests/Compono.Tests.csproj" />
Expand Down
6 changes: 4 additions & 2 deletions docs/mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,12 @@ 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 not yet implemented** — see
**`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
[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 package ships.
that verification lands.

### Scope

Expand Down
57 changes: 50 additions & 7 deletions docs/plans/0005-milestone-5-nsubstitute-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the shipped-state docs in this phase

Marking Phase 1 done while leaving docs/public-api.md:425-427 and docs/mvp.md:270-276 saying that Compono.NSubstitute is “not yet implemented” makes the API and roadmap documentation contradict the packable project and public entry points added by this commit. This affects readers immediately after the change lands, even if verification remains pending in Phase 2; update those statements to distinguish implemented package code from pending tests rather than deferring the correction to Phase 3.

AGENTS.md reference: AGENTS.md:L156-L157

Useful? React with 👍 / 👎.


- [ ] 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<T>()` call
Expand All @@ -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<NSubstituteOptions>)` extension methods
(C# 14 extension-block syntax, per `coding-standards.md`).

Expand Down Expand Up @@ -220,12 +220,22 @@ Each phase ships as its own PR, per `design-decisions.md`'s phase rule.
versus `Compono.NSubstitute` itself not yet — done early, same PR
#28 review round. Delegate substitution already in the scope list
from the design-phase draft.
- [ ] `docs/mvp.md`: mark this milestone's exit criteria met once
`Compono.NSubstitute` actually ships (Phase 1/2) — still open.
- [x] `docs/mvp.md`: updated again in the Phase 1 PR (Codex, P2, same
pattern as the PR #28 round above) — `Compono.NSubstitute` (ADR-0025)
now says "implemented (PLAN-0005 Phase 1)," with test
coverage/end-to-end verification called out as still pending
(Phase 2), rather than leaving the whole package described as "not
yet implemented" once the Phase 1 PR actually shipped it. Milestone
exit criteria still correctly not marked met — that still needs
Phase 2.
- [x] `docs/public-api.md`: Provider Extensibility section now says
"Implemented" rather than "design target" — done early, same PR #28
review round. NSubstitute Integration section correctly still says
"not yet implemented."
- [x] `docs/public-api.md`: NSubstitute Integration section updated again
in the Phase 1 PR (Codex, P2) — now says "Implemented (PLAN-0005
Phase 1)" rather than "not yet implemented," same reasoning as the
`docs/mvp.md` update above.
- [x] `docs/public-api.md`: Naming Vocabulary gains `ICompositionValueProvider`
— already added during the design phase.
- [x] `docs/adr/README.md`/`docs/plans/README.md` index rows (already added
Expand Down Expand Up @@ -317,6 +327,39 @@ 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.
- **PR review (Codex, one P2 finding) caught the same doc-staleness pattern
PR #28's second review round caught for Phase 0.** `docs/public-api.md`'s
NSubstitute Integration section and `docs/mvp.md`'s Milestone 5 section
both still said `Compono.NSubstitute` was "not yet implemented" after
this phase's package/provider/`UseNSubstitute()` code had already landed
in the same PR — pulled forward and fixed in this PR rather than
deferred to Phase 3, on the same reasoning as the earlier round: leaving
the docs contradict the code until a later phase lands is worse than a
small out-of-phase doc correction. Both now say "implemented (PLAN-0005
Phase 1)" and explicitly call out that test coverage/end-to-end
verification remain pending (Phase 2) — this milestone's exit criteria
still correctly not marked met.

**Phase 0 (Done):**

- Implemented in the same branch/PR as the design docs, per explicit user
Expand Down
6 changes: 4 additions & 2 deletions docs/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,9 +422,11 @@ same interface, registered via `AddSemanticProvider` instead of

## NSubstitute Integration

Design target, resolved (not yet implemented) by
Implemented (PLAN-0005 Phase 1) per
[ADR-0025](adr/0025-compono-nsubstitute-package-design.md), built on the Provider
Extensibility contract above.
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).

Activation:

Expand Down
23 changes: 23 additions & 0 deletions src/Compono.NSubstitute/Compono.NSubstitute.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TargetFrameworks>net10.0;net11.0</TargetFrameworks>
<Description>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.</Description>
</PropertyGroup>

<ItemGroup>
<!-- Compono packs its source generator as an analyzer asset. PrivateAssets="none" allows
that analyzer to flow through this integration package to consumers that reference
only Compono.NSubstitute. -->
<ProjectReference Include="..\Compono\Compono.csproj" PrivateAssets="none" />
<PackageReference Include="NSubstitute" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Compono.NSubstitute.Tests" />
</ItemGroup>

</Project>
28 changes: 28 additions & 0 deletions src/Compono.NSubstitute/CompositionBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Compono;

/// <summary>
/// Activates <see cref="NSubstituteProvider"/> on a <see cref="CompositionBuilder"/>. See
/// <c>docs/adr/0025-compono-nsubstitute-package-design.md</c>.
/// </summary>
public static class CompositionBuilderExtensions
{
extension(CompositionBuilder builder)
{
/// <summary>
/// Registers an <see cref="NSubstituteProvider"/> with default <see cref="NSubstituteOptions"/>.
/// </summary>
public CompositionBuilder UseNSubstitute() => builder.UseNSubstitute(static _ => { });

/// <summary>
/// Registers an <see cref="NSubstituteProvider"/>, configured by <paramref name="configure"/>.
/// </summary>
/// <param name="configure">Sets the provider's <see cref="NSubstituteOptions"/>.</param>
public CompositionBuilder UseNSubstitute(Action<NSubstituteOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
var options = new NSubstituteOptions();
configure(options);
return builder.AddTestDoubleProvider(new NSubstituteProvider(options));
}
}
}
15 changes: 15 additions & 0 deletions src/Compono.NSubstitute/NSubstituteOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Compono;

/// <summary>
/// Configuration for <see cref="NSubstituteProvider"/>, set via
/// <c>CompositionBuilderExtensions.UseNSubstitute(Action{NSubstituteOptions})</c>. See
/// <c>docs/adr/0025-compono-nsubstitute-package-design.md</c>.
/// </summary>
public sealed class NSubstituteOptions
{
/// <summary>
/// Whether an unsealed abstract class is substitutable, in addition to every interface and
/// delegate type. Defaults to <see langword="true"/>.
/// </summary>
public bool SubstituteAbstractClasses { get; set; } = true;
}
46 changes: 46 additions & 0 deletions src/Compono.NSubstitute/NSubstituteProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using NSubstitute;

namespace Compono;

/// <summary>
/// A stage-6 test-double provider that composes an interface, delegate, or (when
/// <see cref="NSubstituteOptions.SubstituteAbstractClasses"/> allows it) unsealed abstract-class
/// request as a real NSubstitute substitute, via <see cref="Substitute.For(Type[], object[])"/>.
/// Registered via <c>CompositionBuilderExtensions.UseNSubstitute()</c>. See
/// <c>docs/adr/0025-compono-nsubstitute-package-design.md</c> (Amendment 1 for the corrected shape
/// below).
/// </summary>
public sealed class NSubstituteProvider : ICompositionValueProvider
{
private readonly bool _substituteAbstractClasses;

/// <summary>
/// Creates an <see cref="NSubstituteProvider"/>, snapshotting <paramref name="options"/>'s
/// current values - the provider never retains the caller-owned <see cref="NSubstituteOptions"/>
/// 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).
/// </summary>
/// <param name="options">The options to snapshot.</param>
public NSubstituteProvider(NSubstituteOptions options)
{
ArgumentNullException.ThrowIfNull(options);
_substituteAbstractClasses = options.SubstituteAbstractClasses;
}

/// <inheritdoc />
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);
}
15 changes: 4 additions & 11 deletions src/Compono.XunitV3/Compono.XunitV3.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,10 @@
</PropertyGroup>

<ItemGroup>
<!-- PrivateAssets="none" (PLAN-0004 Phase 3 real-packaged-consumer verification) - an ordinary
PackageReference/ProjectReference defaults PrivateAssets to "contentfiles;build;analyzers",
meaning those asset kinds stop at this package and never flow to a consumer of
Compono.XunitV3 - dotnet pack renders that default as exclude="Build,Analyzers" on the
generated <dependency> for Compono in this package's own nuspec. Left at its default, a
project that references only the Compono.XunitV3 package (never Compono directly - the
whole point of this package existing) never gets Compono.Generators transitively, and every
[Compose]-attributed type silently falls back to "no generated plan" - a real regression a
ProjectReference-based build can't surface. Caught by
test/Compono.XunitV3.SampleTests failing to compose a type reached only via a
[Compose]-attributed method until this was added. -->
<!-- Compono packs its source generator as an analyzer asset. PrivateAssets="none" allows
that analyzer to flow through this integration package to consumers that reference
only Compono.XunitV3 - caught by test/Compono.XunitV3.SampleTests failing to compose
a [Compose]-attributed type until this was added (PLAN-0004 Phase 3). -->
<ProjectReference Include="..\Compono\Compono.csproj" PrivateAssets="none" />
<!-- Ordinary reference, not PrivateAssets="all" - ComposeAttribute/ComposeAttribute{TProfile}
derive from and return types this package owns publicly, so a consumer needs them
Expand Down