[PLAN-0067] Compono.XunitV3.Aot Profile Support ([Compose<TProfile>]/[Compose<TProfile, TConfig>])¶
Status: Done
Implements: ADR-0067
Goal¶
A consumer referencing Compono.XunitV3.Aot can write [Compose<TProfile>] and [Compose<TProfile, TConfig>] theories - identical syntax to Compono.XunitV3 - and publish the test project as Native AOT, with the resulting native binary correctly applying the profile (and, for the two-type-parameter form, the bound TConfig) before composing every test parameter. Done when: both attribute forms ship in Compono.XunitV3.Aot, Compono.Generators emits real, compile-time-verified, reflection-free registrations for them, a real dotnet publish -p:PublishAot=true + native-binary- execution proof exists for both forms (including a negative case proving CMP0041/CMP0042/CMP0043 fire correctly), and that proof is wired into .github/workflows/aot-validation.yaml.
Scope¶
In scope: Compono.XunitV3.Aot.ComposeAttribute<TProfile> (marker, where TProfile : ICompositionProfile, new()); Compono.XunitV3.Aot.ComposeAttribute<TProfile, TConfig> (marker, where TProfile : ICompositionProfile); Compono.Generators extended with two new metadata-name registrations and compile-time TConfig/TProfile constructor-shape + argument validation; three new diagnostics (CMP0041/CMP0042/CMP0043); generated-code changes to construct a profile (and, for the two-type- parameter form, a TConfig) directly, with no reflection; permanent CI proof for both forms, including negative/diagnostic cases.
Explicitly out of scope: inline values and [Shared] for the plain [Compose] form (still deferred per PLAN-0066 - unrelated to profile support, not touched here); any change to Compono.XunitV3 or core Compono (per ADR-0067's backward-compatibility driver, unchanged); converting alexa-vox-craft's Native AOT validator (a post-implementation dogfooding step - see Dogfooding section below, not a task of this plan's own Phase).
Phase 1: Profile-based composition ([Compose<TProfile>] and [Compose<TProfile, TConfig>])¶
Status: Done
Tasks¶
-
src/Compono.XunitV3.Aot/ComposeAttribute{TProfile}.cs-Compono.XunitV3.Aot.ComposeAttribute<TProfile> : Compono.XunitV3.Aot.ComposeAttribute where TProfile : ICompositionProfile, new(), marker-only (noGetDataoverride, no constructor logic - mirrors Phase 1'sComposeAttribute.csexactly). -
src/Compono.XunitV3.Aot/ComposeAttribute{TProfile,TConfig}.cs-Compono.XunitV3.Aot.ComposeAttribute<TProfile, TConfig> : Compono.XunitV3.Aot.ComposeAttribute where TProfile : ICompositionProfile, marker-only, constructor acceptingparams object?[] configArguments(so the C# compiler treats the supplied values as this attribute's own compile-time-constant constructor arguments, readable later viaAttributeData.ConstructorArguments- the attribute itself does nothing with them at runtime). -
src/Compono.Generators/Discovery/AotComposeMethodDiscovery.cs- addGenericAttributeMetadataName = "Compono.XunitV3.Aot.ComposeAttribute\1"andTwoTypeParameterAttributeMetadataName = "Compono.XunitV3.Aot.ComposeAttribute`2"constants, mirroringComposeMethodDiscovery`'s existing three-constant-per-family pattern exactly. -
src/Compono.Generators/ComponoIncrementalGenerator.cs- two newForAttributeWithMetadataNamepipeline registrations for the new constants (both feedingAotComposeMethodDiscovery's extendedTransformMethod, and both also registered withComposeMethodDiscovery's existing pipeline for ordinaryPlanCache<T>parameter-type discovery - a profile doesn't change which parameter types a test method composes, so this reuses the existing mechanism unchanged). -
src/Compono.Generators/Models/AotComposeMethodInfo.cs- add a nullableAotProfileInfo? Profilefield. NewAotProfileInforecord:FullyQualifiedProfileTypeName, and (for the two-type- parameter form only)FullyQualifiedConfigTypeName+EquatableArray<AotProfileConfigArgumentInfo> ConfigArguments(rendered-literal-ready: each argument's C#-source-literal text, computed once at discovery time - see next task). -
src/Compono.Generators/Discovery/AotComposeMethodDiscovery.cs- extendTransformMethod(or add a sibling entry point covering the two new metadata names) to: - For
[Compose<TProfile>]: no new validation needed (where TProfile : ICompositionProfile, new()is a compile error at the use site for anything else) - just captureFullyQualifiedProfileTypeName. - For
[Compose<TProfile, TConfig>]: resolveTConfig's declared constructors (INamedTypeSymbol.Constructors, public, non-static) - exactly one, elseCMP0041. ResolveTProfile's declared constructors for exactly one accepting exactly oneTConfig-typed parameter (SymbolEqualityComparer.DefaultagainstTConfig's symbol, per this repo's established signature-comparison rule) - elseCMP0042. Validate the attribute'sAttributeData.ConstructorArguments(TypedConstants) againstTConfig's single constructor's parameter count/nullability/assignability (mirrorsPositionalArgumentBinder.Validate's rules, Roslyn-side) - elseCMP0043. - New literal-rendering helper (e.g.
src/Compono.Generators/Emitters/TypedConstantLiteralRenderer.cs)- renders one
TypedConstantback to valid C# source for a known target parameter type: a string viaSymbolDisplay.FormatLiteral; aSystem.Type-typed argument (TypedConstantKind.Type) astypeof({{ fully_qualified_name }}); an enum member as a fully-qualified member-access expression (or a cast for a non-defined combined-flags value); a primitive via its literal form; a one-dimensional array via a bracketed literal list. Bounded, closed set - matches the set of attribute-legal argument types C# itself allows, nothing broader needs handling.
- renders one
-
src/Compono.Generators/Emitters/AotTheoryDataRowRegistrationEmitter.cs+src/Compono.Generators/Templates/AotTheoryDataRowRegistration.scriban- extend the generated factory closure: no profile → unchangedglobal::Compono.Composer.Create();[Compose<TProfile>]→global::Compono.Composer.Create(b => b.AddProfile<{{ fully_qualified_profile_type_name }}>());[Compose<TProfile, TConfig>]→ constructconfig/profilelocals via the rendered-literal constructor calls, thenglobal::Compono.Composer.Create(b => b.AddProfile(profile)). -
src/Compono.Generators/Diagnostics/DiagnosticDescriptors.cs+src/Compono.Generators/AnalyzerReleases.Unshipped.md-CMP0041/CMP0042/CMP0043, continuing theCMP004x: Compono.XunitV3.Aot diagnosticsseries Phase 1'sCMP0040started, same file region/comment block. -
test/Compono.Generators.Tests- snapshot coverage for the extended emitter (no-profile unchanged-baseline case still passing;[Compose<TProfile>]case;[Compose<TProfile, TConfig>]success case covering at least a string, an enum, and atypeof(...)argument;CMP0041/CMP0042/CMP0043each triggering cases) plus anIncrementalCachingTestscase for the new discovery path. -
test/Compono.XunitV3.Aot.Tests- real generated-code execution (ProjectReference-based, JIT/dotnet test) for both new attribute forms, including a case provingTProfile.Configure's ownRegister<T>()calls are honored (parity check against the equivalentCompono.XunitV3behavior - "JIT parity" per the brief's Testing requirements). No[Shared]interaction case -[Shared]remains entirely unimplemented inCompono.XunitV3.Aot(deferred alongside inline values, per this plan's own Scope section), so there is no syntax to exercise it with yet. -
test/Compono.XunitV3.Aot.SampleTests- packaged-dependency-chain sample coverage for both forms, added to the existing project rather than a new one: it already doubles as the permanent Tier 3 Native AOT proof project (PLAN-0066's own precedent), and the negative-diagnostic cases (below) turned out not to need to coexist with it at all - they're compile-time-only and belong withCMP0040's own coverage intest/Compono.Generators.Testsinstead. - Real Native AOT proof, both forms:
dotnet publish -p:PublishAot=true(using the working-p:RuntimeIdentifier=<rid> -p:SelfContained=true -p:UseAppHost=trueMSBuild-property form PLAN- 0066's Notes section already recorded, not the-r <rid> --self-containedshorthand), native binary executed directly, zero IL2xxx/IL3xxx warnings, both the[Compose<TProfile>]and[Compose<TProfile, TConfig>]test bodies actually executing with the profile/config genuinely applied (not merely compiling) - the equivalent of RESEARCH-0032 §9's Phase 1 proof, for this phase's new mechanism. Run manually during implementation (osx-arm64); real for CI is the unmodifiedaot-xunitv3-aot-pipelinejob (linux-x64), which now covers these same tests since they live in the same project - see the workflow task below. - Negative-case proof: snapshot tests in
test/Compono.Generators.Tests(AotTheoryDataRowRegistrationVerifyTests), the sameGeneratorTestHelpers.VerifyFailure+ hand-written-stand-in-package mechanismCMP0040's own coverage already uses - not a separate project. Each ofCMP0041/CMP0042/CMP0043has its own case, asserting the diagnostic ID and snapshotting the real reported location/message text (proving it's anchored at the method's real declaration, not just internally exercised against the discovery method in isolation). -
.github/workflows/aot-validation.yaml- no edit needed: the existingaot-xunitv3-aot-pipelinejob already publishes and runs the wholeCompono.XunitV3.Aot.SampleTestsbinary, which now contains the two new profile-form tests alongside the plain-[Compose]one, so it covers them automatically;aot-gate's existing dependency on this job covers the extension too. Its trigger paths already includesrc/Compono.Generators/(viacore_or_shared_change), which this plan's generator changes fall under.
Docs/skill tasks¶
-
docs/packages/compono-xunitv3-aot.md- document[Compose<TProfile>]/[Compose<TProfile, TConfig>]support, and explicitly call out the compile-time-vs-runtime diagnostic-timing divergence fromCompono.XunitV3(ADR-0067's accepted-divergence section) so it reads as documented behavior, not an inconsistency. -
docs/packages/compono-xunitv3.md- cross-reference update if needed (mirrors Phase 1's own cross-ref task). -
componoagent skill (~/.agents/skills/compono/) -references/xunitv3-aot.mdrewritten for the two new attribute forms, the compile-time-vs-runtime diagnostic divergence, andCMP0041/CMP0042/CMP0043;SKILL.md's frontmatter diagnostic range, detection table (Compono.XunitV3.AotPackageReference row,[Compose]/[Compose<...>]row), and reference-file index all updated to drop the "Phase 1 plain[Compose]only" framing. -
evals.json- eval 56 corrected (its "Phase 1 only supports plain[Compose]" reasoning was now stale - narrowed to the still-true inline-values/[Shared]limitation, plus an explicit expectation that the skill not claim profile support is unavailable); three new cases added (58:[Compose<TProfile>]activation, 59:[Compose<TProfile, TConfig>]shape question, 60:CMP0043diagnostic). Baseline-vs-after comparison run: the pre-change skill snapshot (saved before editing) explicitly documents Phase 1 as plain-[Compose]-only and explicitly instructs "if asked about [these forms], say plainly Phase 1 doesn't support it yet" - the expected, safe "before" behavior for cases 58-60 (same shape as PLAN-0066's own case-1 false-positive check: a documented instruction-following outcome, not one needing a live before/after model comparison to establish). The post-change skill was verified live (a fresh subagent loaded the real skill via the Skill tool and answered cases 56/58/59/60): all four passed self-check against their expectations - correct attribute forms, correctTConfig/TProfileconstructor-shape guidance, correctCMP0043explanation (compile-time-by-design, not a bug, distinct fromCMP0041/CMP0042), no false "unsupported" claims, no invented AOT-specific abstraction. No skill content needed correction as a result.
Critical Files¶
src/Compono.XunitV3.Aot/- two new attribute files (above).src/Compono.Generators/Discovery/AotComposeMethodDiscovery.cs- extended with two new metadata names and the compile-time validation logic.src/Compono.Generators/Models/AotComposeMethodInfo.cs- extended withAotProfileInfo.src/Compono.Generators/Emitters/AotTheoryDataRowRegistrationEmitter.cs,src/Compono.Generators/Templates/AotTheoryDataRowRegistration.scriban- extended generated-code shape.src/Compono.Generators/Emitters/TypedConstantLiteralRenderer.cs(new) - the one genuinely new piece of generator machinery this plan introduces (ADR-0067's "Negative Consequences").src/Compono.Generators/Diagnostics/DiagnosticDescriptors.cs,src/Compono.Generators/AnalyzerReleases.Unshipped.md-CMP0041/CMP0042/CMP0043..github/workflows/aot-validation.yaml- extendedaot-xunitv3-aot-pipelinejob.test/Compono.XunitV3.Aot.Tests,test/Compono.XunitV3.Aot.SampleTests, and whatever new negative-diagnostic proof surface implementation determines is cleanest.
Test Plan¶
Same three-tier pattern as PLAN-0066, plus the JIT-parity and negative-diagnostic requirements this phase specifically calls for:
- Unit/snapshot tests of the extended discovery/emission logic (
test/Compono.Generators.Tests), including all three new diagnostics. - Real generated-code execution against the packaged dependency chain (
test/Compono.XunitV3.Aot.Tests/SampleTests), including aCompono.XunitV3-vs-Compono.XunitV3.Aotparity case for at least one profile scenario. - Real
dotnet publish -p:PublishAot=true+ native-binary-execution proof for both attribute forms, through the real generator-emitted registration - extendingaot-xunitv3-aot-pipeline. - A negative-case proof that
CMP0041/CMP0042/CMP0043are real, correctly-located compile-time diagnostics, not just internally-unit-tested discovery-method behavior.
Compatibility¶
- No change to
Compono.XunitV3, coreCompono, or Phase 1's plain-[Compose]mechanism. - No new minimum xUnit v3 AOT or .NET version - this phase introduces no new xUnit API dependency beyond what Phase 1 already requires (
RegisteredEngineConfig.RegisterTheoryDataRowFactory). - No
[DynamicallyAccessedMembers]annotations introduced anywhere in this phase's new code (ADR-0067). - The compile-time-vs-runtime diagnostic-timing divergence from
Compono.XunitV3(ADR-0067) is the only intentional behavioral difference; documented, not silent.
Dogfooding¶
alexa-vox-craft's AlexaVoxCraft.NativeAot.ValidationApp conversion to Compono.XunitV3.Aot is the first post-Phase-2 dogfood target (per the xunitv3aot-dogfood-sequencing project memory) - not a task of this plan. Once this plan's Phase 1 ships, use the repository's normal dogfooding workflow (scripts/dogfood-validate.sh, per CLAUDE.md's "Consumer/dogfood validation gate" section) rather than an ad hoc validation pass, and revisit that repo's own deliberate no-test-infra-dependency policy (its AlexaVoxCraft.NativeAot.ValidationApp.csproj comment, per plan 0003 Task Group 7) as its own explicit product-owner decision, the same way the first dogfood attempt surfaced it.
Notes¶
Post-implementation review correction (this plan's own second pass): the first implementation pass unsealed Compono.XunitV3.Aot.ComposeAttribute and had ComposeAttribute<TProfile>/ ComposeAttribute<TProfile, TConfig> derive from it, mirroring Compono.XunitV3.ComposeAttribute's own deliberately-unsealed design superficially. Review caught that this mirroring didn't actually hold up under scrutiny: Compono.XunitV3.ComposeAttribute's generic siblings extend real, shared runtime state (a cached Composer, cached binding delegates, a real GetData override) - inheritance is load- bearing there. Compono.XunitV3.Aot.ComposeAttribute is a pure marker with zero functional state, and AOT discovery matches purely by each closed attribute type's own fully qualified metadata name, never by inheritance/assignability - so sharing a base class bought nothing functionally, while opening a real hazard unique to this marker-only family: a consumer subclassing any of the three forms would compile cleanly but never be discovered by Compono.Generators (its own metadata name wouldn't match any registered provider), silently never running - exactly the failure mode ADR-0066's CMP0040 exists to prevent for every other unsupported shape. Fixed: ComposeAttribute reverted to sealed (its original Phase 1 shape, unchanged); ComposeAttribute<TProfile>/ComposeAttribute<TProfile, TConfig> now derive directly from Xunit.v3.DataAttribute, with no inheritance relationship to the non-generic form - independent marker siblings unified only by naming convention and the shared Compono.Generators discovery pattern, not by CLR inheritance. Recorded as ADR-0067 Amendment 1 (dated), since the ADR's own "Shape" section originally showed the inheritance relationship. No discovery/codegen logic depended on the inheritance relationship (confirmed: AotComposeMethodDiscovery matches purely on AttributeData.AttributeClass's metadata name), so this is a pure API-shape correction with zero behavioral change to any already-passing test.
Post-implementation review finding, resolved as "no gap exists": review also asked whether a single array-typed TConfig constructor argument (e.g. TConfig(string[] items), supplied as [Compose<TProfile, TConfig>(new string[] { "a", "b" })]) is a real Compono.XunitV3 parity gap this plan's CMP0043 count-check papers over. Investigated directly against Roslyn (a standalone compile probe, not guesswork): a single argument more specifically typed than this attribute's own declared object?[] parameter type is not legal C# attribute-argument syntax at all - CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type rejects [Compose<TProfile, TConfig>(new string[] { "a", "b" })] at compile time, identically for Compono.XunitV3.ComposeAttribute<TProfile, TConfig> - this is a plain C# language restriction, not a choice either package makes. Compono.XunitV3.ComposeAttribute.NormalizeParamsArguments's own "single reference-array argument" handling (the code this plan's AotComposeMethodDiscovery remarks originally cited as the JIT-mode precedent for this shape) is exercised only by ComposeAttributeCachingTests.InlineValues_SingleReferenceArrayArgument_TreatedAsOneSuppliedArrayValue, which calls the ComposeAttribute constructor directly in ordinary C# code - never through a real [Compose(...)] attribute annotation, which is the only way any theory attribute (JIT or AOT) is ever actually applied to a test method. Conclusion: there is no parity gap to close. No code change was needed; AotComposeMethodDiscovery.cs's own comment on this was corrected to record the investigated conclusion instead of an open "revisit if a real case surfaces" note.
One real design refinement, not a correction: Compono.Generators.Tests (the generator test project) has a real ProjectReference to core Compono, so the real Compono.CompositionBuilder.AddProfile<T>() generated code needs its test-double TProfile types to implement the real Compono.ICompositionProfile - not a locally-declared fake one, the way CompositionPlanVerifyTests's own Compono.XunitV3 stand-ins do (that suite only ever exercises ComposeMethodDiscovery's type-discovery concern, never the generated AddProfile call itself, so a fake interface is enough there). AotTheoryDataRowRegistrationVerifyTests's stand-in block leaves ICompositionProfile unqualified instead, letting ordinary C# namespace-nesting resolve it to the real Compono.ICompositionProfile - the identical mechanism the real production Compono.XunitV3.Aot.ComposeAttribute{TProfile}.cs/ComposeAttribute{TProfile,TConfig}.cs files themselves rely on (their own namespace Compono.XunitV3.Aot; declaration is nested under Compono, so ICompositionProfile/CompositionBuilder resolve with no using Compono; needed at all).
Real Native AOT proof (Tier 3), run manually during implementation: dotnet publish test/Compono.XunitV3.Aot.SampleTests -c Release -f net10.0 -p:RuntimeIdentifier=osx-arm64 -p:SelfContained=true -p:PublishAot=true -p:UseAppHost=true, then executing the published native binary directly - Test run summary: Passed! ... total: 3, failed: 0, succeeded: 3, exit code 0, zero IL2xxx/IL3xxx warnings in the publish output. All three tests (plain [Compose], [Compose<TProfile>], [Compose<TProfile, TConfig>]) executed as real native code, with real profile/ config construction happening inside the native process - not merely compiling. Re-run after Amendment 1's inheritance correction (both new generic attributes now derive directly from Xunit.v3.DataAttribute rather than from the non-generic ComposeAttribute) - identical result: build clean (0 warnings/errors, full solution), Compono.Generators.Tests 666/666, Compono.XunitV3.Aot.Tests 15/15, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via the re-published native binary, exit 0, zero IL2xxx/IL3xxx warnings - confirming the correction was a pure type-hierarchy change with no behavioral effect, as predicted (discovery/codegen never depended on the inheritance relationship).
PR #140 Codex review round 1 found five real gaps in the initial implementation, all fixed - three new diagnostics (CMP0044/CMP0045/CMP0046) and two rendering-correctness fixes, none requiring an ADR change:
CMP0044—TProfile/TConfig/atypeof-or-enum-typed argument's own type wasn't checked for accessibility from the generated top-level registration before this pass. Aprivatenested profile/config type (legal at the[Compose<...>]use site, common for a test-local fixture type) would have reached code generation and failed withCS0122in the generated file instead of a clear diagnostic at the real attribute use site - the same class of gapCMP0013already guards against for ordinary composed parameter types, now applied here too.CMP0045—[Compose]/[Compose<TProfile>]/[Compose<TProfile, TConfig>]share no common base class (Amendment 1), so nothing stopped stacking two different forms on one method the wayBindingPlan.ValidateSignature's single reflection query catches this forCompono.XunitV3's three forms. Left unchecked, both would reachAddSourcewith the identical class-and-method-derived hint name and crash the whole generator (not just fail this one method) - now caught first, before any other processing, mirroring the JIT-mode check's own message text. (Known minor cosmetic effect, accepted rather than fixed further: the diagnostic is reported once per matching attribute form on the stacked method - e.g. twice for two stacked attributes - since each has its own independent discovery registration;Compono.XunitV3's own JIT-mode equivalent has the analogous "reported once per attribute instance'sGetDatacall" behavior for the same underlying cause, so this isn't a new inconsistency.)- TConfig non-named-type guard —
TConfigcarries no generic constraint at all, soTConfig = string[](or any other non-named type) is legal[Compose<TProfile, TConfig>]syntax; the initial pass's unconditional(INamedTypeSymbol)cast on both type arguments threwInvalidCastExceptioninside the generator for this shape instead of reportingCMP0041. Fixed by treating a non-named (or abstract) type argument as "zero constructors" for bothTConfigandTProfile- reuses the existingCMP0041/CMP0042diagnostics rather than adding new ones, no crash either way now. double.NaN/PositiveInfinity/NegativeInfinity/negative-zero rendering — these are real, legal compile-time-constant attribute arguments (confirmed by a direct compile probe, not assumed);TypedConstantLiteralRenderer's originalConvert.ToString(...)-based cast rendering produced the bare identifiersNaN/Infinity/-Infinity(not valid C# syntax -(double)NaNdoesn't compile) for the first three, and silently discarded the sign of-0.0for the fourth (Convert.ToString(-0.0)→ the digit string"-0"→(double)-0casts the int literal-0=0, losing the negative-zero bit entirely - a silent wrong-value bug, not a compile failure). Fixed with dedicateddouble.NaN/double.PositiveInfinity/double.NegativeInfinity(andfloatequivalents) rendering, and an explicit-(double)0/-(float)0unary-negation rendering for negative zero (IEEE 754 negation of a real zero value correctly produces negative zero, unlike negating the digit string first).CMP0046— the selectedTConfig/TProfileconstructor can leave arequiredmember unsatisfied (no[SetsRequiredMembers]), which the initial pass's shape checks (constructor count only) never caught - the generated directnewcall would fail withCS9035. Deliberately a compile-time rejection, not an attempt to auto-compose required members the way core Compono's ownRequiredMemberCollectordoes for ordinary composed types:TConfig/TProfileare built from literal attribute arguments, not Compono's provider pipeline, so there's no sensible composed value to auto-supply a required member with - inventing one would be a confusing, undocumented semantic, not a "smallest correct fix."
All five: new/updated snapshot coverage in test/Compono.Generators.Tests (AotTheoryDataRowRegistrationVerifyTests - CMP0044/CMP0045/CMP0046 each get their own VerifyFailure case; the non-named-TConfig case proves CMP0041 fires without crashing; the floating-point case is a Verify - compiles-and-runs, not just a snapshot - proving the rendered literals are real valid C#). Re-validated after these fixes: full build 0 warnings/errors, Compono.Generators.Tests 676/676, Compono.XunitV3.Aot.Tests 15/15, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 2 found three more real gaps and one false positive:
TConfig/TProfileconstructor selection acceptedref/out/inparameters.IParameterSymbol.Typestrips the ref modifier, so aProfile(ref TConfig config)orTConfig(ref int value)constructor matched the "exactly one usable constructor" check by type alone. Forref/out, the generated directnew/AddProfile(profileInstance)call (a plain argument, noref/outkeyword, since it's built from a literal value with no addressable variable to take a reference to) fails withCS1620; forinspecifically, the generated call actually compiles (theinmodifier is call-site-optional in C#) - a real, silent behavioral divergence fromCompono.XunitV3's JIT-modeConfigProfileBinder, which reflects the parameter's real by-ref runtime type (TConfig&) and never matches this shape as a candidate constructor at all. Fixed by requiringRefKind.Noneon every parameter of a candidateTConfigconstructor, and onTProfile's ownTConfig-typed parameter, before counting it as "usable" - reuses the existingCMP0041/CMP0042diagnostics (reported as "0 usable constructors"), no new diagnostic needed.TypedConstantKind.Errorreached the renderer's unhandled default arm. During an incomplete/erroneous compilation (most commonly: live IDE analysis mid-edit), Roslyn can hand back aTypedConstantofKind = ErrorwhoseTypeis still the parameter's own declared type -TypedConstantMatcher'sClassifyConversioncall would find a trivial identity conversion and reportValid, reachingTypedConstantLiteralRenderer.Render's_ => throw NotSupportedException(...)default arm, an unhandled exception that crashes the whole generator invocation (not just this one method) rather than the clean "ignore/diagnose, never crash" behavior every other unsupported shape in this series gets. Fixed by checkingconstant.Kind == TypedConstantKind.ErrorinTypedConstantMatcher.Validatebefore attempting conversion classification at all, reporting it as an ordinaryCMP0043type mismatch. New test drives the realGeneratorDriverdirectly (mirroringIncrementalCachingTests' own pattern, sinceGeneratorTestHelpers.Verify/VerifyFailureboth assume a compilation with no other pre-existing errors) against source containing a genuinely undefined identifier as the attribute argument, and assertsdriver.GetRunResult()doesn't throw - the property under test is generator crash-resilience, not that this deliberately-invalid source compiles (it categorically can't).- Rejected as a false positive, with evidence, not fixed: "unsafe pointer/function-pointer
typeofargument needs an unsafe context in the generated file." The finding claimedtypeof(int*)(or a function-pointer type) as a[Compose<TProfile, TConfig>]argument would fail to compile in the generated top-level file withCS0214since that file isn't declaredunsafe. Verified directly with two standalone compile probes before touching anything:typeof(int*)used as a real attribute argument, andtypeof(delegate*<int, void>)used as an ordinary local-variable initializer in a ordinary (non-unsafe, no<AllowUnsafeBlocks>) method body - both compile cleanly, 0 errors. Atypeof(...)expression over a pointer or function-pointer type is exempt from C#'s unsafe-context requirement entirely (the restriction applies to actually using/dereferencing a pointer value, not to naming a pointer type viatypeof) - the premise of this finding doesn't hold, soint*/function- pointerSystem.Typeprofile-configuration arguments already render and compile correctly with no change needed. Replied on the thread with both probe results; left the thread open (unresolved) rather than resolving it, since nothing was actually changed in response to it.
All three real fixes: new tests (TwoTypeParameterAttribute_TConfigConstructorHasByRefParameter_ ReportsCmp0041, TwoTypeParameterAttribute_TProfileConstructorHasByRefParameter_ReportsCmp0042, TwoTypeParameterAttribute_ErroneousArgumentExpression_DoesNotCrashGenerator). Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 682/682, Compono.XunitV3.Aot.Tests 15/15, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 3 found two more real, confirmed parity divergences against Compono.XunitV3.Binding.ConfigProfileBinder.ResolveSingleConstructor - both verified empirically with standalone probes before touching code, given round 2 had already produced one false positive:
TConfigconstructor counting excluded ref/out/in constructors before counting, not after. Round 2's fix filtered ref/out/in-parameter constructors out of the candidate set first, then counted what remained - so aTConfigwith one ordinary and one ref/out/in-parameter public constructor silently succeeded here (filtered count = 1), whileType.GetConstructors(Public | Instance)(confirmed by a direct reflection probe) returns both constructors, so JIT-mode'sResolveSingleConstructorsees count = 2 and rejects the identicalTConfigas ambiguous - a real, confirmed divergence, not just a wording nicety (ADR-0067's own stated design intent: "performs, at compile time, the same three checksConfigProfileBinderperforms at runtime"). Restructured into the same two sequential gates JIT-mode actually has: raw public-constructor count first (matchesResolveSingleConstructor's own gate exactly, reported with the true raw count), then - only once exactly one constructor exists - whether that constructor is usable for AOT's direct-construction codegen (a ref/out/in parameter still can't be satisfied by a generated literal argument, unlike JIT's reflection-basedConstructorInfo.Invoke, which a second direct probe confirmed actually succeeds for a ref parameter given a plain boxed argument - reflection marshals by-ref parameters transparently, a capability AOT's compile-time-generated directnewcall structurally cannot replicate). Both gates reuse the existingCMP0041diagnostic (the raw count, or0for "exists but unusable"), matching the existing abstract/non-named-type convention - no new diagnostic. (TProfile's own constructor selection needed no equivalent change: JIT-mode'sResolveSingleProfileConstructoralready filters byparameters[0].ParameterType == configTypeas part of its matching criteria, not as a separate count-then-filter step, and a ref/out/inTConfig-typed parameter's reflectedParameterTypeis a distinct byref type that never equalsconfigType- so JIT-mode's own matching logic already excludes it structurally, and this repo's filter-then-count approach forTProfilealready agreed with that.)- A struct
TConfigwith no explicitly declared constructor. Roslyn'sINamedTypeSymbol.Constructorsincludes the compiler-synthesized public parameterless constructor for this shape (IsImplicitlyDeclared = true, confirmed by a direct Roslyn probe), so the "exactly one public constructor" count previously included it and let thisTConfigsucceed (emittingnew TConfig()).Type.GetConstructors(Public | Instance)(confirmed by a direct reflection probe) returns zero constructors for exactly this shape - the implicit constructor isn't reflectable at all - so JIT-mode's binder rejects the identicalTConfigwith "has 0". Fixed by excludingIsImplicitlyDeclaredconstructors from the counted set. - Rejected as a false positive, with evidence, not fixed: "
typeof(int*)/function-pointer profile configuration arguments need an unsafe context in the generated file." This was round 2's finding, not round 3's - listed here only for completeness of the false-positive count (two standalone compile probes in round 2 already showedtypeof(...)over a pointer/function-pointer type is exempt from C#'s unsafe-context requirement entirely; that thread remains open, unresolved, by design).
Two new tests: TwoTypeParameterAttribute_TConfigHasAmbiguousConstructorsIncludingByRef_ ReportsCmp0041WithRawCount (proves the raw count, not the filtered count, is what gets reported - "has 2", matching JIT exactly), TwoTypeParameterAttribute_TConfigIsStructWithOnlyImplicitConstructor_ ReportsCmp0041 (a struct TConfig with zero explicit constructors is rejected, not silently constructed via new TConfig()). Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 686/686, Compono.XunitV3.Aot.Tests 15/15, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 4 found three more real gaps - the first a genuine regression in round 3's own fix, the other two a further-generalized form of round 1's/round 2's "didn't recurse into array elements" limitation, which the implementation had already flagged as a known gap when it shipped:
- Round 3's
IsImplicitlyDeclaredexclusion was scoped too broadly - it also rejected an ordinaryclasswith no explicit constructor. A class's own implicit default constructor isIsImplicitlyDeclared = truein Roslyn too, same as a struct's, but (unlike a struct's) it's real, reflectable IL -Type.GetConstructors(Public | Instance)returns 1 for a no-explicit-ctor class, confirmed by a direct probe - soConfigProfileBindersucceeds constructing it, while the round-3 fix's blanket exclusion made this same, entirely ordinaryTConfigshape fail here with "has 0". Fixed by scoping the exclusion toconfigType.IsValueTypespecifically - the exact condition that distinguishes the two cases (confirmed real by direct reflection probe before touching anything, the same discipline every prior round's finding got). - Nested
TypedConstantKind.Errorinside an array argument reached the renderer unguarded. The round-2 fix (TypedConstantMatcher.Validate) only checked the outer constant'sKind- for a malformed array argument (new int[] { UndefinedIdentifier }), Roslyn reports a well-typed outerArrayconstant whose element isKind = Error, which the outer check never saw, letting it reachTypedConstantLiteralRenderer.Render's recursiveRenderArraycall and crash the generator the same way the round-2 case did. Fixed with aHasErrorhelper that walks the whole constant tree (recursing throughKind = Array's ownValues), replacing the singleKind == Errorcheck. - Nested inaccessible types inside array arguments bypassed
CMP0044the same way. The round-1CMP0044fix only inspected the top-level argument's ownKind(Type/Enum) - an array of a private nested enum's values (e.g.new PrivateKind[] { PrivateKind.Value }bound to anobject-typedTConfigconstructor parameter - confirmed real and legal attribute syntax by a direct probe, since the array-creation-expression's declared parameter type is whatCS0182actually checks, not the outerparams object?[]'s own type) hasKind = Arrayat the top level, so the privatePrivateKindtype embedded in each element slipped through undetected, and the renderer would have emitted an inaccessible-type reference (CS0122) the same way a top-leveltypeof/enum argument already would have without the round-1 fix. Fixed with anEmbeddedTypeshelper that recurses through array elements the same wayHasErrordoes, replacing the single-levelswitch.
Three new tests: TwoTypeParameterAttribute_TConfigClassImplicitCtor_Succeeds (an ordinary no-explicit-ctor class TConfig must still succeed, proving the round-3 fix didn't regress the common case), TwoTypeParameterAttribute_NestedErroneousArrayElement_DoesNotCrashGenerator (drives the real GeneratorDriver directly, same pattern as the round-2 equivalent, with a malformed array argument), TwoTypeParameterAttribute_InaccessibleTypeInsideArrayArgument_ReportsCmp0044. Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 692/692, Compono.XunitV3.Aot.Tests 15/15, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 5 found two more real, confirmed bugs - the second a genuine crash (worse than described, an outright NullReferenceException, not merely an unhandled edge case):
- Generated
TConfigconstruction could silently resolve to a different, more-specific accessible constructor than the oneCompono.Generatorsselected and validated. The generated registration is afile-scoped type living in the consumer's own assembly - so ifTConfighas one public constructor (the oneCMP0041selects) and aninternalsibling constructor with a more specific parameter type (e.g. publicTConfig(object)+ internalTConfig(string)), the generatednew TConfig("value")call is genuinely ambiguous to the C# compiler, which resolves it to the more-specificinternal TConfig(string)overload via ordinary overload resolution - silently constructingTConfigdifferently than what was validated.Compono.XunitV3.Binding .ConfigProfileBindernever has this problem:ConstructorInfo.Invokeinvokes the exactConstructorInfoit already resolved, with no overload resolution involved at all. Fixed by wrapping every rendered constructor argument in an explicit cast to the selected constructor's own declared parameter type ((object)"value", not the bare"value"TypedConstantLiteralRendereralready rendered) -AotProfileConfigArgumentInfonow also carriesFullyQualifiedParameterTypeNamefor this. Confirmed as a real, reachable divergence by reasoning through C# overload-resolution rules (an internal sibling constructor genuinely is accessible from the generated file), then proven concretely with a real, JIT-executed test (TConfigWithMoreSpecificInternalOverload_UsesSelectedPublicConstructorinCompono.XunitV3.Aot.Tests, not just a generator snapshot) - each constructor sets a different observable value, and the test asserts the public one actually ran. EmbeddedTypes' array-element recursion (round 4's ownCMP0044fix) crashed on a legitimately null argument. A bare[Compose<P, C>(null)]targeting a nullableTConfigconstructor parameter is valid, common usage -NormalizeConstructorArgumentsalready handles it (the whole params array binds asIsNull = true, normalized to "one supplied argument, whose value is null"), andTypedConstantMatcher.Validatealready correctly reports itValidfor a nullable parameter. But that same nullTypedConstantstill reportsKind = Array(matching the params array's own declared type), and accessing its.Valuesproperty throwsNullReferenceExceptionoutright - confirmed by a direct probe, worse than the "empty array" framing the finding used.EmbeddedTypesdidn't guardIsNullbefore recursing into.Values, so this real, reachable shape crashed the generator. Fixed with anIsNullguard at the top ofEmbeddedTypes, returning no embedded types for a null constant (mirroring the same guardTypedConstantMatcher.Validate/TypedConstantLiteralRenderer.Renderalready had, just missing from this newer helper).
Three new tests: TwoTypeParameterAttribute_ArgumentCastToSelectedConstructorParameterType (generator snapshot proving the cast is emitted), TConfigWithMoreSpecificInternalOverload_ UsesSelectedPublicConstructor (real JIT execution in Compono.XunitV3.Aot.Tests, described above), TwoTypeParameterAttribute_NullArgumentForNullableParameter_DoesNotCrash. Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 696/696, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
Process correction, round ⅚ boundary: round 5's own fix commit was pushed against only two of round 5's three actual Codex findings - an unpaginated gh api .../comments query silently truncated the result set and a third finding (below) was missed entirely. This was caught during round 6's re-check (a full gh api review-threads query showed more unresolved threads than the single expected false-positive one), not by round 6 itself being clean - the initial round-6 check also under-queried and briefly concluded "no actionable findings" before a paginated (?per_page=100) re-query surfaced the two real round-6 findings below. All gh api .../comments queries from this point on use ?per_page=100 (or full pagination) to avoid repeating this.
- The missed round 5 finding -
EmbeddedTypesnever checked an array's own declared element type, only its elements. An empty array of an inaccessible type (new PrivateKind[] { }) has no elements for the existing per-element recursion to walk, so it slipped pastCMP0044entirely even thoughTypedConstantLiteralRendererstill emits the array's declared element type in the rendered literal (new global::Ns.PrivateKind[] { }), which would failCS0122in the generated top-level file. Fixed by having theTypedConstantKind.Arraycase inEmbeddedTypesalsoyield returnthe constant's ownIArrayTypeSymbol.ElementTypeunconditionally, alongside (not instead of) the existing per-element recursion into.Values(which still independently catches a value embedding some other type, e.g. atypeof(...)element inside an accessibleType[]array). - A
dynamic-typedTConfigconstructor parameter passed every existing check and would have generated a(dynamic)"literal"cast.TypedConstantMatcher.Validate's accepted-conversion criteria (IsImplicit && IsReference) is - correctly, for every other shape it needs to accept - satisfied byClassifyConversion(string, dynamic)(confirmed by a direct Roslyn probe), so the validator itself needed no change; the actual defect was that adynamic-parameter constructor should never have been in the usable-constructor set to begin with. A(dynamic)cast at the generated call site binds throughMicrosoft.CSharp.RuntimeBinder, the C# runtime dynamic binder - not Native-AOT/trim-safe, a direct violation of ADR-0067's zero-reflection guarantee. Unlike a ref/out/in parameter, this is not a JIT-parity gap (ConstructorInfo.Invokecan satisfy adynamicparameter fine, it'sobjectat the metadata level) - it's an AOT-only restriction. Fixed by extending the same second-gate usability filter that already excludes ref/out/in parameters (BuildTwoTypeParameterProfile'sconfigConstructorsfilter) to also exclude any constructor with aTypeKind.Dynamicparameter, folded into the sameCMP0041"0 usable constructors" diagnostic rather than a new one, consistent with the ref/out/in precedent. - A selected
TConfig/TProfileconstructor marked[Obsolete("...", error: true)]passed every shape/accessibility/required-members check but produces an uncompilable generated call. Such a constructor is otherwise completely ordinary and JIT-reflectable -ConstructorInfo.Invokedoesn't care about[Obsolete]at all - so this isn't folded into the existing "0 usable constructors" gates the way ref/out/in anddynamicare; it's purely that the generated registration's directnew T(...)call site can't use it, confirmed by a direct compile probe showingCS0619. Added a new diagnostic,CMP0047, and a post-selection check (mirroringConstructorSatisfiesRequiredMembers's own placement, right after theCMP0046required-members checks) applied to both the selectedconfigConstructorandprofileConstructor, looking for[ObsoleteAttribute]with itserrorconstructor argumenttrue. ([Obsolete("...")]/[Obsolete("...", error: false)]- a mereCS0618warning, not an error - is left alone; the generated registration still compiles.)
Three new tests, all VerifyFailure generator-snapshot tests (no real-execution proof needed - each is a rejection, not a construction-correctness question): TwoTypeParameterAttribute_EmptyArrayOfInaccessibleElementType_ReportsCmp0044, TwoTypeParameterAttribute_TConfigConstructorHasDynamicParameter_ReportsCmp0041, TwoTypeParameterAttribute_TConfigConstructorIsObsoleteAsError_ReportsCmp0047. Re-validated: full Compono.Generators build 0 warnings/errors (including the new CMP0047 AnalyzerReleases.Unshipped.md entry, no RS2000), Compono.Generators.Tests 702/702, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 7 found two more real gaps, both in round 6's own CMP0047 fix:
CMP0047's check only recognized[Obsolete(error: true)], not[System.Diagnostics.CodeAnalysis.Experimental("...")]- a second, independent standard attribute with the same underlying problem: any use of a constructor it marks is a compiler error (its own diagnostic ID, e.g.EXP001, at default severity Error - unlikeObsolete, there's noerror: falseequivalent to opt out of), confirmed by a direct compile probe. Generalized the round-6IsObsoleteAsErrorhelper intoProhibitedCallSiteAttribute, which now recognizes either attribute and returns the rendered attribute syntax found ("[Obsolete(error: true)]"or"[Experimental(\"EXP001\")]") for the diagnostic message -CMP0047's descriptor message was generalized to name whichever attribute was actually found rather than hardcoding[Obsolete(error: true)]/CS0619, since the specific compiler error differs by attribute (CS0619vs. the attribute's ownDiagnosticId). Same diagnostic ID (CMP0047) and same post-selection placement (right after theCMP0046checks) - this is a widened detection surface for the same problem class, not a new problem class.- The round-6
CMP0047test only exercised theTConfigbranch of the check, never the separateTProfilebranch -ProhibitedCallSiteAttribute(configConstructor)andProhibitedCallSiteAttribute(profileConstructor)are two independent call sites inBuildTwoTypeParameterProfile; if theTProfileone were removed or broke, the existing test suite would stay green. Added a dedicated test with only theTProfileconstructor marked obsolete.
Two new tests, both VerifyFailure generator-snapshot tests (same rejection-only reasoning as round 6's own three): TwoTypeParameterAttribute_TProfileConstructorIsObsoleteAsError_ReportsCmp0047, TwoTypeParameterAttribute_TConfigConstructorIsExperimental_ReportsCmp0047. Re-validated: full Compono.Generators build 0 warnings/errors, Compono.Generators.Tests 706/706, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 8 found three more real gaps - the third the most severe of any round so far, a genuine silent-wrong-construction bug in round 5's own overload-hijack fix:
- A constructor marked
[RequiresDynamicCode]/[RequiresUnreferencedCode]compiles and runs fine under JIT but produces a realIL3050/IL2026warning for anyPublishAot=true/trim-analyzed consumer of the generated direct call - confirmed by direct probe. Unlike theCMP0047family (a hard compiler error), this is only an analyzer-surfaced warning - but it directly contradicts this package's own "zeroIL2xxx/IL3xxxwarnings" Native AOT proof, so it's treated the same waydynamicwas in round 6: excluded from the usable-constructor set entirely (folded intoCMP0041/CMP0042), applied to both theTConfigandTProfileconstructor filters. CMP0047's check only recognized[Obsolete(error: true)]and[Experimental(...)], not non-optional[CompilerFeatureRequired("...")]- a third attribute in the same "any use is a compiler error" bucket (CS9041specifically). Source code can never apply this attribute directly (CS8335blocks it), but a constructor imported from referenced metadata (e.g. compiled by a future/different compiler) can carry it, and Roslyn reports it identically to any other constructor attribute on the imported symbol - confirmed by building exactly such a constructor viaSystem.Reflection.Emit.PersistedAssemblyBuilderand referencing it.ProhibitedCallSiteAttributegeneralized again to recognize this third shape.- Round 5's overload-hijack fix (casting every rendered argument to the selected constructor's own declared parameter type) does not defend against
[System.Runtime.CompilerServices.OverloadResolutionPriorityAttribute]. Confirmed by direct probe: an accessible sibling constructor marked with a higher priority value still wins ordinary overload resolution even with the explicit cast in place, because C#'s priority-pruning happens before applicability/conversion-quality comparison is ever reached - unlike the plain accessible-internal- sibling shape round 5 fixed (where the cast genuinely does disambiguate), there is no codegen shape that can defeat this. Added a new diagnostic,CMP0048, and a conservative cross-constructor check (FindHigherPriorityAccessibleSibling): if any accessible sibling constructor ofTConfig/TProfilecarries an explicit priority strictly greater than the selected constructor's own (default 0), reject - regardless of whether that sibling's parameter shape would actually apply to the rendered arguments, since correctly computing real overload applicability at compile time here would mean reimplementing overload resolution itself (consistent with round 3's "diagnostic over cleverness" precedent for ref/out/in ambiguity).
Four new tests: TwoTypeParameterAttribute_TConfigConstructorRequiresDynamicCode_ReportsCmp0041, TwoTypeParameterAttribute_TProfileConstructorRequiresUnreferencedCode_ReportsCmp0042 (round 7's lesson - test both the TConfig and TProfile branches, not just one), TwoTypeParameterAttribute_TConfigConstructorSupersededByOverloadPriority_ReportsCmp0048, and TwoTypeParameterAttribute_TConfigConstructorIsCompilerFeatureRequired_ReportsCmp0047 (the last built against a real IL-emitted reference assembly via PersistedAssemblyBuilder, the only way to legally reproduce a [CompilerFeatureRequired]-marked constructor at all). Re-validated: full Compono.Generators build 0 warnings/errors, Compono.Generators.Tests 714/714, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 9 found three more real gaps - the second the most structurally interesting: an entire code path ([Compose<TProfile>], the one-type-parameter form) had zero constructor-safety coverage at all, unlike the two-type-parameter form this whole diagnostic family had been built against:
[RequiresAssemblyFiles]is a third, independent member of the round-8 AOT-hazard-attribute family (alongside[RequiresDynamicCode]/[RequiresUnreferencedCode]) - producesIL3002rather thanIL3050/IL2026, confirmed reachable the same way by direct probe. Folded into the sameHasProhibitedAotAttributecheck andCMP0041/CMP0042diagnostics.[Compose<TProfile>]'snew()constraint guarantees a public parameterlessTProfileconstructor exists, butBuildOneTypeParameterProfilenever checked whether it was actually safe to call - the two-type-parameter form's entireHasProhibitedAotAttributeexclusion only ever applied toBuildTwoTypeParameterProfile. Confirmed by direct probe that this is a real, reachable gap: the generated registration'sAddProfile<TProfile>()call closes Compono core's own genericnew T()construction over the realTProfileat that generated call site - the trim/AOT analyzer flags the warning there, not insideAddProfile<T>()'s own generic definition (verified with a standaloneFactory.Create<T>() where T : new() => new T();probe: theIL3050warning appeared atFactory.Create<MyType>(), the closing call site, not atCreate<T>'s own declaration). A second probe confirmed the other prohibited-attribute family (CMP0047's[Obsolete(error: true)]/[Experimental]/[CompilerFeatureRequired]) does not need the same treatment - those are compiler-enforced checks against a concrete member, and none of them fire through a genericnew T()construction at all. Added a new diagnostic,CMP0049, and a check inBuildOneTypeParameterProfilethat findsTProfile's parameterless constructor and applies the sameHasProhibitedAotAttributetest.- Round 8's sole
CMP0048test only exercised theTConfigbranch (round 7's own lesson, missed again) - added a dedicatedTProfile-only case.
Three new tests: TwoTypeParameterAttribute_TConfigConstructorRequiresAssemblyFiles_ReportsCmp0041, GenericAttribute_TProfileParameterlessConstructorRequiresDynamicCode_ReportsCmp0049 (the first real coverage of the one-type-parameter form's constructor safety at all), TwoTypeParameterAttribute_TProfileConstructorSupersededByOverloadPriority_ReportsCmp0048. Re-validated: full Compono.Generators build 0 warnings/errors, Compono.Generators.Tests 720/720, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 10 found two real documentation-debt findings - no code/behavior change, both about this ADR/the public API's own XML docs silently falling behind the six rounds of diagnostics added since:
- This ADR's Decision Outcome only ever recorded
CMP0041-CMP0043-CMP0044-CMP0049(all six found across rounds 1-9) existed only in code, the package guide, and this plan's own Notes, never in the ADR itself, silently letting externally-observable support-scope restrictions accumulate outside the decision record they belong in (this repo's own non-negotiable: "a correction or extension found later is recorded as a dated Amendment"). Fixed: added Amendment 2, recording all six diagnostics with a one-line condition each, explicitly noting neither the Decision Outcome nor Amendment 1's Shape correction needed revisiting - each addition is a restriction within the already-accepted design. ComposeAttribute<TProfile>'s own XML doc remarks still claimed "this form needed no new AOT-safety work beyond the attribute-discovery/codegen plumbing itself" - directly contradicted by round 9's ownCMP0049addition three rounds later. Fixed: removed the stale claim, added a paragraph describing theCMP0049restriction; also updatedComposeAttribute<TProfile, TConfig>'s remarks (which still named onlyCMP0041-CMP0043) to reference the fullerCMP0044-CMP0048range and Amendment 2.docs/reference/api/Compono.XunitV3.Aot/regenerated via.github/scripts/generate-api-reference.sh(all eleven packages rebuilt Release/net10.0 first, per the script's own precondition) - exactly the two affected pages changed, confirming no unrelated drift.
No test/build re-validation needed beyond a plain rebuild (XML doc comments and Markdown only, no generator/codegen or runtime behavior touched) - Compono.Generators.Tests re-run anyway as a sanity check: 720/720 unchanged. Per this repo's own re-review guidance, a round touching public documentation (this ADR, the regenerated API reference) still warrants a targeted re-review request even with zero source behavior change, so one was requested rather than treated as below the re-review bar the way a plan-Notes-only round would be.
PR #140 Codex review round 11 found four more real documentation-debt findings - all in round 10's own fix, still purely docs, no code/behavior change:
- Amendment 2's
CMP0044row, and both the package guide's and diagnostics reference'sCMP0044prose, described onlyTProfile/TConfig/atypeof/enum-typed argument's own type - never the array casesEmbeddedTypeshas covered since round ⅚ (a scalar array's declared element type, and anytypeof/enum-typed value recursively embedded in an array's elements). Fixed: broadened the wording in all three places to name the array cases explicitly. - The package guide's
CMP0041/CMP0042table rows still described only constructor-count/shape failures, never the additional exclusions rounds 3/6/8 added (by-ref parameter,dynamicparameter, a prohibited-AOT attribute) - a reader hittingCMP0041on an apparently unique, correctly-shaped constructor had no documented explanation. Fixed: expanded both rows to name every exclusion folded into each diagnostic's "0 usable constructors" count. docs/reference/diagnostics.md'sCMP0045Message field ended with a literal...instead of the descriptor's actual final two sentences (the unrelated-base-class/duplicate-hint-name explanation) - every other diagnostic entry in that file reproduces its complete message; this one alone was truncated. Fixed: replaced with the full message text, copied verbatim fromDiagnosticDescriptors.MultipleAotComposeAttributes.- The package guide's profile-form bullets (
[Compose<TProfile>]/[Compose<TProfile, TConfig>]) were added under the unchanged## What it gives you (Phase 1)heading, telling a reader that Phase 1 included profile support - contradicting PLAN-0066's explicit deferral and this whole PR's own Phase 2 scope. Fixed: split the profile bullets out into their own## Profile-based composition (Phase 2)section, with a one-line note on when each form shipped.
Re-validated: full rebuild 0 warnings/errors, Compono.Generators.Tests re-run as a sanity check (docs- only round, no generator/codegen change expected or found): 720/720 unchanged.
PR #140 Codex review round 12 found three more real findings - the second a genuine user-facing message-accuracy bug this time, not pure prose drift:
- Amendment 2's own framing still characterized
CMP0041/CMP0042as unmodified, exactConfigProfileBindermirrors, even though round 11's fix had already documented their AOT-only exclusions (by-ref/dynamic/prohibited-AOT-attribute) in the package guide. The amendment recorded onlyCMP0044-CMP0049as additions, whenCMP0041/CMP0042themselves - already part of the original Decision Outcome - had gained the same class of AOT-only restriction through this same review. Fixed: added a paragraph to Amendment 2 explaining these restrictions, and updatedAnalyzerReleases.Unshipped.md'sCMP0041/CMP0042/CMP0044rows to match. CMP0041/CMP0042's actual emitted diagnostic message text says "it has {N}" using the filtered, usable constructor count, not the raw public-constructor count - so aTConfig/TProfilewith exactly one public constructor that's merely unusable (a by-ref/dynamic parameter, or a prohibited AOT attribute) reports "it has 0", factually claiming the type has zero public constructors when it actually has one. This is a real product-quality bug, not documentation drift - the compiler error a consumer sees is misleading about what's actually wrong. Fixed: reworded both message templates to describe usable constructors uniformly (accurate for both gates - the raw-count gate's count and usable count are identical there, since nothing's been filtered yet) and to name what disqualifies an otherwise-matching constructor inline in the message itself, not just in external docs. Eleven existingCMP0041/CMP0042snapshot tests'.verified.txtfiles updated for the new message text (no.csgenerated-code snapshot changed - this is purely a diagnostic message wording fix, zero codegen change).AnalyzerReleases.Unshipped.md'sCMP0044row still described only scalartypeof/enum arguments, not the array cases round 11 had already added to the ADR/package guide/diagnostics reference - the rule-metadata catalog shipped with the analyzer itself was the one place round 11 missed. Fixed: updated to match.
Re-validated: full Compono.Generators build 0 warnings/errors, Compono.Generators.Tests 720/720 (after the 11-snapshot promotion above), Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings - full validation run (not just a sanity rebuild) since this round changed a real, user-facing diagnostic message, not only prose.
PR #140 Codex review round 13 found one more real finding - round 12's own message-accuracy fix had over-corrected: CMP0041's raw-ambiguity gate (a TConfig with two or more public constructors, none individually filtered yet) reports the raw count, but round 12's uniform "usable public constructor(s)" wording claimed that raw count was a usable count - so a TConfig with one ordinary constructor and one by-ref-disqualified constructor would report "it has 2 usable public constructor(s)", when only one actually is. Round 12's own reasoning ("the raw count and the usable count are identical when the count itself is the problem") holds for the single-candidate case (0 or 1) but breaks down for the ambiguous case (2+) - a real gap in that round's own fix, not a new category of bug. CMP0042 was unaffected: unlike CMP0041's two sequential gates, TProfile's usability filters (by-ref, prohibited AOT attribute) are folded into one combined .Where(...) clause before counting, so its count is always the true usable count in every case - confirmed by re-reading BuildTwoTypeParameterProfile's own structure rather than assuming symmetry with CMP0041.
Fixed: added a fifth format argument to CMP0041's message template carrying the noun phrase itself, so each gate supplies wording that matches what it's actually counting - "public constructor(s)" for the raw-ambiguity gate, "usable public constructor(s)" plus the disqualifying-shapes explanation for the usability gate. CMP0042's message template needed no change. Diagnostics reference updated to describe both message shapes explicitly. Eight existing CMP0041 snapshot tests' .verified.txt updated (no generated-code change). Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 720/720, Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.
PR #140 Codex review round 14 found two more real findings - both fallout from round 13's own fix, in the exact CMP0041/CMP0042 wording area round 13 had touched:
- Round 13's fix (adding an explicit "public constructor(s)" noun to
CMP0041's raw-ambiguity gate) surfaced a pre-existing shortcut (rounds ¼: an abstractTConfigsynthesizes as having 0 constructors, since abstract types can never benew'd directly regardless of declared count) as an outright false claim - an abstractTConfigthat genuinely declares one or more public constructors now reported "it has 0 public constructor(s)", which is factually wrong (it may well have 1, 2, or more - they're just all unusable because the type is abstract). Before round 13's fix this was merely vague ("it has 0" with no noun); round 13's own improvement is what turned it into a false claim. Fixed: computed a separaterawConfigConstructors(the type's true declared public-constructor count, computed unconditionally rather than gated onIsAbstract: false) used only for the message, while the actual usability-gate check (allConfigConstructors) still correctly forces abstract types to fail regardless of that count. AddedTwoTypeParameterAttribute_TConfigIsAbstractWithDeclaredPublicConstructor_ReportsTrueCountNotZero- the first test coverage for an abstractTConfigwith a declared constructor at all (its absence is exactly why this shipped untested in round 13). CMP0042's message template was never given the{4}noun-phrase treatment round 13 added toCMP0041- round 13's own reasoning correctly concludedCMP0042's count is always accurate (its usability filters are folded into one combined predicate before counting, unlikeCMP0041's two sequential gates), but that conclusion was about accuracy, not completeness - the message text itself still read "it has 0 (a constructor with..." with no noun between the count and the parenthetical, an incomplete sentence. Fixed: added "usable public constructor(s)" directly intoCMP0042's fixed message template (no new format argument needed, sinceCMP0042never varies its noun the wayCMP0041does). ExistingCMP0042snapshot tests updated for the corrected wording.
Re-validated: full build 0 warnings/errors, Compono.Generators.Tests 722/722 (one new test), Compono.XunitV3.Aot.Tests 18/18, Compono.XunitV3.Aot.SampleTests 3/3 (JIT) then 3/3 again via a re-published Native AOT native binary, exit 0, zero IL2xxx/IL3xxx warnings.