Skip to content

[PLAN-0036] Call-Site Values Influencing Nested Composition

Status: Done

Implements: ADR-0036

Goal

A Compono.XunitV3 test can select a profile that needs call-site-known configuration — [Compose<PersistenceTestProfile, PersistenceTestConfig>(RepositoryKind.Player)] — without writing a dedicated profile subclass per configuration variant or falling back to inline Composer.Create(...) per test. Done when: ComposeAttribute<TProfile, TConfig> exists in Compono.XunitV3, binds profile configuration arguments positionally to TConfig's single public constructor (reusing ADR-0022's existing inline-value validation), builds TProfile from that TConfig via TProfile's single qualifying constructor, applies it through the existing AddProfile(ICompositionProfile) core API unchanged, and every constructor-shape/argument-mismatch failure reported by ADR-0036 is a clear, pre-composition, cached-once diagnostic — with trivia-platform's PersistenceAutoData-shaped pattern working end-to-end against a packaged Compono.XunitV3 build as proof.

Scope

In scope, per ADR-0036's Decision Outcome:

  • ComposeAttribute<TProfile, TConfig> in Compono.XunitV3, with TProfile : ICompositionProfile (no new() constraint).
  • Positional binding of profile configuration arguments to TConfig's constructor, reusing ADR-0022's existing count/nullability/assignability validation, retargeted.
  • The two new constructor-shape diagnostics (TConfig not exactly one public constructor; TProfile not exactly one public constructor taking exactly one TConfig-typed parameter) plus the retargeted argument-mismatch diagnostic.
  • Cached, bounded reflection for both constructor invocations (closed once per attribute instance, at binding-plan-cache-construction time), mirroring ADR-0022's existing MakeGenericMethod/Delegate.CreateDelegate pattern.
  • Documentation across every surface listed in "Documentation tasks" below — treated as part of the feature, not a closeout afterthought.
  • The published skills/compono agent skill, reviewed and updated per "Published skill tasks" below — a runtime change isolated to Compono.XunitV3 still changes what an agent should recommend, so the skill is in scope even though no skill code changes.
  • A benchmark-policy evaluation against ADR-0034 — see "Benchmark evaluation" below. Benchmarks are added only if that evaluation finds a real boundary crossed; otherwise the evaluation and its reasoning are recorded here, not silently skipped.

Explicitly deferred (per ADR-0036's Decision Outcome / "Considered Options"):

  • Option 2's ambient scenario-value/per-row-varying mechanism — shelved, no real call site needs it yet.
  • Option 3's source-generated per-call-site specialization — rejected, disproportionate to the one-time-per-method cost it would save.
  • Combining profile configuration arguments with inline test-parameter values on the same attribute — ComposeAttribute<TProfile, TConfig> composes every test-method parameter in full; no evidence yet needs both in one row.
  • Any actual trivia-platform migration work — this plan delivers the Compono-side capability only; migrating trivia-platform itself is separate, future work in that repo.
  • Extending benchmarks/Compono.Benchmarks to cover Compono.XunitV3's attribute-binding cost in general (see "Benchmark evaluation" below — this is a real, pre-existing gap the evaluation surfaces, but fixing it is a separate, appropriately-scoped follow-up, not something to fold into a single-feature plan).

Benchmark evaluation

Per ADR-0034, evaluated before deciding whether this plan adds any benchmark:

Finding: benchmarks/Compono.Benchmarks has no Compono.XunitV3 coverage at all today. The project has no ProjectReference to Compono.XunitV3, and none of its six categories (ImplementationStrategies/ConsumerScenarios/ExternalComparison/ FeatureOverhead/Scalability/SourceGeneration) measure attribute binding-plan construction. This means the already-shipped [Compose<TProfile>]'s own bounded, cached MakeGenericMethod/ Delegate.CreateDelegate construction cost (ADR-0022's "Runtime-Typed CompositionRow Invocation") has never been benchmarked either — this isn't a gap specific to the new attribute.

Decision: no new benchmark added by this plan. Two reasons, not one:

  1. No category fits. ADR-0034's six categories answer questions about the composition engine (core Compono + Compono.Generators); none is scoped to test-framework-integration attribute-binding cost. Adding a benchmark for only the new ComposeAttribute<TProfile, TConfig> path, with no comparable benchmark for the structurally-identical, already-shipped ComposeAttribute<TProfile> path, would produce a number with nothing to compare it against — not a real "did the reflection stay bounded" answer, just an isolated figure.
  2. The property that actually matters is a correctness property, not a performance one, and is already covered. "Does the reflection stay bounded to binding-plan construction and never run on the repeated GetData/composition path" is proven by the Test Plan's invoker-delegate-caching assertion (reflection runs exactly once per attribute instance across many repeated GetData calls) — a unit test proves boundedness; a microbenchmark would only add a relative-cost number on top of a property the test already guarantees.

Recorded, not silently omitted, per the requirement: extending benchmarks/Compono.Benchmarks to cover Compono.XunitV3 attribute binding-plan construction — covering [Compose<TProfile>] and [Compose<TProfile, TConfig>] together, so any future comparison has a baseline — is a real, legitimate future benchmark-suite gap. It's called out here and in "Explicitly deferred" above rather than folded into this plan, because closing it properly means extending ADR-0034's own category structure (a new category, or a case for why an existing one covers it), which is a decision for that ADR's own maintainers/a dedicated follow-up, not something to decide as a side effect of one feature's plan.

Tasks

New files

  • src/Compono.XunitV3/ComposeAttribute{TProfile,TConfig}.cs — the new attribute type.
  • src/Compono.XunitV3/Binding/ConfigProfileBinder.cs (final name; not ConfigBindingPlan.cs as originally sketched — see Notes) — resolves/validates TConfig's and TProfile's constructors and performs the actual binding/construction.

Changes to existing files

  • src/Compono.XunitV3/ComposeAttribute.cs — extracted the existing inline-value params object?[] normalization (the single-null/ single-array edge cases) into an internal static NormalizeParamsArguments helper, reused by the new attribute's constructor for its own, separate configArguments parameter — a behavior-preserving refactor (see Notes), not a change to existing binding semantics.
  • src/Compono.XunitV3/Binding/BindingPlan.cs — no change needed for the core binding mechanism (see Notes for why the originally-sketched approach of extending BindingPlan for construction turned out unnecessary); a later review round did update this file's Compose-family-stacking diagnostic message to name the new attribute form (see Notes' round 5 entry) — a message-text fix, not a reopening of the "no core change needed" finding.

Documentation tasks (part of the feature, verified at closeout — see "Verification and closeout" below, not left implicit):

  • docs/packages/compono-xunitv3.md — new "Profile configuration arguments" section under "What it gives you," cross-linking ADR-0036, with the enum/typeof(...)/attribute-legal-type guidance (no stringly typed examples), and an explicit one-line contrast against inline values.
  • docs/migrating-from-autofixture.md — new "Migrate a parameterized custom AutoDataAttribute" subsection (before/after, drawn from trivia-platform's real PersistenceAutoData(repositoryName) shape per RESEARCH-0002 Finding 1, enum-based example), plus a "Quick concept map" row and a "Migration checklist" line.
  • docs/migrating-from-autofixture.md — RESEARCH-0002 Finding 2's documentation gap closed: CompositionProviderRequest.Name-based ICompositionValueProvider matching added as a documented pattern under "Migrate specimen builders," with an explicit note distinguishing it from profile configuration arguments.
  • docs/troubleshooting/common-errors.md, not docs/reference/diagnostics.md — corrected during implementation (see Notes): diagnostics.md is scoped exclusively to the generator's compile-time CMP codes; these three failures are runtime, plain-message CompositionExceptions, exactly like today's existing inline-value diagnostics, which already live in common-errors.md's "By symptom (runtime)" section, not diagnostics.md. Documented there instead, as a new ### "ComposeAttribute<TProfile, TConfig> throws before my test even runs" subsection.
  • API reference — regenerated via .github/scripts/generate-api-reference.sh (DefaultDocumentation, per ADR-0032) against a Release build; produced the two new pages for ComposeAttribute<TProfile, TConfig> plus updates to the two existing pages that list/link it, deterministically, with no other diff.
  • README / package-table / sample surfaces — grepped; README.md doesn't enumerate individual Compose-family forms, so left unchanged; docs/how-to/use-profiles.md did enumerate the [Compose<TProfile>] constraint specifically and got a new paragraph pointing to the config form. docs/packages/index.md's package-table cell was initially left unchanged by the same "would clutter a summary cell" reasoning, but a later review round (see Notes' round 6 entry) corrected that call — the cell's job is specifically to enumerate what a package gives you, so omitting a shipped form from it was a real gap, not acceptable brevity; fixed there, along with two equivalent stale rows in skills/compono/SKILL.md.

Published skill tasks (skills/compono/, reviewed even though the runtime change is Compono.XunitV3-only — per ADR-0035, one skill, not a new one per package):

  • skills/compono/references/xunit-v3.md[Compose<TProfile, TConfig>] section added, enum example, explicit distinction from [Compose<TProfile>] and from Name-based provider matching.
  • skills/compono/references/patterns-and-antipatterns.md — mapping table row added; three new antipattern entries added (wrong migration moves; stringly typed config args; confusing this feature with Name-based provider matching).
  • skills/compono/references/registrations-profiles-and-scopes.md — new "Custom providers — matching on request shape, including name" section added, closing the pre-existing gap.
  • skills/compono/SKILL.md — workflow-step bullet added.
  • skills/compono-evals/evals.json — eval id 19 added (validated as well-formed JSON; 19 evals total).
  • Confirmed no new skill created — all changes landed inside the existing single skills/compono/ skill.

Critical Files

  • src/Compono.XunitV3/ComposeAttribute{TProfile,TConfig}.cs — new, the public attribute surface this plan adds.
  • src/Compono.XunitV3/Binding/ConfigProfileBinder.cs — new, the constructor-resolution/validation/construction logic.
  • src/Compono.XunitV3/ComposeAttribute.cs — modified (inline-value normalization/validation extracted for reuse; behavior unchanged); left ComposeAttribute{TProfile}.cs untouched.
  • src/Compono.XunitV3/Binding/PositionalArgumentBinder.cs — new (added in review round 5), the shared null/Nullable<T>-unwrap/assignability validator both ComposeAttribute.GetData and ConfigProfileBinder use.
  • src/Compono.XunitV3/Binding/BindingPlan.cs — modified (review round 5: the Compose-family-stacking diagnostic message now names all three attribute forms).
  • src/Compono.Generators/ComponoIncrementalGenerator.cs, src/Compono.Generators/Discovery/ComposeMethodDiscovery.cs — modified (review round 1: a third ForAttributeWithMetadataName registration for ComposeAttribute\2's metadata name, without which a concrete type reached only through[Compose]` got no generated plan at all).
  • test/Compono.XunitV3.Tests/ComposeAttributeConfigBindingTests.cs — new, 19 cases (grew from an initial 10 across review rounds 2-5: seed reporting, abstract-type rejection, TargetInvocationException unwrapping, the Nullable<T>-boxing case).
  • test/Compono.XunitV3.Tests/BindingPlanTests.cs — modified (review round 5: one new stacking-diagnostic test for the new attribute form).
  • test/Compono.XunitV3.Tests/Fixtures/SampleTestMethods.cs, test/Compono.XunitV3.Tests/PublicApiSurfaceTests.cs — modified (new fixtures; exact-public-type-set assertion updated for ComposeAttribute\2`).
  • test/Compono.XunitV3.SampleTests/ConfigProfileTests.cs — new, packaged-consumer proof (real dotnet test run against the packed NuGet, per its own csproj's existing pattern); composes a real concrete RepositoryConsumer class as of review round 1's fix, not a bare provider-resolved string, so it actually exercises generated-plan discovery.
  • test/Compono.XunitV3.SampleTests/FailingConfigProfileTests.cs — new (review round 5), the one deliberately-failing packaged-consumer proof, split into its own class to match package-validation.yaml's Failing*-named-class CI filter (see Notes' round 5 entry for the live CI regression this fixed).
  • .github/workflows/package-validation.yaml — modified (review round 5: the "Local-feed packed-consumer smoke test" filter widened to a trailing wildcard, Failing*, covering both FailingCompositionTests and the new FailingConfigProfileTests).
  • docs/packages/compono-xunitv3.md, docs/migrating-from-autofixture.md, docs/troubleshooting/common-errors.md, docs/how-to/use-profiles.md, docs/packages/index.md (review round 6), docs/adr/0036-*.md's Amendment 1 (review round 4), docs/reference/api/Compono.XunitV3/* (regenerated) — documentation updates, per documentation.md's "update the subsystem doc in the same PR" rule.
  • skills/compono/references/xunit-v3.md, skills/compono/references/patterns-and-antipatterns.md, skills/compono/references/registrations-profiles-and-scopes.md, skills/compono/SKILL.md (also touched again in review round 6), skills/compono-evals/evals.json — published-skill updates, in scope per ADR-0035.

Test Plan

Final state, after all six review rounds (see Notes below for the round-by-round history): full solution dotnet test (Compono.slnx, Debug): 913 passed, 0 failed, 0 skippedtest/Compono.XunitV3.Tests alone carries 134 of those (67 per TFM), including 19 cases in ComposeAttributeConfigBindingTests.cs (grew from an initial 10) and one new case in BindingPlanTests.cs. test/Compono.XunitV3.SampleTests is excluded from Compono.slnx itself (per its own csproj comment) but is run directly by the package-validation CI workflow (a fact this plan originally got wrong — see Notes' round 5 entry), not run "manually" as this section first claimed: 20 passed (10 per TFM), 2 pre-existing FailingCompositionTests failures (expected, unrelated), and 2 deliberate FailingConfigProfileTests failures (expected, its own class as of round 5 — see below), all excluded from the CI gate by --filter-not-class "Compono.XunitV3.SampleTests.Failing*".

Per testing.md's existing Compono.XunitV3.Tests (fast, direct-GetData) / Compono.XunitV3.SampleTests (real xUnit v3 runner) split, matching how ADR-0022's own binding algorithm was verified:

test/Compono.XunitV3.Tests (direct GetData calls, no real runner):

  • TConfig with exactly one public constructor, valid arguments → TProfile constructed and applied correctly (assert the registration it makes is actually in effect on the resulting Composer).
  • TConfig with zero public constructors → clear, named CompositionException, cached (not re-thrown with a different message on a second GetData call on the same attribute instance).
  • TConfig with more than one public constructor → same, distinct message naming the ambiguity.
  • TProfile with no constructor taking exactly one TConfig → clear, named CompositionException.
  • TProfile with more than one qualifying constructor — confirmed during implementation this is unreachable via ordinary C#: two constructors with an identical single-TConfig-parameter signature is a compiler error (duplicate signature), so no test double can exercise this branch; the check itself stays in ConfigProfileBinder as defensive belt-and-suspenders (see Notes).
  • Profile configuration argument count mismatch (too few/too many against TConfig's constructor) → reuses the existing pre-composition "wrong argument count" message shape, retargeted.
  • Profile configuration argument type mismatch (including the existing Nullable<T>-boxing-unwrap case, proving the reused validation still handles it correctly against TConfig's parameters) → reuses the existing message shape.
  • null profile configuration argument for a non-nullable TConfig parameter → rejected, same as the existing inline-value rule.
  • Invoker-delegate caching: MakeGenericMethod/constructor-invocation reflection runs exactly once per attribute instance across many repeated GetData calls (same assertion shape ADR-0022's own caching tests use).
  • Existing ComposeAttribute/ComposeAttribute<TProfile> behavior is unaffected — a regression check, not new coverage, confirming the new type didn't touch the existing binding path.

test/Compono.XunitV3.SampleTests (real xUnit v3 runner):

  • A representative [Compose<TProfile, TConfig>(...)] theory, modeled on trivia-platform's PersistenceAutoData(repositoryName) shape (enum argument, per ADR-0036's "no stringly typed configuration" principle), run end-to-end against a packaged (not project-referenced) Compono.XunitV3 build — proving generated-plan discovery still reaches every type composed inside the resulting profile's Configure method, the same packaged-consumer verification ADR-0022's own Amendment (2026-07-30) required for [Compose]-attributed parameters.
  • A deliberately-failing case (a TProfile with no constructor accepting the TConfig) asserted to fail before the test method ever executes, with the expected diagnostic text — its own FailingConfigProfileTests class as of round 5 (see Notes), matching FailingCompositionTests' established pattern.

skills/compono-evals — a new eval, shaped exactly as required:

{
  "id": 19,
  "category": "migration",
  "prompt": "Convert this parameterized AutoFixture custom AutoDataAttribute to Compono. It takes a RepositoryKind-shaped argument (currently a string constant) at each call site and configures a different repository customization per call.",
  "expected_output": "Recognizes this as the profile-configuration-arguments pattern: a TConfig record (using an enum, not the original string) paired with a profile via [Compose<TProfile, TConfig>(...)], not a combinatorial set of profile subclasses, a per-test Composer.Create(...) escape hatch, invented ambient/global scenario state, or a recommendation to keep the AutoFixture attribute.",
  "files": [],
  "expectations": [
    "Proposes [Compose<TProfile, TConfig>] specifically, not [Compose<TProfile>] with no way to pass the value, and not a new attribute-per-argument-combination subclass",
    "Uses an enum (or other attribute-legal typed value) for the finite-choice argument, not a magic string, per the no-stringly-typed-configuration principle",
    "Does not suggest ambient/global mutable scenario state, a per-test hand-built Composer.Create(...) as the primary recommendation, or retaining the AutoFixture attribute",
    "Correctly distinguishes this from inline values (which bind to the test method's own parameters) and does not conflate the two"
  ]
}

Added to skills/compono-evals/evals.json's existing evals array, following the file's established id/category/prompt/expected_output/ files/expectations shape.

Verification and closeout

Explicit exit checklist — every item confirmed before this plan moves to Done, not assumed from the Tasks list alone:

  • Every new-API example compiles against the packaged Compono.XunitV3 surface — test/Compono.XunitV3.SampleTests references Compono.XunitV3 via PackageReference only (no ProjectReference anywhere in that project, by design), packed fresh from current source via pack-to-local-feed.sh on every restore; ConfigProfileTests.cs's two passing theories and FailingConfigProfileTests.cs's one deliberately-failing theory (its own class as of review round 5) all ran successfully against that real packaged build (see Notes for the exact dotnet test output confirming the failure's stack trace originates in the packaged Compono.XunitV3.dll, not a project reference).
  • Existing [Compose]/[Compose<TProfile>] semantics unchanged — the full solution suite (913 tests, final count after all review rounds) passes; no existing test file's assertions were modified, only PublicApiSurfaceTests.cs's exact-set list extended (expected, additive) and ComposeAttribute.cs's normalization/validation logic extracted for reuse with no behavioral change (verified by every pre-existing inline-value test still passing unmodified).
  • ConfigArguments_AreNeverBoundAsInlineValues (new test) proves profile configuration arguments never populate the base class's InlineValues — structurally impossible for them to be misinterpreted as inline values, not just untested.
  • Benchmark evaluation satisfied by the "no new benchmark, here's why" reasoning above — nothing during implementation contradicted it (no new hot-path reflection was introduced; the invoker-delegate-caching test proves boundedness directly).
  • Every documentation file updated (see Tasks above); README/package-table grep completed with a documented "left unchanged by design" outcome where appropriate.
  • Every skill file updated; the new eval (id 19) is well-formed JSON in the existing array — running it against the now-updated skill is a human/CI eval-harness action outside this coding session's own tool access (no run_eval.py-equivalent invoked here); the skill content itself was written to satisfy every one of the eval's stated expectations directly.
  • docs/roadmap/post-mvp.md, docs/adr/README.md, docs/plans/README.md all reflect final status — reconfirmed below.
  • This plan's Status set to Done — every box above checked.

Notes

Implementation deviated from this plan's original file-level sketch in two ways, neither changing the ADR's decision, both narrowing scope in a good direction:

  1. No BindingPlan.cs changes needed for construction itself (a later review round did touch this file for an unrelated reason — a diagnostic-message-text fix, not a reopening of this finding; see round 5 below). The plan originally assumed the new attribute would need to hook into BindingPlan's cache-construction pass the way test-method-parameter binding does. It doesn't for the actual construction/binding work: ComposeAttribute<TProfile, TConfig>.ApplyProfile is already called exactly once per attribute instance, for free, by the existing Lazy<Composer>-backed _composer field the base ComposeAttribute class already has — TConfig/TProfile are compile-time-closed generic arguments on the attribute class itself, not a runtime-discovered Type requiring MakeGenericMethod the way an arbitrary test-method parameter type does. ConfigProfileBinder uses plain ConstructorInfo/Type.GetConstructors() reflection directly (no MakeGenericMethod/Delegate.CreateDelegate dance), documented as a deliberate, narrower reflection shape in its own XML remarks — still bounded to once per attribute instance, just via the existing caching mechanism rather than a new one.
  2. docs/reference/diagnostics.md was the wrong target — corrected to docs/troubleshooting/common-errors.md once the file's actual scope (compile-time CMP codes only) was checked directly rather than assumed from the plan's original hedge.

Packaged-consumer verification, actual output (from dotnet test test/Compono.XunitV3.SampleTests/Compono.XunitV3.SampleTests.csproj -c Debug, both TFMs): ConfigProfileTests.ComposesTheProfileBuiltFromConfigArguments and .DifferentConfigArguments_ProduceADifferentlyConfiguredProfile both pass, proving RepositoryKind.Player/RepositoryKind.Game produce differently-configured profiles through the real packaged pipeline. ConfigProfileTests.MismatchedProfileConstructorShape_FailsBeforeTheTestExecutes fails exactly as designed, with message 'Compono.XunitV3.SampleTests.ProfileWithNoMatchingConstructor' must have exactly one public constructor accepting a single 'Compono.XunitV3.SampleTests.RepositoryTestConfig' parameter, but has 0., stack-traced through ConfigProfileBinder.ResolveSingleProfileConstructorBuildProfileComposeAttribute\2.ApplyProfileBuildComposerLazy.CreateValue()GetData— confirming the failure happens before the test body runs, from inside the packaged assembly. Full solutiondotnet build/dotnet test(Compono.slnx`): 0 warnings, 0 errors, 893/893 passed.

PR #65 review (Codex) caught a real blocking gap this plan's own verification missed: Compono.Generators' ComposeMethodDiscovery was registered against the non-generic and one-type-parameter ComposeAttribute metadata names only (ComponoIncrementalGenerator.cs) — ComposeAttribute<TProfile, TConfig>'s own arity-suffixed metadata name (Compono.XunitV3.ComposeAttribute\2) was never registered, so a concrete parameter type reached *only* through[Compose](no otherCreate()/[Composable]call site) got no generatedICompositionPlanat all and would fail atGetDatatime in real usage. This plan's own packaged-consumer sample (ConfigProfileTests.cs) didn't catch it because its only composed parameter type was astring— provider-resolved, never needs a generated plan — masking the gap exactly the waytesting.md's "verifying a new public entry point" rule warns against. Fixed: a thirdForAttributeWithMetadataNameregistration added forComposeMethodDiscovery.TwoTypeParameterAttributeMetadataName, merged into the samecomposeMethodResultsAllpipeline as the other two arities (ComponoIncrementalGenerator.cs); a new isolatedCompono.Generators.Testssnapshot test (ComposeTwoTypeParameterAttributedMethodParameter_GeneratesCompositionPlan) proves a concrete type reached only this way now gets a plan; andConfigProfileTests.cswas changed to compose a real concreteRepositoryConsumerclass (with its own nestedstringdependency satisfied by the profile's registration) instead of a barestring, so the packaged sample now actually exercises the fixed path instead of masking it.docs/roadmap/post-mvp.mdwas also corrected in the same review round — the page's own stated purpose (docs/roadmap/index.md: "not fully available") doesn't allow a shipped,Accepted+Done` capability to stay listed as an outstanding candidate; returned to a no-current-candidates state with the historical trail preserved via ADR-0036/RESEARCH-0002/PLAN-0036 links instead of inline restatement.

PR #65's second review round caught two more real gaps, both fixed and pushed:

  1. Missing seed on config/profile binder failures. ApplyProfile runs while the base class's Lazy<Composer> is still being built — before GetData ever calls Composer.CreateRow — so a ConfigProfileBinder failure had no CompositionRow/row.Seed to read from and escaped without the "\n\nSeed: ..." suffix every other Compono.XunitV3-owned pre-composition failure carries (ADR-0022). Fixed: ApplyProfile now catches CompositionException and rethrows via the existing CompositionException.WithSeedInMessage helper, using SeedAsNullable (the attribute's own configured seed) or a freshly generated one otherwise — reproducibility isn't actually meaningful for this failure category (a constructor-shape mismatch fails identically regardless of seed), this is purely about applying the established convention consistently.
  2. Abstract TConfig/TProfile threw the wrong exception type. An abstract class can still declare a public constructor (only a derived type can call it) — ResolveSingleConstructor/ ResolveSingleProfileConstructor would find it, pass the "exactly one constructor" check, and then ConstructorInfo.Invoke would throw MemberAccessException instead of the documented CompositionException. Fixed: both methods now explicitly reject an abstract type with a named CompositionException, checked before the constructor-count logic.

Both fixes have dedicated Compono.XunitV3.Tests regression coverage (GetData_AppendsTheConfiguredSeed_WhenProfileConstructionFailsBeforeARowExists, GetData_AppendsAGeneratedSeed_WhenProfileConstructionFailsWithNoSeedConfigured, GetData_Throws_WhenConfigTypeIsAbstract, GetData_Throws_WhenProfileTypeIsAbstract) and are documented in docs/troubleshooting/common-errors.md. Full solution: 903/903 passed.

PR #65's third review round caught two more, both edge cases of the round-2 fixes rather than newly independent gaps — fixed and pushed:

  1. A negative configured seed lost to a binder failure. Round 2's catch-block fix used SeedAsNullable ?? <fresh seed> unconditionally — if SeedAsNullable itself was negative (Seed = -1) and the config/profile shape was also invalid, the binder failure reported Seed: -1 instead of the documented negative-seed diagnostic the base GetData enforces for every other case. Fixed: ApplyProfile now checks for a negative SeedAsNullable first, before attempting any config/profile binding, throwing the identical negative-seed message the base class uses (AppendSeed promoted from private to private protected so both share the exact convention).
  2. ConstructorInfo.Invoke wrapping constructor-thrown exceptions. If TConfig's or TProfile's own constructor throws (e.g. custom validation logic), reflection wraps that in TargetInvocationExceptionApplyProfile's catch (CompositionException) never saw it, so a constructor's own actionable exception was replaced by an opaque reflection failure with no seed reporting. Fixed: ConfigProfileBinder's shared Invoke helper unwraps TargetInvocationException via ExceptionDispatchInfo.Capture(...).Throw() (preserving the original stack trace), for both the TConfig and TProfile construction call sites.

Regression coverage: GetData_ReportsTheNegativeSeedDiagnostic_NotTheBinderFailure_WhenBothApply, GetData_UnwrapsAndReportsTheOriginalException_WhenTheConfigConstructorThrows, GetData_UnwrapsAndReportsTheOriginalException_WhenTheProfileConstructorThrows. Full solution: 909/909 passed.

PR #65's fourth review round — three findings, two fixed, one deliberately not actioned:

  1. ADR-0036 needed a dated Amendment, not a silent plan-note correction. This plan's own Notes already recorded that ConfigProfileBinder uses direct ConstructorInfo.Invoke, not the cached-delegate (MakeGenericMethod/Delegate.CreateDelegate) shape ADR-0036's "Reflection is bounded and cached" section specified — but per this repo's own rule (design-decisions.md's Amendment mechanic), a correction to an already-Accepted ADR's decision detail belongs as a dated Amendment on that ADR itself, not only a plan-side note. Fixed: added ADR-0036's Amendment 1 (2026-08-09), explaining why the simpler direct-invocation shape still satisfies the ADR's actual guarantee (bounded to once per attribute instance — via the base class's existing Lazy<Composer> caching, not a new delegate cache).
  2. Base ComposeAttribute's XML docs and the NuGet package description were stale. ComposeAttribute's remarks still said ComposeAttribute<TProfile> was "the one designed extension point," and Compono.XunitV3.csproj's <Description> listed only [Compose]/[Compose<TProfile>]. Fixed: updated both to describe both extension points, and regenerated the API reference (docs/reference/api/Compono.XunitV3/Compono.XunitV3.ComposeAttribute.md) from the corrected XML docs.
  3. Not actioned: "move the CompositionProviderRequest.Name migration section to a separate PR." A legitimate scope observation in the abstract, but this was a deliberate, explicit instruction from the user who commissioned this plan (not an oversight) — the original request said, verbatim in spirit, to include RESEARCH-0002 Finding 2's documentation gap in this same work "unless there is a strong reason to keep it separate," and this plan's own "Documentation tasks" section already recorded that no such reason was found. Replied on the thread explaining this and resolved it without a code change - the scope decision stands as the user directed it, not as this review round would have made it unilaterally.

Full solution after round 4: 909/909 passed, 0 warnings, 0 errors.

PR #65's fifth review round — three findings, all fixed, plus a live-CI-caught regression this round's own fix introduced:

  1. ADR-0036's "retargeted rather than reimplemented" promise wasn't actually kept. ConfigProfileBinder.BindConfig independently reimplemented the exact null/Nullable<T>-unwrap/assignability check ComposeAttribute.GetData's own inline-value loop already had — a correction to one would never have reached the other. Fixed: extracted both into a new shared Binding/PositionalArgumentBinder.cs (PositionalArgumentBinder.Validate, returning a PositionalArgumentValidation enum), used by both call sites, with message text ownership staying local to each (the two describe the value differently — "Inline value... on..." vs. "Profile configuration argument... of...").
  2. No regression coverage for a non-null value-typed config argument against a Nullable<T> constructor parameter (e.g. 42 for an int?) — the plan promised this exact case per ADR-0022's own precedent, but every existing config-binding test used a reference type. Fixed: added NullableIntTestConfig/ NullableIntParameterizedTestProfile fixtures and GetData_AcceptsANonNullValueTypeArgument_ForANullableValueTypeParameter.
  3. The Compose-family-stacking diagnostic still named only the original two forms. Detection already worked correctly for the new attribute (GetCustomAttributes<ComposeAttribute>() matches any derived type), but BindingPlan.ValidateSignature's message text hadn't been updated. Fixed: message and its surrounding comment now name all three forms; added WithComposeAndTwoTypeParameterComposeAttributes fixture and Build_ReportsASignatureError_ForComposeStackedWithTheTwoTypeParameterForm.

Live CI regression, caught by the user, not by Codex: round 5's own push broke the package-validation workflow's "Local-feed packed-consumer smoke test" step. That step runs Compono.XunitV3.SampleTests directly (it's excluded from Compono.slnx only, not from CI as a whole — an incorrect assumption baked into this project's own code comments since round 1, now corrected everywhere it appeared) with --filter-not-class "Compono.XunitV3.SampleTests.FailingCompositionTests" to skip exactly one known-always-failing class. ConfigProfileTests.cs's own deliberately-failing MismatchedProfileConstructorShape_... test lived inside the otherwise-green ConfigProfileTests class, so it wasn't excluded and failed the gate. Fixed: moved that test into its own FailingConfigProfileTests class (mirroring FailingCompositionTests' pattern exactly), and changed the workflow's filter to a trailing wildcard, --filter-not-class "Compono.XunitV3.SampleTests.Failing*", covering the whole naming convention instead of one hardcoded class name — verified directly against the built test host that the wildcard must be trailing-only (a mid-string wildcard like Failing*Tests is rejected by the MTP CLI). Verified locally with the exact corrected command: 20/20 passed. CS1591-as-error build and docs/reference/api freshness (the other two package-validation/docs gates) also reverified clean.

Full solution after round 5 + the CI fix: 913/913 passed, 0 warnings, 0 errors.

PR #65's sixth review round — one finding, a continuation of round 4's stale-docs theme: docs/packages/index.md's top-level package catalog table and skills/compono/SKILL.md's Detection table/reference table (two separate rows) still enumerated only the original two Compose-family forms. Round 4 fixed the base attribute's XML docs and the NuGet package description but missed these two additional discovery surfaces. Fixed all three, then ran a repo-wide grep -rn '\[Compose\]/\[Compose<TProfile>\]' across every .md file to confirm no further stale mentions remained (none found). Full solution: 913/913 passed, unchanged (markdown-only change).

PR #65's seventh review round — one finding, about this plan document itself: as a Done plan, the Test Plan summary/Critical Files/Verification-checklist sections above are the record maintainers rely on, and they still described the pre-review-round state (stale test counts, a "left unchanged by design" claim round 6 reversed, an "excluded from CI" claim round 5 already corrected in code comments but not here, and a "no BindingPlan.cs changes" claim round 5's own diagnostic-message fix quietly contradicted). Fixed by editing those sections directly to the final, true state (913/913, PositionalArgumentBinder.cs, FailingConfigProfileTests.cs, package-validation.yaml, and every other file this loop actually touched) rather than adding a seventh dated note on top of six already-accumulated ones — this plan is a living document, not an ADR, so a correction here is a direct edit, not an Amendment. The round-by-round Notes entries above stay as the historical "what changed and why, in order" trail; only the summary sections a reader would check first for final state were stale.