feat: resolve collection dependencies to every registration of a service#39
Conversation
A constructor parameter typed as a collection of a service - `IEnumerable<T>`, `IReadOnlyList<T>`, `IReadOnlyCollection<T>`, `IList<T>`, `ICollection<T>` or `T[]` - now resolves to every unkeyed registration of `T`, materialized eagerly as an array in registration order, with each member keeping its own lifetime (singletons shared, transients fresh, scoped from the scope). `IEnumerable<T>` and `T[]` are also publicly resolvable through `Resolve`. This is the backbone of handler dispatch, notification fan-out, validation pipelines and plugin models. The change relaxes the coalescing invariant (one `ServiceKey` maps to one implementation, first wins, later duplicates dropped) without disturbing single resolution. Duplicate unkeyed registrations now each become an instance; the first still wins by-type resolution, and the losing registrations carry an empty service list so they stay invisible to the single-dispatch, typed-resolver and captive-naming paths, reachable only through the collection via a per-implementation resolver map and the `ServiceMembers` collections model. Keyed registrations keep their existing handling - collections are an unkeyed concern. Collections participate in the object graph: an `Enumerable` parameter contributes an edge to each of its members in both the dependency graph (taint/captive) and the construction graph (cycles), since it captures them eagerly. A singleton holding a collection of scoped services is therefore still an `AWT105` captive dependency, and a cycle closed through a collection is still `AWT102`. An empty collection (no registration for the element type) resolves to an empty array rather than an `AWT101` missing-dependency error, and a parameterized (`[Arg]`) service is excluded from collections since it is reachable only through its `Func<TArg…, T>` factory. Collections are synchronous-only in this version: a member that is async-initialized or async-tainted cannot be materialized synchronously, so in the strict default it is reported as the new `AWT122` (set `SyncResolveAfterInit` and resolve after `InitializeAsync`, or remove the async member). Async-tainted collections are likewise omitted from the public dispatch so no synchronous resolver is referenced where none is emitted. `AWK118` in the prototype maps to `AWT122` here (`AWT118` is already the root-accumulating-factory diagnostic; `AWT122` was the next free id). Adds runtime behavior tests across all target frameworks and source-generator tests for the emitted array literal, the public dispatch, array parameters, empty collections, keyed and parameterized exclusion, captive detection through a collection, and `AWT122`.
🚀 Benchmark ResultsDetails
Details
Details
|
…tainer root
A collection materializes its members eagerly on the resolving scope, but the public `IEnumerable<T>` / `T[]` dispatch honored none of the strict lifetime-safety withholding that governs single resolution. Resolving such a collection by type off the Root repeatedly re-tracked its transient disposable members on the root, accumulating them for the container's lifetime - the exact leak that withholding the singular resolution of such a member (`IsWithheld`) exists to prevent. The collection dispatch is now root-withheld whenever a member is a build-on-demand disposable: it resolves from a child scope (which bounds its members), but by-type resolution on the Root returns false from `TryResolve` and throws targeted guidance from `Resolve`, flowing through the existing `__rootWithheld` mask and `__withheld` table. There is no `Owned<T>` form for a collection, so the new `CollectionWithheldMessage` steers to a child scope, direct injection, or `LifetimeSafety.Loose`.
The transitive-disposable walk `BuildsFreshDisposable` now follows collection edges too: a service whose only fresh-disposable path is through a collection (a consumer of `IEnumerable<T>` over a disposable transient) is detected as building a fresh disposable, so a plain `Func<…>` over it bound to the root is correctly reported as `AWT118` / withheld instead of leaking silently. This mirrors the analysis the branch already extended to the cycle, captive and async-taint graphs - the disposal analysis was the one path that still ignored collection members. A shared `CollectionMemberIndices` helper maps each element service to its member instance indices, threaded through the analyzer and the emitter's `IsFuncWithheld`.
Adds runtime tests (all target frameworks) for a collection of disposable transients being withheld from the Root, resolvable and disposed from a child scope, and root-resolvable under Loose; source-generator tests for the emitted withheld guidance; and an analyzer test for `AWT118` reported through a collection edge. Existing collection diagnostic assertions move off `.Any(...).IsTrue()` to the `Contains("*AWTxxx*").AsWildcard()` form used elsewhere.
…sized collection A constructor parameter typed as a collection (`IEnumerable<T>` and friends, or `T[]`) was unconditionally synthesized from the unkeyed registrations of its element type, even when the collection type was itself registered as a service. That silently shadowed a legitimate opaque-value registration - a `string[]` of command-line arguments, an `IReadOnlyList<T>` of config - and made injection disagree with the public `Resolve<T>` path, which already lets an explicit registration claim the dispatch slot. `ClassifyParameters` now checks whether the collection type is itself registered: if so, the parameter resolves to that registration as an ordinary direct dependency (matching `Resolve<T>`); only an unregistered collection type falls back to the synthesized collection. This is the least-surprising "if you registered it, you get it" rule and needs no new diagnostic. Corrects the `AWT122` message and its detection doc, which said the async-tainted-collection error applied "in strict mode". Like `AWT119`/`AWT120` it is a synchronous-resolution-of-async concern independent of lifetime safety - it fires under both strict and loose safety, and only `SyncResolveAfterInit` suppresses it - so the wording now matches those diagnostics. Refactors the branch's higher-complexity methods without changing behavior: `BuildEdges`, `BuildsFreshDisposable`, `CoalesceByImplementation` and `DetectSynchronousAsyncCollection` in the generator, and `BuildDispatchEntries` in the emitter, each split into focused helpers (`AddParameterEdges`/`AddCollectionMemberEdges`, `PushFreshTransientDependencies`/`PushTransientCollectionMembers`, `ReportCoalescingConflicts`/`AddCollectionMember`, `ReportAsyncTaintedMembers`, `AddCollectionEntries`/`AddCollectionShape`). Adds a runtime test that an explicitly registered `IEnumerable<T>` wins over synthesis for both injection and by-type resolution while the unregistered `T[]` shape still synthesizes, and a source-generator test pinning the emitted direct dependency.
…-nothing synthesis
Recognizes a `[FromKey]` on a collection parameter so `[FromKey("k")] IEnumerable<T>` resolves the registrations of `T` under key `k` instead of silently ignoring the key and returning the unkeyed set. Collection membership is now keyed by `(service type, key)` throughout - coalescing, the dependency/construction graphs, the transitive-disposable walk, AWT122 detection and emission - and `ServiceMembers` carries that key. Public by-type dispatch stays unkeyed-only, matching the container's key model where `Resolve<T>` has no keyed surface. Note that a key identifies at most one registration per service type here (two are AWT117), so a keyed collection holds its single keyed member; the value is that the keyed and unkeyed buckets no longer bleed into each other.
Makes every supported collection shape publicly resolvable, not only `IEnumerable<T>` and `T[]`: `Resolve<IReadOnlyList<T>>()`, `IReadOnlyCollection<T>`, `IList<T>` and `ICollection<T>` now dispatch to the same eagerly materialized array (which satisfies each). Injection already accepted all six shapes, so this makes the public surface symmetric with it.
Adopts all-or-nothing synthesis for an element type whose collection is explicitly registered. Previously a registered `IEnumerable<T>` won its own shape while the other shapes still synthesized from the members, so `Resolve<IEnumerable<T>>()` and `Resolve<T[]>()` could silently return different contents. Now registering any collection shape of `T` suppresses synthesis for every shape of `T`: the registered shape resolves to its opaque value, and an unregistered sibling shape is a plain missing dependency (AWT101 on injection, a throw on by-type resolution) rather than a divergent second collection. `CollectionShapeTypes` is shared by the generator (injection classification) and the emitter (public dispatch) so the two never drift.
Surfaces AWT122-style guidance when a collection with an async-tainted member is requested by type but never injected. Such a collection has no synchronous materialization, so instead of a generic "no registration" its shapes now carry guidance in the `__withheld` table (set `SyncResolveAfterInit` and resolve after `InitializeAsync`, or remove the async member), mirroring how a single async-only service is handled.
Fixes a multidimensional array parameter (`T[,]`) being misclassified as a collection: `TryGetCollectionElement` only matched a rank-1 array before, but any-rank arrays fell through, so an unregistered `int[,]` emitted a rank-1 `new int[] { }` literal that failed to compile (CS1503). It is now recognized as an ordinary direct dependency, so an unregistered one surfaces as AWT101.
Adds runtime and source-generator tests for each: all six shapes resolvable, keyed-collection routing and bucket disjointness, the multidimensional-array AWT101, the async-collection guidance, and the all-or-nothing suppression on both injection and by-type resolution.
…r warm-up Add a runtime test that resolves a collection holding an IAsyncInitializable member from a SyncResolveAfterInit container after InitializeAsync, asserting the member comes back fully initialized both when injected through a host and when resolved publicly by type. This exercises the emitted synchronous collection literal calling the sync-delegating resolver of an async-tainted member after warm-up - the path previously covered only by a diagnostic-absence test (AWT122 is suppressed under SyncResolveAfterInit). Also add the missing trailing newline to DependencyKind.cs to match the surrounding files.
The implInfos dictionary is populated by the EnsureImpl local function across loop iterations, a mutation Sonar's symbolic execution does not follow through the local-function call, so it wrongly reports the TryGetValue as reading an always-empty collection. The looked-up ImplInfo is what ReportCoalescingConflicts compares against to detect the AWT107/AWT111 coalescing conflicts, so the call is required - removing it would silently stop those diagnostics from firing. Suppress the rule locally with a justification, matching the existing S-rule pragma convention in the repo.
Extract the EnsureImpl local function to a private static method that takes implInfos and implOrder as parameters. Its dictionary Add now crosses a real call boundary, so Sonar's symbolic execution no longer misreads the pre-loop implInfos.TryGetValue as reading an always-empty collection - resolving the S4158 false positive without a #pragma suppression. Behavior is unchanged; the pre-loop lookup still returns the implementation's already-recorded ImplInfo for ReportCoalescingConflicts (AWT107/AWT111).
|
…o every registration of a service (#39) by Valentin Breuß
…o every registration of a service (#39) by Valentin Breuß



A constructor parameter typed as a collection of a service -
IEnumerable<T>,IReadOnlyList<T>,IReadOnlyCollection<T>,IList<T>,ICollection<T>orT[]- now resolves to every unkeyed registration ofT, materialized eagerly as an array in registration order, with each member keeping its own lifetime (singletons shared, transients fresh, scoped from the scope).IEnumerable<T>andT[]are also publicly resolvable throughResolve. This is the backbone of handler dispatch, notification fan-out, validation pipelines and plugin models.The change relaxes the coalescing invariant (one
ServiceKeymaps to one implementation, first wins, later duplicates dropped) without disturbing single resolution. Duplicate unkeyed registrations now each become an instance; the first still wins by-type resolution, and the losing registrations carry an empty service list so they stay invisible to the single-dispatch, typed-resolver and captive-naming paths, reachable only through the collection via a per-implementation resolver map and theServiceMemberscollections model. Keyed registrations keep their existing handling - collections are an unkeyed concern.Collections participate in the object graph: an
Enumerableparameter contributes an edge to each of its members in both the dependency graph (taint/captive) and the construction graph (cycles), since it captures them eagerly. A singleton holding a collection of scoped services is therefore still anAWT105captive dependency, and a cycle closed through a collection is stillAWT102. An empty collection (no registration for the element type) resolves to an empty array rather than anAWT101missing-dependency error, and a parameterized ([Arg]) service is excluded from collections since it is reachable only through itsFunc<TArg…, T>factory.Collections are synchronous-only in this version: a member that is async-initialized or async-tainted cannot be materialized synchronously, so in the strict default it is reported as the new
AWT122(setSyncResolveAfterInitand resolve afterInitializeAsync, or remove the async member). Async-tainted collections are likewise omitted from the public dispatch so no synchronous resolver is referenced where none is emitted.AWK118in the prototype maps toAWT122here (AWT118is already the root-accumulating-factory diagnostic;AWT122was the next free id).Adds runtime behavior tests across all target frameworks and source-generator tests for the emitted array literal, the public dispatch, array parameters, empty collections, keyed and parameterized exclusion, captive detection through a collection, and
AWT122.