Skip to content

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

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

feat: let a [Module] self-compile its own [Scan]#117
vbreuss wants to merge 4 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   9m 27s ⏱️ + 1m 3s
1 162 tests +19  1 161 ✅ +19  1 💤 ±0  0 ❌ ±0 
5 603 runs  +57  5 602 ✅ +57  1 💤 ±0  0 ❌ ±0 

Results for commit 2e22e3d. ± Comparison against base commit 8c555d8.

This pull request removes 4 and adds 23 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+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
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt195Awt196Awt197ModuleScanExposure ‑ DoesNotReportForASingleAccessibleExposure
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt200Awt201ModuleScanLimitations ‑ Awt200_ReportsWhenAMatchConstructorParameterIsMarkedArg
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt200Awt201ModuleScanLimitations ‑ Awt200_ReportsWhenAMatchHasAnInjectProperty
Awaiten.SourceGenerators.Tests.DiagnosticTests+Awt200Awt201ModuleScanLimitations ‑ Awt201_ReportsForAGenericModuleWithAScan
…

♻️ 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.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

Build Size Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 8 16.25 ns 0.081 ns 0.072 ns 0.92 144 B 1.00
Awaiten 8 17.708 ns 0.9698 ns 0.9071 ns 1.00 144 B 1.00
MsDI 8 1,505.394 ns 42.6907 ns 37.8442 ns 85.22 5688 B 39.50
Autofac 8 31,216.456 ns 263.3132 ns 246.3033 ns 1,767.09 33098 B 229.85
Jab 8 9.763 ns 0.6002 ns 0.5615 ns 0.55 96 B 0.67
PureDI 8 15.090 ns 0.5558 ns 0.5199 ns 0.85 128 B 0.89
DryIoc 8 692.191 ns 14.8007 ns 13.8446 ns 39.18 1528 B 10.61
SimpleInjector 8 12,153.218 ns 260.1169 ns 217.2094 ns 687.96 24760 B 171.94
baseline* 256 89.40 ns 0.449 ns 0.350 ns 0.94 2128 B 1.00
Awaiten 256 95.366 ns 2.2531 ns 2.1076 ns 1.00 2128 B 1.00
MsDI 256 14,912.712 ns 331.4169 ns 293.7925 ns 156.44 61016 B 28.67
Autofac 256 774,842.720 ns 5,717.6261 ns 5,348.2710 ns 8,128.60 740644 B 348.05
Jab 256 77.641 ns 4.2994 ns 4.0217 ns 0.81 2080 B 0.98
PureDI 256 87.747 ns 4.5637 ns 4.2689 ns 0.92 2112 B 0.99
DryIoc 256 42,219.013 ns 362.9067 ns 321.7073 ns 442.90 80637 B 37.89
SimpleInjector 256 354,346.195 ns 3,614.0282 ns 3,203.7419 ns 3,717.32 573106 B 269.32
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

Realistic Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 288.2 ns 0.71 ns 0.63 ns 1.07 568 B 1.00
Awaiten 270.6 ns 4.08 ns 3.62 ns 1.00 568 B 1.00
MsDI 603.1 ns 14.53 ns 12.88 ns 2.23 1104 B 1.94
Autofac 6,475.4 ns 74.01 ns 69.23 ns 23.93 10784 B 18.99
Jab 209.3 ns 3.14 ns 2.93 ns 0.77 432 B 0.76
DryIoc 438.3 ns 3.64 ns 3.23 ns 1.62 944 B 1.66
SimpleInjector 769.7 ns 4.85 ns 4.53 ns 2.84 1096 B 1.93
PureDI 206.6 ns 0.99 ns 0.93 ns 0.76 632 B 1.11
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-v4

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.93 1.01 -
Awaiten 8 6.185 ns 0.1396 ns 0.1306 ns 1.00 - NA
MsDI 8 6.461 ns 0.0074 ns 0.0069 ns 1.05 - NA
Autofac 8 84.508 ns 1.2603 ns 1.1789 ns 13.67 656 B NA
Jab 8 1.887 ns 0.1202 ns 0.1065 ns 0.31 - NA
PureDI 8 4.228 ns 0.0144 ns 0.0128 ns 0.68 - NA
DryIoc 8 6.455 ns 0.1520 ns 0.1422 ns 1.04 - NA
SimpleInjector 8 8.670 ns 0.1289 ns 0.1206 ns 1.40 - NA
baseline* 256 6.043 ns 0.0063 ns 0.0059 ns 0.96 1.00 -
Awaiten 256 6.302 ns 0.0267 ns 0.0250 ns 1.00 - NA
MsDI 256 6.445 ns 0.0124 ns 0.0110 ns 1.02 - NA
Autofac 256 81.934 ns 0.7499 ns 0.6648 ns 13.00 656 B NA
Jab 256 36.138 ns 0.0155 ns 0.0138 ns 5.73 - NA
PureDI 256 6.708 ns 0.0093 ns 0.0077 ns 1.06 - NA
DryIoc 256 6.553 ns 0.0162 ns 0.0144 ns 1.04 - NA
SimpleInjector 256 8.755 ns 0.0358 ns 0.0318 ns 1.39 - NA

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

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

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
7 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