[ADR-0022] Compono.Xunit Package Design¶
Status: Accepted
Date: 2026-07-30
Decision Makers: solo (inline-value attribute shape and profile scope confirmed with user)
Context¶
docs/mvp.md's Milestone 4 needs a Compono.Xunit package so an xUnit v3 theory can declare composed parameters, mix inline and composed values in one row, share a value into a composed system under test, select a reusable profile, and report a reproducible seed on failure:
[Theory]
[Compose<ApplicationTestProfile>]
public void Creates_service(
[Shared] IRepository repository,
OrderService service,
CreateOrder command)
{
}
ADR-0021 adds the core-side extension point (CompositionRow, CompositionRequestKind.TestParameter, the stage-2 read-gate change) this package needs. This ADR covers everything specific to Compono.Xunit itself: which xUnit v3 extension points it implements, the public attribute surface, the inline/composed binding algorithm, profile selection, seed policy, diagnostics, package dependencies, and which test method shapes are supported.
The xUnit v3 extension point was confirmed directly against the real xunit.v3.extensibility.core 3.2.2 assembly (reflected, not assumed from v2 familiarity) rather than xUnit v2 documentation, per this milestone's explicit "do not assume xUnit v2 behavior applies" instruction:
Xunit.v3.DataAttributeis the abstract base every custom data source implements:Attribute, implementingXunit.v3.IDataAttribute.GetData(MethodInfo testMethod, DisposalTracker disposalTracker)returnsValueTask<IReadOnlyCollection<ITheoryDataRow>>— genuinely async-capable (a future data source could await I/O), but aValueTaskcompletes synchronously with no allocation when there's nothing to await, which is exactlyCompono.Xunit's case (composition is in-memory/CPU-bound, per ADR-0010).SupportsDiscoveryEnumeration()is abstract, not virtual-with-a- default — everyDataAttributesubclass must decide explicitly whether its rows can be enumerated (and, implicitly, serialized/displayed) at discovery time, before any test runs, or only at execution time.ITheoryDataRow.GetData()returns a plainobject?[]; the publicTheoryDataRow(object?[] data)constructor is the straightforward way to produce one.MethodInfo/ParameterInfo(handed directly toGetData, no additional reflection costCompono.Xunitintroduces beyond what xUnit itself already performs to invoke the test) expose everything needed: parameter name, type, ordinal, custom attributes ([Shared]),IsOptional/HasDefaultValue, and — viaSystem.Reflection.NullabilityInfoContext, confirmed working directly against a reflected sample method — nullable annotations on a reference-typed parameter.
Decision Drivers¶
docs/mvp.md's exit criteria: composed parameters work, inline values win, shared values flow into a composed SUT, a failure reports a reproducible seed.- "Avoid relying on undocumented xUnit internals," "keep dependencies minimal" — everything above is public, documented extension surface (
Xunit.v3namespace), not an SDK-internal type. design-decisions.mdrule 3 —Compono.Xunitmay only reach the engine throughCompono's public surface (ADR-0021).- "Prefer existing structured Compono diagnostics rather than inventing a parallel exception hierarchy" — a pipeline composition failure should surface as the same
CompositionException/CompositionDiagnosticCompono.Testsalready relies on, not aCompono.Xunit-specific wrapper. - Smallest discoverable API — confirmed with the user: inline values live on
[Compose(...)]'s own constructor rather than a second attribute name, and profile selection is method-level only for this milestone.
Considered Options¶
Discovery-time vs. execution-time composition: 1. Compose eagerly at discovery time (SupportsDiscoveryEnumeration() => true), so dotnet test --list-tests/IDE test explorers see fully realized rows. 2. Defer all composition to execution time (SupportsDiscoveryEnumeration() => false); discovery sees only that theory data exists, not its content.
Inline-value attribute shape (resolved with the user; recorded here for completeness): 1. Inline values as [Compose(...)]'s own constructor arguments (chosen). 2. A second, distinct [InlineComposeData(...)] attribute. 3. Stack [Compose] with ordinary [InlineData]/[MemberData].
Profile selection scope (resolved with the user; recorded here for completeness): 1. Method-level [Compose<TProfile>] only (chosen). 2. Method-level and class-level, with an explicit precedence rule.
Decision Outcome¶
Discovery-time vs. execution-time: Option 2, defer to execution. ComposeAttribute.SupportsDiscoveryEnumeration() returns false. Composed values — especially a future Compono.NSubstitute substitute, or any reference type without a meaningful serialized form — are not safely enumerable or displayable at discovery time, and forcing composition to run twice (once to enumerate, once for real) would double the random-fork cost and risk two different values being shown at discovery versus used at execution unless a seed were pinned solely to prevent that. Discovery therefore sees only that [Compose]/[Compose<TProfile>] produces theory data; GetData runs for real exactly once per test execution, which is also what makes "one composition context per theory row" true by construction — there is no separate "discovery pass" context to keep synchronized with the "execution pass" one.
Attribute surface¶
namespace Compono.Xunit;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class ComposeAttribute : Xunit.v3.DataAttribute
{
private int? _seed;
public ComposeAttribute(params object?[] inlineValues);
/// Explicit root seed for this row - same underlying contract as
/// CompositionBuilder.WithSeed(int), but restricted to non-negative
/// values (a negative Seed fails with a clear pre-composition
/// exception) so a seed reported in a failure message is always
/// pasteable back here unchanged. Unset: a fresh, non-negative seed
/// is generated on every GetData call. A plain int, not int? -
/// attribute named arguments cannot target a Nullable<T> property
/// (CS0655), so this mirrors Xunit.v3.DataAttribute's own
/// Timeout/TimeoutAsNullable pair exactly (confirmed against the real
/// xunit.v3.core assembly): a public, attribute-legal int property,
/// backed by the private _seed field above, which the internal
/// SeedAsNullable property exposes for the binding algorithm to
/// actually read.
public int Seed
{
get => _seed ?? default;
set => _seed = value;
}
/// The value actually assigned to Seed, or null if it was never set -
/// distinguishes "configured to 0" from "never configured," which the
/// public Seed property alone cannot (its getter falls back to
/// default(int), i.e. 0, when unset). The binding algorithm reads this,
/// never Seed directly.
internal int? SeedAsNullable => _seed;
public override ValueTask<IReadOnlyCollection<ITheoryDataRow>> GetData(
MethodInfo testMethod, DisposalTracker disposalTracker);
public override bool SupportsDiscoveryEnumeration() => false;
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ComposeAttribute<TProfile> : ComposeAttribute
where TProfile : ICompositionProfile, new()
{
public ComposeAttribute(params object?[] inlineValues) : base(inlineValues) { }
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class SharedAttribute : Attribute;
This is the package's entire M4 public surface beyond what ADR-0021 adds to core Compono. ComposeAttribute is deliberately unsealed — coding-standards.md's "classes sealed by default" exception for a type "genuinely designed for extension" applies directly: ComposeAttribute<TProfile> is that extension, mirroring how CompositionBuilder.AddProfile<TProfile>() constrains TProfile : ICompositionProfile, new(). Because C# enforces generic-attribute constraints at the attribute's use site like any other generic type, [Compose<NotAProfile>] for a type that doesn't implement ICompositionProfile or lacks a public parameterless constructor is a compile error — eliminating an entire class of "invalid profile type" runtime diagnostic the milestone's design questions raised; there is nothing left to validate at runtime.
// Default composition
[Theory, Compose]
public void Creates_service(OrderService service, CreateOrder command) { }
// Profile selection
[Theory, Compose<ApplicationTestProfile>]
public void Creates_service(
[Shared] IRepository repository, OrderService service, CreateOrder command) { }
// Inline plus composed (positional; leading parameters only)
[Theory, Compose("alice@example.com")]
public void Accepts_email(string email, Customer customer) { }
// Explicit seed reproduction
[Theory, Compose(Seed = 8492173)]
public void Reproduces_failure(Order order) { }
Inline/composed binding algorithm¶
- Read the cached
Composerand binding metadata (see Caching below) — the reflectedtestMethod.GetParameters(), each parameter's[Shared]presence and nullability, and the result of the generic-method/ref/out/in/params/duplicate-[Shared]-type signature checks (computed once, the first time this attribute instance'sGetDataran; see Diagnostics and Exceptions below for what each check reports). - Create the row:
composer.CreateRow(testMethod.DeclaringType)(ADR-0021). This runs before the cached signature-validation result is consulted, deliberately: a[Compose(Seed = ...)]-configured row's seed is known before this point, but an unseeded row's seed is generated byCreateRowitself — there is no seed to report at all, reproducible or otherwise, until the row exists. Creating the row first, and checking cached validation second, is what lets everyCompono.Xunit-authored failure (signature or binding) report the row's real seed, not a value invented before one existed. - If
SeedAsNullablehas a value and it's negative, throw now, usingrow.Seed(which just echoes the rejected value back — it was supplied explicitly, so there's nothing to discover) — a plain-messageCompositionExceptionstatingCompono.Xunitaccepts only non-negative seeds. See Seed Policy and Reporting below for why: this is what keeps every row's seed non-negative unconditionally, which is what makes theulong/intprint-identical guarantee hold for every failure, not just an auto-generated one. - If the cached signature-validation result is invalid, throw now, using
row.Seed— a plain-messageCompositionExceptionnaming the test class, method, and the specific problem (unsupported shape, or a duplicate[Shared]type naming both parameter names/ordinals and the conflicting type), with the same appended"\n\nSeed: {value}"convention every other failure category uses (see Seed Policy and Reporting below). This still happens before any parameter is bound or composed — no random fork is consumed and no partially-composed row is ever produced for an invalid signature — it is only row creation itself that now precedes it, not composition. ADR-0021'sCompositionRowstill refuses a second same-type share defensively (belt-and-suspenders) if this check were ever bypassed, but this check is what actually produces the named, actionable message. - Bind inline values, strictly positional, left-to-right from parameter 0.
inlineValues[i]supplies parameterifor everyi < inlineValues.Length, regardless of[Shared]; every parameter ati >= inlineValues.Lengthis composed. "Supplied" isi < inlineValues.Length, never null-checked —inlineValues[i] == nullis a supplied explicitnull, distinguished from "not supplied" by array length alone, never by nullness.inlineValues.Length > testMethod.GetParameters().Lengthis a pre-compositionCompositionException("too many inline values"). Every supplied inline value is validated before any parameter is bound, shared, or composed (step 6/⅞ below never start until every inline value has passed this check) — an inline mismatch always fails as its own category, never surfacing indirectly through a laterCompositionRow.ShareExplicit/Resolvecall: inlineValues[i] is null: valid only if parameteri's cachedNullability(from step 1'sNullabilityInfoContextmetadata) isNullable— a nullable reference type (string?) or a nullable value type (int?). Anullsupplied for aNotNullableparameter — a non-nullable reference type or a non-nullable value type — is a pre-compositionCompositionExceptionnaming the parameter. This check is purely nullability-based, never a runtime-type check (nullhas no runtime type to inspect at all).inlineValues[i]is non-null: valid only if its runtime type (inlineValues[i]!.GetType()) is assignable toNullable.GetUnderlyingType(parameterType) ?? parameterType— never the raw declared type directly. A non-nullNullable<T>boxes as a boxedT, not a boxedNullable<T>(a CLR nullable-boxing rule, not a Compono convention), so[Compose(42)]targeting anint?parameter boxes toSystem.Int32; checking assignability against the declaredint?directly would reject it (typeof(int?).IsAssignableFrom(typeof(int))isfalse) despite nullable parameters being fully supported (Async and Unsupported Shapes, below). Unwrapping to the underlying type first — a no-op for a non-nullable parameter, sinceNullable.GetUnderlyingTypereturnsnulland the??falls back toparameterTypeunchanged — is what makes the check correct for both. Otherwise (still not assignable after unwrapping) a pre-compositionCompositionExceptionnaming the parameter and both types. Inline values may not target a later parameter while an earlier one is composed — this is a deliberate limitation (see Non-goals), not a distinct failure mode to diagnose.- Compose or share
[Shared]parameters first, in declaration order among themselves, regardless of where they sit among non-shared parameters: an inline-supplied[Shared]parameter calls that parameter's cachedShareExplicitinvoker (see Runtime-TypedCompositionRowInvocation, below — this is never a direct, runtime-typedrow.ShareExplicit<T>(...)call); a composed[Shared]parameter calls its cachedResolveSharedinvoker. A[Shared]parameter's own generated dependencies are resolved (and, if it is itself composed, its result stored into scope) before the next[Shared]parameter's turn — so a[Shared]parameter that itself needs a later-declared[Shared]sibling will not observe it (see Non-goals: declare a[Shared]dependency before the parameter that needs it, same as ordinary top-to-bottom reading order). - Compose every remaining (non-inline, non-shared) parameter, in declaration order, via its cached
Resolveinvoker. - Assemble the final
object?[]in method declaration order (not binding-processing order) and return it as the row'sTheoryDataRow, withTraits["Compono.Seed"] = [row.Seed.ToString()]set unconditionally — see Seed Policy and Reporting below for why every row carries this, pass or fail.
Optional parameters (a C# default value) are composed exactly like required ones whenever not inline-supplied — Compono.Xunit always supplies every position explicitly, so the CLR-level default is never consulted. Static and instance test methods are handled identically; nothing here depends on testMethod.IsStatic.
Runtime-typed CompositionRow invocation¶
Steps 6 and 7 above call CompositionRow.Resolve<T>/ResolveShared<T>/ ShareExplicit<T> — but Compono.Xunit only ever knows a parameter's type as a runtime Type (ParameterInfo.ParameterType), never as a compile-time generic argument. Calling a generic method with a runtime-only Type needs MethodInfo.MakeGenericMethod/Invoke — but that reflection cost must not land on the per-row composition path (every row would pay a MakeGenericMethod + boxed-object[] Invoke per parameter, on top of the composition it's actually measuring). The fix is the same shape as Compono.Generators' own boxing pattern for a closed generic type: close the generic method once, per parameter, while building the cached binding plan (Phase 1's Lazy<Composer>/binding-plan construction — the same one-time pass that already computes each parameter's descriptor template and nullability), and cache a strongly typed delegate — never the raw MethodInfo — for the per-row path to call.
// Non-generic delegate shapes every invoker is adapted to, regardless of
// the closed T - this is what lets a row call an invoker with no
// reflection at all, only an ordinary delegate invocation.
file delegate object? ResolveInvoker(CompositionRow row, in CompositionRequestDescriptor descriptor);
file delegate object? ResolveSharedInvoker(CompositionRow row, in CompositionRequestDescriptor descriptor);
file delegate void ShareExplicitInvoker(CompositionRow row, in CompositionRequestDescriptor descriptor, object? value);
// Private, closed once per parameter type via MakeGenericMethod, then wrapped in the
// delegate shapes above via Delegate.CreateDelegate - never called through reflection
// directly. Each helper's own declared return type is object?, not T, so the T -> object
// boxing conversion a value-typed T needs happens inside the closed method body itself
// (compiled per closed T), which is what makes an exact, non-covariant CreateDelegate
// binding possible - Delegate.CreateDelegate does not perform boxing-conversion return-type
// adaptation on your behalf.
private static object? InvokeResolve<T>(CompositionRow row, in CompositionRequestDescriptor descriptor) =>
row.Resolve<T>(descriptor);
private static object? InvokeResolveShared<T>(CompositionRow row, in CompositionRequestDescriptor descriptor) =>
row.ResolveShared<T>(descriptor);
private static void InvokeShareExplicit<T>(CompositionRow row, in CompositionRequestDescriptor descriptor, object? value) =>
row.ShareExplicit<T>(descriptor, (T)value!); // safe: step 5 already validated null/assignability for this exact T
For each parameter, once, while building the cached binding plan:
var closedResolve = typeof(BindingPlan)
.GetMethod(nameof(InvokeResolve), BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(parameter.ParameterType);
var resolveInvoker = (ResolveInvoker)Delegate.CreateDelegate(typeof(ResolveInvoker), closedResolve);
// ...ResolveSharedInvoker/ShareExplicitInvoker built the same way, once each.
The per-parameter binding-plan entry then holds resolveInvoker/ resolveSharedInvoker/shareExplicitInvoker (three delegate fields) alongside its descriptor template. Steps 6–8's per-row work becomes three ordinary delegate calls per parameter — resolveInvoker(row, descriptor), etc. — with zero MakeGenericMethod/Invoke calls anywhere on the per-row path. This is the exact ParameterInfo.ParameterType → close a private generic helper once → cache the delegate → invoke the delegate per row shape, and it's what makes the reflection here compatible with ADR-0001's no-runtime-reflection-on- the-composition-hot-path rule despite MakeGenericMethod being involved at all: the reflection happens exactly once per parameter, at binding-plan-cache-construction time (bounded by the test method's own parameter count, not by how many times the test runs), never once per row or once per parameter composition.
Composition order rationale (design question 7)¶
The milestone's own suggested model — bind inline, compose/share [Shared], compose the rest, return in declaration order — is confirmed correct by the binding algorithm above, with one addition the milestone didn't fully specify: inline-supplied [Shared] parameters share via ShareExplicit, not ResolveShared, since there's nothing left to compose for them.
[Shared] semantics (design question 6)¶
- Type-based only, per the confirmed default:
CompositionScope(ADR-0011) is unchanged, still keyed byRequestedTypealone. Name/qualifier-based sharing stays deferred past Milestone 4 — no concrete consumer has motivated it yet. - Duplicate
[Shared]types fail clearly, at signature-validation time (binding algorithm step 4, checked against the cache built in step 1, after the row is created in step 2 and the negative-seed check in step 3), naming both parameters. [Shared]parameters compose before all non-shared parameters, regardless of declaration order — binding algorithm step 6 before step 7 — so a[Shared]value is always in scope before any parameter that might structurally depend on it composes.- Inline shared values are supported (
ShareExplicit). - Shared values are visible only within the current row — each
GetDatacall builds a brand-newCompositionRow/CompositionContext/CompositionScope; nothing survives across rows or across test methods. [Shared]is valid on value types —CompositionScopealready storesobject?keyed byType; boxing a sharedstructis no different from boxing any other stage's result.- A
[Shared]parameter may depend on an earlier-declared[Shared]sibling, never a later one (see Non-goals) — declaration order among[Shared]parameters is the only orderingCompono.Xunitestablishes among them; no dependency graph is built. - Conflicts with exact registrations/rules: none introduced. A
[Shared]parameter still resolves through the full pipeline (ADR-0021'sResolveShared), so an exact registration for its type still wins over generated composition, exactly as it would for any other request — "shared" only changes what happens after a value is produced (it gets stored for reuse), never which stage produces it.
Profile selection (design question 8)¶
Method-level [Compose<TProfile>] only, per the confirmed decision. TProfile : ICompositionProfile, new(). [Compose] with no type parameter builds an empty/default Composer (Composer.Create()). Only one [Compose...] attribute may appear on a method (AllowMultiple = false) — combining several profiles is already fully supported by ADR-0018's existing AddProfile composition (a profile's own Configure can call builder.AddProfile<Other>()), so no attribute-level multi-profile capability is needed. Profile instances cannot be supplied through the attribute — only types — since an attribute constructor argument must be a compile-time constant; this matches AddProfile<TProfile>()'s existing new()-only public shape (AddProfile(ICompositionProfile instance) remains programmatic-only, unreachable from an attribute). A profile is applied exactly once, when the method's cached Composer is first built (see Caching) — never once per row.
Amendment (PR #23 review): AllowMultiple = false is enforced by the compiler per exact attribute type, not across a base/derived family — [Compose] and [Compose<TProfile>] (or two differently-closed [Compose<TProfile>] forms) are distinct types that each individually satisfy their own AllowMultiple = false, so the attribute declarations alone don't stop a method from being decorated with more than one Compose-family attribute; xUnit would then call GetData on each independently, producing overlapping rows from unrelated composer configurations instead of enforcing "only one [Compose...] attribute per method." Enforced instead at the same place the rest of this ADR's signature validation already lives — BindingPlan.Build's ValidateSignature, via testMethod.GetCustomAttributes<ComposeAttribute>() (which finds every Compose-family instance, since GetCustomAttributes<T> matches subtypes of T too) — reported through the same SignatureError Phase 2 throws before any parameter is bound or composed.
Seed policy and reporting (design question 9)¶
ComposeAttribute.SeedAsNullable (backing the attribute-legal int Seed property — see Attribute Surface above for why it isn't int? directly) feeds builder.WithSeed(SeedAsNullable.Value) when it has a value. When it doesn't, Composer.CreateRow(...)'s _configuration.Seed ?? CompositionSeed.GenerateRowSeed() fallback (ADR-0021) applies unchanged — every GetData call without an explicit seed generates a fresh one, drawn from int's range specifically (unlike an unseeded Create<T>() call's full-ulong-range generation), so CompositionRow.Seed is always the complete, reportable, pasteable value — never a truncated view of a wider one. This is a deliberate non-goal, not an oversight: [Compose] always produces exactly one row in Milestone 4 (see Non-goals), so there is no "multiple rows from one attribute" seed-derivation question to answer the way CreateMany's index-forking does.
Two distinct failure moments, per the milestone's own required distinction:
ComposeAttribute.Seedrejects a negative value outright. Checked immediately afterComposer.CreateRow(...)runs (so the row — and itsSeed— already exists), before any parameter is bound or composed: a plain-messageCompositionExceptionnaming the configured value and stating thatCompono.Xunitaccepts only non-negative seeds, with the same appended"\n\nSeed: {row.Seed}"convention every other failure uses (row.Seedhere just echoes the rejected value back, since it was supplied explicitly). This is what makes the guarantee below unconditional rather than "usually true": every rowCompono.Xunitever creates — configured or auto-generated — has a non-negative seed, full stop. CoreCompono's own programmaticCompositionBuilder.WithSeed(int)(ADR-0017) is unaffected and continues to accept the fullintrange — this restriction isComposeAttribute.Seedspecifically, not a core-wide change, since only this attribute makes the "paste the reported seed back into this same attribute" promisedocs/mvp.md's exit criteria require.- Composition failure before the test runs: a pipeline stage can't satisfy a parameter, a recursion cycle, or one of
Compono.Xunit's own pre-composition checks (signature validation, or the negative-seed rejection above).GetDatalets the underlyingCompositionExceptionpropagate un-wrapped — itsDiagnostic(when present) already carriesRootType(the test class), the failing parameter's position in the path tree, the full dependency path, the provider trace, and the seed, rendered exactly asdocs/architecture.md's existing Diagnostics example shows, ending inSeed: {value}. Thatvalueis guaranteed to print identically whether read as the engine's ownulongor asCompositionRow.Seed'sintfor every row, not just an auto-generated one — ADR-0021'sGenerateRowSeed()draws only non-negativeintvalues, and the negative-seed rejection above closes the one remaining gap (an explicitly-configured negative value) — so this holds withoutCompono.Xunitrewriting the propagated pipeline message at all. ACompono.Xunit-authored plain-message exception (noDiagnostic) manually appends the same"\n\nSeed: {value}"convention (usingrow.Seed, theint), so every failure category — pipeline orCompono.Xunit-owned — reports a seed the user can paste directly into[Compose(Seed = ...)]to reproduce it, with no exceptions. - Assertion/test failure after successful composition: xUnit's own concern once
GetDatahas returned a valid row —Compono.Xunitnever re-enters the picture to report or interpret the assertion failure itself. But this is the single most common real-world failure shape (composed data happened to trigger a bug the assertion catches), anddocs/mvp.md's Milestone 4 exit criterion ("failure output includes a seed") isn't scoped to composition failures only — a design that drops the seed the moment composition succeeds leaves exactly this case unreproducible, which an earlier revision of this ADR got wrong. Every row carries its seed as anITheoryDataRowtrait ("Compono.Seed"→row.Seed.ToString()), set unconditionally inGetData, regardless of whether the test that row feeds will pass or fail —Compono.Xunitcannot know the outcome atGetDatatime, so the trait can't be applied only-on-failure. This is what makes the seed discoverable for any failure, composition or assertion, without reopening "avoid noisy default output" for the passing case: a trait is metadata a runner surfaces when inspecting a specific test's details or filtering by trait, not something injected into the default pass/fail console summary line the way aTestDisplayNamechange would be for every row, including ones that never fail at all.
A successful row's default console/IDE output is otherwise unchanged — no seed text is added to TestDisplayName or printed anywhere for a passing test; the trait above is the only artifact, and it's inert until someone actually inspects or filters on it. This is the "avoid noisy default output" instruction honored for the common (passing) case, while still satisfying the exit criterion for every failure case, not just composition ones. A richer, always-visible verbosity opt-in (e.g. the seed inline in the test name) remains a deferred, non-goal capability.
Diagnostics and exceptions (design question 10)¶
| Failure | Mechanism |
|---|---|
Negative ComposeAttribute.Seed | Plain-message CompositionException, Compono.Xunit, checked immediately after row creation — see Seed Policy and Reporting |
Unsupported signature (generic method, ref/out/in/params parameter) | Plain-message CompositionException, Compono.Xunit, pre-composition |
| Invalid inline argument count | Same |
Inline null for a non-nullable parameter | Same — checked against cached Nullability, never a runtime-type check |
Inline value type mismatch (non-null) | Same |
Duplicate [Shared] types | Same |
| Invalid profile type | Compile error (generic constraint) — no runtime diagnostic exists |
| Composition failure for one parameter, recursive graphs, generated-plan absence | Existing CompositionException/CompositionDiagnostic, unchanged, propagated as-is |
| Failure while producing theory data (an unexpected bug) | Propagates un-wrapped, same as any DataAttribute.GetData implementation throwing |
No new exception type is introduced — every case above is either the engine's own CompositionException (used unmodified) or a Compono.Xunit-constructed CompositionException(string message) with the seed line appended by convention, per "prefer existing structured diagnostics" over inventing a parallel hierarchy.
Async and unsupported shapes (design question 13)¶
Compono.Xunit produces an argument array; it never invokes the test method — confirmed directly from Xunit.v3.DataAttribute's contract (GetData returns data, not a result). This means:
| Shape | Supported | Notes |
|---|---|---|
| Synchronous methods | Yes | No special handling |
Task/ValueTask-returning methods | Yes | No special handling — xUnit's own invoker awaits the result; Compono.Xunit never sees the return type |
| Static methods | Yes | No special handling |
| Instance methods | Yes | No special handling |
| Optional parameters | Yes | Composed like any required parameter unless inline-supplied; the CLR default is never consulted |
| Nullable parameters | Yes | NullabilityInfoContext-derived Nullability.Nullable feeds the descriptor |
| Generic test methods | No | MethodInfo.IsGenericMethodDefinition check, pre-composition CompositionException |
ref/out/in parameters | No | ParameterInfo.ParameterType.IsByRef/IsOut, pre-composition CompositionException |
params parameters | No | ParameterInfo.GetCustomAttribute<ParamArrayAttribute>(), pre-composition CompositionException |
CancellationToken parameters | Not specially handled | Composed like any other type; since no built-in provider exists for it (docs/mvp.md's Milestone 2 built-in type list doesn't include it), an un-inline-supplied, non-[Shared]-registered CancellationToken parameter fails with an ordinary "no provider/plan could satisfy" diagnostic — a known limitation, not new Milestone-4 behavior; supply it inline or via a registration/[Shared] value instead |
Source generation boundary (design question 11)¶
No generator changes. MethodInfo/ParameterInfo reflection is simple, bounded (once per attribute instance's first GetData call, cached thereafter — see Caching), and costs nothing beyond what xUnit itself already performs to discover and invoke the test in the first place — there is no "hot path" here in the sense ADR-0001's no-reflection rule targets (repeated, per-composed-object construction reflection). This includes the one genuinely reflection-heavier piece, MakeGenericMethod (Runtime- Typed CompositionRow Invocation, above): it runs exactly once per parameter, at binding-plan-cache-construction time, never on the per-row path — every row after that calls a cached delegate, not MethodInfo.Invoke. Composed values still flow entirely through generated ICompositionPlan<T>s via PlanCache<T> — unchanged, and untouched by this package. A generated-test-method-metadata alternative was considered and rejected: it would need to duplicate what MethodInfo already gives Compono.Xunit for free, for a cost this package doesn't actually pay.
Package boundaries and dependencies (design question 12)¶
Compono.Xunit
├── Compono (ProjectReference/PackageReference, ordinary)
└── xunit.v3.extensibility.core (PackageReference, ordinary - NOT PrivateAssets)
- Target frameworks:
net10.0;net11.0, matchingCompono's ownTargetFrameworks— no reason to diverge. Compono.Xunitreferencesxunit.v3.extensibility.core(the package that ownsXunit.v3.DataAttribute/ITheoryDataRow) as an ordinary reference, notPrivateAssets="all"—Compono.Xunit's own public attribute types derive from/return those types, so a consuming project needs them transitively assignable. This is harmless: a consumer ofCompono.Xunitnecessarily already references a full xUnit v3 runner package (xunit.v3.mtp-v2or equivalent) directly, the same waytest/Compono.Testsdoes today —Compono.Xunitdoes not attempt to supply a runner itself.- No dependency on
Compono.Generators.Compono.Xunitperforms no source generation of its own (per the previous section's decision). Generated-plan dispatch for composed values is entirelyCompono's existing concern. - Generator assets already flow transitively:
Compono.Generators' compiled output is packed intoCompono's own nupkg underanalyzers/dotnet/cs(ADR-0003). An ordinary NuGet package reference toCompono.Xunitpulls inComponotransitively, which brings the analyzer along automatically — no special-casing needed inCompono.Xunit's own.csproj. - A consuming test project's references:
Compono.Xunit(bringsCompono+ the generator analyzer transitively) plus its own direct reference to an xUnit v3 runner package (xunit.v3.mtp-v2, matchingtesting.md's existing convention) — nothing beyond an ordinary xUnit v3 project setup plus one added package.
Caching (design question 14)¶
ComposeAttribute (and its generic subclass) lazily builds and caches, in an instance field the first time GetData runs on that attribute instance, exactly two things:
- The
Composerfor this method/profile combination (Lazy<Composer>, default thread-safety mode — the built-in double-checked initialization this repo's "lock-free options first" guidance already favors over a hand-rolled lock). - The parsed, immutable binding plan for this method: which parameters are
[Shared], each parameter's nullability, the inline-value count/type-compatibility check (computed once againsttestMethod, which is stable for a given attribute instance), and — per Runtime- TypedCompositionRowInvocation, above — each parameter's cachedresolveInvoker/resolveSharedInvoker/shareExplicitInvokerdelegate, closed over that parameter'sTypeexactly once here viaMakeGenericMethod, never rebuilt or re-reflected on the per-row path.
Both are safe under concurrent GetData calls (parallel theory rows, or a re-run) because Lazy<Composer> publication is thread-safe and the binding plan is immutable once computed. Never cached: anything row-scoped — the CompositionRow, its CompositionContext/scope/seed, and any composed value. A fresh CompositionRow is created on every GetData call, per ADR-0021. No disposable test instance is ever retained by either cache. Whether an attribute instance itself is reused across a test case's discovery and every subsequent execution (rather than reconstructed per call) is governed by xUnit's own attribute-caching behavior, not by Compono.Xunit — this package's caching is correct either way, since a freshly-constructed attribute instance simply repopulates its own Lazy<Composer>/binding-plan fields on first use.
Testing strategy (design question 15)¶
test/Compono.Xunit.Tests(fast, no real xUnit runner): callsGetData(MethodInfo, DisposalTracker)directly against attribute instances constructed over hand-built sample methods, covering the binding algorithm (inline-only, composed-only, mixed, too-many-inline, non-null type-mismatch), a non-null inline value accepted for aNullable<T>parameter specifically (e.g.[Compose(42)]for anint?parameter) — the case a naive declared-type assignability check gets wrong via CLR nullable boxing, proving theNullable.GetUnderlyingTypeunwrap actually fixes it — inlinenullhandling (anullinline value accepted for a nullable reference-typed parameter and for a nullable value-typed (Nullable<T>) parameter; rejected with a clear pre-composition exception for a non-nullable reference-typed parameter and for a non-nullable value-typed parameter — all four combinations, covering both an ordinary and an inline-[Shared]target parameter, per the requirement that this validation runs beforeShareExplicitis ever invoked),[Shared]detection (duplicate types, a[Shared]parameter declared before/after the parameter that depends on it), profile-attribute construction and caching (asserting the sameComposerinstance is reused across repeatedGetDatacalls on one attribute instance), the cached invoker delegates specifically (assertingMakeGenericMethodruns exactly once per parameter across many repeatedGetDatacalls on one attribute instance — e.g. by countingComposer.CreateRow/binding-plan construction invocations, or an equivalent seam — never once per row), unsupported-signature detection (generic method,ref/out/params), seed determinism (same explicit seed twice produces byte-identical row data), a negativeSeedrejected with a distinct, clear exception, and — proving the pasteable-seed promise itself, not just its presence — a deliberately-failing composition's message containing exactlyrow.Seed'sintvalue for both an auto-generated seed and an explicit non-negative one, the"Compono.Seed"trait present on every returnedITheoryDataRowwith a value matchingrow.Seedexactly — for both a passing-shaped and a failing-shaped row, proving it's unconditional rather than only attached on the failure path — and a concurrency-stress test (Parallel.ForcallingGetDatamany times on one shared, cached attribute instance, asserting no exceptions or data races fromLazy<Composer>publication).test/Compono.Xunit.SampleTests: a genuinely separate, ordinary xUnit v3 project (ProjectReferencetoCompono.Xunitduring development; also consumed from a local package feed afterdotnet pack, satisfying "packed-package verification from a separate test project") containing representative theories actually run by a real xUnit v3 runner — inline-only, composed-only, mixed rows; a[Shared]parameter declared before the SUT that needs it; class- and method-scoped profile selection is out of scope (method-only, per this ADR); a deliberately-failing composition (asserted, fromCompono.Xunit.Tests, to produce output containing"Seed:"); and an ordinaryasync Tasktheory, proving Async and Unsupported Shapes' "no special handling needed" claim against a real runner rather than only asserting it by inspection.- This satisfies the milestone's explicit requirement that "at least one test suite must prove behavior through the real xUnit v3 discovery and execution pipeline" —
Compono.Xunit.Testsalone, callingGetDatadirectly, would not have caught a wrong assumption aboutSupportsDiscoveryEnumeration()'s actual effect on discovery/execution sequencing the way a real runner does.
Positive Consequences¶
- The entire public surface is two attributes and their constructors — smaller than
docs/public-api.md's original two-attribute ([Compose]+[InlineComposeData]) sketch. - Every failure category reuses existing
CompositionExceptionmachinery; nothing new to teach a consumer about exception handling. - Verified against the real xUnit v3 assembly rather than assumed from v2 — the discovery/execution split, the exact
GetDatasignature, andNullabilityInfoContext's applicability were all confirmed by reflecting the actual 3.2.2 package before this ADR was written. - Every row's seed is reproducible, not just a composition-failing one — the
"Compono.Seed"trait closes the gap a first pass at this ADR left open (an assertion failure after successful composition had no way to recover which seed produced the data that triggered it), satisfyingdocs/mvp.md's Milestone 4 exit criterion for every failure shape rather than only the composition-failure one.
Negative Consequences¶
- Inline values are strictly positional with no way to target a later parameter while composing an earlier one — a real limitation compared to, say, named-argument binding, accepted as a Milestone 4 non-goal (see below) rather than solved now.
ComposeAttribute.Seedaccepts a narrower range (non-negativeintonly) thanCompositionBuilder.WithSeed(int)'s full range — a real, intentional asymmetry between the programmatic and xUnit-attribute seed APIs, not a bug. Accepted because it's what makes "paste the reported seed back into[Compose(Seed = ...)]" unconditionally true rather than true-except-for-negative-values, which the alternative (rewriting core's diagnostic rendering, or restrictingWithSeed(int)itself) would have required a wider, out-of-scope change to achieve.[Shared]parameters that depend on each other only work in declaration order — an undocumented-until-you-hit-it footgun if not clearly called out (mitigated: this ADR, the package's own XML docs, andpublic-api.mdall state the rule explicitly).
Deferred Decisions and Non-goals¶
- Class-level
[Compose<TProfile>]— confirmed with the user as out of scope; method-level only for Milestone 4. - Inline values targeting a later parameter while an earlier one is composed — strictly positional, leading parameters only; no "skip a position" mechanism.
- Multiple rows from one
[Compose]attribute — always exactly one row in Milestone 4; a futureCreateMany-style multi-row attribute would need its own index-forking design, not built here. - A
[Shared]parameter depending on a later-declared[Shared]sibling — unsupported; declaration order is the only ordering rule. - Name/qualifier-based
[Shared]matching — still deferred past Milestone 4; type-based only, per ADR-0011. CancellationTokencomposition support — no built-in provider; ordinary "unsupported type" failure unless inline/shared/registered.- Exposing the seed inline in a successful row's
TestDisplayNameor console output — deliberately not built, to avoid noisy default output for the passing case; the"Compono.Seed"trait (Seed Policy and Reporting) already makes the seed discoverable for every row, including a later assertion failure, without needing this. A future, always-visible verbosity opt-in beyond the trait is not designed here. - Combining
[Compose]with ordinary[InlineData]/[MemberData]— not supported; each xUnit data attribute is an independent row source. - Stacking multiple
[Compose...]attributes on one method —AllowMultiple = false; not needed, since profile composition already covers multi-profile scenarios viaAddProfile. - Generic test methods,
ref/out/in/paramsparameters — fail clearly at discovery/execution; no support planned for a future milestone unless a concrete need appears. Compono.NSubstitute/Compono.Bogus-specific test-double or semantic-data ergonomics insideCompono.Xunit— out of scope; those are Milestone ⅚ packages that plug into the same core pipelineCompono.Xunit's composed parameters already flow through, with noCompono.Xunit-specific integration needed.
Pros and Cons of the Options¶
Discovery enumeration — eager (rejected)¶
- Good, because discovery-time test explorers would show fully realized argument values.
- Bad, because it forces composing every row twice (once to enumerate, once for real) unless a seed is pinned, and a composed reference type (a substitute, a live object) has no meaningful enumerable/serializable form at discovery time regardless.
Discovery enumeration — deferred to execution (chosen)¶
- Good, because it composes each row exactly once, matching "one composition context per theory row" precisely.
- Good, because it needs no serialization story for composed values at all — they never leave the process before the test that consumes them runs.
- Bad, because a discovery-time test explorer sees only that theory data exists, not its shape — an accepted limitation for a source of intentionally-not-pre-known values.
Amendment (2026-07-30): generator discovery is required after all, for two distinct call shapes¶
PR #22 review (Codex, on PLAN-0004 Phase 0) caught that design question 11's "No generator changes" decision above was wrong on one count: CompositionRow.Resolve<T>()/Resolve<T>(descriptor)/ResolveShared<T>(descriptor) dispatch through the exact same PlanCache<T> stage-8 mechanism Composer.Create<T>() does, and a type reached only through one of those calls never gets a generated plan unless something else in the same compilation independently triggers discovery for it (a Create<T>()/CreateMany<T>() call site elsewhere, or [Composable]). This was masked in Compono.Tests' own row-composition tests, which hand-assign PlanCache<T>.Instance directly rather than going through the real generator — the exact masking pattern testing.md's "verifying a new public entry point" rule already names and requires guarding against (the CreateMany<T>() precedent it cites).
Two distinct fixes follow, addressed separately because they are genuinely different discovery problems:
1. Direct CompositionRow usage — fixed immediately (PLAN-0004 Phase 0, same PR). CreateInvocationDiscovery (ADR-0004's call-site mechanism) is extended to also match CompositionRow.Resolve<T>()/Resolve<T>(descriptor)/ResolveShared<T>(descriptor) call sites, alongside its existing Composer.Create<T>()/CreateMany<T>() matching — the same discovery path, disambiguated by the resolved method symbol's containing type (Compono.Composer vs. Compono.CompositionRow), same as Create/CreateMany are already disambiguated from any other type's same-named method. Verified with an isolated Compono.Generators.Tests snapshot test per call shape (a type reached only through that one call, no [Composable], no Create<T>()/CreateMany<T>()) and a real dotnet pack + local-feed + throwaway-consumer manual check (the same proof shape Milestone 1's plan used) — a packaged Compono consumed via PackageReference, never ProjectReference, correctly composed a type reached only via row.Resolve<T>(descriptor).
2. [Compose]-attributed test-method parameters — deferred, tracked for before Phase 1 begins. This section's original reasoning assumed Compono.Xunit's MethodInfo.MakeGenericMethod-based binding (Runtime-Typed CompositionRow Invocation, above) would be the only way test-method parameter types are ever reached, and that its reflection cost was bounded and acceptable — both still hold. What the original reasoning missed: there is no textual row.Resolve<T>(...) call site anywhere in a consumer's own source for even the now-fixed mechanism above to match against — Compono.Xunit's cached invoker delegates are built entirely from runtime ParameterInfo.ParameterType reflection, inside Compono.Xunit's own compiled binary, never emitted as source in the consuming test project. A type reached only as a [Compose]-attributed test method's own parameter therefore still gets no generated plan under fix #1 alone — a fundamentally different discovery problem from a missing call-site pattern, since there is no call site to find.
The resolution: a separate discovery component, deliberately not folded into CreateInvocationDiscovery — recognizing methods attributed with [Compose]/[Compose<TProfile>] (ForAttributeWithMetadataName, the same mechanism ComposableAttributeDiscovery already uses for [Composable]) and generating a plan for each eligible parameter type in that method's signature. "Eligible" mirrors Phase 2's own supported-shape table above (excludes generic methods and ref/out/in/params parameters — the same shapes Compono.Xunit's binding algorithm itself rejects pre-composition). Every eligible parameter gets a plan generated unconditionally, even one that's always supplied inline at every call site in practice — statically predicting which parameters will actually be inline-supplied at a given call site would mean duplicating Phase 2's own runtime inline-binding calculation inside the generator, for a benefit (skipping plan generation for a type that's cheap to generate a plan for anyway) not worth that duplication.
This is scoped as design/planning work to close out before Phase 1 implementation begins — not implemented by this amendment. docs/plans/0004-milestone-4-xunit-integration.md's Phase 1 task list gets the new discovery-component tasks, and Phase 3's packaged-consumer verification (test/Compono.Xunit.SampleTests) gets an explicit requirement: prove a parameter type discovered only from a [Compose]-attributed method (no [Composable], no Create<T>()/CreateMany<T>(), no direct CompositionRow call site) receives a generated plan through the real packaged sample.
Amendment 2 (2026-07-31): the descriptor-less Resolve<T>() overload must never be discovered¶
A further Codex review round on the same PR caught that Amendment (2026-07-30)'s fix #1 was itself wrong on one count: it matched all of CompositionRow.Resolve<T>()/Resolve<T>(descriptor)/ResolveShared<T>(descriptor), including the descriptor-less Resolve<T>() overload. That overload exists on CompositionRow solely to satisfy ICompositionContext's full interface shape — it forwards to ICompositionContext.Resolve<TValue>()'s manual-resolve seam (docs/adr/0019-registrations-and-service-provider-injection.md), which throws InvalidOperationException unless a registration/ configuration-rule factory is actively being invoked. A caller holding a CompositionRow can never satisfy that condition: InvokeFactory (CompositionContext's single factory-invocation point) always hands a factory the raw internal context, never the CompositionRow wrapper — confirmed both by inspection and by the required manual pack-and-consume verification itself, which hit this exact throw before being reworked to use the descriptor overload instead. Discovering (and, worse, documenting in docs/public-api.md) this overload as an ordinary row-composition entry point advertised a call shape that always throws at runtime — the opposite of what discovery is supposed to guarantee.
Fix: CreateInvocationDiscovery's row-resolve match now additionally requires method.Parameters.Length == 1, excluding the descriptor-less overload while still matching both overloads that genuinely work (Resolve<T>(descriptor), ResolveShared<T>(descriptor), both single-parameter). The isolated Compono.Generators.Tests coverage for this call shape now asserts the opposite of before: no plan is generated for a type reached only through row.Resolve<T>(), proving discovery correctly excludes it rather than proving it (wrongly) included. No change to Amendment 2026-07-30's fix #2 (the still-deferred [Compose]-attributed-parameter discovery) — that work is unaffected by this correction.
Amendment 3 (2026-07-31): a profile-configured seed pins every row, clarifying "fresh seed" scope¶
PR #24 review (Codex, on PLAN-0004 Phase 2) caught that Seed Policy and Reporting's "every GetData call without an explicit seed generates a fresh one" statement, above, is ambiguous about what "explicit" covers. Phase 1's cached Lazy<Composer> construction applies a profile (builder.AddProfile<TProfile>(), running Configure immediately) before ComposeAttribute.Seed is ever read; if that Configure itself calls builder.WithSeed(...), the resulting Composer's configured seed is cached for the lifetime of that attribute instance, so every subsequent GetData call — and therefore every row Composer.CreateRow produces — reuses that exact seed, never a freshly generated one, even though ComposeAttribute.Seed itself was never set.
This is the intended, correct behavior, not a bug to fix: a profile that calls WithSeed(...) is deliberately pinning composition for reproducible profile-level testing, the same contract CompositionBuilder.WithSeed already has everywhere else in Compono (a configured seed applies to every operation built from that composer, not just the next one) — silently discarding a profile author's own explicit seed choice just because it arrived through Configure rather than through ComposeAttribute.Seed directly would be the more surprising behavior of the two, and would contradict ADR-0018's existing profile model with no compensating benefit. The fix is to the wording above, not the code: "without an explicit seed" means neither ComposeAttribute.Seed nor a profile's own Configure configured one — when either source configures a seed, that composer's rows are pinned to it, by design; the fresh-seed-per-call guarantee applies only when neither source does.
This same "explicit" definition governs negative-seed rejection, too (PR #24 review, second finding on the same commit) — the Decision Outcome's numbered algorithm step and Seed Policy and Reporting's "Two distinct failure moments" bullet both describe the negative-seed check as scoped to ComposeAttribute.Seed/SeedAsNullable specifically, leaving CompositionBuilder.WithSeed(int)'s full range unaffected. That wording predates this amendment's finding that a profile-configured seed counts as "explicit" for freshness purposes; the same reasoning extends to rejection — a profile author who pins a negative seed gets the same clear, named CompositionException an attribute-configured negative seed does, rather than a confusing downstream composition failure with no indication the seed itself was the problem. The implementation (row.Seed < 0, checked against the row's actual effective seed regardless of source) has enforced this since Phase 2's first commit on this PR; this amendment is the corresponding decision-text update, not a new code change.
Amendment 4 (2026-07-31): automatic disposal tracking attempted, then reverted - ownership can't be determined safely¶
PR #24 review (Codex, on PLAN-0004 Phase 2) caught that automatic disposal tracking - added earlier in the same review cycle to close a real gap (a composed IDisposable/IAsyncDisposable value was never released after the test ran, since nothing registered it with GetData's own disposalTracker parameter) - was itself unsafe. CompositionRow .Resolve/ResolveShared return whatever the pipeline produced with no visibility into which stage produced it: a value freshly constructed by Compono's own generated composition is indistinguishable, from Compono.Xunit's vantage point, from one returned by an exact registration or a configured IServiceProvider - the latter case explicitly governed by ADR-0019's "the caller owns the provider and its entire lifetime; Compono is a pure consumer" contract. Registering the latter with DisposalTracker would have xUnit dispose an externally-owned instance after the test - possibly a shared singleton reused across many tests - silently violating that contract and corrupting shared state in a way far harder to diagnose than the original leak.
Decision: automatic disposal tracking is reverted, not fixed with a heuristic. Composer/CompositionBuilder expose no public way for Compono.Xunit to ask "was a service provider or registration involved in producing this value" - the write-only builder API (design-decisions.md rule 3: Compono.Xunit may only reach the engine through Compono's public surface) has no such query, and inventing one only for this purpose, without a real design dive weighing what "provenance" should mean pipeline-wide, would be exactly the kind of one-off inline decision this repo's process exists to avoid. A narrower heuristic (e.g. skip tracking only when UseServiceProvider was configured) was considered and rejected: an exact registration can just as easily return a shared/cached instance the registration author owns, so the risk isn't scoped to IServiceProvider alone, and Compono.Xunit has no way to distinguish a "fresh per-call" registration factory from a "returns a cached instance" one either.
Consequence: a test method with a composed IDisposable parameter is the consumer's own responsibility to clean up (e.g. compose an explicit factory/wrapper the test itself disposes, or use [Compose(theInstance)] with a value the consumer owns and disposes elsewhere) - the same "Compono has no opinion on lifetime it wasn't explicitly handed" posture ADR-0019 already takes for UseServiceProvider, now applied consistently to every composed value regardless of source. Extending Compono's pipeline to expose enough per-value provenance for Compono.Xunit (or any future integration) to distinguish "safe to dispose" from "externally owned" is its own design question, deferred - not designed here, and not assumed necessary until a concrete need materializes (this milestone's own non-goals already exclude speculative future-proofing).
Amendment 5 (2026-07-31): a genuine composition failure's own Message must carry the seed too, not only Diagnostic¶
PR #26 review (Codex, on PLAN-0004 Phase 3) caught that test/Compono .XunitV3.SampleTests' deliberately-failing theory used [Compose(Seed = -1)] - a negative, rejected seed - which exercises Compono.XunitV3's own seed-validation failure (a pre-composition check that always rejects -1 regardless of what it's paired with), not a genuine composition failure. That meant RealRunnerTests never actually proved the milestone's own Goal statement - "a composition failure's message contains a seed that, pasted into [Compose(Seed = ...)], reproduces the same failure" - for the case that statement is actually about: a real, unsatisfied composition request.
Investigating why a genuine case wasn't used originally (Phase 3's own SeedReportingTests, added under time pressure to close out that task list item) surfaced the real gap: a pipeline-propagated CompositionException (an ordinary composition failure - nothing could satisfy the requested type) has never carried its seed in Exception.Message itself, only in Diagnostic/Diagnostic.ToString() (the Console.WriteLine(exception.Diagnostic) convention docs/architecture.md documents, from Milestone ⅔, per ADR-0010). A real xUnit v3/MTP test-runner failure display shows Exception.Message, not Diagnostic.ToString() - so the pasteable-seed promise was invisible in real console output for a genuine composition failure the entire time, not just in this one sample theory.
Decision: ComposeAttribute.GetData now catches a CompositionException propagating from a composition call (ResolveInvoker/ResolveSharedInvoker/ ShareExplicitInvoker, via a new private InvokeWithSeedOnFailure helper wrapping each call site) and rethrows it via a new public Compono core method, CompositionException.WithSeedInMessage(original, seed) - rewriting Message to $"{original.Message}\n\nSeed: {seed}" while preserving Diagnostic completely unchanged (so Diagnostic.ToString() still renders correctly, without a duplicated "Seed:" line) and the original exception as InnerException. Originally shipped as an internal seam with a new InternalsVisibleTo("Compono.XunitV3") grant on Compono.csproj - reverted after PR #26 review's fourth round; see the amendment below.
Why a new core method rather than a workaround entirely inside Compono.XunitV3: CompositionException's existing constructors always derive Message directly from Diagnostic.Message (or accept a plain string with no Diagnostic/InnerException at all) - there's no existing public shape that lets a caller supply a custom Message string alongside an existing Diagnostic and InnerException together. Compono.XunitV3 reconstructing its own CompositionDiagnostic copy with a seed-appended Message field was considered and rejected: Diagnostic .ToString()'s own format independently appends "\n\nSeed: {Seed}", so a diagnostic whose Message field also had the seed appended would render that line twice - a visible regression for the documented Console .WriteLine(exception.Diagnostic) path. WithSeedInMessage avoids that entirely by leaving Diagnostic untouched and only rewriting the outer exception's own Message.
Consequence for test/Compono.XunitV3.SampleTests: Failing CompositionTests now composes GatewayConsumer (a concrete class whose constructor takes IUnregisteredGateway, an interface satisfied by nothing), with an explicit Seed = 24601 so an assertion against its output stays deterministic rather than needing to parse an auto-generated seed out of console output. IUnregisteredGateway itself is deliberately never used as the [Compose]-attributed parameter's own root type - that would hit the CMP0003 diagnostic this plan's own Open Items section already documents as deliberate (an abstract/interface/delegate root always fails at compile time, regardless of whether a registration might satisfy it at runtime) - wrapping it as a nested constructor parameter uses the more lenient member-level check instead, so it compiles and fails only at runtime, which is the failure shape this fixture actually needs.
Amendment to this amendment (PR #26 review, third round): the fix above only covered a diagnosed CompositionException. InvokeWithSeedOnFailure's original catch clause was guarded by when (exception.Diagnostic is not null), which let a plain-message CompositionException (no Diagnostic at all) escape completely unmodified - exactly the shape a generated HashSet<T>/Dictionary collection plan's unique-value-exhaustion path throws (Compono.Generators/Templates/CollectionPlan.scriban: throw new CompositionException($"Could not generate {size} unique values..."), no Diagnostic constructed at all). Composing a default-sized (3) HashSet<bool> - bool's value space has only 2 members - deterministically hits this path with zero seed anywhere in the output. Fixed by broadening CompositionException.WithSeedInMessage itself to build its rewritten Message from original.Message (not original.Diagnostic!.Message) and to preserve original.Diagnostic as-is, whatever it is - null stays null, a real diagnostic stays unchanged - rather than requiring one. InvokeWithSeedOnFailure's catch clause no longer needs the Diagnostic is not null guard at all: every CompositionException, diagnosed or not, now gets the same treatment uniformly.
Amendment to this amendment (PR #26 review, fourth round): the InternalsVisibleTo grant directly reversed ADR-0021's own accepted, public-only integration boundary. ADR-0021's Pros and Cons already has an entry for exactly this option - "Reaching the engine — InternalsVisibleTo("Compono.Xunit")" - rejected specifically because "it directly reverses a deliberate, documented policy... adopted specifically to keep a shipped package's internal surface from becoming a de facto public contract for 'trusted' consumers." Granting Compono.XunitV3 InternalsVisibleTo for WithSeedInMessage did exactly that: because Compono is unsigned, the grant authenticates only the simple assembly name, handing any assembly that can claim that name unrestricted access to every internal member of Compono core - not scoped to the one method that needed it.
Decision: the grant is removed. WithSeedInMessage itself moved from internal to public instead - it already lives inside CompositionException, so it already has full access to the private constructor and DiagnosingContextIdentity it needs; the only reason it required InternalsVisibleTo at all was its own access modifier, not anything about where it's implemented. Making the one method public, rather than granting blanket internal access, is the minimal-footprint fix docs/public-api.md's "keep public APIs minimal" goal actually calls for: Compono.XunitV3 gets exactly the operation it needs and nothing else, CompositionContext and every other internal type stay exactly as inaccessible to Compono.XunitV3 as ADR-0021 intended, and any other consumer building custom composition-failure tooling gets the same utility for free. No InternalsVisibleTo entry for Compono.XunitV3 exists in Compono.csproj after this fix - the only remaining grant is Compono.Tests, unchanged from before this PR.
Amendment 6 (2026-07-31): the automated real-runner test is dropped; the sample project stays, unautomated¶
The RealRunnerTests test this amendment's own "Consequence" section above referred to - which shelled out dotnet test against Compono.XunitV3.SampleTests on every CI run - is removed. Across four consecutive rounds of fixes (a cross-process file lock, a global-cache clear, an isolated restore path, and finally a machine-wide named Mutex fully serializing the nested subprocess), each verified extensively via local reproduction of CI's exact concurrency at the time, CI still failed on the next push in a new way tied specifically to this repo's CI runner's process-concurrency characteristics for a nested-dotnet-invoking test - never once on the actual composition behavior the sample project's theories exercise, which passed every single local and CI run across the whole sequence.
Decision: stop iterating on CI-only concurrency once it was clear the thing being verified (that Compono.XunitV3 composes correctly as a real consumed package, through a real xUnit v3 runner) was already conclusively proven, repeatedly - keep Compono.XunitV3.SampleTests on disk exactly as built (its representative theories, its PackToLocalFeed/RestorePackagesPath local-feed infrastructure), not referenced by Compono.slnx or any other test, available to run by hand whenever packaging behavior needs re-checking by a person. This mirrors Phase 1's own precedent for this exact milestone - a one-time manual dotnet pack + local feed + throwaway-consumer verification, not permanent CI automation - rather than introducing a new philosophy. See PLAN-0004's Phase 3 Notes for the full blow-by-blow of all four fix attempts.
Links¶
- ADR-0021 —
CompositionRow,CompositionRequestKind.TestParameter, the stage-2 read-gate change this package's[Shared]support depends on entirely. - ADR-0010 —
CompositionException/CompositionDiagnostic, reused unmodified here. - ADR-0018 —
ICompositionProfile,AddProfile, reused unmodified for[Compose<TProfile>]. - ADR-0003 — why
Compono.Xunitneeds no direct dependency onCompono.Generators.