RESEARCH-0038: Composed Theory Data Rows for xUnit v3 (Issue #163)¶
Status: Concluded (2026-11-05). Product-owner interview (§9) and review rounds 1 (§9.1) and 2 (§9.2) are complete. Recorded as ADR-0070 (Accepted 2026-11-05). Implementation is planned in PLAN-0071 (In Progress). Review round 1 added §2.6 (metadata), §2.7 (row shapes), and §5.1 (effective seed).
Issue: #163 (child of #161, item 3). Builds on: RESEARCH-0036 (intake), RESEARCH-0037 §4–§6 and §12 (fixture lifecycle; the delayed-enumeration constraint), ADR-0069 ("Constraint carried forward"). Governing process: docs/architecture/capability-admission.md. Gate B (evidence) is met by #161's migration. Gate A is assessed in §8.
Release constraint (product owner, final): all of #161's children (#162–#166) ship together in one Compono release. No interim release.
1. Summary of findings¶
- xUnit never merges rows across data attributes. On every supported engine (JIT 3.2.2 and 4.0.1, AOT 4.0.1), each data attribute (or each AOT-registered row factory) produces complete, independent rows. So the #163 candidate "
[InlineData]/[MemberData]supplies the leading values and[Compose]fills the rest" can't be built on top of xUnit. It would require Compono to own a theory discoverer. Rejected (§2.1). - One non-discoverable source collapses the whole theory into one delay-enumerated test case, on both engines. Every row of every data attribute on that method is then built inside one
CreateTests()call, at execution time, before the first row runs (§2.2). - Consequence for failure reporting: a composition failure in any row aborts that
CreateTests()call, so the whole theory test case fails and no row runs. A per-row failure would need a fake row or a custom invoker. The product owner rejected both (§9, Q12). - Execution-time composition is already where #162 needs it. Composing inside the delayed
GetData/row factory keeps fixtures available (RESEARCH-0037 §12). Nothing in the chosen design pre-enumerates. - JIT xUnit disposes theory arguments; AOT xUnit core does not.
XunitDelayEnumeratedTheoryTestCase.CreateTestscallsDisposalTracker.AddRange(data)for every row on 3.2.2 and 4.0.1, so anyIDisposable/IAsyncDisposableargument, composed or literal, is disposed by xUnit after the test case.TheoryTestCaseFactory.GenerateDelayEnumerated(AOT) adds nothing. This is pre-existing behavior for[Compose]today and isn't Compono's (§2.4). - Row metadata is reusable, but the engines don't merge it the same way (§2.6). JIT groups the skip properties (
Skip/SkipType/SkipUnless/SkipWhen) as one unit, while AOT merges them field by field. Row-levelSkipUnless/SkipWhenare member-name strings on JIT andFunc<bool>delegates on AOT. xUnit's own JITConvertDataRowsilently drops row-levelSkipType/SkipUnless/SkipWhen.DisableParallelizationexists only on xUnit 4.x. So Compono must own one merge rule and implement it on each engine, not inherit either engine's behavior. ALabelreplaces the argument-based display name entirely (XunitTestMethod.GetDisplayName), which gives deterministic row names even though composed arguments are random (§2.3). - Seed precedence has no override. An attribute
Seedcombined with a profileWithSeedis aDuplicateConfigurationOptionconflict in core (CompositionBuilder.Build), not "one wins". The effective seed is exactly one of: attributeSeed, profileWithSeed, or a fresh per-row seed (§5.1). - Row shapes verified empirically on xUnit 3.2.2 and 4.0.1 (§2.7).
- AOT member data is direct-call, not reflection. xUnit's own AOT
MemberDataAttributeGeneratoraccepts public static fields, properties, and methods, includingTask/ValueTaskandIAsyncEnumerableresults, and emits a direct call. AOT runtime exposes the publicXunit.v3.DataAttributeRegistration.CreateDataRow(...)forobject?[],ITuple, andITheoryDataRowrows. It merges row over attribute field by field. Compono's generator can use the same direct-call approach (§2.5), but not that merge, because it differs from JIT (§2.6). - AOT parity needs one additive core API.
Composer.CreateRow,CompositionRow.Resolve<T>/ResolveShared<T>/ShareExplicit<T>, andCompositionException.WithSeedInMessageare all public, so generated code can bind row values,[Shared]parameters, and seeds directly (§3). Building the R0 row-failure wrapper additionally needs a publicCompositionException(string message, CompositionDiagnostic? diagnostic, Exception innerException)constructor, so the cause is preserved asInnerExceptionand the exactDiagnosticreference is carried through (ADR-0070 Amendment 1 - the sole approved core change for #163). - AOT [Compose] today has no inline values, no
[Shared], and no seed reporting (noSeed:line, noCompono.Seedtrait, noSeedproperty). The seed gap is #166.
2. xUnit v3 theory lifecycle (decompiled, verified)¶
Sources: xunit.v3.extensibility.core 3.2.2 and 4.0.1 (xunit.v3.core.dll, netstandard2.0), xunit.v3.extensibility.core.aot 4.0.1 (xunit.v3.core.aot.dll, net9.0), xunit.v3.common 3.2.2 / 4.0.1, and the AOT source generator xunit.v3.core.aot.generators.dll (xunit.v3.core.aot.mtp-v2 4.0.1). Decompiled with ilspycmd into the session scratchpad. Nothing was added to the repo.
2.1 Discovery: data attributes are independent¶
TheoryDiscoverer.Discover (JIT):
- If the theory is statically skipped, or pre-enumeration is off, or
ITheoryAttribute.DisableDiscoveryEnumerationis set, it returnsCreateTestCasesForTheory(...): oneXunitDelayEnumeratedTheoryTestCase. - Otherwise it loops over
testMethod.DataAttributes. The first attribute whoseSupportsDiscoveryEnumeration()isfalsemakes it returnCreateTestCasesForTheory(...)for the entire method. The same happens for non-serializable data, data registering disposables at discovery, or any discovery exception. - Otherwise it makes one
XunitTestCaseper row, per attribute, with no merging between attributes.
AOT (TheoryTestCaseFactory.GenerateTestCases): if any registered factory for the method has DisableDiscoveryEnumeration = true (or pre-enumeration is off), it calls GenerateDelayEnumerated with all factories. Otherwise it pre-enumerates. Factories are registered per (testClassIndex, methodName) in a list, one per data attribute.
So [InlineData(10m)] plus [Compose] on one method yields two independent rows: InlineData's row (with only one argument, which fails argument-count validation if there are more parameters) and Compose's fully composed row. There's no hook to fill one row from two sources.
2.2 Execution: delay-enumerated test case¶
JIT XunitDelayEnumeratedTheoryTestCase.CreateTests() (3.2.2 and 4.0.1):
foreach dataAttribute in TestMethod.DataAttributes:
foreach row in await dataAttribute.GetData(method, DisposalTracker):
DisposalTracker.AddRange(row.GetData()) // §2.4
resolve generic types, ResolveMethodArguments, TypeHelper.ConvertArguments
displayName = method.GetDisplayName(row.TestDisplayName ?? attr.TestDisplayName ?? caseName,
row.Label, args, genericTypes)
traits = GetTraits(method, row)
timeout = row.Timeout ?? attr.Timeout ?? case.Timeout
skip = row.Skip* ?? attr.Skip* ?? case.Skip*
new XunitTest(..., testIndex++, ..., row.Explicit, row.Label,
row.DisableParallelization ?? attr.DisableParallelization)
AOT GenerateDelayEnumerated: one CodeGenTestCase with one test factory. That factory awaits each registered row factory in order and calls the generated MethodInvokerFactory(row) per row. Each test gets UniqueIDGenerator.ForTest(caseId, index), so test identity is stable by row order, not by argument values.
Implications:
- Timing. All rows of the theory (from all its data attributes) are produced before the first row runs. That happens at
TestPipelineStage.TestMethodExecution, after class, collection, and assembly fixtures are initialized (RESEARCH-0037 §4.1, §5). - Granularity. If
GetDataor the factory throws,CreateTeststhrows and the test case fails as a unit. No row runs. - Identity. Test Explorer and CI show one test case with N test results. The display name is
Method(arg: value, ...)unlessLabelis set, in which case it'sMethod [label](XunitTestMethod.GetDisplayName, identical on AOT throughGetTestDisplayName). Composed argument values are random, so argument-based names vary run to run. Labels don't. - Parallelism. Rows inside one test case run sequentially. Separate methods and classes run in parallel per the collection rules. One theory's rows never race each other, but different theories do.
2.3 Row metadata members (all supported versions)¶
DataAttribute (3.2.2): Label, Skip, SkipType, SkipUnless, SkipWhen, TestDisplayName, Traits, plus Timeout/Explicit through the base. 4.0.1 adds DisableParallelization. ITheoryDataRow mirrors the row-level counterparts (Label, Skip, SkipUnless, SkipWhen, Timeout, Explicit, Traits, TestDisplayName, DisableParallelization). TheoryData<T1..Tn> enumerates TheoryDataRow<T1..Tn>, which is an ITheoryDataRow.
2.4 Disposal¶
- JIT:
DisposalTracker.AddRange(data)per row.DisposalTracker.Addkeeps onlyIDisposable/IAsyncDisposableinstances and disposes them (async preferred) when the test case is disposed. This applies to every argument: composed values, literal row values, and a fixture-owned instance if a test passes one as a parameter. - AOT: core doesn't add row data. xUnit's own generated
MemberDatafactories receive the tracker. Compono's generated factory doesn't register anything (current[Compose]AOT template). - Compono's position (unchanged): ADR-0022 Amendment 4. Compono never registers composed values for disposal, because it can't tell owned instances from shared ones.
2.5 AOT member-data mechanics¶
MemberDataAttributeGenerator (xUnit AOT generator, 4.0.1):
- It resolves the member on
MemberType ?? testClass. The member must be public static (field, property, or method). An open-generic declaring type is skipped. GetTheoryDataInfoclassifies the return type: synchronous enumerable,Task<>/ValueTask<>, orIAsyncEnumerable<>.- It emits
async disposalTracker => { var attr = <registration>; var dataRows = [await] Type.Member[(args)]; [await] foreach (...) result.Add(attr.CreateDataRow(dataRow)); }. DataAttributeRegistration.CreateDataRowis public on the AOT runtime. It has overloads forobject?[],ITuple, andITheoryDataRow([OverloadResolutionPriority]). Row values take precedence over attribute values, and traits are merged.SkipUnless/SkipWhenareFunc<bool>on the registration, rendered by the generator as direct member-access lambdas.
JIT MemberDataAttributeBase does the equivalent with reflection.
2.6 Row-level vs. attribute-level metadata (verified, review round 1)¶
Where each property lives:
| Property | DataAttribute (attribute level) | ITheoryDataRow (row level) |
|---|---|---|
Label, Skip, TestDisplayName | string?, all versions | string?, all versions |
Explicit | bool + protected ExplicitAsNullable | bool? |
Timeout | int + protected TimeoutAsNullable | int? |
Traits | string[]? (name/value pairs) | Dictionary<string, HashSet<string>>? |
SkipType | Type?, all versions | Type?, all versions (unused by the AOT runtime) |
SkipUnless, SkipWhen | string? member name, all versions (AOT included) | JIT: string? member name. AOT: Func<bool>? |
DisableParallelization | bool, 4.x only | bool?, 4.x only |
How each engine combines them for a delay-enumerated row:
- JIT data attribute → row (
DataAttribute.ConvertDataRow, used by xUnit's ownMemberData): row value ?? attribute value forExplicit,Label,Skip,TestDisplayName,Timeout,DisableParallelization(4.x), with merged traits. It drops row-levelSkipType/SkipUnless/SkipWhen(3.2.2 and 4.0.1). - JIT row → test (
XunitDelayEnumeratedTheoryTestCase.CreateTests):TestDisplayName,Timeout, andDisableParallelizationfall back row → attribute → theory. Skip is grouped: ifrow.Skip != null, the row's(Skip, SkipType, SkipUnless, SkipWhen)is used, else ifattr.Skip != nullthe attribute's group, else the theory's group.ExplicitandLabelcome from the row only, and traits come from the method plus the row. - JIT evaluation (
XunitTestRunnerBaseContext.SafeGetRuntimeSkipReason):SkipUnlessandSkipWhenare mutually exclusive and requireSkip. The member is a public staticboolproperty found onSkipType ?? test classwithFlattenHierarchy, read through reflection at run time. Violations are aTestPipelineExceptionat run time. - AOT attribute → row (xUnit generator plus
DataAttributeRegistration.CreateDataRow): the generator validates at compile time thatSkipUnless/SkipWhenare not both set and that the member is a public staticboolproperty onSkipType ?? test class(walking base types), then rendersSkipUnless = () => global::T.Prop. The runtime merge is field by field:row.X ?? attr.Xfor every property, includingSkipUnless/SkipWhen. - AOT row → test (
TheoryTestCaseFactory.CreateDelayEnumeratedTest): field by field again,row.X ?? theory.X.
Consequences for ComposeData:
- Neither engine's built-in merge can be reused as-is if parity is required. Compono defines one rule and both engines implement it in the shared helper. The rule is xUnit JIT's
CreateTestssemantics, which is the more conservative one: grouped skip, with each other property falling back row → attribute. The output row Compono hands xUnit is already fully merged, so xUnit's own downstream fallback can only ever reach the theory level. SkipTypeapplicability. At attribute level it applies on both engines (JIT resolves at run time, AOT at compile time). At row level it applies on JIT (it qualifies the row'sstringmember name). On AOT a row'sSkipUnless/SkipWhenare delegates, so a row-levelSkipTypehas nothing to qualify and xUnit's AOT runtime ignores it. That's inherent to xUnit's AOT row API, not a Compono choice. Both engines produce the same behavior for the same conditions.- Residual theory-level edge. If the merged
ComposeDatagroup setsSkipwith no condition, and the[Theory]itself setsSkipUnless/SkipWhen, the AOT runtime's field-by-fieldrow ?? theorymerge applies the theory's condition, while JIT applies none. Compono can't express "explicitly no condition" to the AOT runtime without changing semantics. This must be checked empirically in B6. If it's real, it's an obstacle to bring back to the product owner, not something to paper over. DisableParallelizationon JIT.Compono.XunitV3compiles against its xUnit floor (3.2.2), where the property doesn't exist. Copying a 4.x source row's value onto the output row needs either a one-time cachedPropertyInfolookup (JIT package only), or a documented gap. The attribute-level value is still honored by xUnit 4.x'sCreateTestswith no Compono involvement. See §10.
2.7 Collection vs. row shapes (empirically verified, review round 1)¶
A scratch console project outside the repo, referencing xunit.v3.extensibility.core 3.2.2 and then 4.0.1 (net10.0), enumerated each shape and classified every element. Output was identical on both versions:
| Member result (collection) | Element (row) seen | Row values (GetData/indexer) |
|---|---|---|
TheoryData<int?, string?, DateTimeOffset> | ITheoryDataRow (TheoryDataRow<…>) | 1:Int32, a:String, …:DateTimeOffset; null, null, default |
TheoryDataRow<int, long?>[] (with Label/Skip) | ITheoryDataRow, metadata intact | 1:Int32, null; 2:Int32, 3:Int64 |
List<object?[]> | object?[] | 1:Int32, null; a nested Int32[] stays one value |
(int, string?)[] | ITuple | 1:Int32, null |
9-element ValueTuple | ITuple | 9 flattened values (Rest expanded) |
object[] of object[] | object?[] | valid rows |
object[] of scalars | Int32, String | not rows, so a row-shape error |
Other checks: string is IEnumerable (so it must be rejected as a collection), a TheoryDataRow is neither IEnumerable nor ITuple (so a single row returned as the member is a collection-shape error), a boxed int? with a value is a boxed Int32, and a boxed int is not long (no widening).
Rules adopted from this:
-
Terminology.
TheoryData<T…>is a collection (IReadOnlyCollection<TheoryDataRow<T…>>, verified on 3.2.2). It is not itself anITheoryDataRow. Its elements areTheoryDataRow<T…>, and those are theITheoryDataRowrows. -
Collection = the member's (awaited) result:
IEnumerableother thanstring(this includesTheoryData<…>, arrays, and lists), orIAsyncEnumerable<T>. Each element is one row. - Row =
ITheoryDataRow(checked first, matching xUnit's[OverloadResolutionPriority(2)]), thenobject?[], thenITuple. Anything else, includingnull, is a row error. - Values are boxed:
Nullable<T>arrives asTornull. Validation is ADR-0022's:nullneeds a nullable parameter (a nullable value type, or a reference type annotated?; a reference parameter in an oblivious nullable context counts as not nullable, matchingBindingPlan.GetNullability). Non-null values must satisfyNullable.GetUnderlyingType(p) ?? p.IsInstanceOfType. No numeric widening orTypeHelperconversion, unlike xUnit's ownMemberData. It's the same strictness as[Compose(42)]today.
3. Current Compono architecture (relevant parts)¶
| Concern | Compono.XunitV3 (JIT) | Compono.XunitV3.Aot |
|---|---|---|
| Entry point | ComposeAttribute.GetData (runtime DataAttribute) | Marker attributes. The generator emits RegisterTheoryDataRowFactory(..., disableDiscoveryEnumeration: true, ...) (AotTheoryDataRowRegistration.scriban) |
| Composer lifetime | Lazy<Composer> per attribute instance. Configure runs once per attribute instance | Composer.Create(...) per factory call, which is per row today |
| Signature checks | BindingPlan.Build/ValidateSignature (cached once per attribute) | Compile time: CMP0040, and CMP0041–CMP0049 for profiles |
| Compose-family exclusivity | BindingPlan: GetCustomAttributes<ComposeAttribute>().Count() > 1 → CompositionException | CMP0045 |
| Parameter dispatch | RowInvokers.Build(Type) → RowInvokerRegistry (generator-registered per parameter type, ADR-0041). No reflection | Literal row.Resolve<T>(descriptor) in generated code |
| Generator discovery | ComposeMethodDiscovery matches Compono.XunitV3.ComposeAttribute, `1, `2 and registers parameter types for plans and row invokers | AotComposeMethodDiscovery matches the AOT marker names |
| Inline values | Leading positional, validated by PositionalArgumentBinder (nullable-unwrapped assignability) | Not supported |
[Shared] | Shared parameters first (inline → ShareExplicit, else ResolveShared), then the rest | Not supported |
| Seed | row.Seed non-negative check, "\n\nSeed: n" on every failure, Compono.Seed trait, Seed property | None (#166) |
TConfig profile | ConfigProfileBinder reflection, cached per attribute | Direct new TConfig(literals) / new TProfile(config) |
Extension points that already exist and are reused:
- Execution-time row composition:
Composer.CreateRow(Type)gives oneCompositionRowper row (fresh seed, row-scoped[Shared]scope). It's the same seam for every framework (ADR-0021). - Typed dispatch: generator-registered row invokers (JIT) and literal generic calls (AOT).
- Positional binding and validation:
PositionalArgumentBinder,BindingPlan(JIT). - Profile construction from a config instance:
ConfigProfileBinder.BuildProfile<TProfile, TConfig>(config)(JIT), and generatednew TProfile(config)with theCMP0042/CMP0044/CMP0046–CMP0048constructor checks (AOT). - Linked shared source between the two xUnit packages (ADR-0069 precedent), and public generator-facing hooks marked
[EditorBrowsable(Never)](ADR-0058).
No new core seam is needed: per-row composition through CreateRow + Resolve/ResolveShared/ShareExplicit is already the parameter-resolution mechanism. The feature changes where row values come from, not how parameters are composed.
4. Candidate shapes compared¶
| Shape | Non-constant values | Per-row config | xUnit fit | AOT feasibility | Verdict | |
|---|---|---|---|---|---|---|
| a | Stack several [Compose…] (AllowMultiple = true) | No (attribute constants) | Constants only | Medium (like InlineAutoData) | Easy | Rejected. Doesn't solve DateTimeOffset. Reverses ADR-0022. The TConfig form has no slot for expected values |
| b | [Compose] + xUnit [InlineData]/[MemberData] | Via MemberData | Not expressible | High on paper | Not possible without replacing xUnit's discoverer/factory merge | Rejected. xUnit never merges rows across attributes (§2.1) |
| c | MemberName property on existing [Compose…] | Yes | Yes | Medium | Feasible | Rejected. Ambiguous with params inline and config arguments, and changes single-row attribute semantics |
| d | New ComposeData family reading a public static member (MemberData-style) | Yes | Yes (TConfig per row) | High (mirrors MemberData and AutoFixture MemberAutoData) | Direct call, like xUnit AOT MemberData | Chosen |
| e | Explicit "compose here" marker in row slots | Yes | n/a | Low (untyped sentinels) | Feasible | Rejected (§9, Q3). Leading-positional matches ADR-0022 |
| f | New Compono row type ComposeRow<TConfig> | Yes | Yes | Lower (new vocabulary) | Feasible with linked source | Rejected (§9, Q2). xUnit TheoryData/TheoryDataRow already carry values and metadata |
5. Lifecycle and correctness analysis (chosen shape)¶
- Exact moment of composition. Inside the delayed
GetData(JIT) or generated factory (AOT), duringCreateTests()atTestMethodExecution, after fixtures exist. The member is read at the same time. No discovery-time enumeration, so ADR-0069's carried-forward constraint holds. - No captured per-test state in
Configure. Every row builds a fresh profile instance (forTConfigforms, from that row's config) and a freshComposer, on both engines. JIT's per-attributeLazy<Composer>is deliberately not used byComposeData. A profile'sConfiguretherefore never sees another row's state, and fixture reads stay correct. - Multiple rows sharing one profile type. Each row has its own profile instance, composer,
CompositionRow, seed, and[Shared]scope. Rows share nothing that Compono creates. Values the member returns (for example astatic readonly TheoryData) are consumer-owned. Guidance: return fresh, immutable rows. - Parallel execution. No static mutable state is added. The JIT attribute caches only immutable, method-derived metadata (binding plan, member accessor, profile constructor), using the existing
LazyInitializerpattern. AOT generated code is stateless. - Fixture-dependent values. Two supported paths, both execution-time: a profile registration factory calling
RequireFixture<T>()(ADR-0069), or the member itself readingTestContext.Current.RequireFixture<T>(). The second works because the member is invoked duringCreateTests. It's documented but secondary. - Disposal and ownership. Compono registers nothing (ADR-0022 Amendment 4). The JIT/AOT xUnit difference (§2.4) is documented, with a warning not to pass fixture-owned disposables as theory parameters.
- Exceptions. A failure while reading the member or composing any row throws a
CompositionExceptionnaming the member, zero-based row index, label (if any), the row's actual seed, and the cause. The original is kept asInnerExceptionandDiagnosticis carried through. The message is byte-identical on both engines (shared source). - Cancellation.
IAsyncEnumerablesources are enumerated withTestContext.Current.CancellationToken. Between rows, the token is checked. ParameterlessTask/ValueTaskmembers get no token (no member arguments, by decision). - Generics. Open-generic test methods stay rejected (existing rule). The member's declaring type must be closed. Row element types are checked against parameter types at compile time on AOT when statically known (a
TheoryData<T…>collection, or a collection whose element type isTheoryDataRow<T…>), otherwise at runtime on both engines with the ADR-0022 rule. - Nullable arguments. Same rule as
PositionalArgumentBinder:nullonly for nullable parameters, and non-null values are checked againstNullable.GetUnderlyingType(p) ?? p. - Determinism. The effective seed rules are in §5.1. Row order follows the member's enumeration order. Test identity is the row index (§2.2). A
Labelgives a stable display name.
5.1 Effective seed and seed reporting (review round 1)¶
Precedence (verified in core). CompositionBuilder.WithSeed stores into a ConfigurationOptionSlot that records every call. CompositionBuilder.Build() (run by Composer.Create) reports more than one call as DuplicateConfigurationOption. Composer.CreateRow uses configuration.Seed ?? CompositionSeed.GenerateRowSeed(). So for one row the effective seed is exactly one of:
- the attribute's
Seed(applied withWithSeedafterAddProfile), or - the profile's own
WithSeedfromConfigure(ADR-0022 Amendment 3), or - a fresh non-negative seed that
CreateRowgenerates for that row.
Attribute Seed plus profile WithSeed doesn't override anything. It's a configuration error that fails composition of every row, with core's conflict message naming both sources. That's existing behavior for [Compose], and ComposeData keeps it. To reproduce a failure from a profile that pins its own seed, the seed is already fixed. To vary it, change the profile.
When the effective seed becomes known for a row: for (1), before anything runs. For (2) and (3), only once Composer.Create has finished and CreateRow has returned (row.Seed).
Reporting rule (no fabricated seeds). A reported seed must be the one the failing composition attempt used, or would deterministically use. Compono never generates a seed only to print it. This deliberately differs from [Compose]'s ApplyProfile path, which prints Random.Shared.Next(...) for profile-construction failures.
| Failure point | Seed line |
|---|---|
| Member resolution, invocation, or enumeration (no row attempt) | none: not seed-dependent |
Row shape, TConfig extraction, profile construction, Configure, Composer.Create (including a seed conflict), before CreateRow | Seed: {n} if attribute Seed is set (it's necessarily the effective seed, because a second source would itself be the reported conflict). Otherwise Seed: not assigned (the failure occurred before the row's composition started) |
| Negative effective seed | the negative-seed message, reporting the offending value (from the attribute, or row.Seed for a profile seed) |
Row-value validation, [Shared], composition (after CreateRow) | Seed: {row.Seed}, the actual seed |
| Success | no message; the Compono.Seed trait equals row.Seed |
Per-row ordering that maximizes seed availability: normalize the row, extract TConfig, build the profile and composer, CreateRow, then the negative-seed check, then value validation, then [Shared], then the rest. Validation that doesn't need the profile runs after CreateRow, so it reports a real seed, which matches ADR-0022's order for [Compose].
6. Native AOT findings¶
- Feasible without reflection, dynamic code, or unbounded generics: the member is a direct generated call, row element conversion uses
ispattern checks against compile-time parameter types, and composition uses literalResolve<T>/ResolveShared<T>/ShareExplicit<T>. TheTConfigprofile is a directnew TProfile(config). - Row metadata: generated code passes attribute-level metadata (rendered constants, plus
SkipUnless/SkipWhenas() => global::T.Proplambdas resolved onSkipType ?? test classat compile time, the same technique as xUnit's own AOT generator) into the shared helper. The helper applies Compono's single merge rule (§2.6), which is grouped skip. xUnit's field-by-fieldDataAttributeRegistrationmerge is not used, because it would diverge from JIT. Invalid skip usage (both conditions set, or the member isn't a public staticboolproperty) is a compile-time diagnostic, matching the JIT runtimeTestPipelineException. Valid usage is never rejected. - Genuine asymmetries that remain (documented, not bugs):
- xUnit disposal of row arguments (JIT yes, AOT no). It's xUnit behavior.
- Assembly fixtures under AOT remain blocked by xunit/xunit#3664 (not Compono).
- Shape errors that the generator can see are compile-time
CMPxxxxon AOT and runtimeCompositionExceptionon JIT. The same conditions are rejected. - Existing AOT
[Compose]still lacks inline values,[Shared], and (until #166) seed reporting.ComposeDatahas them. This is a temporary intra-AOT asymmetry the product owner accepted (§9, Q9). - Not attempted: AOT-publishing
Compono.XunitV3(the JIT package). It isn't an AOT package. Its member lookup uses reflection exactly as xUnit's own JITMemberDatadoes, and it's annotated withDynamicallyAccessedMemberswhere aTypeflows (issue #119 lesson).
7. #166 coordination (seed parity)¶
ComposeData on AOT must report seeds (failure Seed: line, Compono.Seed trait, Seed property). #166 needs the same for AOT [Compose]. To avoid two implementations:
- The seed-and-failure logic lives in one place: a shared runtime helper (linked source, §ADR-0070) plus one reusable generator emission component ("seeded row scaffold":
WithSeed, negative-seed check,CreateRow, failure wrapping, trait). - PLAN-0071 builds that component for
ComposeDatawithout wiring it into[Compose]. #166's design should adopt it. If #166 is designed first, PLAN-0071's bead B5 must instead reuse #166's component. That's a checkpoint in the plan, not two parallel implementations.
8. Capability admission¶
- Gate A.1 (meaningful abstraction): yes. It removes one method per row and string stand-ins, and it keeps composition per row with seeds.
- Gate A.2 (ecosystem fit): yes. It reuses xUnit
TheoryData/TheoryDataRow,MemberDatamember rules, and row metadata. The name mirrorsMemberData/MemberAutoData. - Gate A.3 (no reflection by default, AOT): the AOT path is fully generated. The JIT path uses reflection only where the JIT package already does and where xUnit itself does (member lookup,
TConfigprofile constructor), cached once per attribute. - Gate B: #161 migration evidence.
- Cross-framework: TUnit, NUnit, and MSTest are deferred. Each has its own row model and there's no evidence yet (§9, Q8).
9. Product-owner interview (2026-11-05)¶
| # | Question | Decision |
|---|---|---|
| Q1 | Attribute shape | New member-sourced ComposeData family |
| Q2 | Row representation | xUnit's types: TheoryData<…> collections of TheoryDataRow<…> rows, plus any collection of ITheoryDataRow, object?[], or ITuple rows, per xUnit convention. For ComposeData<TProfile, TConfig>, element 0 is the row's TConfig |
| Q3 | Binding | Leading positional (ADR-0022). No markers |
| Q4 | AOT scope | Full JIT/AOT parity for ComposeData, including [Shared] and row values. Reuse existing infrastructure, keep per-invocation isolation, validate with real Native AOT. No unrelated AOT enhancements. Flag substantial architectural change for review (none found, §6) |
| Q5 | Member shapes | Sync and async (Task/ValueTask/IAsyncEnumerable), parameterless, on both engines. Member arguments deferred |
| Q6 | Seed | Fresh per row. Existing Seed/WithSeed semantics preserved. Every row reports its actual seed. Coordinate AOT seed with #166. Per-row overrides deferred |
| Q7 | Naming and forms | ComposeData, ComposeData<TProfile>, ComposeData<TProfile, TConfig> in both xUnit packages. The non-generic form has a clear default (default composition) |
| Q8 | Cross-framework | xUnit only. Others deferred until there's evidence |
| Q9 | Intra-AOT asymmetry | Accept temporarily. Build reusable generator components. Document limits and record the follow-up |
| Q10 | Disposal | Keep ADR-0022 Amendment 4: no Compono ownership. Document the JIT/AOT xUnit difference and warn about fixture-owned disposables |
| Q11 | Combining | AllowMultiple = false. ComposeData joins the Compose-family exclusivity rule. Standard xUnit data attributes stay allowed, with independent rows. Combine data sets in the member. Test mixed attributes, exclusivity, and isolation |
| Q12 | Failures | Fail the theory with a CompositionException carrying the member, zero-based row index, optional label, actual seed, and cause. InnerException preserved. Consistent across JIT and AOT. No placeholder rows |
| — | Skill (added requirement) | skills/compono updates are mandatory for #163 completion: SKILL.md, references/xunit-v3.md, other relevant references, examples consistent with the final API, validated using the repo's process |
9.1 Review round 1 (2026-11-05)¶
| # | Decision |
|---|---|
| R1 | Keep MemberType in the public API |
| R2 | JIT/AOT parity is required for SkipUnless, SkipWhen, and applicable SkipType behavior. No preapproved diagnostic-only AOT fallback. A significant obstacle stops implementation and comes back for review (see §2.6 item 3) |
| R3 | A reported seed must be the composition attempt's actual seed wherever possible. Never generate one just for a message. Precedence clarified (§5.1) |
| R4 | Collection vs. row shapes clarified and verified (§2.7) |
| R5 | One cohesive feature PR (JIT, AOT, tests, docs, skill). Beads stay as checkpoints. No partial-feature merges |
| R6 | Inherited DataAttribute metadata verified and documented, row level vs. attribute level (§2.6) |
| R7 | Keep the #166 checkpoint: whichever issue lands first owns the shared seed infrastructure, and the other reuses it |
9.2 Review round 2 (2026-11-05): all §10 items resolved; ADR-0070 Accepted¶
| # | Decision |
|---|---|
| S1 | Row-level DisableParallelization on xUnit 4.x is supported in Compono.XunitV3 through a narrowly scoped, cached reflection lookup. xUnit 3.2.2 compatibility is preserved |
| S2 | Targeted JIT and AOT tests are required for the theory-level SkipUnless/SkipWhen interaction. If the planned approach can't reach real parity, stop and request review. No speculative workarounds |
| S3 | Strict row-value typing per ADR-0022 is confirmed. No implicit numeric conversions |
| S4 | #166 coordination stays an implementation checkpoint (B5) |
| S5 | Terminology: TheoryData<T…> is a collection of TheoryDataRow<T…> elements, not itself an ITheoryDataRow. Checked across this document, ADR-0070, and PLAN-0071 |
10. Open questions for product-owner review (resolved, see §9.2)¶
- Row-level
DisableParallelizationonCompono.XunitV3(JIT). It's 4.x-only, and the package compiles against xUnit 3.2.2. Recommended: copy it with one cachedPropertyInfolookup (JIT package only, null on 3.2.2, no AOT impact), which keeps metadata parity. The alternative is a documented JIT gap for row-level values only (attribute level works either way). - Theory-level skip edge on AOT (§2.6 item 3). Planned as an empirical check in B6. If it diverges, implementation stops for review. No mitigation is preapproved.
- Strict value typing.
ComposeDatakeeps ADR-0022's strictness (noint→longwidening), unlike xUnit'sMemberData. Confirm, or ask for xUnit-style conversion (which would need its own AOT-safe design). - #166 ordering (§7): whichever lands first owns the seeded-row component.