[PLAN-0001] Milestone 1: Source-Generation Foundation¶
Status: Done
Implements: ADR-0002 (constructor selection), ADR-0003 (generator package distribution), ADR-0004 (discovery/dispatch), ADR-0005 (implementation conventions)
Goal¶
var customer = composer.Create<Customer>(); uses a generated ICompositionPlan<T> — not runtime reflection — for a representative record or class, including one with nested composable properties, per docs/mvp.md's Milestone 1 exit criteria.
Scope¶
Per docs/mvp.md's Milestone 1 section, mapped onto ADR-0004/0005's concrete shape:
- The
Compono.Generatorsproject itself (ADR-0003's shape). - Discovery of constructible source types, including the transitive closure of a discovered type's constructor parameter types (ADR-0004).
- Constructor selection (ADR-0002).
- Generated direct constructor invocation.
- Generated request metadata (the compile-time shape a generated plan needs to ask
ICompositionContextfor a value). - A plan registration mechanism (
PlanCache<T>+ generated module initializer, ADR-0004). - Compile-time diagnostics for unsupported or ambiguous construction.
- A benchmark harness comparing generated construction against a reflection baseline.
Explicitly deferred (later milestones per docs/mvp.md):
CreateMany<T>()— this is Milestone 2 scope, not Milestone 1 (docs/mvp.md's Milestone 2 "Scope" list includes it explicitly). Milestone 1 only needs to makeCreate<T>()work.- The full
CompositionContext/provider resolution pipeline, built-in primitive generation, and everything else under Milestone 2's "Core Composition Engine" heading. - The disambiguation attribute ADR-0002 calls out as a future escape hatch for genuinely multi-constructor types is in scope for this milestone (see Phase 4 below) but is its own slice, not required for the single-constructor happy path other phases depend on.
Compono.XunitV3/Compono.NSubstitute/Compono.Bogus— separate milestones (4, 5, 6).
Phases¶
Ordered so each phase either unblocks the next or closes a real gap between the current state and the exit criteria. A phase's tasks are checked off as work proceeds, same as before — the phase grouping is what's new, so the whole remaining shape of the milestone stays visible at a glance instead of living only in this file's edit history.
Each phase ships as its own PR (design-decisions.md's "Writing a Plan" section) — don't bundle two phases into one diff even if both are finished by the time a PR gets opened.
Phase 0 — Foundation (Done)¶
The generator exists, is wired into Compono's own package, and can discover a flat (no nested composable parameters) type from a Create<T>() call site, select its constructor, emit a plan, and register it into PlanCache<T>.
- Create generator project (
src/Compono.Generators, ADR-0003's shape; analyzer-onlyProjectReferencefromCompono.csproj, confirmed packing intoCompono's own nupkg) -
WellKnownTypessymbol cache (ADR-0005) —Compono.Composerso far - Define generated-plan contract:
ICompositionPlan<T>, the minimal Milestone-1-onlyICompositionContext,PlanCache<T> - Discover
Create<T>()call sites, resolve closed genericT - Apply ADR-0002's constructor selection rule + ambiguity diagnostic (
CMP0001ambiguous,CMP0002no accessible constructor) - Generate plan registration:
PlanCache<T>+ generated module initializer - Scriban template for plan-class assembly (ADR-0005)
- Generator snapshot tests (
Compono.Generators.Tests,Verify.SourceGenerators/Verify.XunitV3— happy path + ambiguous-constructor diagnostic)
Phase 1 — Transitive closure (Done)¶
The real gap between "works for a flat type" and the exit criteria's "representative record or class" — a Customer with an Address property needs Address to get its own generated plan too, not just a context.Resolve<Address>() call with nothing behind it yet.
Recursion-boundary rule, confirmed with the user before implementing: recurse into a constructor parameter type (give it its own generated plan) when it's a concrete type eligible for generated composition — implemented as LeafTypeClassifier.IsProviderResolved, which leaves a parameter as a bare context.Resolve<TParam>() call only for interfaces, abstract types, delegates, enums, the built-in C# simple types (bool/byte/.../string/nint/nuint), and a handful of BCL value types with no meaningful composable constructor shape (DateTime, DateTimeOffset, Guid, TimeSpan). A concrete type that is eligible but fails constructor selection (ambiguous, no accessible constructor, unsupported parameter kind, unassigned required members) is diagnosed at the original Composer.Create<T>() call site instead of silently left as Resolve<TParam>() — the diagnostic message names the traversal path (e.g. 'Address' (reached via Customer.HomeAddress) has 2 accessible constructors...) so a nested failure isn't reported with no context about where in the graph it came from.
- Confirm the recursion-boundary rule above with the user before implementing (or replace it with whatever's actually decided)
- Walk each discovered type's constructor parameter types recursively, applying that rule (
TransitiveClosureWalker) - Dedupe the resulting set (a type reachable from two different parents still gets exactly one generated plan) — a
SymbolEqualityComparer-backed visited set in the BFS walk - Emit a generated plan +
PlanCache<T>registration for every type in the closure, not just the top-level requested type - Snapshot test: a type with a nested composable property (e.g.
Customer(string Name, Address HomeAddress)) produces plans for bothCustomerandAddress(NestedComposableProperty_GeneratesPlansForBothTypes), plus dedup-across-parents, leaf-type non-recursion, and nested-failure diagnostic coverage
Phase 2 — Escape-hatch attribute (Done, PR #6 merged)¶
ADR-0004's second discovery path: a type that needs a plan but has no local Create<T>() call site in the compilation.
Attribute decision, confirmed with the user before implementing: Compono.ComposableAttribute ([Composable]), framed as an opt-in marker rather than a fallback — the normal rule stays "reachable from a Create<T>() call site gets a plan automatically," and [Composable] exists for the cases discovery can't reach on its own. Two placements, both equivalent plan-generation requests deduplicated alongside call-site discovery: [Composable] directly on a type declaration this compilation owns (preferred whenever the type is owned locally), and [assembly: Composable(typeof(SomeType))] for a type in a referenced assembly that can't be annotated directly. [Composable(typeof(Other))] on a type declaration is also accepted as a request for Other rather than the annotated type itself, for symmetry with the assembly-level form.
- Decide the attribute's name/namespace/shape (
AskUserQuestion, confirmed above) - Discover attributed types alongside call-site-discovered ones, feeding into the same constructor-selection/emission pipeline (
ComposedTypeAnalyzerfactors the shared closed-type validation +TransitiveClosureWalkerhand-off out ofCreateInvocationDiscoveryso both discovery paths funnel through identical validation;ComposableAttributeDiscoverycovers both placements —ForAttributeWithMetadataNamefor the type-level form,CreateSyntaxProviderover[assembly: ...]attribute lists for the assembly-level form, sinceForAttributeWithMetadataNameonly matches attributes on declarations) - Snapshot test: an attributed type with no local
Create<T>()call site still gets a generated plan (type-level and assembly-level cases, plus a dedupe case where both a call site and[Composable]discover the same type, plusCMP0008for assembly-level[Composable]missing itstypeof(...)argument)
Phase 3 — Required members and nullability (Done, PR #7 merged)¶
Design decided in ADR-0006: object-initializer emission for required members (reusing the constructor parameter pipeline), and a new Nullability enum parameter on ICompositionContext.Resolve<TValue>() carrying each request's nullable-annotation, since generic type arguments erase that information at runtime.
- Add
Compono.Nullabilityenum (NotNullable/Nullable) and changeICompositionContext.Resolve<TValue>()toResolve<TValue>(Nullability nullability) -
ConstructorParameterInfogains nullability; a newRequiredMemberInfomodel (name, fully-qualified type, nullability) -
ConstructorSelector.HasUnassignedRequiredMembers→ collect required members instead of rejecting the type; reuseValidateParameterKinds-style checks +TransitiveClosureWalker/LeafTypeClassifierper required-member type (newRequiredMemberCollector, since the collection now returns data on success instead of only ever failing) -
CompositionPlanEmitter/CompositionPlan.scribanemit an object initializer block after the constructor call; everyResolve<...>()call site (constructor args and required members) passes an explicitNullabilityargument - Narrow
CMP0007per ADR-0006 rather than removing it - Snapshot tests: required properties, required fields, a
[SetsRequiredMembers]constructor (no initializer emitted), nullable and non-nullable constructor parameters, nullable and non-nullable required members, a ref-like required member (CMP0007), and a required member reaching a composable nested type (recursion through required members, not just constructor parameters)
Phase 4 — Benchmark harness (Done)¶
- Add a benchmark project comparing generated construction against a reflection baseline (
docs/mvp.md's explicit Milestone 1 ask) - Record a baseline result somewhere durable (this plan's Notes, or a
docs/*.mdsubsystem doc once one exists for the generator)
Phase 5 — Close-out (Done)¶
- Real manual verification against an actual consuming project (not just the test suite) — per
tasks/implement.md's "real manual verification for anything source-generator-facing" step. The original wording here ("callcomposer.Create<Customer>()for a representative nested type, confirm the call resolves to generated code") assumed nested construction fully succeeds - it doesn't yet,PlaceholderCompositionContext.Resolve<TValue>()(src/Compono/Composer.cs) unconditionally throwsNotSupportedException, so that call was always going to throw. See Notes for what was actually verified instead, and why that's still the real proof the exit criteria needs. - Update
docs/architecture.md/docs/mvp.mdto reflect current (not just intended) state now that code exists - reviewed both; neither asserts anything false about Milestone 1's current state (docs/architecture.md's "Open Architectural Decisions" already tracks resolved-vs-open items with strikethrough per ADR, anddocs/mvp.mdis scope/exit-criteria language, not a status tracker - that's this plan's job). No edits needed. - Set this plan's
Status: Done
Critical Files¶
src/Compono.Generators/— the generator project (ADR-0003):ComponoIncrementalGenerator.cs(pipeline entry point — merges call-site, type-level[Composable], and assembly-level[Composable]discovery before dedup/emission),Discovery/CreateInvocationDiscovery.cs(call-site discovery) +ComposableAttributeDiscovery.cs(Phase 2: both[Composable]placements) +ComposedTypeAnalyzer.cs(Phase 2: the closed-type validation +TransitiveClosureWalkerhand-off shared by every discovery path) +ConstructorSelector.cs+TransitiveClosureWalker.cs(Phase 1's recursive parameter-type walk, Phase 3: also walks required members) +LeafTypeClassifier.cs(Phase 1's recurse-vs-Resolve<T>()rule) +RequiredMemberCollector.cs(Phase 3: collects/validates required properties/fields not satisfied by[SetsRequiredMembers]),WellKnownTypes/(symbol cache, vendoredBoundedCacheWithFactory),Diagnostics/(DiagnosticDescriptors,DiagnosticInfo),Models/(DiscoveredTypeInfo,ConstructorParameterInfo,RequiredMemberInfo(Phase 3),LocationInfo),Types/EquatableArray.cs(vendored fromLayeredCraft.SourceGeneratorTools, not referenced as a package — see attribution comment),Emitters/(TemplateHelper,CompositionPlanEmitter),Templates/CompositionPlan.scriban(Phase 3: emits an object-initializer block and an explicitNullabilityargument on everyResolve<...>()call),AnalyzerReleases.{Shipped,Unshipped}.md.src/Compono/—ICompositionPlan.cs,ICompositionContext.cs(Milestone-1-only placeholder; Phase 3 changedResolve<TValue>()toResolve<TValue>(Nullability nullability)),Nullability.cs(Phase 3),PlanCache.cs,Composer.cs(minimal placeholder entry point),ComposableAttribute.cs(Phase 2's opt-in discovery marker).Compono.csproj— analyzer-onlyProjectReferencetoCompono.Generators, packs its output intoanalyzers/dotnet/cs(Scriban is source-embedded intoCompono.Generators.dll, so no separate DLL to pack for it).Directory.Packages.props—Microsoft.CodeAnalysis.CSharp/Analyzers(generator-only, private),Scriban(source-embedded viaPackageScribanIncludeSource),Meziantou.Polyfill/Microsoft.CSharp(netstandard2.0 language/BCL polyfills, matchingdynamo-mapper's pattern),Verify.SourceGenerators/Verify.XunitV3/Basic.Reference.Assemblies.Net100/Net110(generator testing).Compono.slnx—Compono.Generators,Compono.Generators.Tests.test/Compono.Generators.Tests/—GeneratorTestHelpers.cs(drives the generator viaCSharpGeneratorDriver, asserts generated code actually compiles, not just that it snapshots),CompositionPlanVerifyTests.cs,Snapshots/*.verified.{cs,txt}.benchmarks/Compono.Benchmarks/(Phase 4) —BenchmarkDotNetproject, referencingCompono.Generatorsas an analyzer-onlyProjectReference(same shapetest/Compono.Generators.TestsandCompono.csprojitself use) so the generator actually runs against the types declared here.BenchmarkTypes.cs(the representativeLeaftype),ReflectionComposer.cs(the reflection baseline),AutoFixtureComposer.cs(ecosystem-comparison reference point, added after initial Phase 4 completion at the user's request — see Notes),ArchitectureBenchmarks.cs(Direct/Generated/ Reflection — the architecture question) +EcosystemBenchmarks.cs(Generated/AutoFixture — the ecosystem question, kept as a separate[Benchmark]class so it can't be mistaken for the architecture's success criterion),Program.cs(BenchmarkSwitcher.FromAssembly).
Test Plan¶
- Generator snapshot tests (
Verify.SourceGenerators) for every phase — each phase above lists its own representative cases. - Every snapshot test asserts the generated code actually compiles back into the original compilation (
GeneratorTestHelpers.Verify's existing behavior), not just that it matches saved text. - Phase 5's real, manual, build-a-consuming-project verification is the actual exit-criteria proof — the milestone isn't
Doneon green tests alone, pertasks/implement.md.
Notes¶
Implementation history and lessons learned, kept for context — not a task list; see Phases above for what's actually left to do.
- PR #4 review feedback (Codex) surfaced six real Phase 0 bugs, all fixed in the same PR rather than deferred, per
tasks/respond-to-pr-feedback.md: CompositionPlanEmitterused to hint files by simpleTypeNamealone — two same-simple-name types in different namespaces (Sales.Customer/Support.Customer) collided, and Roslyn requires unique hint names per generator run. Now derived from a sanitizedFullyQualifiedName.- The Scriban template always wrapped output in a
namespace { }block. A type with no namespace produced invalid C#. Worth remembering:INamedTypeSymbol.ContainingNamespace.ToDisplayString()returns the literal string"<global namespace>", not an empty string, for a type with no namespace —IsGlobalNamespaceis the actual check; this cost a debugging detour since the wrong assumption looked plausible and the template itself rendered fine in isolation. ConstructorSelectorfiltered byAccessibility.Public or Internal, which doesn't account for cross-assembly visibility — aninternalconstructor on a type from a referenced assembly, with noInternalsVisibleTogrant, isn't actually callable from generated code living in a different assembly. Now usescompilation.IsSymbolAccessibleWithin(constructor, compilation.Assembly), which correctly implements real C# accessibility-domain rules.ConstructorSelectordidn't reject abstract types — an abstract class with exactly one public constructor (legal, called only by derived classes) was reported as successfully selected, and the template emittednew AbstractType(...), which is never legal C#. AddedCMP0003and anIsAbstractcheck ahead of constructor selection.DiagnosticInfo.Equals/GetHashCodeonly comparedDescriptor.IdandLocation, ignoringMessageArgs— an ambiguous type's constructor count changing (2 → 3) with the same location/descriptor read as "unchanged" to Roslyn's incremental caching, keeping a stale message. Now includesMessageArgsviaSequenceEqual/incrementalHashCode.- Discovery only matched
MemberAccessExpressionSyntax(.Create<T>()), missingcomposer?.Create<T>()(MemberBindingExpressionSyntax, a different syntax node inside aConditionalAccessExpressionSyntax). Both are now matched in the predicate. - Regression tests for all six added to
CompositionPlanVerifyTests.cs(not a separate PR-specific test class — this is ongoing coverage of the same generator behavior the rest of that class already tests, not a one-off tied to this PR), including one that compiles a genuinely separate library assembly in-memory (GeneratorTestHelpers.CompileLibrary) to prove the cross-assembly accessibility case for real, not just in-compilation. - A second Codex review round on PR #4 surfaced four more real bugs, fixed the same way, and produced a new repo standard (the "Generated code" section in
coding-standards.md): - Same-simple-name plan classes collided (
Box<int>/Box<string>both emitBoxCompositionPlan—type.Namedrops generic args). Fixed by making every generated plan classfile-scoped, now the standing rule for all generator-emitted types: file-scoped identity makes cross-file name collisions structurally impossible, no mangling scheme needed. - Emitted type names weren't
global::-qualified — plainToDisplayString()doesn't emitglobal::despite how it reads, and mis-binds when a consumer type shadows a namespace segment. All emitted names now useSymbolDisplayFormat.FullyQualifiedFormat. - A
ref/out/ref readonlyconstructor parameter generated non-compiling code; nowCMP0004(unsupported parameter kind).instays allowed — callers may legally pass a plain value. - Hint-name sanitization was lossy (
N.Foo<int>vs. literalN.Foo_int_collide) — hints now append an FNV-1a hash of the raw identity (notstring.GetHashCode(), which is per-process randomized and would churn hints across builds). - Test-harness gotcha worth remembering:
GeneratorTestHelpers.Verifyre-parses generated trees to prove they compile, and must preserve each tree'sFilePathwhen doing so —file-scoped type identity is per file path, so re-parsing with the default empty path makes same-named file-scoped types spuriously collide in the harness while being perfectly legal in a real build. - A third Codex review round on PR #4 surfaced three more real bugs, addressed proactively rather than one at a time — the user asked to reject every constructor parameter shape the current
context.Resolve<T>()-per-parameter codegen can't correctly handle, not just whatever Codex happened to flag: composer.Create<Box<T>>()called inside a generic method:Tis the enclosing method's own type parameter, a validINamedTypeSymbolthat sailed through the existing checks, but emittingglobal::N.Box<T>into a namespace-level plan references aTout of scope there. Fixed by a recursiveContainsTypeParameterwalk (handles nested generics, arrays, pointers) inCreateInvocationDiscovery, rejecting any non-closed type argument with a new diagnostic,CMP0005.ValidateParameterKindsonly checkedRefKind; broadened to also reject ref-like (ref struct, e.g.Span<int>) and pointer/function-pointer constructor parameters — both fail for the same underlying reason as ref/out (can't be written as, or can't compile as, a generic type argument toResolve<T>()), so they reuseCMP0004with a parameter-shape-specific message.- The
[ModuleInitializer]attribute in the template wasn'tglobal::-qualified, so a consumer type namedSystemin the composed type's namespace would shadow the real one and break the generated plan's compile. Qualified per the same "Generated code" standard as every other framework reference in the template. - A fourth round (Copilot + Codex) surfaced five more real issues, triaged one at a time via
AskUserQuestionbefore any implementation this time (a prior round had batched straight to implementation without checking in first — corrected per explicit user feedback): composer.Create<T>()whereTis directly the enclosing generic method's own type parameter (not nested inside a constructed type likeBox<T>) is a narrower symbol shape —ITypeParameterSymbol, not even anINamedTypeSymbol— that fell through the existingis not INamedTypeSymbol composedTypeguard as a silentreturn nullbeforeCMP0005ever ran. Fixed by checkingContainsTypeParameteron the raw type argument before theINamedTypeSymbolcast inCreateInvocationDiscovery.Transform, so this shape is now diagnosed the same way as the nested case.Outer<T>.Inner(a non-generic nested type inside a generic container): the unresolvedTlives onContainingType, not onInner's own (empty)TypeArguments, a pathContainsTypeParameterdidn't walk. Fixed by also recursing throughContainingType.- Generator diagnostics pointed at
T's own declaration site (or nowhere, for referenced-assembly/external types) instead of theComposer.Create<T>()call site that actually triggered discovery — visible in snapshots as e.g.CMP0001pointing at a type name in its own declaration rather than the failing call. Fixed by capturing the type-argument syntax's location inTransformand threading it as aLocationInfo?parameter throughAnalyze/ConstructorSelector, replacing everyLocationInfo.From(type)call with the threaded call-site location. - The
GeneratedCodeAttributeversion was hard-coded to"1.0.0"with no real versioning wired up yet. Fixed by readingAssemblyInformationalVersionAttribute(falling back to the assembly version, then"0.0.0") off the generator's own assembly once, passed into the template model asgenerator_version— stays accurate automatically once real release versioning lands, no generator code change needed then. - ADR-0005 requires
.WithTrackingName(...)on every named incremental pipeline stage so cache-hit behavior is assertable later; the discovery pipeline had none. Added tracking names for all four stages (CreateInvocations,.NotNull,.Collected,.Distinct) via a newTrackingNamesclass — no incremental-caching test exists yet to consume them (that's still open work, not done in this pass). - One Copilot finding was triaged as no action: flagged
test/Directory.Build.props's shared<Using/>ItemGroupas inconsistent with the comment on the separatePackageReferenceItemGroupbelow it (which explicitly scopes toIsTestProject). Confirmed with the user this is intentional — every project undertest/should get the shared usings unconditionally; theIsTestProjectcomment only ever applied to the package references, not the usings above them. - Another Copilot finding (the
PlaceholderCompositionContextexception message reading as if recursive composition works today) was also triaged as no action per the user. - A fifth, final review round on PR #4 (3 Codex findings), triaged one at a time with the user before any implementation:
Create<T>()whereTis a delegate type: not abstract, and Roslyn exposes a delegate's synthetic(object, IntPtr)constructor, so it passedConstructorSelector.Selectand the template emittednew SomeDelegate(arg1, arg2)- not legal delegate-construction syntax in C#. Fixed by rejectingTypeKind.Delegatealongside the existing abstract-type check, reusingCMP0003(generalized its message format from'{0}' is abstract and...to'{0}' is {1} and...so both reasons share one diagnostic ID).- Deferred as an Open Item (see below): two extern-aliased references defining the same metadata name reduce to the same
global::name in generated code and could dedupe/collide. - No action: pinning the generator's compile-time
Microsoft.CodeAnalysis.CSharpreference to the oldest supported Roslyn/SDK version - a real supply-chain concern, but the user deferred deciding an actual minimum-supported version for now. - A sixth review round (2 Codex findings, from an extra manually-triggered review) surfaced two more real gaps, both fixed after per-item triage:
composer.Create<Customer[]>()(or any non-INamedTypeSymbolshape - array, pointer, function pointer):Transformsilently returnednull, so the call compiled clean with no plan generated and no diagnostic, failing only at runtime viaComposer's generic "no plan registered" message. Fixed with a new diagnostic,CMP0006, reported wherever the type-argument-isn't-INamedTypeSymbolcheck previously just returnednull.- A type with a
requiredmember whose selected constructor isn't annotated[SetsRequiredMembers]: Phase 0 only ever emits barenew T(...)(no object initializer - required-member assignment is explicitly later-milestone scope), so this would produce CS9035 in the generated file. Fixed with a new diagnostic,CMP0007, checked inConstructorSelector.Selectright after the single accessible constructor is chosen (walkstypeand its base-type chain for anyIPropertySymbol/IFieldSymbolwithIsRequired: true, then checks the constructor's attributes forSetsRequiredMembersAttribute). -
This round confirmed the review loop had converged: two clearly distinct, real gaps, no repeats of earlier-round territory - a good stopping point for this PR's review cycle.
-
Phase 4 (benchmark harness):
benchmarks/Compono.Benchmarks/, aBenchmarkDotNetconsole project referencingCompono.Generatorsas an analyzer-onlyProjectReference(the same shapetest/Compono.Generators.TestsandCompono.csprojitself already use) so the generator genuinely runs against types declared in the benchmark project itself, rather than benchmarking against a pre-packed nupkg. - A representative nested-composable type (
docs/mvp.md's exit criteria shape, e.g.Customer(Address HomeAddress)) turned out to be impossible to benchmark end-to-end in Milestone 1, discovered by actually trying it first: every constructor argument in generated code — including one whose type has its own generated plan — is resolved viacontext.Resolve<TParam>()(CompositionPlan.scriban), and Milestone 1's placeholderICompositionContext(Composer.cs) throwsNotSupportedExceptionunconditionally there, regardless of whetherPlanCache<TParam>is populated. DispatchingResolve<TParam>()to the matchingPlanCache<TParam>is real provider-resolution — Milestone 2 scope, not yet wired up. Confirmed by generating and inspecting the actual.g.csoutput for aRoot(Branch Left, Branch Right)shape: bothRootandBranchgot their own correct generated plans, butRoot.Compose(...)still throws at runtime because it callscontext.Resolve<Branch>()rather than invokingBranch's plan directly. So the benchmark only measures a flat, parameterless type (Leaf) — the one shape Milestone 1 can actually execute — rather than the nested shape originally planned; revisit once Milestone 2's realCompositionContextmakes nested end-to-end composition possible. - Baseline result (Apple M3 Max, .NET 10.0.3 arm64 RyuJIT, Release,
BenchmarkDotNetDefaultJob, recorded at this phase's completion): generated construction (composer.Create<Leaf>()) averaged 3.29 ns and 24 B allocated per op, vs. a reflection baseline (ReflectionComposer.Compose<Leaf>(),Type.GetConstructors().Single()ConstructorInfo.Invoke) averaging 20.07 ns and 56 B allocated per op — generated construction ran ~6.1x faster and allocated ~43% as much as the reflection baseline.
- Full solution build is still 0 warnings/0 errors;
dotnet testis 90/90 passing (unchanged - a benchmark project isn't a test project and adds no new tests).
Open Items¶
Tracked but deliberately not fixed yet - valid points raised in review, out of scope for the PR that surfaced them:
-
Extern-aliased type identity isn't preserved through discovery (Codex, PR #4 round 5). Two aliased references defining the same metadata name (e.g.
A::Ns.WidgetandB::Ns.Widget) both reduce toglobal::Ns.WidgetinCreateInvocationDiscovery, so theirDiscoveredTypeInfovalues could dedupe as "the same type" and the emitted reference would be unresolved or ambiguous. Niche - extern alias usage is uncommon - so deferred rather than fixed; revisit if it ever actually comes up in practice (either preserve alias/assembly identity through discovery and emit a correspondingextern alias, or diagnose the case explicitly instead of producing uncompilable code). -
Started with "Create generator project" as the first slice, since every later task depends on the project existing and being wired correctly (ADR-0003's packing shape in particular is easy to get subtly wrong and worth confirming in isolation before layering codegen logic on top of it).
ICompositionContext/Composerare Milestone-1-only placeholders, confirmed with the user before writing them:ICompositionContextis a bare synchronousResolve<TValue>()with no real implementation (throwsNotSupportedException) — Milestone 2'sCompositionContextreplaces this wholesale, an accepted breaking change pre-1.0.Compono.Generatorsreferences Meziantou.Polyfill and Scriban the same waydynamo-mapper's generator does —Meziantou.Polyfill(fullIncludeAssets,PrivateAssets="all") forIsExternalInit/nullable- and-trimming attributes,Microsoft.CSharp(Scriban's embedded source uses the C# dynamic runtime binder), andScribanreferenced withIncludeAssets="Build"+PackageScribanIncludeSource=trueso its source compiles directly intoCompono.Generators.dll— no separateScriban.dllto ship/load at generator runtime. An initial attempt at this exact setup produced ~1000 duplicate-definition errors that looked like a fundamental Meziantou.Polyfill incompatibility — false lead: a strayCompilerGeneratedFilesOutputPathdebug setting (copied from a different template, not present indynamo-mapper's csproj) wrote real.csfiles tosrc/Compono.Generators/generated/, outsideobj/, which got compiled twice. Removing that setting fixed it cleanly.EquatableArray<T>alone is still vendored locally inCompono.Generators/Types(per the user's explicit instruction not to take a package dependency onLayeredCraft.SourceGeneratorTools) — itsGetHashCode()usesSystem.HashCode(from Meziantou.Polyfill) rather than a second vendoredHashCodestruct.- List-pattern matching (
is [x],switch { [] => ... }) does compile fine with Meziantou.Polyfill installed (verified) — a few spots use plain.Length/index checks instead anyway, matchingdynamo-mapper's ownWellKnownTypes.GetTypeByMetadataNameInTargetAssembly, which uses the same plain-if style rather than list patterns. - As of Phase 0's completion: full solution build is 0 warnings/0 errors;
dotnet testis 10/10 passing (Compono.Tests+Compono.Generators.Tests, both TFMs);dotnet packonComponoverified to contain justCompono.Generators.dllunderanalyzers/dotnet/cs. - Phase 1 real manual verification:
dotnet pack'dComponointo a local feed, referenced it from a genuinely separate throwaway console project (not the repo's own test suite), and confirmedPlanCache<Customer>.InstanceandPlanCache<Address>.Instance(Address only reachable through Customer's constructor parameter, no localCreate<Address>()call site of its own) were both populated by generated module initializers, and thatComposer.Create<Customer>()actually reaches the generated plan'sCompose()method (which callscontext.Resolve<string>()and throws the expected Milestone-1-placeholderNotSupportedException, not some other failure) — proves the transitive-closure codegen works in a real build, not just the snapshot test harness. As of Phase 1's completion: full solution build is still 0 warnings/0 errors;dotnet testis 50/50 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - Phase 2 design decisions, confirmed with the user via
AskUserQuestionbefore implementing:[Composable](over[GenerateCompositionPlan]/[Compose]) framed as an opt-in marker for cases discovery can't reach on its own, not a fallback/workaround - the user's framing, kept verbatim in the plan's Phase 2 section above since it's the reasoning future readers need. Both a type-level and an assembly-leveltypeofplacement are in scope for this phase (the user asked for both, not type-level-only as initially proposed), with both forms treated as equivalent requests and deduplicated against each other and against call-site discovery. - Shared discovery validation factored out as
ComposedTypeAnalyzer:CreateInvocationDiscovery's closed-type-validation-then-TransitiveClosureWalker-handoff logic used to be call-site-specific; Phase 2 needed the identical validation for[Composable]-requested types too (an open-generic type argument, a non-INamedTypeSymbolshape), so it moved into its own class rather than being duplicated acrossCreateInvocationDiscoveryandComposableAttributeDiscovery.CMP0005/CMP0006's message text was generalized fromComposer.Create<T>()-specific wording to plain "Compono requires..." since both diagnostics can now be reported from either discovery path. - Assembly-level
[Composable(typeof(...))]needs its own syntax provider:ForAttributeWithMetadataNameonly matches attributes applied to a declaration, not[assembly: ...]attribute lists, so the assembly-level placement is discovered via aCreateSyntaxProviderpredicate overAttributeSyntaxnodes whose parentAttributeListSyntaxtargets theassemblykeyword, resolving the attribute constructor symbol viaGetSymbolInfoto confirm it's genuinelyCompono.ComposableAttribute(not an unrelated type that happens to be namedComposable) before extracting itstypeof(...)argument. - Phase 2 real manual verification:
dotnet pack'dComponointo a local feed (after clearing the local NuGet package cache - a stale cached copy of the same package version silently masked the newComposableAttributetype on the first attempt) and referenced it from a genuinely separate throwaway console project. Confirmed all three discovery paths populatePlanCache<T>.Instanceand reach generated code in a real build: a plainCreate<Order>()call site (Phase 1's existing path, still working),[Composable]directly on aReportRequesttype with no localCreate<T>()call site anywhere in the project, and[assembly: Composable(typeof(ExternalStandIn))]for a type standing in for one this project doesn't own and can't annotate directly. As of Phase 2's completion: full solution build is still 0 warnings/0 errors;dotnet testis 60/60 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - PR #6 review feedback (Codex + Copilot) surfaced two real Phase 2 gaps, both fixed after per-item triage with the user (
tasks/respond-to-pr-feedback.md): ComposableAttributeDiscovery.IsAssemblyCandidatefiltered on the attribute's literal syntax name ("Composable"/"ComposableAttribute") beforeTransformAssemblyLevel's semantic check ever ran - a consumer aliasing the attribute (using Marker = Compono.ComposableAttribute;then[assembly: Marker(typeof(SomeType))]) had the syntax name silently fail the filter, so the escape hatch never even reached the semantic check that would have resolved the alias correctly, and no plan or diagnostic was produced. Fixed by dropping the name filter entirely - the predicate now admits every[assembly: ...]attribute syntactically and relies on the existingGetSymbolInfo+wellKnownTypes.IsTypecheck downstream to do the real filtering, which already handles aliases correctly.ComposableAttribute's[AttributeUsage]excludedAttributeTargets.Interface, so[Composable]couldn't be written on an interface at all - the C# compiler rejected it with a bareCS0592before Compono's own generator ever saw it, even though the assembly-level form ([assembly: Composable(typeof(ISomeInterface))]) already handled the same case gracefully:ConstructorSelectorreportsCMP0003for an interface the same way it does for an abstract class, sinceINamedTypeSymbol.IsAbstractistruefor interfaces in Roslyn too. Fixed by addingAttributeTargets.Interfaceto the usage list, so both placements now produce the same clean diagnostic instead of the type-level form hitting a raw compiler error with no explanation.- Regression tests added: an assembly-level
[Composable]request via an aliasedusingdirective still generates a plan (ComposableAttributeAtAssemblyLevelViaAlias_GeneratesCompositionPlan), and[Composable]on an interface reportsCMP0003(ComposableAttributeOnInterface_ReportsDiagnostic). As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 64/64 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - A second round of PR #6 review feedback (Codex, via
@codex review) surfaced two more real Phase 2 gaps, both triaged with the user before implementing: ComposedTypeAnalyzer.Analyzenever checked whether the requested type itself is a ref struct (ref-like type) - a ref-like constructor parameter was already rejected byConstructorSelector'sValidateParameterKinds(CMP0004) whenever validating some other type's constructor, but that check only ever fires in a parameter context, so nothing stopped[Composable](or the assembly-level form) from targeting a ref struct directly.ICompositionPlan<out T>andPlanCache<T>both declare a bareTwith noallows ref structconstraint, so the resultingICompositionPlan<global::N.SomeRefStruct>would fail to compile (CS9244) in the generated file. Fixed with a new diagnostic,CMP0009, checked inComposedTypeAnalyzer.Analyzeright after the named-type check and before handing off toTransitiveClosureWalker- a dedicated ID rather than folding intoCMP0004(parameter-specific message shape) orCMP0006(would have required restructuring an already-tested static message), since this is a distinct failure mode: the type is a perfectly valid named type, just not usable as a type argument to Compono's own plan/cache types.ComposableAttributeDiscovery's assembly-levelExtractTypeArgumentmatched the argument expression againstis TypeOfExpressionSyntaxdirectly, so[assembly: Composable((typeof(Customer)))](legal, redundant parens) wrapped thetypeofin aParenthesizedExpressionSyntaxthe pattern never unwrapped -requestedTypecame backnulland a perfectly valid request incorrectly reportedCMP0008("missing type argument"). Fixed by unwrapping anyParenthesizedExpressionSyntaxin a loop before theTypeOfExpressionSyntaxcheck, rather than switching to a full semantic-binding lookup (the reviewer's suggested alternative) - the only realistic wrapper shape around atypeof(...)argument is redundant parens, so the small, targeted unwrap covers it without a bigger rewrite.- Regression tests added:
[Composable]on aref structreportsCMP0009(ComposableAttributeOnRefStruct_ReportsDiagnostic), and a parenthesizedtypeof(...)at assembly level still generates a plan (ComposableAttributeAtAssemblyLevelWithParenthesizedTypeof_GeneratesCompositionPlan). As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 68/68 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - Phase 3 design and implementation: nullability shape confirmed with the user via
AskUserQuestionbefore writing ADR-0006 (a bareboolwas considered and rejected in favor of aNullabilityenum, per the user's stated rationale — see the ADR). Implementation notes not already in the ADR: - Required-member collection couldn't stay inside
ConstructorSelector.HasUnassignedRequiredMembersonce it needed to return data on success (not just fail) — moved to a newRequiredMemberCollector.Collect, called fromTransitiveClosureWalker.Analyzeright after constructor selection succeeds, mirroring howConstructorSelector.Selectitself is called. TransitiveClosureWalker'sEnqueueIfEligibletook anIParameterSymbolbefore this phase; generalized to take a bareITypeSymbol/name pair so both constructor parameters and required members enqueue through the same eligibility check (LeafTypeClassifier.IsProviderResolved) without a second, parallel enqueue path.- The Scriban template's
required_members.size > 0check only works becauseCompositionPlanEmittermaterializesParameters/RequiredMemberswith.ToArray()before handing them to the template — a plainIEnumerable<T>from.Select(...)doesn't expose.sizeto Scriban (onlyIList-backed collections do), which first manifested as the required-memberifblock silently never rendering (no compile error, no verify-snapshot mismatch — the "New" received snapshot just showed a bare constructor call with the required member left unset, i.e. a real CS9035 compile failure caught byGeneratorTestHelpers.Verify's recompile-and-check-zero-errors step). - Manual verification: same
dotnet pack+ local-feed + throwaway console project approach as Phase 2. ACustomertype with a required, composableAddressproperty (no[SetsRequiredMembers]) and a nullable constructor parameter built and ran against the packedComponopackage:composer.Create<Customer>()reached the generatedCompose(...)and threw the expectedNotSupportedExceptionfrom Milestone 1's placeholderICompositionContext(proving the object-initializer block and theNullabilityargument are real, executed code, not just something that type-checked), and bothPlanCache<Customer>.InstanceandPlanCache<Address>.Instancewere non-null (proving required-member recursion generates and registers a plan for the nested type). Full solution build is 0 warnings/0 errors;dotnet testis 80/80 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - PR #7 review feedback (Codex) surfaced three real Phase 3 gaps, all triaged with the user one at a time before implementing (
tasks/respond-to-pr-feedback.md): - P1 — duplicate
AddSourcehint names could crash the whole generator run:Box<string>andBox<string?>composed in the same compilation emit to the identical hint name (SymbolDisplayFormat.FullyQualifiedFormaterases the top-level nullable annotation), but Roslyn substitutesBox<T>'s constructor parameter's ownNullableAnnotationdifferently for each closed generic, so the twoDiscoveredTypeInforecords differ inParametersand survivedComponoIncrementalGenerator's plain.Distinct()(full structural equality) as two "different" entries -AddSourcethen threw on the duplicate hint name. Fixed by grouping on the actual emission identity (Namespace,TypeName,FullyQualifiedName) and keeping the first-discovered entry, so two discoveries that would emit to the same hint name always collapse to one regardless of what a later duplicate disagreed about. - P2 — an overridden required property was collected twice:
RequiredMemberCollectorwalks a type and its base types outward: both a base's virtual required property and a derived override of it reportIsRequired: trueand share the same name, so both were collected, and the template emitted the same object-initializer target twice (a compile error in the generated file). Fixed by grouping the collected members by name and keeping the first occurrence -EnumerateTypeAndBaseswalks the type itself before its base types, so the derived override is always encountered first. - P2 — an unescaped reserved-keyword required member name emitted invalid code:
public required string @class { get; init; }reportsmember.Nameas the bare keyword text (class), which landed directly in the object initializer (class = ...) - a reserved word, not a valid identifier there. Fixed by escaping viaSyntaxFacts.GetKeywordKind(name) != SyntaxKind.Noneat collection time (a contextual keyword likevaris a legal identifier unescaped and must not get an@prefix, hence the real keyword-kind check rather than a hand-maintained list). - Regression tests added: composing both
Box<string>andBox<string?>dedupes to a single plan (later superseded - see below), an overridden required property is emitted once (OverriddenRequiredProperty_EmittedOnlyOnce), and a@class-named required member escapes correctly (RequiredMemberNamedWithReservedKeyword_EscapesIdentifier). Also addedAGENTS.mdat repo root in this same round (repo-orientation file for any coding agent, not Claude-specific) - designed with the user, incorporating a second round of external review feedback on the file itself. As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 86/86 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - A second Codex pass on PR #7 (triggered by
@codex reviewagainst the fix commit above) caught a real gap in that same fix, triaged with the user before implementing: thegroup.First()dedup stopped the duplicate-AddSource-hint-name crash, but didn't stop the other call site from silently getting the wrongNullabilityfor its own request -Box<string>andBox<string?>share exactly one generated plan and onePlanCache<Box<string>>.Instance, so if two call sites genuinely disagree about it, there's no value that's correct for both; picking one arbitrarily just makes the outcome depend on discovery order. Fixed by replacing "keep the first" with "diagnose the conflict": a group's entries are deduped via.Distinct()first (the legitimate case - the same type discovered identically via both a call site and
[Composable], say - still collapses to one with no diagnostic), and only if more than one genuinely different entry survives is a new diagnostic reported (CMP0010) and no plan emitted for that identity - the same "diagnose an ambiguity, don't guess" ruleCMP0001(ambiguous constructor) already follows. Regression test renamed/repurposed to match:NullableAndNonNullableGenericInstantiation_ReportsConflictDiagnosticnow assertsCMP0010instead of asserting a (silently wrong) generated plan. As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 86/86 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - A third Codex pass on PR #7 (against the CMP0010 fix commit above) found two more real gaps in the same area, both triaged with the user before implementing:
- P1 — the conflict check never ran for a conflict reached within a single closure walk: CMP0010 (above) only compares entries across separate top-level discoveries (e.g. two different
Create<T>()call sites), becauseTransitiveClosureWalker'svisitedset usedSymbolEqualityComparer.Default, which ignores nullable annotations - if the same type's closure reached bothBox<string>andBox<string?>as sibling constructor parameters (or a parameter and a required member), the second variant silently failedvisited.Add(...)and was dropped before it ever became its ownDiscoveredTypeInfo- one layer upstream of where CMP0010's check could ever see it. Fixed by swapping the comparer toSymbolEqualityComparer.IncludeNullability(a purpose-built Roslyn comparer for exactly this) - both variants now get walked and each produces its own entry, which flows into the already-existing CMP0010 check rather than needing a second, separate detection path. - P2 — the CMP0010 fix could itself erase real diagnostics:
.Distinct()on the grouped entries usesDiscoveredTypeInfo's full structural equality, which includes each failure's embeddedDiagnosticInfo- andDiagnosticInfo.EqualsincludesLocation. So the same failing type (e.g. an ambiguous constructor) reached from two differentCreate<T>()call sites produced two "distinct" failure entries purely because their locations differed, which the previous round's conflict logic then folded into a single synthetic, locationless CMP0010 - silently replacing two legitimateCMP0001s with a misleading one. Fixed by excluding any entry that already carries a diagnostic from conflict-detection entirely: failures always pass through as-is, however many there are for the same identity: CMP0010 now only ever fires among entries that all succeeded (no diagnostics) but disagree on metadata. - Regression tests added: a single closure reaching both
Box<string>andBox<string?>via sibling constructor parameters reports CMP0010 (SameClosureReachesConflictingNullableInstantiations_ReportsConflictDiagnostic), and the same ambiguous type reached from two differentCreate<T>()call sites still reportsCMP0001at both locations rather than a swallowedCMP0010(AmbiguousTypeReachedFromTwoCallSites_StillReportsAtBothLocations). As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 90/90 passing (Compono.Tests+Compono.Generators.Tests, both TFMs). - A fourth Codex pass on PR #7 found one more real gap, unrelated to the nullable-conflict area above (a genuinely independent finding, not another layer of the same bug) - triaged with the user, who also asked directly whether this round of iteration had hit diminishing returns:
RequiredMemberCollectornever validated that a required member could actually be assigned from generated code - a required property with no accessible setter, or a required readonly/inaccessible field, would still be collected and emitted as an object-initializer assignment that fails to compile (CS0272/CS0191).ConstructorSelectoralready guards the analogous case for constructors (compilation.IsSymbolAccessibleWithin), and this just hadn't been carried over to the required-member path added in this same phase. Fixed by addingIsAssignableFromGeneratedCode(checks the property'sSetMethodaccessibility, or that a field is both non-readonly and accessible) alongside the existing ref-like/pointer checks. No automated regression test: the exact shape this defends against (no accessible setter, or a readonly required field) is one the C# compiler itself refuses to let any C#-authored type declare (confirmed by attempting it viaGeneratorTestHelpers.CompileLibrary- CS9032 fired at the library's own declaration) - the gap only exists for a non-C#-compiler-authored assembly (hand-crafted IL, a different .NET language), which this test harness has no way to produce without adding real IL-emission infrastructure, judged disproportionate for one edge case. As of this round's completion: full solution build is still 0 warnings/0 errors;dotnet testis 90/90 passing (Compono.Tests+Compono.Generators.Tests, both TFMs - unchanged count, since this round added no new test). - Phase 4 was extended, after its initial completion, to add an AutoFixture ecosystem comparison at the user's request (citing outside advice that users will inevitably compare Compono to AutoFixture even though it's not marketed as a replacement). Kept as a genuinely separate
EcosystemBenchmarksclass rather than folded into the originalCompositionBenchmarks(renamedArchitectureBenchmarks) - mixing the two would let an "AutoFixture is much slower" number read as the architecture's proof point, when the actual architecture question is answered byDirect/Generated/Reflectionalone.Direct(new Leaf()) was added toArchitectureBenchmarksat the same time, as the theoretical floor. The suggestedCustomer -> Addressnested-graph comparison was not adopted -Leaf(flat, parameterless) remains the only type either engine can honestly construct end-to-end until Milestone 2's provider-resolution pipeline exists (seedocs/performance.md's "What's measured, and what isn't yet"), so a richer type would have made the comparison less fair, not more. This is a benchmark-project-only dependency -Directory.Packages.props's existing "no AutoFixture" rule is about Compono's own test suite (test data generation would contradict the product), not benchmarking an external framework as a reference point, so it stays unchanged in intent, just annotated to avoid future confusion between the two. - Phase 5's manual verification used real dogfooding rather than a throwaway project:
/Users/ncipollina/source/repos/ncipollina/lightsaber-skill(a real, unrelated production repo not previously using AutoFixture) addedCompono0.1.0-alpha.8 from nuget.org as a normalPackageReferencein its test project and addedtest/Lightsaber.Skill.Tests/Compono/ComponoVerificationTests.cswith two cases: a flat parameterless type (LightsaberHilt) constructs end-to-end via its generated plan, and a nested type (Lightsaber, taking aCrystal) dispatches to its own generatedICompositionPlan<Lightsaber>and throwsNotSupportedExceptionspecifically from insideResolve<Crystal>()- not earlier from a missing plan registration (InvalidOperationException) or a compile error. Both passed, in a realdotnet build/dotnet testrun against the published NuGet package (not aProjectReferenceback into this repo) - confirming discovery, transitive closure, constructor selection, andPlanCache<T>dispatch all work correctly end-to-end outside this repo's own test harness, for a nested type, which is exactly what the exit criteria needs even though full nested value resolution is honestly out of scope until Milestone 2. Committed locally on that repo'smain(not pushed, pending the user's own review).