feat: let a [Module] self-compile its own [Scan]#117
Conversation
A library can now keep implementations `internal` and expose only interfaces: a `[Module]` that declares a `[Scan]` compiles that scan in its own build, emitting a `public static` factory per match (which constructs the implementation and returns its accessible exposure interface) plus a single-arg `[Singleton<IExposure>(Factory = …, Fallback = Silent)]` registration into the module's partial. To a consuming container this is an ordinary module factory registration, so nothing new crosses the assembly boundary. - Add a second generator root on `[Module]`, gated on the module declaring a `[Scan]`; a plain module still emits nothing. - Expand the scan against the module's own assembly (so `internal` matches are seen), reusing the container scan's candidate discovery, filters and diagnostics. - Disposal of an internal `IDisposable` behind an interface is handled by the existing runtime disposal check for interface-returning factories. - Retire AWT154 (a module `[Scan]` is no longer rejected on import). - Add AWT194 (module with a scan not partial), AWT195 (inaccessible constructor parameter), AWT196 (no accessible exposure), AWT197 (multiple exposures) — all reported in the library's own build. v1 limitations (each reported at the library's source): a match exposes through exactly one accessible interface, and its constructor parameters must be types a consumer can name. Version-skew detection is deferred. Self-compilation is a cross-assembly feature.
Test Results 18 files ± 0 18 suites ±0 9m 15s ⏱️ +51s Results for commit 46f226a. ± Comparison against base commit 8c555d8. This pull request removes 4 and adds 38 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
🚀 Benchmark ResultsDetails
Details
Details
|
A [Module]'s self-compiled [Scan] now emits a [GeneratedScanRegistration] per match, which a consuming container reads as an IsScan factory registration with a synthetic per-match identity. This gives self-compiled scans container-scan semantics across the assembly boundary: - several matches under one interface collect as IEnumerable<T> instead of colliding as conflicting single-service factories (previously a consumer-side AWT111) - a match is overridable by an explicit registration (scans rank last) AWT197 still blocks a single match exposing multiple interfaces: a factory returns one type, so a singleton could not share one instance across them. Adds the generator-only GeneratedScanRegistrationAttribute<TService> to the public surface (the one new cross-assembly signal this requires).
…n module scans container-side The synthetic implementation identity of a [GeneratedScanRegistration] match was keyed by service and factory name alone, but factory names are only unique per module, so two imported modules whose matches shared a simple type name under one service collapsed into a single collection member and the later module's match was silently dropped from the collection. The identity is now qualified with the declaring module's fully-qualified name. A [Module] declared in the same compilation as its importing container never reached the container at all: a generator cannot see its own output, so the module's self-compiled [GeneratedScanRegistration] attributes are invisible within the compilation that declares the module, and the scan silently contributed nothing besides a misleading AWT151. The container now expands a same-compilation module's [Scan] directly, exactly as if it were declared on the container, with full container-scan semantics; a referenced-assembly module keeps the self-compiled path, and AWT151 counts a same-compilation module's [Scan] as a registration.
…T200, AWT201) A match carrying injection metadata the generated factory cannot mirror is now rejected as AWT200 instead of silently degrading: an [Inject] property, or a constructor parameter marked [Inject] or [Arg], carries keys, optionality, deferral or resolution-argument semantics that a mirrored plain parameter list would drop, so the consumer would construct the match differently than a container scan. A generic [Module] (or one nested in a generic type) with a [Scan] is rejected as AWT201: no closed module type exists for a consumer to import, and re-opening the declaration by its bare name silently emitted an unrelated non-generic partial carrying the registrations. Factory parameter names are emitted verbatim-prefixed (@clock), so a scanned constructor parameter legally named with a reserved keyword (@event) no longer breaks the module''s own build. Two [Scan]s on one module matching the same type under the same exposure now dedup to a single factory, mirroring the container''s per-implementation collection dedup, instead of registering two separate instances in the consumer''s collection; a differing lifetime across the overlap reports AWT142 and the first scan''s lifetime wins, as it does on a container. The same-compilation expansion no longer double-reports: the container-side re-expansion of a same-compilation module''s [Scan] discards its diagnostics (the module pipeline already reports the identical set at the same location), and a same-compilation module''s [GeneratedScanRegistration] attributes are skipped during collection, which is a no-op for the generator (it never sees its own output) but keeps an analyzer running over the post-generation compilation from doubling every match in its graph.
…tates AWT154, adds AWT202) - Reinstate AWT154 as the version-skew guard: the generator stamps every expanded module with [GeneratedScanExpansion], even when the scan matched nothing, so a consumer importing a referenced module whose metadata carries a [Scan] without the marker gets an error instead of a silent drop. AWT151 no longer fires for a scan-bearing module, keeping the blame on the real fault. - AWT197 now also rejects one match exposed under different interfaces by two scans of the same module; previously that emitted two factories and silently split the shared instance a container scan would give the implementation. - SkipUnconstructable travels on [GeneratedScanRegistration] and is honored by the consumer: the unconstructable-match prune checks a generated module factory's parameters, so an unresolvable match drops with AWT141 exactly like a container scan match. - AWT196 (no accessible exposure) is demoted from error to warning, mirroring the container scan's skip-with-warning severity (AWT182/AWT188/AWT193). - AWT202 rejects InAssembliesOf on a module [Scan] in v1: a module sweeping another assembly would see only its public types, which a container scan already covers, while its internal matches were silently skipped without the AWT193 a container scan reports. - Generated factory names are version-stable: Awaiten__Scan_<Name>_<fnv1a-of-full-name> instead of an ordinal index, so a later library version adding or removing matches never renames existing factories under a consumer compiled against the older assembly, and same-named matches in different namespaces stay distinct without relying on discovery order. - A non-static module with a [Scan] reports AWT152 in the module's own build instead of surfacing as a partial-modifier compiler error inside the generated static partial.
…tent same-compilation semantics) - The container's own [Scan] now outranks an imported module's scan for the same service in both packagings. A module's [GeneratedScanRegistration] matches were collected before the container's own scan expansion, so within the scan tier a cross-assembly module won the resolution slot while a same-compilation module lost it, and moving a module into a NuGet package silently flipped which implementation resolved. The module matches are pulled out and re-appended after the container's scan expansion, so the container wins ties like everywhere else, regardless of where the module lives. - A same-compilation module's [Scan] is now expanded through the module pipeline's own logic (CollectModuleScanFactories) rather than re-run with full container-scan semantics via CollectScans. A match the module build skipped with AWT196 (no accessible exposure) is therefore genuinely not registered by a same-compilation container either, so the warning and the behavior agree and a module scan registers the same matches wherever the module is imported from. Previously an AWT196-warned match warned yet still resolved same-compilation. - Construction of a self-compiled match is consistent across packagings: a same-compilation match now builds through the greediest accessible constructor (the one a cross-assembly module's generated factory mirrors, carried on a GreedyConstructor flag) instead of quietly falling back to a smaller constructor the local graph happens to satisfy, so the same match reports the same AWT101 for an unregistered dependency whether imported same-compilation or cross-assembly. - AWT194 now covers a [Scan] module nested in a non-partial containing type: the generated partial re-opens the whole nesting chain, so a non-partial outer type surfaced as a raw CS0260 in generated code instead of a clean diagnostic. Every enclosing type must be partial too, mirroring the AWT201 generic-outer check. - CollectModuleScanFactories threads the pipeline's CancellationToken through the candidate sweep, so a module scanning a large assembly stays responsive in the IDE. - Docs: the same-assembly paragraph no longer promises container-scan multi-interface exposure the v1 errors forbid, and the em-dashes in the sections this branch added are removed.
…ry parameters A self-compiled module [Scan] factory is a public cross-assembly member whose signature must mirror the scanned constructor exactly. The factory parameter types were emitted with FullyQualifiedFormat, which omits the nullable reference type modifier, so a constructor parameter declared IClock? was widened to a non-nullable IClock on the generated public factory. Emit the parameter types with a FullyQualified format that includes IncludeNullableReferenceTypeModifier, so the annotation the author declared survives into the generated signature.
…n the boundary-analyzer behavior Add an explicit AWT134 assertion to the same-compilation module-scan test so the boundary analyzer's acceptance of the generated factory's construction inside a [Module] is covered directly rather than only implied by an empty-diagnostics check. Decompose RegisterModuleScanMatch into AccessibleExposures, OverlapResolved, and TryMirrorFactoryParameters to cut its cognitive complexity, bundle the marker triple into a ScanMarkerInfo record struct to drop its parameter count, simplify the exposure dedup with a Where clause, and replace the single-iteration [Inject]-property loop with FirstOrDefault. Bundle the round's satisfiable surface into a ScanSatisfiability record struct so FirstUnconstructableReason and its siblings take one value instead of four, and extract the factory-vs-constructor prune dispatch into UnconstructableScanMatchReason to reduce PruneUnconstructableScanRound's complexity. Fold the generated-scan-registration handling in CollectLifetimeRegistrations into a bool-returning CollectGeneratedScanRegistration, and iterate the same-compilation expansion over the module symbol projection.
|
… own `[Scan]` (#117) by Valentin Breuß
… own `[Scan]` (#117) by Valentin Breuß



A library can now keep implementations internal and expose only interfaces: a [Module] that declares a [Scan] compiles that scan in its own build, emitting a public static factory per match (which constructs the implementation and returns its accessible exposure interface) plus a [GeneratedScanRegistration(factory, Lifetime = …)] and a [GeneratedScanExpansion] marker into the module's partial. A consuming container reads the registration back like a container [Scan] match — collection-eligible and overridable by an explicit registration — so nothing new crosses the assembly boundary.
[Module]compile its own[Scan], so a library can keep implementations internal #113