Skip to content

feat: let a [Module] self-compile its own [Scan]#117

Merged
vbreuss merged 8 commits into
mainfrom
feat/113-self-compiled-module-scans
Jul 20, 2026
Merged

feat: let a [Module] self-compile its own [Scan]#117
vbreuss merged 8 commits into
mainfrom
feat/113-self-compiled-module-scans

Conversation

@vbreuss

@vbreuss vbreuss commented Jul 17, 2026

Copy link
Copy Markdown
Member

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.

  • 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.
  • Handle the same-compilation case too: when the module and container share an assembly the container expands the module's [Scan] directly and constructs the match itself, so a scan means the same thing wherever the module is packaged.
  • Disposal of an internal IDisposable behind an interface is handled by the existing runtime disposal check for interface-returning factories.
  • Repurpose AWT154: a module [Scan] is no longer rejected on import; instead AWT154 fires when an imported module's metadata carries a [Scan] but no generated expansion marker (built without the Awaiten generator, or a version predating this feature).
  • Add AWT194 (module with a scan not partial), AWT195 (inaccessible constructor parameter), AWT196 (no accessible exposure, a warning), AWT197 (multiple exposures), AWT200 ([Inject]/[Arg] metadata the factory can't mirror), AWT201 (generic module), AWT202 (InAssembliesOf on a module scan) — 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.

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.
@vbreuss vbreuss self-assigned this Jul 17, 2026
@vbreuss vbreuss added the enhancement New feature or request label Jul 17, 2026
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

Test Results

   18 files  ±  0     18 suites  ±0   9m 15s ⏱️ +51s
1 177 tests + 34  1 176 ✅ + 34  1 💤 ±0  0 ❌ ±0 
5 648 runs  +102  5 647 ✅ +102  1 💤 ±0  0 ❌ ±0 

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.
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ScanOnModule ‑ DoesNotReportForAScanOnTheContainerItself
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ScanOnModule ‑ ReportsAlongsideAwt151ForAScanOnlyModule
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ScanOnModule ‑ ReportsWhenAnImportedModuleDeclaresAScan
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ScanOnModule ‑ StillImportsTheModulesLifetimeRegistrations_AndDoesNotAlsoReportAwt151
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ModuleScanNotExpanded ‑ Awt154_ReportsForAScanOnlyModuleWithoutMisleadingAwt151
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ModuleScanNotExpanded ‑ Awt154_ReportsWhenTheModuleAssemblyWasBuiltWithoutTheGenerator
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt154ModuleScanNotExpanded ‑ NoAwt154WhenTheGeneratorExpandedAScanThatMatchedNothing
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ Awt152_ReportsForANonStaticModuleWithAScanInTheModulesOwnBuild
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ DoesNotReportForAModuleNestedInAPartialType
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ DoesNotReportForAPartialModuleWithAScan
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ DoesNotReportForAPlainModuleWithoutAScan
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ ReportsWhenAContainingTypeIsNotPartial
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ ReportsWhenAModuleWithAScanIsNotPartial
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt195Awt196Awt197ModuleScanExposure ‑ Awt195_ReportsWhenAMatchConstructorParameterIsInaccessible
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 16.764 ns 0.3465 ns 0.3072 ns 0.96 144 B 1.00
Awaiten 8 17.455 ns 0.2369 ns 0.2216 ns 1.00 144 B 1.00
MsDI 8 1,451.949 ns 11.9996 ns 11.2244 ns 83.19 5688 B 39.50
Autofac 8 34,064.352 ns 217.4772 ns 203.4283 ns 1,951.82 33098 B 229.85
Jab 8 8.337 ns 0.1185 ns 0.1108 ns 0.48 96 B 0.67
PureDI 8 15.562 ns 0.0884 ns 0.0738 ns 0.89 128 B 0.89
DryIoc 8 750.048 ns 15.7133 ns 13.9294 ns 42.98 1528 B 10.61
SimpleInjector 8 11,154.661 ns 65.6650 ns 61.4231 ns 639.14 24761 B 171.95
baseline* 256 79.153 ns 0.4079 ns 0.3616 ns 1.00 2128 B 1.00
Awaiten 256 79.535 ns 0.2355 ns 0.2088 ns 1.00 2128 B 1.00
MsDI 256 13,482.507 ns 75.0382 ns 62.6603 ns 169.52 61016 B 28.67
Autofac 256 823,422.159 ns 3,693.3660 ns 3,454.7768 ns 10,353.01 736653 B 346.17
Jab 256 65.519 ns 0.1078 ns 0.0842 ns 0.82 2080 B 0.98
PureDI 256 73.016 ns 0.1902 ns 0.1686 ns 0.92 2112 B 0.99
DryIoc 256 44,198.942 ns 366.0867 ns 342.4378 ns 555.72 79820 B 37.51
SimpleInjector 256 333,947.360 ns 1,863.3015 ns 1,651.7682 ns 4,198.77 573075 B 269.30
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.76GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 261.1 ns 1.92 ns 1.70 ns 1.07 568 B 1.00
Awaiten 243.0 ns 6.63 ns 6.20 ns 1.00 568 B 1.00
MsDI 608.0 ns 16.74 ns 15.66 ns 2.50 1104 B 1.94
Autofac 6,970.0 ns 86.54 ns 72.26 ns 28.70 10784 B 18.99
Jab 185.2 ns 3.98 ns 3.52 ns 0.76 432 B 0.76
DryIoc 427.9 ns 6.54 ns 6.12 ns 1.76 944 B 1.66
SimpleInjector 758.3 ns 5.81 ns 5.44 ns 3.12 1096 B 1.93
PureDI 191.0 ns 2.94 ns 2.45 ns 0.79 632 B 1.11
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.302
[Host] : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 5.448 ns 0.0503 ns 0.0392 ns 0.67 - NA
Awaiten 8 8.094 ns 0.1312 ns 0.1163 ns 1.00 - NA
MsDI 8 6.040 ns 0.1351 ns 0.1264 ns 0.75 - NA
Autofac 8 98.052 ns 1.9161 ns 1.7923 ns 12.12 656 B NA
Jab 8 3.410 ns 0.0815 ns 0.0763 ns 0.42 - NA
PureDI 8 4.950 ns 0.2362 ns 0.2210 ns 0.61 - NA
DryIoc 8 9.136 ns 0.2748 ns 0.2571 ns 1.13 - NA
SimpleInjector 8 10.638 ns 0.1559 ns 0.1458 ns 1.31 - NA
baseline* 256 6.212 ns 0.1297 ns 0.1213 ns 0.67 - NA
Awaiten 256 9.208 ns 0.1838 ns 0.1630 ns 1.00 - NA
MsDI 256 6.002 ns 0.1403 ns 0.1312 ns 0.65 - NA
Autofac 256 95.026 ns 1.7426 ns 1.5448 ns 10.32 656 B NA
Jab 256 45.584 ns 0.5968 ns 0.5291 ns 4.95 - NA
PureDI 256 8.536 ns 0.2388 ns 0.2234 ns 0.93 - NA
DryIoc 256 9.003 ns 0.1796 ns 0.1680 ns 0.98 - NA
SimpleInjector 256 10.885 ns 0.3439 ns 0.3217 ns 1.18 - NA

baseline* rows show the corresponding Awaiten benchmark from the most recent successful main branch build with results, for regression comparison.

vbreuss added 7 commits July 17, 2026 17:05
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.
@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit 9d43988 into main Jul 20, 2026
12 checks passed
@vbreuss
vbreuss deleted the feat/113-self-compiled-module-scans branch July 20, 2026 10:39
github-actions Bot added a commit that referenced this pull request Jul 20, 2026
github-actions Bot added a commit that referenced this pull request Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant