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
113 changes: 90 additions & 23 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ not every stage is the same *kind* of thing:
| 3 | Exact registrations | **Hybrid**, per [ADR-0019](adr/0019-registrations-and-service-provider-injection.md) (implemented, Milestone 3 Phase 1): a context-owned deterministic lookup against the exact-registration table, then β€” only on a miss, if a consumer called `UseServiceProvider(...)` β€” a fallback `IServiceProvider.GetService(typeof(T))` call. Milestone 2 shipped this stage as internal-only with no public builder; Milestone 3 Phase 1 shipped its real public shape (`builder.Register<T>(...)`, `builder.UseServiceProvider(...)`). |
| 4 | Configuration rules | Ordered `ICompositionProvider` collection, per [ADR-0020](adr/0020-composition-configuration-rules.md) (implemented, Milestone 3 Phase 3). Renamed from "profile rules" (`PipelineStage.ConfigurationRule`): populated by type/member value rules compiled from `builder.For<T>()...`, whether reached directly or via a profile's `Configure` β€” a profile is a reusable application mechanism over this stage, not its owner ([ADR-0018](adr/0018-composition-profiles.md)). Collection-size configuration does **not** populate this stage β€” see Configuration Rules, below. |
| 5 | Semantic value providers | Ordered `ICompositionProvider` collection. The public registration surface (`builder.AddSemanticProvider(ICompositionValueProvider)`) is implemented as of Milestone 5 Phase 0 ([ADR-0024](adr/0024-public-provider-extensibility-model.md)) β€” but no `Compono`-shipped package registers anything into it by default, since `Compono.Bogus` (Milestone 6) doesn't exist yet. Empty in practice until then, populated in mechanism now. |
| 6 | Test-double providers | Ordered `ICompositionProvider` collection. Same status as stage 5: the public registration surface (`builder.AddTestDoubleProvider(ICompositionValueProvider)`) is implemented ([ADR-0024](adr/0024-public-provider-extensibility-model.md)), but empty in practice until `Compono.NSubstitute` (Milestone 5's own package, [ADR-0025](adr/0025-compono-nsubstitute-package-design.md)) ships β€” see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for its phase status. |
| 6 | Test-double providers | Ordered `ICompositionProvider` collection. The public registration surface (`builder.AddTestDoubleProvider(ICompositionValueProvider)`) is implemented ([ADR-0024](adr/0024-public-provider-extensibility-model.md)), and `Compono.NSubstitute` ([ADR-0025](adr/0025-compono-nsubstitute-package-design.md), implemented and test-covered β€” see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md)) is a real registrant via `UseNSubstitute()`. Still empty by default in any composition that doesn't opt into it β€” registration is per-`Composer`, not automatic just because the package is referenced. |
Comment thread
ncipollina marked this conversation as resolved.
| 7 | Built-in value providers | **Hybrid** (ADR-0014): an ordered `ICompositionProvider` collection (primitive/simple types, enums, nullable value types), populated internally by `Compono` itself, tried first β€” followed by a context-owned deterministic dispatch through `CollectionPlanCache<T>` for the five built-in collection shapes (array, `List<T>`, `IReadOnlyList<T>`, `HashSet<T>`, `Dictionary<TKey, TValue>`), the same closed-generic-field-read mechanism stage 8 uses, since `ICompositionProvider` can't itself construct a generic collection without reflection |
| 8 | Generated composition plans | Context-owned deterministic dispatch via `PlanCache<T>` β€” **not** an `ICompositionProvider` (see Source-Generated Composition Plans, below) |
| 9 | Diagnostic failure | Context-owned terminal stage |
Expand All @@ -201,12 +201,14 @@ Stages 4/5/6/7 each hold an actual ordered collection of providers, and
every one of their public registration surfaces is implemented today
(`.For<T>()` for stage 4, since Milestone 3; `AddSemanticProvider`/
`AddTestDoubleProvider` for stages 5/6, since Milestone 5 Phase 0 β€”
[ADR-0024](adr/0024-public-provider-extensibility-model.md)) β€” but only 4
and 7 have anything registered in them *by default*: stage 4 only when a
consumer actually calls `.For<T>()`, stage 7 unconditionally
(`BuiltInProviders.Default`). Stages 5/6 stay empty until a consumer
registers a provider directly, or until `Compono.Bogus`/`Compono.NSubstitute`
(which don't exist yet) do it on their behalf. Provider order
[ADR-0024](adr/0024-public-provider-extensibility-model.md)). Only stage 7
has anything registered *unconditionally* (`BuiltInProviders.Default`);
every other stage is opt-in, populated only when a consumer actually does
something β€” calls `.For<T>()` (stage 4), calls `UseNSubstitute()`
(`Compono.NSubstitute`, implemented, stage 6 β€” [ADR-0025](adr/0025-compono-nsubstitute-package-design.md)),
or calls `AddSemanticProvider`/`AddTestDoubleProvider` directly with a
hand-written provider (either stage). Stage 5 alone has no shipped package
to opt into yet β€” `Compono.Bogus` (Milestone 6) doesn't exist. Provider order
*within* an extensible stage is registration order; stage 7 alone already
holds three real providers (`PrimitiveValueProvider`, `EnumValueProvider`,
`NullableValueProvider` β€” `BuiltInProviders.Default`), so "no stage has
Expand Down Expand Up @@ -259,6 +261,49 @@ accidentally blocking a later stage (or a generated plan) that could
have. This avoids exception-driven provider selection and preserves
meaningful failures.

### Public providers (stages 5/6)

`ICompositionProvider` above is `Compono`-internal β€” stages 4/7 (registered
type/member rules, built-in providers) are implemented entirely inside the
core package and never exposed for an outside package to author its own.
Stages 5/6 (semantic values, test doubles) are different: they exist
specifically for an integration package to contribute open-ended,
pattern-matching logic ("any interface type"), which means the contract a
provider author implements has to be public, small, and decoupled from
`Compono`'s internal request/pipeline plumbing. Resolved by
[ADR-0024](adr/0024-public-provider-extensibility-model.md), implemented
(PLAN-0005 Phase 0):

```csharp
public interface ICompositionValueProvider
{
CompositionProviderResult TryProvide(
in CompositionProviderRequest request,
ICompositionContext context);
}
```

`CompositionProviderRequest` (`RequestedType`/`DeclaringType`/`Name`/
`Nullability`) and `CompositionProviderResult` (`NotHandled`/`Handled(value)`)
are their own public types, decoupled from the internal
`CompositionRequest`/`CompositionResult` pair above β€” no path, no
shared-scope flag, no pipeline plumbing a provider author has no legitimate
use for. `CompositionBuilder.AddSemanticProvider`/`AddTestDoubleProvider`
register a public provider into stage 5/6 respectively, in registration
order; internally, each is wrapped in a `PublicProviderAdapter :
ICompositionProvider` β€” an adapter, not a second provider contract β€” so the
rest of the pipeline (dispatch, tracing, diagnostics identity via
`ICompositionProvider.ProviderType`) treats a public provider exactly like an
internal one, with diagnostics naming the real wrapped provider's type, never
the adapter. A public provider may call `context.Resolve<T>()`
(descriptor-less) to compose part of its value from a nested request, exactly
as an internal provider already may; a thrown exception from `TryProvide`
propagates uncaught, per this ADR's Provider Failure Semantics β€” same
"exceptions signal a bug" principle as everywhere else in this pipeline, not
a stronger contract than an internal provider gets.
`Compono.NSubstitute`'s `NSubstituteProvider` (registered via
`AddTestDoubleProvider`) is the first real consumer of this contract.

## Source-Generated Composition Plans

Source generation is the preferred construction strategy.
Expand Down Expand Up @@ -749,14 +794,17 @@ Owns:

### Compono.Generators

Potentially owns:
Resolved by [ADR-0003](adr/0003-generator-package-distribution.md): never
published to NuGet on its own β€” its compiled output is packed directly into
the `Compono` nupkg as an analyzer dependency, so from a consumer's point of
view it doesn't exist as a separate package at all.

Owns:

- Incremental source generator
- Generated plan registration
- Compile-time diagnostics

Whether this ships separately or is bundled as an analyzer dependency of `Compono` remains open.

### Compono.XunitV3

Design: [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md)
Expand All @@ -765,9 +813,11 @@ Design: [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integ
package itself), [ADR-0023](adr/0023-rename-compono-xunit-to-compono-xunitv3.md)
(the `Compono.Xunit` β†’ `Compono.XunitV3` rename). Implemented - see
[PLAN-0004](plans/0004-milestone-4-xunit-integration.md) for the phase-by-phase
account and its Open Items for one known compile-time gap (an interface/
abstract/delegate-typed `[Compose]`-attributed parameter reports CMP0003
unconditionally).
account. The one compile-time gap tracked in that plan's Open Items (an
interface/abstract/delegate-typed `[Compose]`-attributed parameter reported
CMP0003 unconditionally) is resolved by
[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) Phase 2, see
[ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md).

Owns:

Expand All @@ -780,12 +830,29 @@ Owns:

### Compono.NSubstitute

Design: [ADR-0024](adr/0024-public-provider-extensibility-model.md) (the
public provider extension point this package builds on, owned by core
`Compono`), [ADR-0025](adr/0025-compono-nsubstitute-package-design.md) (this
package itself). Implemented and test-covered/end-to-end verified - see
[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for the
phase-by-phase account.

Owns:

- NSubstitute-backed test-double provider
- Interface support
- Optional abstract-class support
- NSubstitute-specific diagnostics
- `NSubstituteProvider` β€” the stage-6 test-double provider (registered via
`AddTestDoubleProvider`), composing an interface, delegate, or (when
configured) unsealed abstract-class request as a real
`Substitute.For(Type[], object[])` value
- `NSubstituteOptions` β€” `SubstituteAbstractClasses`
- `CompositionBuilderExtensions.UseNSubstitute()`/`UseNSubstitute(Action<NSubstituteOptions>)`

Contributes no diagnostics of its own β€” an unsubstitutable request (a sealed
concrete class) falls through `NotHandled` to later pipeline stages exactly
like any other stage-6 decline, so it still composes normally at stage 8 if a
generated plan exists for it. Only when nothing later in the pipeline can
satisfy the request either does it reach the engine's existing stage-9
"nothing could satisfy this" diagnostic, naming the type and path β€” never a
package-specific one (ADR-0025's Diagnostics section).

### Compono.Bogus

Expand Down Expand Up @@ -882,12 +949,12 @@ Owns:
`AddTestDoubleProvider`, compiled into stage 5/6 the same way ADR-0020's rules
compile into stage 4 β€” **implemented, PLAN-0005 Phase 0**) **and
[ADR-0025](adr/0025-compono-nsubstitute-package-design.md)** (`Compono.NSubstitute`,
the first real consumer of that contract β€” design only, not yet implemented;
see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for its
phase status). The core extension point itself is real and tested; stages
5/6 in the Resolution Pipeline table above stay empty in practice only
because no `Compono`-shipped package (`Compono.Bogus`, `Compono.NSubstitute`)
registers anything into them yet.
the first real consumer of that contract β€” **implemented and test-covered/
end-to-end verified, PLAN-0005**, all phases done). Both the core extension
point and its first real consumer are shipped: stage 6 in the Resolution
Pipeline table above is populated whenever a consumer calls
`UseNSubstitute()`; stage 5 stays empty in practice only because
`Compono.Bogus` (Milestone 6) doesn't exist yet.
- **Richer `Microsoft.Extensions.DependencyInjection` integration** (`IServiceCollection`
auto-registration, per-composition scoping, keyed services) β€” explicitly out of
scope for `Compono` core per
Expand Down
37 changes: 21 additions & 16 deletions docs/mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,13 @@ diagnostics, package dependencies), [ADR-0023](adr/0023-rename-compono-xunit-to-
Phases 0-4 (core entry point, attribute skeleton, binding algorithm, test
suites/verification, docs/cleanup) β€” see
[PLAN-0004](plans/0004-milestone-4-xunit-integration.md) for the phase-by-phase
account. One known gap remains open past Phase 4: an interface/abstract/
account. The one gap that remained open past Phase 4 β€” an interface/abstract/
delegate-typed `[Compose]`-attributed parameter (including the `IRepository`
shape in the Example below) reports CMP0003 and fails to compile even when a
profile registration or an inline value would satisfy it at runtime β€” see
PLAN-0004's Open Items.
shape in the Example below) reported CMP0003 and failed to compile even when a
profile registration or a runtime provider would satisfy it β€” is now resolved
by [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) Phase 2, see
[ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md) and
PLAN-0004's Open Items for the full account.

### Scope

Expand All @@ -233,18 +235,21 @@ public void Creates_service(
}
```

This is the target shape; `IRepository` being interface-typed currently hits
the CMP0003 gap noted above β€” `test/Compono.XunitV3.SampleTests` uses a
concrete `Repository` type for its own `[Shared]` theory to route around it
until that gap is resolved.
This shape compiles and runs today β€” an interface-typed `[Shared]` parameter
like `IRepository` is composed as a provider-resolved leaf
([ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md)),
satisfied at runtime by whatever `TProfile.Configure` registers (a plain
`Register<T>(...)`, or `Compono.NSubstitute`'s `UseNSubstitute()`).
`test/Compono.XunitV3.SampleTests`' original `[Shared]` theory still uses a
concrete `Repository` type (predating this fix); `NSubstituteTests.cs` in the
same project runs this exact interface-typed shape for real.

### Exit Criteria

Met for the parameter shapes `Compono.XunitV3` currently supports, verified
through `test/Compono.XunitV3.Tests` and a real xUnit v3 runner against
`test/Compono.XunitV3.SampleTests` (PLAN-0004 Phase 3); the CMP0003 gap above
means "composed parameters work under xUnit v3" doesn't yet extend to a bare
interface/abstract/delegate-typed `[Compose]` parameter:
Met, verified through `test/Compono.XunitV3.Tests` and a real xUnit v3 runner
against `test/Compono.XunitV3.SampleTests` (PLAN-0004 Phase 3), including a
bare interface/abstract/delegate-typed `[Compose]` parameter as of PLAN-0005
Phase 2:

- Composed parameters work under xUnit v3
- Inline values take precedence
Expand Down Expand Up @@ -274,9 +279,9 @@ diagnostics). **ADR-0024's core engine extension point is implemented
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 (Phase 3, docs/cleanup, still open) and
run of this milestone's own Goal scenario. **This milestone is complete** β€”
see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for the
phase-by-phase tracker (all four phases `Done`) 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/
Expand Down
59 changes: 26 additions & 33 deletions docs/plans/0004-milestone-4-xunit-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,39 +1079,32 @@ exercised indirectly through `Compono.XunitV3.Tests`.

## Open Items

- **`ComposeMethodDiscovery` reports CMP0003 for an interface/abstract/
delegate-typed `[Compose]`-attributed parameter unconditionally, even
when the author intends it to be satisfied entirely by
`TProfile.Configure`'s own `Register<T>(...)` or an always-supplied
inline value (PR #23 review) β€” genuinely blocking: the project fails
to compile for that otherwise-valid usage.** Deliberately *not* fixed
as a drive-by change here, because it isn't a gap unique to
`Compono.XunitV3` β€” `ComposeMethodDiscovery.TransformMethod` reaches
every eligible parameter through the exact same
`ComposedTypeAnalyzer.Analyze` root-request path
`Composer.Create<T>()` and `[Composable]` already use, and that
path's "an abstract/delegate root always reaches constructor selection
and gets CMP0003, even when a registration might satisfy it at
runtime" behavior is deliberate, cross-cutting, and specifically
regression-tested (`LeafTypeClassifier.IsRuntimeProviderResolved` is
documented as *narrower than* `IsProviderResolved` for exactly this
reason; `CompositionPlanVerifyTests.AbstractRootType_
StillReportsDiagnostic_AfterRootProviderCheck` exists specifically to
keep a registered-but-abstract `Create<T>()` root reaching CMP0003
rather than silently compiling into a call that can only fail at
runtime with a useless message - the PR #11 regression this guards
against). Loosening that check for `[Compose]`-attributed parameters
alone - e.g. reusing `LeafTypeClassifier.IsProviderResolved`'s lenient,
member-style leaf treatment for a parameter root instead of the
stricter `IsRuntimeProviderResolved` - would resolve this specific
complaint, but it's a change to a foundational, deliberately-tested
diagnostic philosophy spanning every discovery path, not a one-line
fix scoped to this package; it needs its own design dive
(`design-decisions.md`) weighing the same false-positive-vs-silent-
failure tradeoff `Create<T>()`'s root already settled, not a decision
made inline while triaging PR feedback. Phase 2's binding algorithm
didn't touch generator-level discovery, so this item is unresolved by
it β€” still tracked here for a dedicated design dive or follow-up.
- ~~**`ComposeMethodDiscovery` reports CMP0003 for an interface/abstract/
delegate-typed `[Compose]`-attributed parameter unconditionally...**~~
**Resolved by [PLAN-0005](0005-milestone-5-nsubstitute-integration.md)
Phase 2 (2026-07-31), see [ADR-0024 Amendment 2](../adr/0024-public-provider-extensibility-model.md).**
This item predicted exactly the right fix (reusing
`LeafTypeClassifier.IsProviderResolved`'s lenient, member-style leaf
treatment for a parameter root instead of the stricter
`IsRuntimeProviderResolved`) and correctly identified it as needing its
own design dive rather than a drive-by fix here β€” that dive happened as
part of PLAN-0005 Phase 2's real end-to-end verification, when this
plan's own `[Shared] IRepository repository` Goal-section shape (see the
note below) failed to compile against a real packaged consumer, giving
the "a registration/provider might satisfy this at runtime" side of the
false-positive-vs-silent-failure tradeoff real dogfooding evidence for
the first time. `IsRuntimeProviderResolved` now delegates directly to
`IsProviderResolved` β€” an interface/abstract-class/delegate root
(including a `[Compose]`-attributed parameter, since
`ComposeMethodDiscovery` shares the same root-request path this item
described) is classified identically to a member, at every discovery
path, not just this package's own. The regression test this item named,
`CompositionPlanVerifyTests.AbstractRootType_
StillReportsDiagnostic_AfterRootProviderCheck`, was rewritten (its
assertion inverted) as part of that fix, alongside a new
`ConcreteRootTypeWithNoAccessibleConstructor_
StillReportsDiagnostic_AfterRootProviderCheck` proving a genuinely
unconstructible concrete root is unaffected.

The one item Phase 0 left open (no generator discovery path for a
`[Compose]`-attributed method's own parameter) was closed by Phase 1's
Expand Down
Loading