Skip to content

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

Open
vbreuss wants to merge 5 commits into
mainfrom
feat/113-self-compiled-module-scans
Open

feat: let a [Module] self-compile its own [Scan]#117
vbreuss wants to merge 5 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 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.

Current 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.


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   8m 42s ⏱️ +18s
1 169 tests +26  1 168 ✅ +26  1 💤 ±0  0 ❌ ±0 
5 624 runs  +78  5 623 ✅ +78  1 💤 ±0  0 ❌ ±0 

Results for commit 701d1ec. ± Comparison against base commit 8c555d8.

This pull request removes 4 and adds 30 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 ‑ DoesNotReportForAPartialModuleWithAScan
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ DoesNotReportForAPlainModuleWithoutAScan
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt194ModuleScanNotPartial ‑ ReportsWhenAModuleWithAScanIsNotPartial
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt195Awt196Awt197ModuleScanExposure ‑ Awt195_ReportsWhenAMatchConstructorParameterIsInaccessible
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt195Awt196Awt197ModuleScanExposure ‑ Awt196_ReportsWhenNoExposureIsAccessible
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt195Awt196Awt197ModuleScanExposure ‑ Awt197_ReportsWhenAMatchWouldHaveMultipleExposures
…

♻️ 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 7763 2.45GHz, 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.25 ns 0.081 ns 0.072 ns 0.79 144 B 1.00
Awaiten 8 20.63 ns 0.427 ns 0.399 ns 1.00 144 B 1.00
MsDI 8 1,649.69 ns 20.115 ns 18.815 ns 79.99 5688 B 39.50
Autofac 8 31,834.43 ns 193.384 ns 180.892 ns 1,543.61 33094 B 229.82
Jab 8 11.26 ns 0.572 ns 0.535 ns 0.55 96 B 0.67
PureDI 8 17.25 ns 0.556 ns 0.521 ns 0.84 128 B 0.89
DryIoc 8 758.88 ns 6.224 ns 5.517 ns 36.80 1528 B 10.61
SimpleInjector 8 13,012.83 ns 478.661 ns 447.740 ns 630.97 24761 B 171.95
baseline* 256 89.40 ns 0.449 ns 0.350 ns 0.68 2128 B 1.00
Awaiten 256 131.54 ns 6.416 ns 6.001 ns 1.00 2128 B 1.00
MsDI 256 15,802.13 ns 202.836 ns 189.733 ns 120.39 61016 B 28.67
Autofac 256 765,960.59 ns 4,103.950 ns 3,838.838 ns 5,835.34 720921 B 338.78
Jab 256 92.43 ns 1.065 ns 0.890 ns 0.70 2080 B 0.98
PureDI 256 111.44 ns 4.384 ns 4.100 ns 0.85 2112 B 0.99
DryIoc 256 44,693.00 ns 686.345 ns 642.008 ns 340.49 80099 B 37.64
SimpleInjector 256 384,814.59 ns 6,785.916 ns 6,347.550 ns 2,931.64 573106 B 269.32
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.59GHz, 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* 288.2 ns 0.71 ns 0.63 ns 1.16 568 B 1.00
Awaiten 249.0 ns 0.90 ns 0.84 ns 1.00 568 B 1.00
MsDI 614.6 ns 4.14 ns 3.67 ns 2.47 1104 B 1.94
Autofac 6,709.7 ns 21.30 ns 18.88 ns 26.95 10784 B 18.99
Jab 186.9 ns 0.91 ns 0.81 ns 0.75 432 B 0.76
DryIoc 417.7 ns 4.58 ns 4.06 ns 1.68 944 B 1.66
SimpleInjector 764.8 ns 4.03 ns 3.37 ns 3.07 1096 B 1.93
PureDI 190.3 ns 1.58 ns 1.48 ns 0.76 632 B 1.11
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 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

Resolve Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 5.738 ns 0.6620 ns 0.6193 ns 0.60 1.01 -
Awaiten 8 9.630 ns 0.1145 ns 0.1071 ns 1.00 - NA
MsDI 8 7.467 ns 0.2591 ns 0.2164 ns 0.78 - NA
Autofac 8 107.596 ns 1.2524 ns 1.1715 ns 11.17 656 B NA
Jab 8 3.613 ns 0.0290 ns 0.0272 ns 0.38 - NA
PureDI 8 5.488 ns 0.0628 ns 0.0588 ns 0.57 - NA
DryIoc 8 10.198 ns 0.0808 ns 0.0756 ns 1.06 - NA
SimpleInjector 8 12.123 ns 0.0401 ns 0.0355 ns 1.26 - NA
baseline* 256 6.043 ns 0.0063 ns 0.0059 ns 0.59 1.00 -
Awaiten 256 10.305 ns 0.0235 ns 0.0196 ns 1.00 - NA
MsDI 256 7.763 ns 0.1528 ns 0.1429 ns 0.75 - NA
Autofac 256 106.920 ns 0.8016 ns 0.6694 ns 10.38 656 B NA
Jab 256 43.620 ns 0.1020 ns 0.0954 ns 4.23 - NA
PureDI 256 7.813 ns 0.1181 ns 0.1105 ns 0.76 - NA
DryIoc 256 10.071 ns 0.0304 ns 0.0285 ns 0.98 - NA
SimpleInjector 256 15.539 ns 0.0279 ns 0.0233 ns 1.51 - NA

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

vbreuss added 4 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.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
8 New issues
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

💡 Need a hand with PR review? Try Gitar by Sonar!

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